From 20e6a57cf1f0965df8f65f527e22c819101ae8d6 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 5 Jun 2024 23:01:57 +1000
Subject: [PATCH] feat(ui): raster layer logic
- Deduplicate shared logic
- Split up giant renderers file into separate cohesive files
- Tons of cleanup
- Progress on raster layer functionality
---
.../listeners/controlAdapterPreprocessor.ts | 2 +-
.../listeners/imageDeletionListeners.ts | 4 +-
.../components/AddPromptButtons.tsx | 2 +-
.../components/CALayer/CALayer.tsx | 7 +-
.../CALayer/CALayerControlAdapterWrapper.tsx | 7 +-
.../components/CALayer/CALayerOpacity.tsx | 4 +-
.../components/ControlLayersPanelContent.tsx | 3 +-
.../components/IILayer/IILayer.tsx | 9 +-
.../components/IPALayer/IPALayer.tsx | 7 +-
.../IPALayer/IPALayerIPAdapterWrapper.tsx | 7 +-
.../LayerCommon/LayerMenuArrangeActions.tsx | 2 +-
.../LayerCommon/LayerMenuRGActions.tsx | 2 +-
.../LayerOpacity.tsx} | 17 +-
.../components/RGLayer/RGLayer.tsx | 7 +-
.../RGLayer/RGLayerAutoNegativeCheckbox.tsx | 7 +-
.../components/RGLayer/RGLayerColorPicker.tsx | 7 +-
.../RGLayer/RGLayerIPAdapterList.tsx | 3 +-
.../components/RasterLayer/RasterLayer.tsx | 12 +-
.../RasterLayer/RasterLayerOpacity.tsx | 84 --
.../components/StageComponent.tsx | 4 +-
.../controlLayers/hooks/addLayerHooks.ts | 2 +-
.../controlLayers/hooks/layerStateHooks.ts | 22 +-
.../features/controlLayers/konva/constants.ts | 5 +
.../features/controlLayers/konva/events.ts | 5 +
.../features/controlLayers/konva/renderers.ts | 1183 -----------------
.../konva/renderers/background.ts | 67 +
.../konva/{ => renderers}/bbox.ts | 110 +-
.../controlLayers/konva/renderers/caLayer.ts | 162 +++
.../controlLayers/konva/renderers/iiLayer.ts | 149 +++
.../controlLayers/konva/renderers/layers.ts | 118 ++
.../konva/renderers/noLayersMessage.ts | 53 +
.../controlLayers/konva/renderers/objects.ts | 77 ++
.../konva/renderers/rasterLayer.ts | 135 ++
.../controlLayers/konva/renderers/rgLayer.ts | 229 ++++
.../konva/renderers/toolPreview.ts | 161 +++
.../src/features/controlLayers/konva/util.ts | 38 +
.../controlLayers/store/controlLayersSlice.ts | 371 +++---
.../src/features/controlLayers/store/types.ts | 60 +-
.../deleteImageModal/store/selectors.ts | 6 +-
.../util/graph/generation/addControlLayers.ts | 8 +-
.../generation/buildGenerationTabGraph.ts | 2 +-
41 files changed, 1592 insertions(+), 1568 deletions(-)
rename invokeai/frontend/web/src/features/controlLayers/components/{IILayer/IILayerOpacity.tsx => LayerCommon/LayerOpacity.tsx} (85%)
delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerOpacity.tsx
delete mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers.ts
create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts
rename invokeai/frontend/web/src/features/controlLayers/konva/{ => renderers}/bbox.ts (58%)
create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/caLayer.ts
create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/iiLayer.ts
create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts
create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/noLayersMessage.ts
create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/objects.ts
create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/rasterLayer.ts
create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/rgLayer.ts
create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/toolPreview.ts
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts
index a1eb917ebb..cd8fb69ca0 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts
@@ -10,8 +10,8 @@ import {
caLayerProcessorConfigChanged,
caLayerProcessorPendingBatchIdChanged,
caLayerRecalled,
- isControlAdapterLayer,
} from 'features/controlLayers/store/controlLayersSlice';
+import { isControlAdapterLayer } from 'features/controlLayers/store/types';
import { CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts
index 489adb7476..61df8846f0 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts
@@ -8,13 +8,13 @@ import {
selectControlAdapterAll,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
+import { layerDeleted } from 'features/controlLayers/store/controlLayersSlice';
import {
isControlAdapterLayer,
isInitialImageLayer,
isIPAdapterLayer,
isRegionalGuidanceLayer,
- layerDeleted,
-} from 'features/controlLayers/store/controlLayersSlice';
+} from 'features/controlLayers/store/types';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AddPromptButtons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AddPromptButtons.tsx
index 26d9c8ce69..e339d8315e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/AddPromptButtons.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/AddPromptButtons.tsx
@@ -3,11 +3,11 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAddIPAdapterToIPALayer } from 'features/controlLayers/hooks/addLayerHooks';
import {
- isRegionalGuidanceLayer,
rgLayerNegativePromptChanged,
rgLayerPositivePromptChanged,
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
+import { isRegionalGuidanceLayer } from 'features/controlLayers/store/types';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayer.tsx
index 9e71ad943c..868693e58c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayer.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayer.tsx
@@ -6,7 +6,8 @@ import { LayerMenu } from 'features/controlLayers/components/LayerCommon/LayerMe
import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle';
import { LayerIsEnabledToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle';
import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper';
-import { layerSelected, selectCALayerOrThrow } from 'features/controlLayers/store/controlLayersSlice';
+import { layerSelected, selectLayerOrThrow } from 'features/controlLayers/store/controlLayersSlice';
+import { isControlAdapterLayer } from 'features/controlLayers/store/types';
import { memo, useCallback } from 'react';
import CALayerOpacity from './CALayerOpacity';
@@ -17,7 +18,9 @@ type Props = {
export const CALayer = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch();
- const isSelected = useAppSelector((s) => selectCALayerOrThrow(s.controlLayers.present, layerId).isSelected);
+ const isSelected = useAppSelector(
+ (s) => selectLayerOrThrow(s.controlLayers.present, layerId, isControlAdapterLayer).isSelected
+ );
const onClick = useCallback(() => {
dispatch(layerSelected(layerId));
}, [dispatch, layerId]);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx
index a44ae32c13..6c498fe1aa 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx
@@ -8,8 +8,9 @@ import {
caLayerProcessorConfigChanged,
caOrIPALayerBeginEndStepPctChanged,
caOrIPALayerWeightChanged,
- selectCALayerOrThrow,
+ selectLayerOrThrow,
} from 'features/controlLayers/store/controlLayersSlice';
+import { isControlAdapterLayer } from 'features/controlLayers/store/types';
import type { ControlModeV2, ProcessorConfig } from 'features/controlLayers/util/controlAdapters';
import type { CALayerImageDropData } from 'features/dnd/types';
import { memo, useCallback, useMemo } from 'react';
@@ -26,7 +27,9 @@ type Props = {
export const CALayerControlAdapterWrapper = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch();
- const controlAdapter = useAppSelector((s) => selectCALayerOrThrow(s.controlLayers.present, layerId).controlAdapter);
+ const controlAdapter = useAppSelector(
+ (s) => selectLayerOrThrow(s.controlLayers.present, layerId, isControlAdapterLayer).controlAdapter
+ );
const onChangeBeginEndStepPct = useCallback(
(beginEndStepPct: [number, number]) => {
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx
index e272282ea8..94f7cdf5fe 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx
@@ -15,7 +15,7 @@ import {
import { useAppDispatch } from 'app/store/storeHooks';
import { stopPropagation } from 'common/util/stopPropagation';
import { useCALayerOpacity } from 'features/controlLayers/hooks/layerStateHooks';
-import { caLayerIsFilterEnabledChanged, caLayerOpacityChanged } from 'features/controlLayers/store/controlLayersSlice';
+import { caLayerIsFilterEnabledChanged, layerOpacityChanged } from 'features/controlLayers/store/controlLayersSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -34,7 +34,7 @@ const CALayerOpacity = ({ layerId }: Props) => {
const { opacity, isFilterEnabled } = useCALayerOpacity(layerId);
const onChangeOpacity = useCallback(
(v: number) => {
- dispatch(caLayerOpacityChanged({ layerId, opacity: v / 100 }));
+ dispatch(layerOpacityChanged({ layerId, opacity: v / 100 }));
},
[dispatch, layerId]
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
index 4f17870e68..d4baabab8b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
@@ -11,8 +11,9 @@ import { IILayer } from 'features/controlLayers/components/IILayer/IILayer';
import { IPALayer } from 'features/controlLayers/components/IPALayer/IPALayer';
import { RasterLayer } from 'features/controlLayers/components/RasterLayer/RasterLayer';
import { RGLayer } from 'features/controlLayers/components/RGLayer/RGLayer';
-import { isRenderableLayer, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
+import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
import type { Layer } from 'features/controlLayers/store/types';
+import { isRenderableLayer } from 'features/controlLayers/store/types';
import { partition } from 'lodash-es';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx
index c53c4c7631..43857b6fc3 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx
@@ -1,9 +1,9 @@
import { Flex, Spacer, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import IILayerOpacity from 'features/controlLayers/components/IILayer/IILayerOpacity';
import { InitialImagePreview } from 'features/controlLayers/components/IILayer/InitialImagePreview';
import { LayerDeleteButton } from 'features/controlLayers/components/LayerCommon/LayerDeleteButton';
import { LayerMenu } from 'features/controlLayers/components/LayerCommon/LayerMenu';
+import { LayerOpacity } from 'features/controlLayers/components/LayerCommon/LayerOpacity';
import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle';
import { LayerIsEnabledToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle';
import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper';
@@ -11,8 +11,9 @@ import {
iiLayerDenoisingStrengthChanged,
iiLayerImageChanged,
layerSelected,
- selectIILayerOrThrow,
+ selectLayerOrThrow,
} from 'features/controlLayers/store/controlLayersSlice';
+import { isInitialImageLayer } from 'features/controlLayers/store/types';
import type { IILayerImageDropData } from 'features/dnd/types';
import ImageToImageStrength from 'features/parameters/components/ImageToImage/ImageToImageStrength';
import { memo, useCallback, useMemo } from 'react';
@@ -24,7 +25,7 @@ type Props = {
export const IILayer = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch();
- const layer = useAppSelector((s) => selectIILayerOrThrow(s.controlLayers.present, layerId));
+ const layer = useAppSelector((s) => selectLayerOrThrow(s.controlLayers.present, layerId, isInitialImageLayer));
const onClick = useCallback(() => {
dispatch(layerSelected(layerId));
}, [dispatch, layerId]);
@@ -69,7 +70,7 @@ export const IILayer = memo(({ layerId }: Props) => {
-
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayer.tsx
index e8f60c8d07..e4d3dd9e4f 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayer.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayer.tsx
@@ -5,7 +5,8 @@ import { LayerDeleteButton } from 'features/controlLayers/components/LayerCommon
import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle';
import { LayerIsEnabledToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle';
import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper';
-import { layerSelected, selectIPALayerOrThrow } from 'features/controlLayers/store/controlLayersSlice';
+import { layerSelected, selectLayerOrThrow } from 'features/controlLayers/store/controlLayersSlice';
+import { isIPAdapterLayer } from 'features/controlLayers/store/types';
import { memo, useCallback } from 'react';
type Props = {
@@ -14,7 +15,9 @@ type Props = {
export const IPALayer = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch();
- const isSelected = useAppSelector((s) => selectIPALayerOrThrow(s.controlLayers.present, layerId).isSelected);
+ const isSelected = useAppSelector(
+ (s) => selectLayerOrThrow(s.controlLayers.present, layerId, isIPAdapterLayer).isSelected
+ );
const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true });
const onClick = useCallback(() => {
dispatch(layerSelected(layerId));
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayerIPAdapterWrapper.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayerIPAdapterWrapper.tsx
index 9f99710dac..6492e3cf32 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayerIPAdapterWrapper.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayerIPAdapterWrapper.tsx
@@ -7,8 +7,9 @@ import {
ipaLayerImageChanged,
ipaLayerMethodChanged,
ipaLayerModelChanged,
- selectIPALayerOrThrow,
+ selectLayerOrThrow,
} from 'features/controlLayers/store/controlLayersSlice';
+import { isIPAdapterLayer } from 'features/controlLayers/store/types';
import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/util/controlAdapters';
import type { IPALayerImageDropData } from 'features/dnd/types';
import { memo, useCallback, useMemo } from 'react';
@@ -20,7 +21,9 @@ type Props = {
export const IPALayerIPAdapterWrapper = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch();
- const ipAdapter = useAppSelector((s) => selectIPALayerOrThrow(s.controlLayers.present, layerId).ipAdapter);
+ const ipAdapter = useAppSelector(
+ (s) => selectLayerOrThrow(s.controlLayers.present, layerId, isIPAdapterLayer).ipAdapter
+ );
const onChangeBeginEndStepPct = useCallback(
(beginEndStepPct: [number, number]) => {
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenuArrangeActions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenuArrangeActions.tsx
index 9c51671a39..3e65eda783 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenuArrangeActions.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenuArrangeActions.tsx
@@ -2,13 +2,13 @@ import { MenuItem } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
- isRenderableLayer,
layerMovedBackward,
layerMovedForward,
layerMovedToBack,
layerMovedToFront,
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
+import { isRenderableLayer } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowDownBold, PiArrowLineDownBold, PiArrowLineUpBold, PiArrowUpBold } from 'react-icons/pi';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenuRGActions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenuRGActions.tsx
index 172709ec14..905abfd00d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenuRGActions.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenuRGActions.tsx
@@ -3,11 +3,11 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAddIPAdapterToIPALayer } from 'features/controlLayers/hooks/addLayerHooks';
import {
- isRegionalGuidanceLayer,
rgLayerNegativePromptChanged,
rgLayerPositivePromptChanged,
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
+import { isRegionalGuidanceLayer } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayerOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerOpacity.tsx
similarity index 85%
rename from invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayerOpacity.tsx
rename to invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerOpacity.tsx
index 9918dda5b8..f488d9600a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayerOpacity.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerOpacity.tsx
@@ -15,14 +15,14 @@ import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { stopPropagation } from 'common/util/stopPropagation';
import {
- iiLayerOpacityChanged,
- isInitialImageLayer,
+ layerOpacityChanged,
selectControlLayersSlice,
+ selectLayerOrThrow,
} from 'features/controlLayers/store/controlLayersSlice';
+import { isLayerWithOpacity } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDropHalfFill } from 'react-icons/pi';
-import { assert } from 'tsafe';
type Props = {
layerId: string;
@@ -31,14 +31,13 @@ type Props = {
const marks = [0, 25, 50, 75, 100];
const formatPct = (v: number | string) => `${v} %`;
-const IILayerOpacity = ({ layerId }: Props) => {
+export const LayerOpacity = memo(({ layerId }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const selectOpacity = useMemo(
() =>
createSelector(selectControlLayersSlice, (controlLayers) => {
- const layer = controlLayers.present.layers.filter(isInitialImageLayer).find((l) => l.id === layerId);
- assert(layer, `Layer ${layerId} not found`);
+ const layer = selectLayerOrThrow(controlLayers.present, layerId, isLayerWithOpacity);
return Math.round(layer.opacity * 100);
}),
[layerId]
@@ -46,7 +45,7 @@ const IILayerOpacity = ({ layerId }: Props) => {
const opacity = useAppSelector(selectOpacity);
const onChangeOpacity = useCallback(
(v: number) => {
- dispatch(iiLayerOpacityChanged({ layerId, opacity: v / 100 }));
+ dispatch(layerOpacityChanged({ layerId, opacity: v / 100 }));
},
[dispatch, layerId]
);
@@ -93,6 +92,6 @@ const IILayerOpacity = ({ layerId }: Props) => {
);
-};
+});
-export default memo(IILayerOpacity);
+LayerOpacity.displayName = 'LayerOpacity';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayer.tsx
index cc331017d3..fa552dd4cf 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayer.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayer.tsx
@@ -8,11 +8,8 @@ import { LayerMenu } from 'features/controlLayers/components/LayerCommon/LayerMe
import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle';
import { LayerIsEnabledToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle';
import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper';
-import {
- isRegionalGuidanceLayer,
- layerSelected,
- selectControlLayersSlice,
-} from 'features/controlLayers/store/controlLayersSlice';
+import { layerSelected, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
+import { isRegionalGuidanceLayer } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { assert } from 'tsafe';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerAutoNegativeCheckbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerAutoNegativeCheckbox.tsx
index 89edb58d2f..c5a7be1c3e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerAutoNegativeCheckbox.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerAutoNegativeCheckbox.tsx
@@ -1,11 +1,8 @@
import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import {
- isRegionalGuidanceLayer,
- rgLayerAutoNegativeChanged,
- selectControlLayersSlice,
-} from 'features/controlLayers/store/controlLayersSlice';
+import { rgLayerAutoNegativeChanged, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
+import { isRegionalGuidanceLayer } from 'features/controlLayers/store/types';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerColorPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerColorPicker.tsx
index 624047caf3..78c16a773b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerColorPicker.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerColorPicker.tsx
@@ -4,11 +4,8 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import RgbColorPicker from 'common/components/RgbColorPicker';
import { stopPropagation } from 'common/util/stopPropagation';
import { rgbColorToString } from 'features/canvas/util/colorToString';
-import {
- isRegionalGuidanceLayer,
- rgLayerPreviewColorChanged,
- selectControlLayersSlice,
-} from 'features/controlLayers/store/controlLayersSlice';
+import { rgLayerPreviewColorChanged, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
+import { isRegionalGuidanceLayer } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import type { RgbColor } from 'react-colorful';
import { useTranslation } from 'react-i18next';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerIPAdapterList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerIPAdapterList.tsx
index 578d3789bf..1d5698ce03 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerIPAdapterList.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerIPAdapterList.tsx
@@ -2,7 +2,8 @@ import { Divider, Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { RGLayerIPAdapterWrapper } from 'features/controlLayers/components/RGLayer/RGLayerIPAdapterWrapper';
-import { isRegionalGuidanceLayer, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
+import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
+import { isRegionalGuidanceLayer } from 'features/controlLayers/store/types';
import { memo, useMemo } from 'react';
import { assert } from 'tsafe';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx
index 80a32509b4..b2f54c8302 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx
@@ -2,21 +2,23 @@ import { Flex, Spacer, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { LayerDeleteButton } from 'features/controlLayers/components/LayerCommon/LayerDeleteButton';
import { LayerMenu } from 'features/controlLayers/components/LayerCommon/LayerMenu';
+import { LayerOpacity } from 'features/controlLayers/components/LayerCommon/LayerOpacity';
import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle';
import { LayerIsEnabledToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle';
import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper';
-import { layerSelected, selectRasterLayerOrThrow } from 'features/controlLayers/store/controlLayersSlice';
+import { layerSelected, selectLayerOrThrow } from 'features/controlLayers/store/controlLayersSlice';
+import { isRasterLayer } from 'features/controlLayers/store/types';
import { memo, useCallback } from 'react';
-import { RasterLayerOpacity } from './RasterLayerOpacity';
-
type Props = {
layerId: string;
};
export const RasterLayer = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch();
- const isSelected = useAppSelector((s) => selectRasterLayerOrThrow(s.controlLayers.present, layerId).isSelected);
+ const isSelected = useAppSelector(
+ (s) => selectLayerOrThrow(s.controlLayers.present, layerId, isRasterLayer).isSelected
+ );
const onClick = useCallback(() => {
dispatch(layerSelected(layerId));
}, [dispatch, layerId]);
@@ -28,7 +30,7 @@ export const RasterLayer = memo(({ layerId }: Props) => {
-
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerOpacity.tsx
deleted file mode 100644
index 05e4acd849..0000000000
--- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerOpacity.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import {
- CompositeNumberInput,
- CompositeSlider,
- Flex,
- FormControl,
- FormLabel,
- IconButton,
- Popover,
- PopoverArrow,
- PopoverBody,
- PopoverContent,
- PopoverTrigger,
-} from '@invoke-ai/ui-library';
-import { useAppDispatch } from 'app/store/storeHooks';
-import { stopPropagation } from 'common/util/stopPropagation';
-import { useRasterLayerOpacity } from 'features/controlLayers/hooks/layerStateHooks';
-import { rasterLayerOpacityChanged } from 'features/controlLayers/store/controlLayersSlice';
-import { memo, useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import { PiDropHalfFill } from 'react-icons/pi';
-
-type Props = {
- layerId: string;
-};
-
-const marks = [0, 25, 50, 75, 100];
-const formatPct = (v: number | string) => `${v} %`;
-
-export const RasterLayerOpacity = memo(({ layerId }: Props) => {
- const { t } = useTranslation();
- const dispatch = useAppDispatch();
- const opacity = useRasterLayerOpacity(layerId);
- const onChangeOpacity = useCallback(
- (v: number) => {
- dispatch(rasterLayerOpacityChanged({ layerId, opacity: v / 100 }));
- },
- [dispatch, layerId]
- );
- return (
-
-
- }
- variant="ghost"
- onDoubleClick={stopPropagation}
- />
-
-
-
-
-
-
- {t('controlLayers.opacity')}
-
-
-
-
-
-
-
- );
-});
-
-RasterLayerOpacity.displayName = 'RasterLayerOpacity';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
index a4dc52751e..dc82e30716 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
@@ -6,7 +6,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { BRUSH_SPACING_PCT, MAX_BRUSH_SPACING_PX, MIN_BRUSH_SPACING_PX } from 'features/controlLayers/konva/constants';
import { setStageEventHandlers } from 'features/controlLayers/konva/events';
-import { debouncedRenderers, renderers as normalRenderers } from 'features/controlLayers/konva/renderers';
+import { debouncedRenderers, renderers as normalRenderers } from 'features/controlLayers/konva/renderers/layers';
import {
$brushColor,
$brushSize,
@@ -21,7 +21,6 @@ import {
brushLineAdded,
brushSizeChanged,
eraserLineAdded,
- isRegionalGuidanceLayer,
layerBboxChanged,
layerTranslated,
linePointsAdded,
@@ -34,6 +33,7 @@ import type {
AddPointToLineArg,
AddRectShapeArg,
} from 'features/controlLayers/store/types';
+import { isRegionalGuidanceLayer } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { IRect } from 'konva/lib/types';
import { clamp } from 'lodash-es';
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
index dcbbeb8db5..244e57c655 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
@@ -3,9 +3,9 @@ import {
caLayerAdded,
iiLayerAdded,
ipaLayerAdded,
- isInitialImageLayer,
rgLayerIPAdapterAdded,
} from 'features/controlLayers/store/controlLayersSlice';
+import { isInitialImageLayer } from 'features/controlLayers/store/types';
import {
buildControlNet,
buildIPAdapter,
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts
index b036b25742..c643b863fd 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts
@@ -1,12 +1,8 @@
import { createSelector } from '@reduxjs/toolkit';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
-import {
- isControlAdapterLayer,
- isRasterLayer,
- isRegionalGuidanceLayer,
- selectControlLayersSlice,
-} from 'features/controlLayers/store/controlLayersSlice';
+import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
+import { isControlAdapterLayer, isRegionalGuidanceLayer } from 'features/controlLayers/store/types';
import { useMemo } from 'react';
import { assert } from 'tsafe';
@@ -81,17 +77,3 @@ export const useCALayerOpacity = (layerId: string) => {
const opacity = useAppSelector(selectLayer);
return opacity;
};
-
-export const useRasterLayerOpacity = (layerId: string) => {
- const selectLayer = useMemo(
- () =>
- createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
- const layer = controlLayers.present.layers.filter(isRasterLayer).find((l) => l.id === layerId);
- assert(layer, `Layer ${layerId} not found`);
- return Math.round(layer.opacity * 100);
- }),
- [layerId]
- );
- const opacity = useAppSelector(selectLayer);
- return opacity;
-};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/constants.ts b/invokeai/frontend/web/src/features/controlLayers/konva/constants.ts
index 27bfc8b731..638b6da748 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/constants.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/constants.ts
@@ -34,3 +34,8 @@ export const MIN_BRUSH_SPACING_PX = 5;
* The maximum brush spacing in pixels.
*/
export const MAX_BRUSH_SPACING_PX = 15;
+
+/**
+ * The debounce time in milliseconds for debounced renderers.
+ */
+export const DEBOUNCE_MS = 300;
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts
index 0a26dba92d..e07753127d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts
@@ -67,6 +67,7 @@ export const setStageEventHandlers = ({
onRectShapeAdded,
onBrushSizeChanged,
}: SetStageEventHandlersArg): (() => void) => {
+ //#region mouseenter
stage.on('mouseenter', (e) => {
const stage = e.target.getStage();
if (!stage) {
@@ -76,6 +77,7 @@ export const setStageEventHandlers = ({
stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(tool === 'brush' || tool === 'eraser');
});
+ //#region mousedown
stage.on('mousedown', (e) => {
const stage = e.target.getStage();
if (!stage) {
@@ -110,6 +112,7 @@ export const setStageEventHandlers = ({
}
});
+ //#region mouseup
stage.on('mouseup', (e) => {
const stage = e.target.getStage();
if (!stage) {
@@ -143,6 +146,7 @@ export const setStageEventHandlers = ({
$lastMouseDownPos.set(null);
});
+ //#region mousemove
stage.on('mousemove', (e) => {
const stage = e.target.getStage();
if (!stage) {
@@ -191,6 +195,7 @@ export const setStageEventHandlers = ({
}
});
+ //#region mouseleave
stage.on('mouseleave', (e) => {
const stage = e.target.getStage();
if (!stage) {
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers.ts
deleted file mode 100644
index d69c14afa3..0000000000
--- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers.ts
+++ /dev/null
@@ -1,1183 +0,0 @@
-import { rgbaColorToString, rgbColorToString } from 'features/canvas/util/colorToString';
-import { getLayerBboxFast, getLayerBboxPixels } from 'features/controlLayers/konva/bbox';
-import { LightnessToAlphaFilter } from 'features/controlLayers/konva/filters';
-import {
- BACKGROUND_LAYER_ID,
- BACKGROUND_RECT_ID,
- CA_LAYER_IMAGE_NAME,
- CA_LAYER_NAME,
- COMPOSITING_RECT_NAME,
- getCALayerImageId,
- getIILayerImageId,
- getLayerBboxId,
- getObjectGroupId,
- INITIAL_IMAGE_LAYER_IMAGE_NAME,
- INITIAL_IMAGE_LAYER_NAME,
- LAYER_BBOX_NAME,
- NO_LAYERS_MESSAGE_LAYER_ID,
- RASTER_LAYER_NAME,
- RASTER_LAYER_OBJECT_GROUP_NAME,
- RG_LAYER_LINE_NAME,
- RG_LAYER_NAME,
- RG_LAYER_OBJECT_GROUP_NAME,
- RG_LAYER_RECT_NAME,
- TOOL_PREVIEW_BRUSH_BORDER_INNER_ID,
- TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID,
- TOOL_PREVIEW_BRUSH_FILL_ID,
- TOOL_PREVIEW_BRUSH_GROUP_ID,
- TOOL_PREVIEW_LAYER_ID,
- TOOL_PREVIEW_RECT_ID,
-} from 'features/controlLayers/konva/naming';
-import { getScaledFlooredCursorPosition, snapPosToStage } from 'features/controlLayers/konva/util';
-import {
- isControlAdapterLayer,
- isInitialImageLayer,
- isRasterLayer,
- isRegionalGuidanceLayer,
- isRenderableLayer,
-} from 'features/controlLayers/store/controlLayersSlice';
-import type {
- BrushLine,
- ControlAdapterLayer,
- EraserLine,
- InitialImageLayer,
- Layer,
- RasterLayer,
- RectShape,
- RegionalGuidanceLayer,
- RgbaColor,
- Tool,
-} from 'features/controlLayers/store/types';
-import { DEFAULT_RGBA_COLOR } from 'features/controlLayers/store/types';
-import { t } from 'i18next';
-import Konva from 'konva';
-import type { IRect, Vector2d } from 'konva/lib/types';
-import { debounce } from 'lodash-es';
-import type { ImageDTO } from 'services/api/types';
-import { assert } from 'tsafe';
-import { v4 as uuidv4 } from 'uuid';
-
-import {
- BBOX_SELECTED_STROKE,
- BRUSH_BORDER_INNER_COLOR,
- BRUSH_BORDER_OUTER_COLOR,
- TRANSPARENCY_CHECKER_PATTERN,
-} from './constants';
-
-/**
- * Creates the singleton tool preview layer and all its objects.
- * @param stage The konva stage
- */
-const createToolPreviewLayer = (stage: Konva.Stage): Konva.Layer => {
- // Initialize the brush preview layer & add to the stage
- const toolPreviewLayer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, visible: false, listening: false });
- stage.add(toolPreviewLayer);
-
- // Create the brush preview group & circles
- const brushPreviewGroup = new Konva.Group({ id: TOOL_PREVIEW_BRUSH_GROUP_ID });
- const brushPreviewFill = new Konva.Circle({
- id: TOOL_PREVIEW_BRUSH_FILL_ID,
- listening: false,
- strokeEnabled: false,
- });
- brushPreviewGroup.add(brushPreviewFill);
- const brushPreviewBorderInner = new Konva.Circle({
- id: TOOL_PREVIEW_BRUSH_BORDER_INNER_ID,
- listening: false,
- stroke: BRUSH_BORDER_INNER_COLOR,
- strokeWidth: 1,
- strokeEnabled: true,
- });
- brushPreviewGroup.add(brushPreviewBorderInner);
- const brushPreviewBorderOuter = new Konva.Circle({
- id: TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID,
- listening: false,
- stroke: BRUSH_BORDER_OUTER_COLOR,
- strokeWidth: 1,
- strokeEnabled: true,
- });
- brushPreviewGroup.add(brushPreviewBorderOuter);
- toolPreviewLayer.add(brushPreviewGroup);
-
- // Create the rect preview - this is a rectangle drawn from the last mouse down position to the current cursor position
- const rectPreview = new Konva.Rect({ id: TOOL_PREVIEW_RECT_ID, listening: false, stroke: 'white', strokeWidth: 1 });
- toolPreviewLayer.add(rectPreview);
-
- return toolPreviewLayer;
-};
-
-/**
- * Renders the brush preview for the selected tool.
- * @param stage The konva stage
- * @param tool The selected tool
- * @param color The selected layer's color
- * @param selectedLayerType The selected layer's type
- * @param globalMaskLayerOpacity The global mask layer opacity
- * @param cursorPos The cursor position
- * @param lastMouseDownPos The position of the last mouse down event - used for the rect tool
- * @param brushSize The brush size
- */
-const renderToolPreview = (
- stage: Konva.Stage,
- tool: Tool,
- brushColor: RgbaColor,
- selectedLayerType: Layer['type'] | null,
- globalMaskLayerOpacity: number,
- cursorPos: Vector2d | null,
- lastMouseDownPos: Vector2d | null,
- brushSize: number
-): void => {
- const layerCount = stage.find(selectRenderableLayers).length;
- // Update the stage's pointer style
- if (layerCount === 0) {
- // We have no layers, so we should not render any tool
- stage.container().style.cursor = 'default';
- } else if (selectedLayerType !== 'regional_guidance_layer' && selectedLayerType !== 'raster_layer') {
- // Non-mask-guidance layers don't have tools
- stage.container().style.cursor = 'not-allowed';
- } else if (tool === 'move') {
- // Move tool gets a pointer
- stage.container().style.cursor = 'default';
- } else if (tool === 'rect') {
- // Move rect gets a crosshair
- stage.container().style.cursor = 'crosshair';
- } else {
- // Else we hide the native cursor and use the konva-rendered brush preview
- stage.container().style.cursor = 'none';
- }
-
- const toolPreviewLayer = stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`) ?? createToolPreviewLayer(stage);
-
- if (!cursorPos || layerCount === 0) {
- // We can bail early if the mouse isn't over the stage or there are no layers
- toolPreviewLayer.visible(false);
- return;
- }
-
- toolPreviewLayer.visible(true);
-
- const brushPreviewGroup = stage.findOne(`#${TOOL_PREVIEW_BRUSH_GROUP_ID}`);
- assert(brushPreviewGroup, 'Brush preview group not found');
-
- const rectPreview = stage.findOne(`#${TOOL_PREVIEW_RECT_ID}`);
- assert(rectPreview, 'Rect preview not found');
-
- // No need to render the brush preview if the cursor position or color is missing
- if (cursorPos && (tool === 'brush' || tool === 'eraser')) {
- // Update the fill circle
- const brushPreviewFill = brushPreviewGroup.findOne(`#${TOOL_PREVIEW_BRUSH_FILL_ID}`);
- brushPreviewFill?.setAttrs({
- x: cursorPos.x,
- y: cursorPos.y,
- radius: brushSize / 2,
- fill: rgbaColorToString(brushColor),
- globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out',
- });
-
- // Update the inner border of the brush preview
- const brushPreviewInner = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_BRUSH_BORDER_INNER_ID}`);
- brushPreviewInner?.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2 });
-
- // Update the outer border of the brush preview
- const brushPreviewOuter = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID}`);
- brushPreviewOuter?.setAttrs({
- x: cursorPos.x,
- y: cursorPos.y,
- radius: brushSize / 2 + 1,
- });
-
- brushPreviewGroup.visible(true);
- } else {
- brushPreviewGroup.visible(false);
- }
-
- if (cursorPos && lastMouseDownPos && tool === 'rect') {
- const snappedPos = snapPosToStage(cursorPos, stage);
- const rectPreview = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_RECT_ID}`);
- rectPreview?.setAttrs({
- x: Math.min(snappedPos.x, lastMouseDownPos.x),
- y: Math.min(snappedPos.y, lastMouseDownPos.y),
- width: Math.abs(snappedPos.x - lastMouseDownPos.x),
- height: Math.abs(snappedPos.y - lastMouseDownPos.y),
- });
- rectPreview?.visible(true);
- } else {
- rectPreview?.visible(false);
- }
-};
-
-/**
- * Creates a regional guidance layer.
- * @param stage The konva stage
- * @param layerState The regional guidance layer state
- * @param onLayerPosChanged Callback for when the layer's position changes
- */
-const createRGLayer = (
- stage: Konva.Stage,
- layerState: RegionalGuidanceLayer,
- onLayerPosChanged?: (layerId: string, x: number, y: number) => void
-): Konva.Layer => {
- // This layer hasn't been added to the konva state yet
- const konvaLayer = new Konva.Layer({
- id: layerState.id,
- name: RG_LAYER_NAME,
- draggable: true,
- dragDistance: 0,
- });
-
- // When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing
- // the position - we do not need to call this on the `dragmove` event.
- if (onLayerPosChanged) {
- konvaLayer.on('dragend', function (e) {
- onLayerPosChanged(layerState.id, Math.floor(e.target.x()), Math.floor(e.target.y()));
- });
- }
-
- // The dragBoundFunc limits how far the layer can be dragged
- konvaLayer.dragBoundFunc(function (pos) {
- const cursorPos = getScaledFlooredCursorPosition(stage);
- if (!cursorPos) {
- return this.getAbsolutePosition();
- }
- // Prevent the user from dragging the layer out of the stage bounds by constaining the cursor position to the stage bounds
- if (
- cursorPos.x < 0 ||
- cursorPos.x > stage.width() / stage.scaleX() ||
- cursorPos.y < 0 ||
- cursorPos.y > stage.height() / stage.scaleY()
- ) {
- return this.getAbsolutePosition();
- }
- return pos;
- });
-
- // The object group holds all of the layer's objects (e.g. lines and rects)
- const konvaObjectGroup = new Konva.Group({
- id: getObjectGroupId(layerState.id, uuidv4()),
- name: RG_LAYER_OBJECT_GROUP_NAME,
- listening: false,
- });
- konvaLayer.add(konvaObjectGroup);
-
- stage.add(konvaLayer);
-
- return konvaLayer;
-};
-//#endregion
-
-/**
- * Creates a konva line for a brush line.
- * @param brushLine The brush line state
- * @param layerObjectGroup The konva layer's object group to add the line to
- */
-const createBrushLine = (brushLine: BrushLine, layerObjectGroup: Konva.Group): Konva.Line => {
- const konvaLine = new Konva.Line({
- id: brushLine.id,
- key: brushLine.id,
- name: RG_LAYER_LINE_NAME,
- strokeWidth: brushLine.strokeWidth,
- tension: 0,
- lineCap: 'round',
- lineJoin: 'round',
- shadowForStrokeEnabled: false,
- globalCompositeOperation: 'source-over',
- listening: false,
- stroke: rgbaColorToString(brushLine.color),
- });
- layerObjectGroup.add(konvaLine);
- return konvaLine;
-};
-
-/**
- * Creates a konva line for a eraser line.
- * @param eraserLine The eraser line state
- * @param layerObjectGroup The konva layer's object group to add the line to
- */
-const createEraserLine = (eraserLine: EraserLine, layerObjectGroup: Konva.Group): Konva.Line => {
- const konvaLine = new Konva.Line({
- id: eraserLine.id,
- key: eraserLine.id,
- name: RG_LAYER_LINE_NAME,
- strokeWidth: eraserLine.strokeWidth,
- tension: 0,
- lineCap: 'round',
- lineJoin: 'round',
- shadowForStrokeEnabled: false,
- globalCompositeOperation: 'destination-out',
- listening: false,
- stroke: rgbaColorToString(DEFAULT_RGBA_COLOR),
- });
- layerObjectGroup.add(konvaLine);
- return konvaLine;
-};
-
-/**
- * Creates a konva rect for a rect shape.
- * @param rectShape The rect shape state
- * @param layerObjectGroup The konva layer's object group to add the line to
- */
-const createRectShape = (rectShape: RectShape, layerObjectGroup: Konva.Group): Konva.Rect => {
- const konvaRect = new Konva.Rect({
- id: rectShape.id,
- key: rectShape.id,
- name: RG_LAYER_RECT_NAME,
- x: rectShape.x,
- y: rectShape.y,
- width: rectShape.width,
- height: rectShape.height,
- listening: false,
- fill: rgbaColorToString(rectShape.color),
- });
- layerObjectGroup.add(konvaRect);
- return konvaRect;
-};
-
-/**
- * Creates the "compositing rect" for a regional guidance layer.
- * @param konvaLayer The konva layer
- */
-const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => {
- const compositingRect = new Konva.Rect({ name: COMPOSITING_RECT_NAME, listening: false });
- konvaLayer.add(compositingRect);
- return compositingRect;
-};
-
-/**
- * Renders a raster layer.
- * @param stage The konva stage
- * @param layerState The regional guidance layer state
- * @param globalMaskLayerOpacity The global mask layer opacity
- * @param tool The current tool
- * @param onLayerPosChanged Callback for when the layer's position changes
- */
-const renderRGLayer = (
- stage: Konva.Stage,
- layerState: RegionalGuidanceLayer,
- globalMaskLayerOpacity: number,
- tool: Tool,
- onLayerPosChanged?: (layerId: string, x: number, y: number) => void
-): void => {
- const konvaLayer =
- stage.findOne(`#${layerState.id}`) ?? createRGLayer(stage, layerState, onLayerPosChanged);
-
- // Update the layer's position and listening state
- konvaLayer.setAttrs({
- listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events
- x: Math.floor(layerState.x),
- y: Math.floor(layerState.y),
- });
-
- // Convert the color to a string, stripping the alpha - the object group will handle opacity.
- const rgbColor = rgbColorToString(layerState.previewColor);
-
- const konvaObjectGroup = konvaLayer.findOne(`.${RG_LAYER_OBJECT_GROUP_NAME}`);
- assert(konvaObjectGroup, `Object group not found for layer ${layerState.id}`);
-
- // We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required.
- let groupNeedsCache = false;
-
- const objectIds = layerState.objects.map(mapId);
- // Destroy any objects that are no longer in the redux state
- for (const objectNode of konvaObjectGroup.find(selectVectorMaskObjects)) {
- if (!objectIds.includes(objectNode.id())) {
- objectNode.destroy();
- groupNeedsCache = true;
- }
- }
-
- for (const obj of layerState.objects) {
- if (obj.type === 'brush_line') {
- const konvaBrushLine = stage.findOne(`#${obj.id}`) ?? createBrushLine(obj, konvaObjectGroup);
-
- // Only update the points if they have changed. The point values are never mutated, they are only added to the
- // array, so checking the length is sufficient to determine if we need to re-cache.
- if (konvaBrushLine.points().length !== obj.points.length) {
- konvaBrushLine.points(obj.points);
- groupNeedsCache = true;
- }
- // Only update the color if it has changed.
- if (konvaBrushLine.stroke() !== rgbColor) {
- konvaBrushLine.stroke(rgbColor);
- groupNeedsCache = true;
- }
- } else if (obj.type === 'eraser_line') {
- const konvaEraserLine = stage.findOne(`#${obj.id}`) ?? createEraserLine(obj, konvaObjectGroup);
-
- // Only update the points if they have changed. The point values are never mutated, they are only added to the
- // array, so checking the length is sufficient to determine if we need to re-cache.
- if (konvaEraserLine.points().length !== obj.points.length) {
- konvaEraserLine.points(obj.points);
- groupNeedsCache = true;
- }
- // Only update the color if it has changed.
- if (konvaEraserLine.stroke() !== rgbColor) {
- konvaEraserLine.stroke(rgbColor);
- groupNeedsCache = true;
- }
- } else if (obj.type === 'rect_shape') {
- const konvaRectShape = stage.findOne(`#${obj.id}`) ?? createRectShape(obj, konvaObjectGroup);
-
- // Only update the color if it has changed.
- if (konvaRectShape.fill() !== rgbColor) {
- konvaRectShape.fill(rgbColor);
- groupNeedsCache = true;
- }
- }
- }
-
- // Only update layer visibility if it has changed.
- if (konvaLayer.visible() !== layerState.isEnabled) {
- konvaLayer.visible(layerState.isEnabled);
- groupNeedsCache = true;
- }
-
- if (konvaObjectGroup.getChildren().length === 0) {
- // No objects - clear the cache to reset the previous pixel data
- konvaObjectGroup.clearCache();
- return;
- }
-
- const compositingRect =
- konvaLayer.findOne(`.${COMPOSITING_RECT_NAME}`) ?? createCompositingRect(konvaLayer);
-
- /**
- * When the group is selected, we use a rect of the selected preview color, composited over the shapes. This allows
- * shapes to render as a "raster" layer with all pixels drawn at the same color and opacity.
- *
- * Without this special handling, each shape is drawn individually with the given opacity, atop the other shapes. The
- * effect is like if you have a Photoshop Group consisting of many shapes, each of which has the given opacity.
- * Overlapping shapes will have their colors blended together, and the final color is the result of all the shapes.
- *
- * Instead, with the special handling, the effect is as if you drew all the shapes at 100% opacity, flattened them to
- * a single raster image, and _then_ applied the 50% opacity.
- */
- if (layerState.isSelected && tool !== 'move') {
- // We must clear the cache first so Konva will re-draw the group with the new compositing rect
- if (konvaObjectGroup.isCached()) {
- konvaObjectGroup.clearCache();
- }
- // The user is allowed to reduce mask opacity to 0, but we need the opacity for the compositing rect to work
- konvaObjectGroup.opacity(1);
-
- compositingRect.setAttrs({
- // The rect should be the size of the layer - use the fast method if we don't have a pixel-perfect bbox already
- ...(!layerState.bboxNeedsUpdate && layerState.bbox ? layerState.bbox : getLayerBboxFast(konvaLayer)),
- fill: rgbColor,
- opacity: globalMaskLayerOpacity,
- // 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: konvaObjectGroup.getChildren().length,
- });
- } else {
- // The compositing rect should only be shown when the layer is selected.
- compositingRect.visible(false);
- // Cache only if needed - or if we are on this code path and _don't_ have a cache
- if (groupNeedsCache || !konvaObjectGroup.isCached()) {
- konvaObjectGroup.cache();
- }
- // Updating group opacity does not require re-caching
- konvaObjectGroup.opacity(globalMaskLayerOpacity);
- }
-};
-
-/**
- * Creates a raster layer.
- * @param stage The konva stage
- * @param layerState The raster layer state
- * @param onLayerPosChanged Callback for when the layer's position changes
- */
-const createRasterLayer = (
- stage: Konva.Stage,
- layerState: RasterLayer,
- onLayerPosChanged?: (layerId: string, x: number, y: number) => void
-): Konva.Layer => {
- // This layer hasn't been added to the konva state yet
- const konvaLayer = new Konva.Layer({
- id: layerState.id,
- name: RASTER_LAYER_NAME,
- draggable: true,
- dragDistance: 0,
- });
-
- // When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing
- // the position - we do not need to call this on the `dragmove` event.
- if (onLayerPosChanged) {
- konvaLayer.on('dragend', function (e) {
- onLayerPosChanged(layerState.id, Math.floor(e.target.x()), Math.floor(e.target.y()));
- });
- }
-
- // The dragBoundFunc limits how far the layer can be dragged
- konvaLayer.dragBoundFunc(function (pos) {
- const cursorPos = getScaledFlooredCursorPosition(stage);
- if (!cursorPos) {
- return this.getAbsolutePosition();
- }
- // Prevent the user from dragging the layer out of the stage bounds by constaining the cursor position to the stage bounds
- if (
- cursorPos.x < 0 ||
- cursorPos.x > stage.width() / stage.scaleX() ||
- cursorPos.y < 0 ||
- cursorPos.y > stage.height() / stage.scaleY()
- ) {
- return this.getAbsolutePosition();
- }
- return pos;
- });
-
- // The object group holds all of the layer's objects (e.g. lines and rects)
- const konvaObjectGroup = new Konva.Group({
- id: getObjectGroupId(layerState.id, uuidv4()),
- name: RASTER_LAYER_OBJECT_GROUP_NAME,
- listening: false,
- });
- konvaLayer.add(konvaObjectGroup);
-
- stage.add(konvaLayer);
-
- return konvaLayer;
-};
-
-/**
- * Renders a regional guidance layer.
- * @param stage The konva stage
- * @param layerState The regional guidance layer state
- * @param tool The current tool
- * @param onLayerPosChanged Callback for when the layer's position changes
- */
-const renderRasterLayer = (
- stage: Konva.Stage,
- layerState: RasterLayer,
- tool: Tool,
- onLayerPosChanged?: (layerId: string, x: number, y: number) => void
-): void => {
- const konvaLayer =
- stage.findOne(`#${layerState.id}`) ?? createRasterLayer(stage, layerState, onLayerPosChanged);
-
- // Update the layer's position and listening state
- konvaLayer.setAttrs({
- listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events
- x: Math.floor(layerState.x),
- y: Math.floor(layerState.y),
- });
-
- const konvaObjectGroup = konvaLayer.findOne(`.${RASTER_LAYER_OBJECT_GROUP_NAME}`);
- assert(konvaObjectGroup, `Object group not found for layer ${layerState.id}`);
-
- const objectIds = layerState.objects.map(mapId);
- // Destroy any objects that are no longer in the redux state
- for (const objectNode of konvaObjectGroup.getChildren()) {
- if (!objectIds.includes(objectNode.id())) {
- objectNode.destroy();
- }
- }
-
- for (const obj of layerState.objects) {
- if (obj.type === 'brush_line') {
- const konvaBrushLine = stage.findOne(`#${obj.id}`) ?? createBrushLine(obj, konvaObjectGroup);
- // Only update the points if they have changed.
- if (konvaBrushLine.points().length !== obj.points.length) {
- konvaBrushLine.points(obj.points);
- }
- } else if (obj.type === 'eraser_line') {
- const konvaEraserLine = stage.findOne(`#${obj.id}`) ?? createEraserLine(obj, konvaObjectGroup);
- // Only update the points if they have changed.
- if (konvaEraserLine.points().length !== obj.points.length) {
- konvaEraserLine.points(obj.points);
- }
- } else if (obj.type === 'rect_shape') {
- if (!stage.findOne(`#${obj.id}`)) {
- createRectShape(obj, konvaObjectGroup);
- }
- }
- }
-
- // Only update layer visibility if it has changed.
- if (konvaLayer.visible() !== layerState.isEnabled) {
- konvaLayer.visible(layerState.isEnabled);
- }
-
- konvaObjectGroup.opacity(layerState.opacity);
-};
-
-/**
- * Creates an initial image konva layer.
- * @param stage The konva stage
- * @param layerState The initial image layer state
- */
-const createIILayer = (stage: Konva.Stage, layerState: InitialImageLayer): Konva.Layer => {
- const konvaLayer = new Konva.Layer({
- id: layerState.id,
- name: INITIAL_IMAGE_LAYER_NAME,
- imageSmoothingEnabled: true,
- listening: false,
- });
- stage.add(konvaLayer);
- return konvaLayer;
-};
-
-/**
- * Creates the konva image for an initial image layer.
- * @param konvaLayer The konva layer
- * @param imageEl The image element
- */
-const createIILayerImage = (konvaLayer: Konva.Layer, imageEl: HTMLImageElement): Konva.Image => {
- const konvaImage = new Konva.Image({
- name: INITIAL_IMAGE_LAYER_IMAGE_NAME,
- image: imageEl,
- });
- konvaLayer.add(konvaImage);
- return konvaImage;
-};
-
-/**
- * Updates an initial image layer's attributes (width, height, opacity, visibility).
- * @param stage The konva stage
- * @param konvaImage The konva image
- * @param layerState The initial image layer state
- */
-const updateIILayerImageAttrs = (stage: Konva.Stage, konvaImage: Konva.Image, layerState: InitialImageLayer): void => {
- // Konva erroneously reports NaN for width and height when the stage is hidden. This causes errors when caching,
- // but it doesn't seem to break anything.
- // TODO(psyche): Investigate and report upstream.
- const newWidth = stage.width() / stage.scaleX();
- const newHeight = stage.height() / stage.scaleY();
- if (
- konvaImage.width() !== newWidth ||
- konvaImage.height() !== newHeight ||
- konvaImage.visible() !== layerState.isEnabled
- ) {
- konvaImage.setAttrs({
- opacity: layerState.opacity,
- scaleX: 1,
- scaleY: 1,
- width: stage.width() / stage.scaleX(),
- height: stage.height() / stage.scaleY(),
- visible: layerState.isEnabled,
- });
- }
- if (konvaImage.opacity() !== layerState.opacity) {
- konvaImage.opacity(layerState.opacity);
- }
-};
-
-/**
- * Update an initial image layer's image source when the image changes.
- * @param stage The konva stage
- * @param konvaLayer The konva layer
- * @param layerState The initial image layer state
- * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
- */
-const updateIILayerImageSource = async (
- stage: Konva.Stage,
- konvaLayer: Konva.Layer,
- layerState: InitialImageLayer,
- getImageDTO: (imageName: string) => Promise
-): Promise => {
- if (layerState.image) {
- const imageName = layerState.image.name;
- const imageDTO = await getImageDTO(imageName);
- if (!imageDTO) {
- return;
- }
- const imageEl = new Image();
- const imageId = getIILayerImageId(layerState.id, imageName);
- imageEl.onload = () => {
- // Find the existing image or create a new one - must find using the name, bc the id may have just changed
- const konvaImage =
- konvaLayer.findOne(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`) ??
- createIILayerImage(konvaLayer, imageEl);
-
- // Update the image's attributes
- konvaImage.setAttrs({
- id: imageId,
- image: imageEl,
- });
- updateIILayerImageAttrs(stage, konvaImage, layerState);
- imageEl.id = imageId;
- };
- imageEl.src = imageDTO.image_url;
- } else {
- konvaLayer.findOne(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`)?.destroy();
- }
-};
-
-/**
- * Renders an initial image layer.
- * @param stage The konva stage
- * @param layerState The initial image layer state
- * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
- */
-const renderIILayer = (
- stage: Konva.Stage,
- layerState: InitialImageLayer,
- getImageDTO: (imageName: string) => Promise
-): void => {
- const konvaLayer = stage.findOne(`#${layerState.id}`) ?? createIILayer(stage, layerState);
- const konvaImage = konvaLayer.findOne(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`);
- const canvasImageSource = konvaImage?.image();
- let imageSourceNeedsUpdate = false;
- if (canvasImageSource instanceof HTMLImageElement) {
- const image = layerState.image;
- if (image && canvasImageSource.id !== getCALayerImageId(layerState.id, image.name)) {
- imageSourceNeedsUpdate = true;
- } else if (!image) {
- imageSourceNeedsUpdate = true;
- }
- } else if (!canvasImageSource) {
- imageSourceNeedsUpdate = true;
- }
-
- if (imageSourceNeedsUpdate) {
- updateIILayerImageSource(stage, konvaLayer, layerState, getImageDTO);
- } else if (konvaImage) {
- updateIILayerImageAttrs(stage, konvaImage, layerState);
- }
-};
-
-/**
- * Creates a control adapter layer.
- * @param stage The konva stage
- * @param layerState The control adapter layer state
- */
-const createCALayer = (stage: Konva.Stage, layerState: ControlAdapterLayer): Konva.Layer => {
- const konvaLayer = new Konva.Layer({
- id: layerState.id,
- name: CA_LAYER_NAME,
- imageSmoothingEnabled: true,
- listening: false,
- });
- stage.add(konvaLayer);
- return konvaLayer;
-};
-
-/**
- * Creates a control adapter layer image.
- * @param konvaLayer The konva layer
- * @param imageEl The image element
- */
-const createCALayerImage = (konvaLayer: Konva.Layer, imageEl: HTMLImageElement): Konva.Image => {
- const konvaImage = new Konva.Image({
- name: CA_LAYER_IMAGE_NAME,
- image: imageEl,
- });
- konvaLayer.add(konvaImage);
- return konvaImage;
-};
-
-/**
- * Updates the image source for a control adapter layer. This includes loading the image from the server and updating the konva image.
- * @param stage The konva stage
- * @param konvaLayer The konva layer
- * @param layerState The control adapter layer state
- * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
- */
-const updateCALayerImageSource = async (
- stage: Konva.Stage,
- konvaLayer: Konva.Layer,
- layerState: ControlAdapterLayer,
- getImageDTO: (imageName: string) => Promise
-): Promise => {
- const image = layerState.controlAdapter.processedImage ?? layerState.controlAdapter.image;
- if (image) {
- const imageName = image.name;
- const imageDTO = await getImageDTO(imageName);
- if (!imageDTO) {
- return;
- }
- const imageEl = new Image();
- const imageId = getCALayerImageId(layerState.id, imageName);
- imageEl.onload = () => {
- // Find the existing image or create a new one - must find using the name, bc the id may have just changed
- const konvaImage =
- konvaLayer.findOne(`.${CA_LAYER_IMAGE_NAME}`) ?? createCALayerImage(konvaLayer, imageEl);
-
- // Update the image's attributes
- konvaImage.setAttrs({
- id: imageId,
- image: imageEl,
- });
- updateCALayerImageAttrs(stage, konvaImage, layerState);
- // Must cache after this to apply the filters
- konvaImage.cache();
- imageEl.id = imageId;
- };
- imageEl.src = imageDTO.image_url;
- } else {
- konvaLayer.findOne(`.${CA_LAYER_IMAGE_NAME}`)?.destroy();
- }
-};
-
-/**
- * Updates the image attributes for a control adapter layer's image (width, height, visibility, opacity, filters).
- * @param stage The konva stage
- * @param konvaImage The konva image
- * @param layerState The control adapter layer state
- */
-const updateCALayerImageAttrs = (
- stage: Konva.Stage,
- konvaImage: Konva.Image,
- layerState: ControlAdapterLayer
-): void => {
- let needsCache = false;
- // Konva erroneously reports NaN for width and height when the stage is hidden. This causes errors when caching,
- // but it doesn't seem to break anything.
- // TODO(psyche): Investigate and report upstream.
- const newWidth = stage.width() / stage.scaleX();
- const newHeight = stage.height() / stage.scaleY();
- const hasFilter = konvaImage.filters() !== null && konvaImage.filters().length > 0;
- if (
- konvaImage.width() !== newWidth ||
- konvaImage.height() !== newHeight ||
- konvaImage.visible() !== layerState.isEnabled ||
- hasFilter !== layerState.isFilterEnabled
- ) {
- konvaImage.setAttrs({
- opacity: layerState.opacity,
- scaleX: 1,
- scaleY: 1,
- width: stage.width() / stage.scaleX(),
- height: stage.height() / stage.scaleY(),
- visible: layerState.isEnabled,
- filters: layerState.isFilterEnabled ? [LightnessToAlphaFilter] : [],
- });
- needsCache = true;
- }
- if (konvaImage.opacity() !== layerState.opacity) {
- konvaImage.opacity(layerState.opacity);
- }
- if (needsCache) {
- konvaImage.cache();
- }
-};
-
-/**
- * Renders a control adapter layer. If the layer doesn't already exist, it is created. Otherwise, the layer is updated
- * with the current image source and attributes.
- * @param stage The konva stage
- * @param layerState The control adapter layer state
- * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
- */
-const renderCALayer = (
- stage: Konva.Stage,
- layerState: ControlAdapterLayer,
- getImageDTO: (imageName: string) => Promise
-): void => {
- const konvaLayer = stage.findOne(`#${layerState.id}`) ?? createCALayer(stage, layerState);
- const konvaImage = konvaLayer.findOne(`.${CA_LAYER_IMAGE_NAME}`);
- const canvasImageSource = konvaImage?.image();
- let imageSourceNeedsUpdate = false;
- if (canvasImageSource instanceof HTMLImageElement) {
- const image = layerState.controlAdapter.processedImage ?? layerState.controlAdapter.image;
- if (image && canvasImageSource.id !== getCALayerImageId(layerState.id, image.name)) {
- imageSourceNeedsUpdate = true;
- } else if (!image) {
- imageSourceNeedsUpdate = true;
- }
- } else if (!canvasImageSource) {
- imageSourceNeedsUpdate = true;
- }
-
- if (imageSourceNeedsUpdate) {
- updateCALayerImageSource(stage, konvaLayer, layerState, getImageDTO);
- } else if (konvaImage) {
- updateCALayerImageAttrs(stage, konvaImage, layerState);
- }
-};
-
-/**
- * Renders the layers on the stage.
- * @param stage The konva stage
- * @param layerStates Array of all layer states
- * @param globalMaskLayerOpacity The global mask layer opacity
- * @param tool The current tool
- * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
- * @param onLayerPosChanged Callback for when the layer's position changes
- */
-const renderLayers = (
- stage: Konva.Stage,
- layerStates: Layer[],
- globalMaskLayerOpacity: number,
- tool: Tool,
- getImageDTO: (imageName: string) => Promise,
- onLayerPosChanged?: (layerId: string, x: number, y: number) => void
-): void => {
- const layerIds = layerStates.filter(isRenderableLayer).map(mapId);
- // Remove un-rendered layers
- for (const konvaLayer of stage.find(selectRenderableLayers)) {
- if (!layerIds.includes(konvaLayer.id())) {
- konvaLayer.destroy();
- }
- }
-
- for (const layer of layerStates) {
- if (isRegionalGuidanceLayer(layer)) {
- renderRGLayer(stage, layer, globalMaskLayerOpacity, tool, onLayerPosChanged);
- }
- if (isControlAdapterLayer(layer)) {
- renderCALayer(stage, layer, getImageDTO);
- }
- if (isInitialImageLayer(layer)) {
- renderIILayer(stage, layer, getImageDTO);
- }
- if (isRasterLayer(layer)) {
- renderRasterLayer(stage, layer, tool, onLayerPosChanged);
- }
- // IP Adapter layers are not rendered
- }
-};
-
-/**
- * Creates a bounding box rect for a layer.
- * @param layerState The layer state for the layer to create the bounding box for
- * @param konvaLayer The konva layer to attach the bounding box to
- */
-const createBboxRect = (layerState: Layer, konvaLayer: Konva.Layer): Konva.Rect => {
- const rect = new Konva.Rect({
- id: getLayerBboxId(layerState.id),
- name: LAYER_BBOX_NAME,
- strokeWidth: 1,
- visible: false,
- });
- konvaLayer.add(rect);
- return rect;
-};
-
-/**
- * Renders the bounding boxes for the layers.
- * @param stage The konva stage
- * @param layerStates An array of layers to draw bboxes for
- * @param tool The current tool
- * @returns
- */
-const renderBboxes = (stage: Konva.Stage, layerStates: Layer[], tool: Tool): void => {
- // Hide all bboxes so they don't interfere with getClientRect
- for (const bboxRect of stage.find(`.${LAYER_BBOX_NAME}`)) {
- bboxRect.visible(false);
- bboxRect.listening(false);
- }
- // No selected layer or not using the move tool - nothing more to do here
- if (tool !== 'move') {
- return;
- }
-
- for (const layer of layerStates.filter(isRegionalGuidanceLayer)) {
- if (!layer.bbox) {
- continue;
- }
- const konvaLayer = stage.findOne(`#${layer.id}`);
- assert(konvaLayer, `Layer ${layer.id} not found in stage`);
-
- const bboxRect = konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(layer, konvaLayer);
-
- bboxRect.setAttrs({
- visible: !layer.bboxNeedsUpdate,
- listening: layer.isSelected,
- x: layer.bbox.x,
- y: layer.bbox.y,
- width: layer.bbox.width,
- height: layer.bbox.height,
- stroke: layer.isSelected ? BBOX_SELECTED_STROKE : '',
- });
- }
-};
-
-/**
- * Calculates the bbox of each regional guidance layer. Only calculates if the mask has changed.
- * @param stage The konva stage
- * @param layerStates An array of layers to calculate bboxes for
- * @param onBboxChanged Callback for when the bounding box changes
- */
-const updateBboxes = (
- stage: Konva.Stage,
- layerStates: Layer[],
- onBboxChanged: (layerId: string, bbox: IRect | null) => void
-): void => {
- for (const rgLayer of layerStates.filter(isRegionalGuidanceLayer)) {
- const konvaLayer = stage.findOne(`#${rgLayer.id}`);
- assert(konvaLayer, `Layer ${rgLayer.id} not found in stage`);
- // We only need to recalculate the bbox if the layer has changed
- if (rgLayer.bboxNeedsUpdate) {
- const bboxRect = konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(rgLayer, konvaLayer);
-
- // Hide the bbox while we calculate the new bbox, else the bbox will be included in the calculation
- const visible = bboxRect.visible();
- bboxRect.visible(false);
-
- if (rgLayer.objects.length === 0) {
- // No objects - no bbox to calculate
- onBboxChanged(rgLayer.id, null);
- } else {
- // Calculate the bbox by rendering the layer and checking its pixels
- onBboxChanged(rgLayer.id, getLayerBboxPixels(konvaLayer));
- }
-
- // Restore the visibility of the bbox
- bboxRect.visible(visible);
- }
- }
-};
-
-/**
- * Creates the background layer for the stage.
- * @param stage The konva stage
- */
-const createBackgroundLayer = (stage: Konva.Stage): Konva.Layer => {
- const layer = new Konva.Layer({
- id: BACKGROUND_LAYER_ID,
- });
- const background = new Konva.Rect({
- id: BACKGROUND_RECT_ID,
- x: stage.x(),
- y: 0,
- width: stage.width() / stage.scaleX(),
- height: stage.height() / stage.scaleY(),
- listening: false,
- opacity: 0.2,
- });
- layer.add(background);
- stage.add(layer);
- const image = new Image();
- image.onload = () => {
- background.fillPatternImage(image);
- };
- image.src = TRANSPARENCY_CHECKER_PATTERN;
- return layer;
-};
-
-/**
- * Renders the background layer for the stage.
- * @param stage The konva stage
- * @param width The unscaled width of the canvas
- * @param height The unscaled height of the canvas
- */
-const renderBackground = (stage: Konva.Stage, width: number, height: number): void => {
- const layer = stage.findOne(`#${BACKGROUND_LAYER_ID}`) ?? createBackgroundLayer(stage);
-
- const background = layer.findOne(`#${BACKGROUND_RECT_ID}`);
- assert(background, 'Background rect not found');
- // ensure background rect is in the top-left of the canvas
- background.absolutePosition({ x: 0, y: 0 });
-
- // set the dimensions of the background rect to match the canvas - not the stage!!!
- background.size({
- width: width / stage.scaleX(),
- height: height / stage.scaleY(),
- });
-
- // Calculate the amount the stage is moved - including the effect of scaling
- const stagePos = {
- x: -stage.x() / stage.scaleX(),
- y: -stage.y() / stage.scaleY(),
- };
-
- // Apply that movement to the fill pattern
- background.fillPatternOffset(stagePos);
-};
-
-/**
- * Arranges all layers in the z-axis by updating their z-indices.
- * @param stage The konva stage
- * @param layerIds An array of redux layer ids, in their z-index order
- */
-const arrangeLayers = (stage: Konva.Stage, layerIds: string[]): void => {
- let nextZIndex = 0;
- // Background is the first layer
- stage.findOne(`#${BACKGROUND_LAYER_ID}`)?.zIndex(nextZIndex++);
- // Then arrange the redux layers in order
- for (const layerId of layerIds) {
- stage.findOne(`#${layerId}`)?.zIndex(nextZIndex++);
- }
- // Finally, the tool preview layer is always on top
- stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.zIndex(nextZIndex++);
-};
-
-/**
- * Creates the "no layers" fallback layer
- * @param stage The konva stage
- */
-const createNoLayersMessageLayer = (stage: Konva.Stage): Konva.Layer => {
- const noLayersMessageLayer = new Konva.Layer({
- id: NO_LAYERS_MESSAGE_LAYER_ID,
- opacity: 0.7,
- listening: false,
- });
- const text = new Konva.Text({
- x: 0,
- y: 0,
- align: 'center',
- verticalAlign: 'middle',
- text: t('controlLayers.noLayersAdded', 'No Layers Added'),
- fontFamily: '"Inter Variable", sans-serif',
- fontStyle: '600',
- fill: 'white',
- });
- noLayersMessageLayer.add(text);
- stage.add(noLayersMessageLayer);
- return noLayersMessageLayer;
-};
-
-/**
- * Renders the "no layers" message when there are no layers to render
- * @param stage The konva stage
- * @param layerCount The current number of layers
- * @param width The target width of the text
- * @param height The target height of the text
- */
-const renderNoLayersMessage = (stage: Konva.Stage, layerCount: number, width: number, height: number): void => {
- const noLayersMessageLayer =
- stage.findOne(`#${NO_LAYERS_MESSAGE_LAYER_ID}`) ?? createNoLayersMessageLayer(stage);
- if (layerCount === 0) {
- noLayersMessageLayer.findOne('Text')?.setAttrs({
- width,
- height,
- fontSize: 32 / stage.scaleX(),
- });
- } else {
- noLayersMessageLayer?.destroy();
- }
-};
-
-export const renderers = {
- renderToolPreview,
- renderLayers,
- renderBboxes,
- renderBackground,
- renderNoLayersMessage,
- arrangeLayers,
- updateBboxes,
-};
-
-const DEBOUNCE_MS = 300;
-
-export const debouncedRenderers = {
- renderToolPreview: debounce(renderToolPreview, DEBOUNCE_MS),
- renderLayers: debounce(renderLayers, DEBOUNCE_MS),
- renderBboxes: debounce(renderBboxes, DEBOUNCE_MS),
- renderBackground: debounce(renderBackground, DEBOUNCE_MS),
- renderNoLayersMessage: debounce(renderNoLayersMessage, DEBOUNCE_MS),
- arrangeLayers: debounce(arrangeLayers, DEBOUNCE_MS),
- updateBboxes: debounce(updateBboxes, DEBOUNCE_MS),
-};
-
-//#region util
-const mapId = (object: { id: string }): string => object.id;
-
-/**
- * Konva selection callback to select all renderable layers. This includes RG, CA and II layers.
- */
-const selectRenderableLayers = (n: Konva.Node): boolean =>
- n.name() === RG_LAYER_NAME ||
- n.name() === CA_LAYER_NAME ||
- n.name() === INITIAL_IMAGE_LAYER_NAME ||
- n.name() === RASTER_LAYER_NAME;
-
-/**
- * Konva selection callback to select RG mask objects. This includes lines and rects.
- */
-const selectVectorMaskObjects = (node: Konva.Node): boolean => {
- return node.name() === RG_LAYER_LINE_NAME || node.name() === RG_LAYER_RECT_NAME;
-};
-//#endregion
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts
new file mode 100644
index 0000000000..d5dcfddcda
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts
@@ -0,0 +1,67 @@
+import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
+import { BACKGROUND_LAYER_ID, BACKGROUND_RECT_ID } from 'features/controlLayers/konva/naming';
+import Konva from 'konva';
+import { assert } from 'tsafe';
+
+/**
+ * The stage background is a semi-transparent checkerboard pattern. We use konva's `fillPatternImage` to apply the
+ * a data URL of the pattern image to the background rect. Some scaling and positioning is required to ensure the
+ * everything lines up correctly.
+ */
+
+/**
+ * Creates the background layer for the stage.
+ * @param stage The konva stage
+ */
+const createBackgroundLayer = (stage: Konva.Stage): Konva.Layer => {
+ const layer = new Konva.Layer({
+ id: BACKGROUND_LAYER_ID,
+ });
+ const background = new Konva.Rect({
+ id: BACKGROUND_RECT_ID,
+ x: stage.x(),
+ y: 0,
+ width: stage.width() / stage.scaleX(),
+ height: stage.height() / stage.scaleY(),
+ listening: false,
+ opacity: 0.2,
+ });
+ layer.add(background);
+ stage.add(layer);
+ const image = new Image();
+ image.onload = () => {
+ background.fillPatternImage(image);
+ };
+ image.src = TRANSPARENCY_CHECKER_PATTERN;
+ return layer;
+};
+
+/**
+ * Renders the background layer for the stage.
+ * @param stage The konva stage
+ * @param width The unscaled width of the canvas
+ * @param height The unscaled height of the canvas
+ */
+export const renderBackground = (stage: Konva.Stage, width: number, height: number): void => {
+ const layer = stage.findOne(`#${BACKGROUND_LAYER_ID}`) ?? createBackgroundLayer(stage);
+
+ const background = layer.findOne(`#${BACKGROUND_RECT_ID}`);
+ assert(background, 'Background rect not found');
+ // ensure background rect is in the top-left of the canvas
+ background.absolutePosition({ x: 0, y: 0 });
+
+ // set the dimensions of the background rect to match the canvas - not the stage!!!
+ background.size({
+ width: width / stage.scaleX(),
+ height: height / stage.scaleY(),
+ });
+
+ // Calculate the amount the stage is moved - including the effect of scaling
+ const stagePos = {
+ x: -stage.x() / stage.scaleX(),
+ y: -stage.y() / stage.scaleY(),
+ };
+
+ // Apply that movement to the fill pattern
+ background.fillPatternOffset(stagePos);
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/bbox.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/bbox.ts
similarity index 58%
rename from invokeai/frontend/web/src/features/controlLayers/konva/bbox.ts
rename to invokeai/frontend/web/src/features/controlLayers/konva/renderers/bbox.ts
index 505998cb39..869fe847d1 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/bbox.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/bbox.ts
@@ -1,10 +1,17 @@
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { imageDataToDataURL } from 'features/canvas/util/blobToDataURL';
+import { BBOX_SELECTED_STROKE } from 'features/controlLayers/konva/constants';
+import { getLayerBboxId, LAYER_BBOX_NAME, RG_LAYER_OBJECT_GROUP_NAME } from 'features/controlLayers/konva/naming';
+import type { Layer, Tool } from 'features/controlLayers/store/types';
+import { isRegionalGuidanceLayer } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { IRect } from 'konva/lib/types';
import { assert } from 'tsafe';
-import { RG_LAYER_OBJECT_GROUP_NAME } from './naming';
+/**
+ * Logic to create and render bounding boxes for layers.
+ * Some utils are included for calculating bounding boxes.
+ */
type Extents = {
minX: number;
@@ -15,7 +22,6 @@ type Extents = {
const GET_CLIENT_RECT_CONFIG = { skipTransform: true };
-//#region getImageDataBbox
/**
* Get the bounding box of an image.
* @param imageData The ImageData object to get the bounding box of.
@@ -53,9 +59,7 @@ const getImageDataBbox = (imageData: ImageData): Extents | null => {
return isEmpty ? null : { minX, minY, maxX, maxY };
};
-//#endregion
-//#region getIsolatedRGLayerClone
/**
* Clones a regional guidance konva layer onto an offscreen stage/canvas. This allows the pixel data for a given layer
* to be captured, manipulated or analyzed without interference from other layers.
@@ -92,15 +96,13 @@ const getIsolatedRGLayerClone = (layer: Konva.Layer): { stageClone: Konva.Stage;
return { stageClone, layerClone };
};
-//#endregion
-//#region getLayerBboxPixels
/**
* Get the bounding box of a regional prompt konva layer. This function has special handling for regional prompt layers.
* @param layer The konva layer to get the bounding box of.
* @param preview Whether to open a new tab displaying the rendered layer, which is used to calculate the bbox.
*/
-export const getLayerBboxPixels = (layer: Konva.Layer, preview: boolean = false): IRect | null => {
+const getLayerBboxPixels = (layer: Konva.Layer, preview: boolean = false): IRect | null => {
// To calculate the layer's bounding box, we must first export it to a pixel array, then do some math.
//
// Though it is relatively fast, we can't use Konva's `getClientRect`. It programmatically determines the rect
@@ -143,9 +145,7 @@ export const getLayerBboxPixels = (layer: Konva.Layer, preview: boolean = false)
return correctedLayerBbox;
};
-//#endregion
-//#region getLayerBboxFast
/**
* Get the bounding box of a konva layer. This function is faster than `getLayerBboxPixels` but less accurate. It
* should only be used when there are no eraser strokes or shapes in the layer.
@@ -161,4 +161,94 @@ export const getLayerBboxFast = (layer: Konva.Layer): IRect => {
height: Math.floor(bbox.height),
};
};
-//#endregion
+
+/**
+ * Creates a bounding box rect for a layer.
+ * @param layerState The layer state for the layer to create the bounding box for
+ * @param konvaLayer The konva layer to attach the bounding box to
+ */
+const createBboxRect = (layerState: Layer, konvaLayer: Konva.Layer): Konva.Rect => {
+ const rect = new Konva.Rect({
+ id: getLayerBboxId(layerState.id),
+ name: LAYER_BBOX_NAME,
+ strokeWidth: 1,
+ visible: false,
+ });
+ konvaLayer.add(rect);
+ return rect;
+};
+
+/**
+ * Calculates the bbox of each regional guidance layer. Only calculates if the mask has changed.
+ * @param stage The konva stage
+ * @param layerStates An array of layers to calculate bboxes for
+ * @param onBboxChanged Callback for when the bounding box changes
+ */
+export const updateBboxes = (
+ stage: Konva.Stage,
+ layerStates: Layer[],
+ onBboxChanged: (layerId: string, bbox: IRect | null) => void
+): void => {
+ for (const rgLayer of layerStates.filter(isRegionalGuidanceLayer)) {
+ const konvaLayer = stage.findOne(`#${rgLayer.id}`);
+ assert(konvaLayer, `Layer ${rgLayer.id} not found in stage`);
+ // We only need to recalculate the bbox if the layer has changed
+ if (rgLayer.bboxNeedsUpdate) {
+ const bboxRect = konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(rgLayer, konvaLayer);
+
+ // Hide the bbox while we calculate the new bbox, else the bbox will be included in the calculation
+ const visible = bboxRect.visible();
+ bboxRect.visible(false);
+
+ if (rgLayer.objects.length === 0) {
+ // No objects - no bbox to calculate
+ onBboxChanged(rgLayer.id, null);
+ } else {
+ // Calculate the bbox by rendering the layer and checking its pixels
+ onBboxChanged(rgLayer.id, getLayerBboxPixels(konvaLayer));
+ }
+
+ // Restore the visibility of the bbox
+ bboxRect.visible(visible);
+ }
+ }
+};
+
+/**
+ * Renders the bounding boxes for the layers.
+ * @param stage The konva stage
+ * @param layerStates An array of layers to draw bboxes for
+ * @param tool The current tool
+ * @returns
+ */
+export const renderBboxes = (stage: Konva.Stage, layerStates: Layer[], tool: Tool): void => {
+ // Hide all bboxes so they don't interfere with getClientRect
+ for (const bboxRect of stage.find(`.${LAYER_BBOX_NAME}`)) {
+ bboxRect.visible(false);
+ bboxRect.listening(false);
+ }
+ // No selected layer or not using the move tool - nothing more to do here
+ if (tool !== 'move') {
+ return;
+ }
+
+ for (const layer of layerStates.filter(isRegionalGuidanceLayer)) {
+ if (!layer.bbox) {
+ continue;
+ }
+ const konvaLayer = stage.findOne(`#${layer.id}`);
+ assert(konvaLayer, `Layer ${layer.id} not found in stage`);
+
+ const bboxRect = konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(layer, konvaLayer);
+
+ bboxRect.setAttrs({
+ visible: !layer.bboxNeedsUpdate,
+ listening: layer.isSelected,
+ x: layer.bbox.x,
+ y: layer.bbox.y,
+ width: layer.bbox.width,
+ height: layer.bbox.height,
+ stroke: layer.isSelected ? BBOX_SELECTED_STROKE : '',
+ });
+ }
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/caLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/caLayer.ts
new file mode 100644
index 0000000000..d08d0bd60e
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/caLayer.ts
@@ -0,0 +1,162 @@
+import { LightnessToAlphaFilter } from 'features/controlLayers/konva/filters';
+import { CA_LAYER_IMAGE_NAME, CA_LAYER_NAME, getCALayerImageId } from 'features/controlLayers/konva/naming';
+import type { ControlAdapterLayer } from 'features/controlLayers/store/types';
+import Konva from 'konva';
+import type { ImageDTO } from 'services/api/types';
+
+/**
+ * Logic for creating and rendering control adapter (control net & t2i adapter) layers. These layers have image objects
+ * and require some special handling to update the source and attributes as control images are swapped or processed.
+ */
+
+/**
+ * Creates a control adapter layer.
+ * @param stage The konva stage
+ * @param layerState The control adapter layer state
+ */
+const createCALayer = (stage: Konva.Stage, layerState: ControlAdapterLayer): Konva.Layer => {
+ const konvaLayer = new Konva.Layer({
+ id: layerState.id,
+ name: CA_LAYER_NAME,
+ imageSmoothingEnabled: true,
+ listening: false,
+ });
+ stage.add(konvaLayer);
+ return konvaLayer;
+};
+
+/**
+ * Creates a control adapter layer image.
+ * @param konvaLayer The konva layer
+ * @param imageEl The image element
+ */
+const createCALayerImage = (konvaLayer: Konva.Layer, imageEl: HTMLImageElement): Konva.Image => {
+ const konvaImage = new Konva.Image({
+ name: CA_LAYER_IMAGE_NAME,
+ image: imageEl,
+ });
+ konvaLayer.add(konvaImage);
+ return konvaImage;
+};
+
+/**
+ * Updates the image source for a control adapter layer. This includes loading the image from the server and updating
+ * the konva image.
+ * @param stage The konva stage
+ * @param konvaLayer The konva layer
+ * @param layerState The control adapter layer state
+ * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
+ */
+const updateCALayerImageSource = async (
+ stage: Konva.Stage,
+ konvaLayer: Konva.Layer,
+ layerState: ControlAdapterLayer,
+ getImageDTO: (imageName: string) => Promise
+): Promise => {
+ const image = layerState.controlAdapter.processedImage ?? layerState.controlAdapter.image;
+ if (image) {
+ const imageName = image.name;
+ const imageDTO = await getImageDTO(imageName);
+ if (!imageDTO) {
+ return;
+ }
+ const imageEl = new Image();
+ const imageId = getCALayerImageId(layerState.id, imageName);
+ imageEl.onload = () => {
+ // Find the existing image or create a new one - must find using the name, bc the id may have just changed
+ const konvaImage =
+ konvaLayer.findOne(`.${CA_LAYER_IMAGE_NAME}`) ?? createCALayerImage(konvaLayer, imageEl);
+
+ // Update the image's attributes
+ konvaImage.setAttrs({
+ id: imageId,
+ image: imageEl,
+ });
+ updateCALayerImageAttrs(stage, konvaImage, layerState);
+ // Must cache after this to apply the filters
+ konvaImage.cache();
+ imageEl.id = imageId;
+ };
+ imageEl.src = imageDTO.image_url;
+ } else {
+ konvaLayer.findOne(`.${CA_LAYER_IMAGE_NAME}`)?.destroy();
+ }
+};
+
+/**
+ * Updates the image attributes for a control adapter layer's image (width, height, visibility, opacity, filters).
+ * @param stage The konva stage
+ * @param konvaImage The konva image
+ * @param layerState The control adapter layer state
+ */
+
+const updateCALayerImageAttrs = (
+ stage: Konva.Stage,
+ konvaImage: Konva.Image,
+ layerState: ControlAdapterLayer
+): void => {
+ let needsCache = false;
+ // Konva erroneously reports NaN for width and height when the stage is hidden. This causes errors when caching,
+ // but it doesn't seem to break anything.
+ // TODO(psyche): Investigate and report upstream.
+ const newWidth = stage.width() / stage.scaleX();
+ const newHeight = stage.height() / stage.scaleY();
+ const hasFilter = konvaImage.filters() !== null && konvaImage.filters().length > 0;
+ if (
+ konvaImage.width() !== newWidth ||
+ konvaImage.height() !== newHeight ||
+ konvaImage.visible() !== layerState.isEnabled ||
+ hasFilter !== layerState.isFilterEnabled
+ ) {
+ konvaImage.setAttrs({
+ opacity: layerState.opacity,
+ scaleX: 1,
+ scaleY: 1,
+ width: stage.width() / stage.scaleX(),
+ height: stage.height() / stage.scaleY(),
+ visible: layerState.isEnabled,
+ filters: layerState.isFilterEnabled ? [LightnessToAlphaFilter] : [],
+ });
+ needsCache = true;
+ }
+ if (konvaImage.opacity() !== layerState.opacity) {
+ konvaImage.opacity(layerState.opacity);
+ }
+ if (needsCache) {
+ konvaImage.cache();
+ }
+};
+
+/**
+ * Renders a control adapter layer. If the layer doesn't already exist, it is created. Otherwise, the layer is updated
+ * with the current image source and attributes.
+ * @param stage The konva stage
+ * @param layerState The control adapter layer state
+ * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
+ */
+export const renderCALayer = (
+ stage: Konva.Stage,
+ layerState: ControlAdapterLayer,
+ getImageDTO: (imageName: string) => Promise
+): void => {
+ const konvaLayer = stage.findOne(`#${layerState.id}`) ?? createCALayer(stage, layerState);
+ const konvaImage = konvaLayer.findOne(`.${CA_LAYER_IMAGE_NAME}`);
+ const canvasImageSource = konvaImage?.image();
+ let imageSourceNeedsUpdate = false;
+ if (canvasImageSource instanceof HTMLImageElement) {
+ const image = layerState.controlAdapter.processedImage ?? layerState.controlAdapter.image;
+ if (image && canvasImageSource.id !== getCALayerImageId(layerState.id, image.name)) {
+ imageSourceNeedsUpdate = true;
+ } else if (!image) {
+ imageSourceNeedsUpdate = true;
+ }
+ } else if (!canvasImageSource) {
+ imageSourceNeedsUpdate = true;
+ }
+
+ if (imageSourceNeedsUpdate) {
+ updateCALayerImageSource(stage, konvaLayer, layerState, getImageDTO);
+ } else if (konvaImage) {
+ updateCALayerImageAttrs(stage, konvaImage, layerState);
+ }
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/iiLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/iiLayer.ts
new file mode 100644
index 0000000000..cf1b69d666
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/iiLayer.ts
@@ -0,0 +1,149 @@
+import {
+ getCALayerImageId,
+ getIILayerImageId,
+ INITIAL_IMAGE_LAYER_IMAGE_NAME,
+ INITIAL_IMAGE_LAYER_NAME,
+} from 'features/controlLayers/konva/naming';
+import type { InitialImageLayer } from 'features/controlLayers/store/types';
+import Konva from 'konva';
+import type { ImageDTO } from 'services/api/types';
+
+/**
+ * Logic for creating and rendering initial image layers. Well, just the one, actually, because it's a singleton.
+ * TODO(psyche): Raster layers effectively supersede the initial image layer type.
+ */
+
+/**
+ * Creates an initial image konva layer.
+ * @param stage The konva stage
+ * @param layerState The initial image layer state
+ */
+const createIILayer = (stage: Konva.Stage, layerState: InitialImageLayer): Konva.Layer => {
+ const konvaLayer = new Konva.Layer({
+ id: layerState.id,
+ name: INITIAL_IMAGE_LAYER_NAME,
+ imageSmoothingEnabled: true,
+ listening: false,
+ });
+ stage.add(konvaLayer);
+ return konvaLayer;
+};
+
+/**
+ * Creates the konva image for an initial image layer.
+ * @param konvaLayer The konva layer
+ * @param imageEl The image element
+ */
+const createIILayerImage = (konvaLayer: Konva.Layer, imageEl: HTMLImageElement): Konva.Image => {
+ const konvaImage = new Konva.Image({
+ name: INITIAL_IMAGE_LAYER_IMAGE_NAME,
+ image: imageEl,
+ });
+ konvaLayer.add(konvaImage);
+ return konvaImage;
+};
+
+/**
+ * Updates an initial image layer's attributes (width, height, opacity, visibility).
+ * @param stage The konva stage
+ * @param konvaImage The konva image
+ * @param layerState The initial image layer state
+ */
+const updateIILayerImageAttrs = (stage: Konva.Stage, konvaImage: Konva.Image, layerState: InitialImageLayer): void => {
+ // Konva erroneously reports NaN for width and height when the stage is hidden. This causes errors when caching,
+ // but it doesn't seem to break anything.
+ // TODO(psyche): Investigate and report upstream.
+ const newWidth = stage.width() / stage.scaleX();
+ const newHeight = stage.height() / stage.scaleY();
+ if (
+ konvaImage.width() !== newWidth ||
+ konvaImage.height() !== newHeight ||
+ konvaImage.visible() !== layerState.isEnabled
+ ) {
+ konvaImage.setAttrs({
+ opacity: layerState.opacity,
+ scaleX: 1,
+ scaleY: 1,
+ width: stage.width() / stage.scaleX(),
+ height: stage.height() / stage.scaleY(),
+ visible: layerState.isEnabled,
+ });
+ }
+ if (konvaImage.opacity() !== layerState.opacity) {
+ konvaImage.opacity(layerState.opacity);
+ }
+};
+
+/**
+ * Update an initial image layer's image source when the image changes.
+ * @param stage The konva stage
+ * @param konvaLayer The konva layer
+ * @param layerState The initial image layer state
+ * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
+ */
+const updateIILayerImageSource = async (
+ stage: Konva.Stage,
+ konvaLayer: Konva.Layer,
+ layerState: InitialImageLayer,
+ getImageDTO: (imageName: string) => Promise
+): Promise => {
+ if (layerState.image) {
+ const imageName = layerState.image.name;
+ const imageDTO = await getImageDTO(imageName);
+ if (!imageDTO) {
+ return;
+ }
+ const imageEl = new Image();
+ const imageId = getIILayerImageId(layerState.id, imageName);
+ imageEl.onload = () => {
+ // Find the existing image or create a new one - must find using the name, bc the id may have just changed
+ const konvaImage =
+ konvaLayer.findOne(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`) ??
+ createIILayerImage(konvaLayer, imageEl);
+
+ // Update the image's attributes
+ konvaImage.setAttrs({
+ id: imageId,
+ image: imageEl,
+ });
+ updateIILayerImageAttrs(stage, konvaImage, layerState);
+ imageEl.id = imageId;
+ };
+ imageEl.src = imageDTO.image_url;
+ } else {
+ konvaLayer.findOne(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`)?.destroy();
+ }
+};
+
+/**
+ * Renders an initial image layer.
+ * @param stage The konva stage
+ * @param layerState The initial image layer state
+ * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
+ */
+export const renderIILayer = (
+ stage: Konva.Stage,
+ layerState: InitialImageLayer,
+ getImageDTO: (imageName: string) => Promise
+): void => {
+ const konvaLayer = stage.findOne(`#${layerState.id}`) ?? createIILayer(stage, layerState);
+ const konvaImage = konvaLayer.findOne(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`);
+ const canvasImageSource = konvaImage?.image();
+ let imageSourceNeedsUpdate = false;
+ if (canvasImageSource instanceof HTMLImageElement) {
+ const image = layerState.image;
+ if (image && canvasImageSource.id !== getCALayerImageId(layerState.id, image.name)) {
+ imageSourceNeedsUpdate = true;
+ } else if (!image) {
+ imageSourceNeedsUpdate = true;
+ }
+ } else if (!canvasImageSource) {
+ imageSourceNeedsUpdate = true;
+ }
+
+ if (imageSourceNeedsUpdate) {
+ updateIILayerImageSource(stage, konvaLayer, layerState, getImageDTO);
+ } else if (konvaImage) {
+ updateIILayerImageAttrs(stage, konvaImage, layerState);
+ }
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts
new file mode 100644
index 0000000000..8243b81504
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts
@@ -0,0 +1,118 @@
+import { DEBOUNCE_MS } from 'features/controlLayers/konva/constants';
+import { BACKGROUND_LAYER_ID, TOOL_PREVIEW_LAYER_ID } from 'features/controlLayers/konva/naming';
+import { renderBackground } from 'features/controlLayers/konva/renderers/background';
+import { renderBboxes, updateBboxes } from 'features/controlLayers/konva/renderers/bbox';
+import { renderCALayer } from 'features/controlLayers/konva/renderers/caLayer';
+import { renderIILayer } from 'features/controlLayers/konva/renderers/iiLayer';
+import { renderNoLayersMessage } from 'features/controlLayers/konva/renderers/noLayersMessage';
+import { renderRasterLayer } from 'features/controlLayers/konva/renderers/rasterLayer';
+import { renderRGLayer } from 'features/controlLayers/konva/renderers/rgLayer';
+import { renderToolPreview } from 'features/controlLayers/konva/renderers/toolPreview';
+import { mapId, selectRenderableLayers } from 'features/controlLayers/konva/util';
+import type { Layer, Tool } from 'features/controlLayers/store/types';
+import {
+ isControlAdapterLayer,
+ isInitialImageLayer,
+ isRasterLayer,
+ isRegionalGuidanceLayer,
+ isRenderableLayer,
+} from 'features/controlLayers/store/types';
+import type Konva from 'konva';
+import { debounce } from 'lodash-es';
+import type { ImageDTO } from 'services/api/types';
+
+/**
+ * Logic for rendering arranging and rendering all layers.
+ */
+
+/**
+ * Arranges all layers in the z-axis by updating their z-indices.
+ * @param stage The konva stage
+ * @param layerIds An array of redux layer ids, in their z-index order
+ */
+const arrangeLayers = (stage: Konva.Stage, layerIds: string[]): void => {
+ let nextZIndex = 0;
+ // Background is the first layer
+ stage.findOne(`#${BACKGROUND_LAYER_ID}`)?.zIndex(nextZIndex++);
+ // Then arrange the redux layers in order
+ for (const layerId of layerIds) {
+ stage.findOne(`#${layerId}`)?.zIndex(nextZIndex++);
+ }
+ // Finally, the tool preview layer is always on top
+ stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.zIndex(nextZIndex++);
+};
+
+/**
+ * Renders the layers on the stage.
+ * @param stage The konva stage
+ * @param layerStates Array of all layer states
+ * @param globalMaskLayerOpacity The global mask layer opacity
+ * @param tool The current tool
+ * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
+ * @param onLayerPosChanged Callback for when the layer's position changes
+ */
+const renderLayers = (
+ stage: Konva.Stage,
+ layerStates: Layer[],
+ globalMaskLayerOpacity: number,
+ tool: Tool,
+ getImageDTO: (imageName: string) => Promise,
+ onLayerPosChanged?: (layerId: string, x: number, y: number) => void
+): void => {
+ const layerIds = layerStates.filter(isRenderableLayer).map(mapId);
+ // Remove un-rendered layers
+ for (const konvaLayer of stage.find(selectRenderableLayers)) {
+ if (!layerIds.includes(konvaLayer.id())) {
+ konvaLayer.destroy();
+ }
+ }
+
+ for (const layer of layerStates) {
+ if (isRegionalGuidanceLayer(layer)) {
+ renderRGLayer(stage, layer, globalMaskLayerOpacity, tool, onLayerPosChanged);
+ }
+ if (isControlAdapterLayer(layer)) {
+ renderCALayer(stage, layer, getImageDTO);
+ }
+ if (isInitialImageLayer(layer)) {
+ renderIILayer(stage, layer, getImageDTO);
+ }
+ if (isRasterLayer(layer)) {
+ renderRasterLayer(stage, layer, tool, onLayerPosChanged);
+ }
+ // IP Adapter layers are not rendered
+ }
+};
+
+/**
+ * All the renderers for the Konva stage.
+ */
+export const renderers = {
+ renderToolPreview,
+ renderLayers,
+ renderBboxes,
+ renderBackground,
+ renderNoLayersMessage,
+ arrangeLayers,
+ updateBboxes,
+};
+
+/**
+ * Gets the renderers with debouncing applied.
+ * @param ms The debounce time in milliseconds
+ * @returns The renderers with debouncing applied
+ */
+const getDebouncedRenderers = (ms = DEBOUNCE_MS): typeof renderers => ({
+ renderToolPreview: debounce(renderToolPreview, ms),
+ renderLayers: debounce(renderLayers, ms),
+ renderBboxes: debounce(renderBboxes, ms),
+ renderBackground: debounce(renderBackground, ms),
+ renderNoLayersMessage: debounce(renderNoLayersMessage, ms),
+ arrangeLayers: debounce(arrangeLayers, ms),
+ updateBboxes: debounce(updateBboxes, ms),
+});
+
+/**
+ * All the renderers for the Konva stage, debounced.
+ */
+export const debouncedRenderers: typeof renderers = getDebouncedRenderers();
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/noLayersMessage.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/noLayersMessage.ts
new file mode 100644
index 0000000000..eae41d70d8
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/noLayersMessage.ts
@@ -0,0 +1,53 @@
+import { NO_LAYERS_MESSAGE_LAYER_ID } from 'features/controlLayers/konva/naming';
+import { t } from 'i18next';
+import Konva from 'konva';
+
+/**
+ * Logic for creating and rendering a fallback message when there are no layers to render.
+ */
+
+/**
+ * Creates the "no layers" fallback layer
+ * @param stage The konva stage
+ */
+const createNoLayersMessageLayer = (stage: Konva.Stage): Konva.Layer => {
+ const noLayersMessageLayer = new Konva.Layer({
+ id: NO_LAYERS_MESSAGE_LAYER_ID,
+ opacity: 0.7,
+ listening: false,
+ });
+ const text = new Konva.Text({
+ x: 0,
+ y: 0,
+ align: 'center',
+ verticalAlign: 'middle',
+ text: t('controlLayers.noLayersAdded', 'No Layers Added'),
+ fontFamily: '"Inter Variable", sans-serif',
+ fontStyle: '600',
+ fill: 'white',
+ });
+ noLayersMessageLayer.add(text);
+ stage.add(noLayersMessageLayer);
+ return noLayersMessageLayer;
+};
+
+/**
+ * Renders the "no layers" message when there are no layers to render
+ * @param stage The konva stage
+ * @param layerCount The current number of layers
+ * @param width The target width of the text
+ * @param height The target height of the text
+ */
+export const renderNoLayersMessage = (stage: Konva.Stage, layerCount: number, width: number, height: number): void => {
+ const noLayersMessageLayer =
+ stage.findOne(`#${NO_LAYERS_MESSAGE_LAYER_ID}`) ?? createNoLayersMessageLayer(stage);
+ if (layerCount === 0) {
+ noLayersMessageLayer.findOne('Text')?.setAttrs({
+ width,
+ height,
+ fontSize: 32 / stage.scaleX(),
+ });
+ } else {
+ noLayersMessageLayer?.destroy();
+ }
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/objects.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/objects.ts
new file mode 100644
index 0000000000..50d23bd63c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/objects.ts
@@ -0,0 +1,77 @@
+import { rgbaColorToString } from 'features/canvas/util/colorToString';
+import { RG_LAYER_LINE_NAME, RG_LAYER_RECT_NAME } from 'features/controlLayers/konva/naming';
+import type { BrushLine, EraserLine, RectShape } from 'features/controlLayers/store/types';
+import { DEFAULT_RGBA_COLOR } from 'features/controlLayers/store/types';
+import Konva from 'konva';
+
+/**
+ * Utilities to create various konva objects from layer state. These are used by both the raster and regional guidance
+ * layers types.
+ */
+
+/**
+ * Creates a konva line for a brush line.
+ * @param brushLine The brush line state
+ * @param layerObjectGroup The konva layer's object group to add the line to
+ */
+export const createBrushLine = (brushLine: BrushLine, layerObjectGroup: Konva.Group): Konva.Line => {
+ const konvaLine = new Konva.Line({
+ id: brushLine.id,
+ key: brushLine.id,
+ name: RG_LAYER_LINE_NAME,
+ strokeWidth: brushLine.strokeWidth,
+ tension: 0,
+ lineCap: 'round',
+ lineJoin: 'round',
+ shadowForStrokeEnabled: false,
+ globalCompositeOperation: 'source-over',
+ listening: false,
+ stroke: rgbaColorToString(brushLine.color),
+ });
+ layerObjectGroup.add(konvaLine);
+ return konvaLine;
+};
+
+/**
+ * Creates a konva line for a eraser line.
+ * @param eraserLine The eraser line state
+ * @param layerObjectGroup The konva layer's object group to add the line to
+ */
+export const createEraserLine = (eraserLine: EraserLine, layerObjectGroup: Konva.Group): Konva.Line => {
+ const konvaLine = new Konva.Line({
+ id: eraserLine.id,
+ key: eraserLine.id,
+ name: RG_LAYER_LINE_NAME,
+ strokeWidth: eraserLine.strokeWidth,
+ tension: 0,
+ lineCap: 'round',
+ lineJoin: 'round',
+ shadowForStrokeEnabled: false,
+ globalCompositeOperation: 'destination-out',
+ listening: false,
+ stroke: rgbaColorToString(DEFAULT_RGBA_COLOR),
+ });
+ layerObjectGroup.add(konvaLine);
+ return konvaLine;
+};
+
+/**
+ * Creates a konva rect for a rect shape.
+ * @param rectShape The rect shape state
+ * @param layerObjectGroup The konva layer's object group to add the line to
+ */
+export const createRectShape = (rectShape: RectShape, layerObjectGroup: Konva.Group): Konva.Rect => {
+ const konvaRect = new Konva.Rect({
+ id: rectShape.id,
+ key: rectShape.id,
+ name: RG_LAYER_RECT_NAME,
+ x: rectShape.x,
+ y: rectShape.y,
+ width: rectShape.width,
+ height: rectShape.height,
+ listening: false,
+ fill: rgbaColorToString(rectShape.color),
+ });
+ layerObjectGroup.add(konvaRect);
+ return konvaRect;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rasterLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rasterLayer.ts
new file mode 100644
index 0000000000..81251b5f2b
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rasterLayer.ts
@@ -0,0 +1,135 @@
+import {
+ getObjectGroupId,
+ RASTER_LAYER_NAME,
+ RASTER_LAYER_OBJECT_GROUP_NAME,
+} from 'features/controlLayers/konva/naming';
+import { createBrushLine, createEraserLine, createRectShape } from 'features/controlLayers/konva/renderers/objects';
+import { getScaledFlooredCursorPosition, mapId } from 'features/controlLayers/konva/util';
+import type { RasterLayer, Tool } from 'features/controlLayers/store/types';
+import Konva from 'konva';
+import { assert } from 'tsafe';
+import { v4 as uuidv4 } from 'uuid';
+
+/**
+ * Logic for creating and rendering raster layers.
+ */
+
+/**
+ * Creates a raster layer.
+ * @param stage The konva stage
+ * @param layerState The raster layer state
+ * @param onLayerPosChanged Callback for when the layer's position changes
+ */
+const createRasterLayer = (
+ stage: Konva.Stage,
+ layerState: RasterLayer,
+ onLayerPosChanged?: (layerId: string, x: number, y: number) => void
+): Konva.Layer => {
+ // This layer hasn't been added to the konva state yet
+ const konvaLayer = new Konva.Layer({
+ id: layerState.id,
+ name: RASTER_LAYER_NAME,
+ draggable: true,
+ dragDistance: 0,
+ });
+
+ // When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing
+ // the position - we do not need to call this on the `dragmove` event.
+ if (onLayerPosChanged) {
+ konvaLayer.on('dragend', function (e) {
+ onLayerPosChanged(layerState.id, Math.floor(e.target.x()), Math.floor(e.target.y()));
+ });
+ }
+
+ // The dragBoundFunc limits how far the layer can be dragged
+ konvaLayer.dragBoundFunc(function (pos) {
+ const cursorPos = getScaledFlooredCursorPosition(stage);
+ if (!cursorPos) {
+ return this.getAbsolutePosition();
+ }
+ // Prevent the user from dragging the layer out of the stage bounds by constaining the cursor position to the stage bounds
+ if (
+ cursorPos.x < 0 ||
+ cursorPos.x > stage.width() / stage.scaleX() ||
+ cursorPos.y < 0 ||
+ cursorPos.y > stage.height() / stage.scaleY()
+ ) {
+ return this.getAbsolutePosition();
+ }
+ return pos;
+ });
+
+ // The object group holds all of the layer's objects (e.g. lines and rects)
+ const konvaObjectGroup = new Konva.Group({
+ id: getObjectGroupId(layerState.id, uuidv4()),
+ name: RASTER_LAYER_OBJECT_GROUP_NAME,
+ listening: false,
+ });
+ konvaLayer.add(konvaObjectGroup);
+
+ stage.add(konvaLayer);
+
+ return konvaLayer;
+};
+
+/**
+ * Renders a regional guidance layer.
+ * @param stage The konva stage
+ * @param layerState The regional guidance layer state
+ * @param tool The current tool
+ * @param onLayerPosChanged Callback for when the layer's position changes
+ */
+export const renderRasterLayer = (
+ stage: Konva.Stage,
+ layerState: RasterLayer,
+ tool: Tool,
+ onLayerPosChanged?: (layerId: string, x: number, y: number) => void
+): void => {
+ const konvaLayer =
+ stage.findOne(`#${layerState.id}`) ?? createRasterLayer(stage, layerState, onLayerPosChanged);
+
+ // Update the layer's position and listening state
+ konvaLayer.setAttrs({
+ listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events
+ x: Math.floor(layerState.x),
+ y: Math.floor(layerState.y),
+ });
+
+ const konvaObjectGroup = konvaLayer.findOne(`.${RASTER_LAYER_OBJECT_GROUP_NAME}`);
+ assert(konvaObjectGroup, `Object group not found for layer ${layerState.id}`);
+
+ const objectIds = layerState.objects.map(mapId);
+ // Destroy any objects that are no longer in the redux state
+ for (const objectNode of konvaObjectGroup.getChildren()) {
+ if (!objectIds.includes(objectNode.id())) {
+ objectNode.destroy();
+ }
+ }
+
+ for (const obj of layerState.objects) {
+ if (obj.type === 'brush_line') {
+ const konvaBrushLine = stage.findOne(`#${obj.id}`) ?? createBrushLine(obj, konvaObjectGroup);
+ // Only update the points if they have changed.
+ if (konvaBrushLine.points().length !== obj.points.length) {
+ konvaBrushLine.points(obj.points);
+ }
+ } else if (obj.type === 'eraser_line') {
+ const konvaEraserLine = stage.findOne(`#${obj.id}`) ?? createEraserLine(obj, konvaObjectGroup);
+ // Only update the points if they have changed.
+ if (konvaEraserLine.points().length !== obj.points.length) {
+ konvaEraserLine.points(obj.points);
+ }
+ } else if (obj.type === 'rect_shape') {
+ if (!stage.findOne(`#${obj.id}`)) {
+ createRectShape(obj, konvaObjectGroup);
+ }
+ }
+ }
+
+ // Only update layer visibility if it has changed.
+ if (konvaLayer.visible() !== layerState.isEnabled) {
+ konvaLayer.visible(layerState.isEnabled);
+ }
+
+ konvaObjectGroup.opacity(layerState.opacity);
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rgLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rgLayer.ts
new file mode 100644
index 0000000000..471f23ac5a
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rgLayer.ts
@@ -0,0 +1,229 @@
+import { rgbColorToString } from 'features/canvas/util/colorToString';
+import {
+ COMPOSITING_RECT_NAME,
+ getObjectGroupId,
+ RG_LAYER_NAME,
+ RG_LAYER_OBJECT_GROUP_NAME,
+} from 'features/controlLayers/konva/naming';
+import { getLayerBboxFast } from 'features/controlLayers/konva/renderers/bbox';
+import { createBrushLine, createEraserLine, createRectShape } from 'features/controlLayers/konva/renderers/objects';
+import { getScaledFlooredCursorPosition, mapId, selectVectorMaskObjects } from 'features/controlLayers/konva/util';
+import type { RegionalGuidanceLayer, Tool } from 'features/controlLayers/store/types';
+import Konva from 'konva';
+import { assert } from 'tsafe';
+import { v4 as uuidv4 } from 'uuid';
+
+/**
+ * Logic for creating and rendering regional guidance layers.
+ *
+ * Some special handling is needed to render layer opacity correctly using a "compositing rect". See the comments
+ * in `renderRGLayer`.
+ */
+
+/**
+ * Creates the "compositing rect" for a regional guidance layer.
+ * @param konvaLayer The konva layer
+ */
+const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => {
+ const compositingRect = new Konva.Rect({ name: COMPOSITING_RECT_NAME, listening: false });
+ konvaLayer.add(compositingRect);
+ return compositingRect;
+};
+
+/**
+ * Creates a regional guidance layer.
+ * @param stage The konva stage
+ * @param layerState The regional guidance layer state
+ * @param onLayerPosChanged Callback for when the layer's position changes
+ */
+const createRGLayer = (
+ stage: Konva.Stage,
+ layerState: RegionalGuidanceLayer,
+ onLayerPosChanged?: (layerId: string, x: number, y: number) => void
+): Konva.Layer => {
+ // This layer hasn't been added to the konva state yet
+ const konvaLayer = new Konva.Layer({
+ id: layerState.id,
+ name: RG_LAYER_NAME,
+ draggable: true,
+ dragDistance: 0,
+ });
+
+ // When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing
+ // the position - we do not need to call this on the `dragmove` event.
+ if (onLayerPosChanged) {
+ konvaLayer.on('dragend', function (e) {
+ onLayerPosChanged(layerState.id, Math.floor(e.target.x()), Math.floor(e.target.y()));
+ });
+ }
+
+ // The dragBoundFunc limits how far the layer can be dragged
+ konvaLayer.dragBoundFunc(function (pos) {
+ const cursorPos = getScaledFlooredCursorPosition(stage);
+ if (!cursorPos) {
+ return this.getAbsolutePosition();
+ }
+ // Prevent the user from dragging the layer out of the stage bounds by constaining the cursor position to the stage bounds
+ if (
+ cursorPos.x < 0 ||
+ cursorPos.x > stage.width() / stage.scaleX() ||
+ cursorPos.y < 0 ||
+ cursorPos.y > stage.height() / stage.scaleY()
+ ) {
+ return this.getAbsolutePosition();
+ }
+ return pos;
+ });
+
+ // The object group holds all of the layer's objects (e.g. lines and rects)
+ const konvaObjectGroup = new Konva.Group({
+ id: getObjectGroupId(layerState.id, uuidv4()),
+ name: RG_LAYER_OBJECT_GROUP_NAME,
+ listening: false,
+ });
+ konvaLayer.add(konvaObjectGroup);
+
+ stage.add(konvaLayer);
+
+ return konvaLayer;
+};
+
+/**
+ * Renders a raster layer.
+ * @param stage The konva stage
+ * @param layerState The regional guidance layer state
+ * @param globalMaskLayerOpacity The global mask layer opacity
+ * @param tool The current tool
+ * @param onLayerPosChanged Callback for when the layer's position changes
+ */
+export const renderRGLayer = (
+ stage: Konva.Stage,
+ layerState: RegionalGuidanceLayer,
+ globalMaskLayerOpacity: number,
+ tool: Tool,
+ onLayerPosChanged?: (layerId: string, x: number, y: number) => void
+): void => {
+ const konvaLayer =
+ stage.findOne(`#${layerState.id}`) ?? createRGLayer(stage, layerState, onLayerPosChanged);
+
+ // Update the layer's position and listening state
+ konvaLayer.setAttrs({
+ listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events
+ x: Math.floor(layerState.x),
+ y: Math.floor(layerState.y),
+ });
+
+ // Convert the color to a string, stripping the alpha - the object group will handle opacity.
+ const rgbColor = rgbColorToString(layerState.previewColor);
+
+ const konvaObjectGroup = konvaLayer.findOne(`.${RG_LAYER_OBJECT_GROUP_NAME}`);
+ assert(konvaObjectGroup, `Object group not found for layer ${layerState.id}`);
+
+ // We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required.
+ let groupNeedsCache = false;
+
+ const objectIds = layerState.objects.map(mapId);
+ // Destroy any objects that are no longer in the redux state
+ for (const objectNode of konvaObjectGroup.find(selectVectorMaskObjects)) {
+ if (!objectIds.includes(objectNode.id())) {
+ objectNode.destroy();
+ groupNeedsCache = true;
+ }
+ }
+
+ for (const obj of layerState.objects) {
+ if (obj.type === 'brush_line') {
+ const konvaBrushLine = stage.findOne(`#${obj.id}`) ?? createBrushLine(obj, konvaObjectGroup);
+
+ // Only update the points if they have changed. The point values are never mutated, they are only added to the
+ // array, so checking the length is sufficient to determine if we need to re-cache.
+ if (konvaBrushLine.points().length !== obj.points.length) {
+ konvaBrushLine.points(obj.points);
+ groupNeedsCache = true;
+ }
+ // Only update the color if it has changed.
+ if (konvaBrushLine.stroke() !== rgbColor) {
+ konvaBrushLine.stroke(rgbColor);
+ groupNeedsCache = true;
+ }
+ } else if (obj.type === 'eraser_line') {
+ const konvaEraserLine = stage.findOne(`#${obj.id}`) ?? createEraserLine(obj, konvaObjectGroup);
+
+ // Only update the points if they have changed. The point values are never mutated, they are only added to the
+ // array, so checking the length is sufficient to determine if we need to re-cache.
+ if (konvaEraserLine.points().length !== obj.points.length) {
+ konvaEraserLine.points(obj.points);
+ groupNeedsCache = true;
+ }
+ // Only update the color if it has changed.
+ if (konvaEraserLine.stroke() !== rgbColor) {
+ konvaEraserLine.stroke(rgbColor);
+ groupNeedsCache = true;
+ }
+ } else if (obj.type === 'rect_shape') {
+ const konvaRectShape = stage.findOne(`#${obj.id}`) ?? createRectShape(obj, konvaObjectGroup);
+
+ // Only update the color if it has changed.
+ if (konvaRectShape.fill() !== rgbColor) {
+ konvaRectShape.fill(rgbColor);
+ groupNeedsCache = true;
+ }
+ }
+ }
+
+ // Only update layer visibility if it has changed.
+ if (konvaLayer.visible() !== layerState.isEnabled) {
+ konvaLayer.visible(layerState.isEnabled);
+ groupNeedsCache = true;
+ }
+
+ if (konvaObjectGroup.getChildren().length === 0) {
+ // No objects - clear the cache to reset the previous pixel data
+ konvaObjectGroup.clearCache();
+ return;
+ }
+
+ const compositingRect =
+ konvaLayer.findOne(`.${COMPOSITING_RECT_NAME}`) ?? createCompositingRect(konvaLayer);
+
+ /**
+ * When the group is selected, we use a rect of the selected preview color, composited over the shapes. This allows
+ * shapes to render as a "raster" layer with all pixels drawn at the same color and opacity.
+ *
+ * Without this special handling, each shape is drawn individually with the given opacity, atop the other shapes. The
+ * effect is like if you have a Photoshop Group consisting of many shapes, each of which has the given opacity.
+ * Overlapping shapes will have their colors blended together, and the final color is the result of all the shapes.
+ *
+ * Instead, with the special handling, the effect is as if you drew all the shapes at 100% opacity, flattened them to
+ * a single raster image, and _then_ applied the 50% opacity.
+ */
+ if (layerState.isSelected && tool !== 'move') {
+ // We must clear the cache first so Konva will re-draw the group with the new compositing rect
+ if (konvaObjectGroup.isCached()) {
+ konvaObjectGroup.clearCache();
+ }
+ // The user is allowed to reduce mask opacity to 0, but we need the opacity for the compositing rect to work
+ konvaObjectGroup.opacity(1);
+
+ compositingRect.setAttrs({
+ // The rect should be the size of the layer - use the fast method if we don't have a pixel-perfect bbox already
+ ...(!layerState.bboxNeedsUpdate && layerState.bbox ? layerState.bbox : getLayerBboxFast(konvaLayer)),
+ fill: rgbColor,
+ opacity: globalMaskLayerOpacity,
+ // 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: konvaObjectGroup.getChildren().length,
+ });
+ } else {
+ // The compositing rect should only be shown when the layer is selected.
+ compositingRect.visible(false);
+ // Cache only if needed - or if we are on this code path and _don't_ have a cache
+ if (groupNeedsCache || !konvaObjectGroup.isCached()) {
+ konvaObjectGroup.cache();
+ }
+ // Updating group opacity does not require re-caching
+ konvaObjectGroup.opacity(globalMaskLayerOpacity);
+ }
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/toolPreview.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/toolPreview.ts
new file mode 100644
index 0000000000..ae085f8ad8
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/toolPreview.ts
@@ -0,0 +1,161 @@
+import { rgbaColorToString } from 'features/canvas/util/colorToString';
+import { BRUSH_BORDER_INNER_COLOR, BRUSH_BORDER_OUTER_COLOR } from 'features/controlLayers/konva/constants';
+import {
+ TOOL_PREVIEW_BRUSH_BORDER_INNER_ID,
+ TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID,
+ TOOL_PREVIEW_BRUSH_FILL_ID,
+ TOOL_PREVIEW_BRUSH_GROUP_ID,
+ TOOL_PREVIEW_LAYER_ID,
+ TOOL_PREVIEW_RECT_ID,
+} from 'features/controlLayers/konva/naming';
+import { selectRenderableLayers, snapPosToStage } from 'features/controlLayers/konva/util';
+import type { Layer, RgbaColor, Tool } from 'features/controlLayers/store/types';
+import Konva from 'konva';
+import type { Vector2d } from 'konva/lib/types';
+import { assert } from 'tsafe';
+
+/**
+ * Logic to create and render the singleton tool preview layer.
+ */
+
+/**
+ * Creates the singleton tool preview layer and all its objects.
+ * @param stage The konva stage
+ */
+const createToolPreviewLayer = (stage: Konva.Stage): Konva.Layer => {
+ // Initialize the brush preview layer & add to the stage
+ const toolPreviewLayer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, visible: false, listening: false });
+ stage.add(toolPreviewLayer);
+
+ // Create the brush preview group & circles
+ const brushPreviewGroup = new Konva.Group({ id: TOOL_PREVIEW_BRUSH_GROUP_ID });
+ const brushPreviewFill = new Konva.Circle({
+ id: TOOL_PREVIEW_BRUSH_FILL_ID,
+ listening: false,
+ strokeEnabled: false,
+ });
+ brushPreviewGroup.add(brushPreviewFill);
+ const brushPreviewBorderInner = new Konva.Circle({
+ id: TOOL_PREVIEW_BRUSH_BORDER_INNER_ID,
+ listening: false,
+ stroke: BRUSH_BORDER_INNER_COLOR,
+ strokeWidth: 1,
+ strokeEnabled: true,
+ });
+ brushPreviewGroup.add(brushPreviewBorderInner);
+ const brushPreviewBorderOuter = new Konva.Circle({
+ id: TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID,
+ listening: false,
+ stroke: BRUSH_BORDER_OUTER_COLOR,
+ strokeWidth: 1,
+ strokeEnabled: true,
+ });
+ brushPreviewGroup.add(brushPreviewBorderOuter);
+ toolPreviewLayer.add(brushPreviewGroup);
+
+ // Create the rect preview - this is a rectangle drawn from the last mouse down position to the current cursor position
+ const rectPreview = new Konva.Rect({ id: TOOL_PREVIEW_RECT_ID, listening: false, stroke: 'white', strokeWidth: 1 });
+ toolPreviewLayer.add(rectPreview);
+
+ return toolPreviewLayer;
+};
+
+/**
+ * Renders the brush preview for the selected tool.
+ * @param stage The konva stage
+ * @param tool The selected tool
+ * @param color The selected layer's color
+ * @param selectedLayerType The selected layer's type
+ * @param globalMaskLayerOpacity The global mask layer opacity
+ * @param cursorPos The cursor position
+ * @param lastMouseDownPos The position of the last mouse down event - used for the rect tool
+ * @param brushSize The brush size
+ */
+export const renderToolPreview = (
+ stage: Konva.Stage,
+ tool: Tool,
+ brushColor: RgbaColor,
+ selectedLayerType: Layer['type'] | null,
+ globalMaskLayerOpacity: number,
+ cursorPos: Vector2d | null,
+ lastMouseDownPos: Vector2d | null,
+ brushSize: number
+): void => {
+ const layerCount = stage.find(selectRenderableLayers).length;
+ // Update the stage's pointer style
+ if (layerCount === 0) {
+ // We have no layers, so we should not render any tool
+ stage.container().style.cursor = 'default';
+ } else if (selectedLayerType !== 'regional_guidance_layer' && selectedLayerType !== 'raster_layer') {
+ // Non-mask-guidance layers don't have tools
+ stage.container().style.cursor = 'not-allowed';
+ } else if (tool === 'move') {
+ // Move tool gets a pointer
+ stage.container().style.cursor = 'default';
+ } else if (tool === 'rect') {
+ // Move rect gets a crosshair
+ stage.container().style.cursor = 'crosshair';
+ } else {
+ // Else we hide the native cursor and use the konva-rendered brush preview
+ stage.container().style.cursor = 'none';
+ }
+
+ const toolPreviewLayer = stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`) ?? createToolPreviewLayer(stage);
+
+ if (!cursorPos || layerCount === 0) {
+ // We can bail early if the mouse isn't over the stage or there are no layers
+ toolPreviewLayer.visible(false);
+ return;
+ }
+
+ toolPreviewLayer.visible(true);
+
+ const brushPreviewGroup = stage.findOne(`#${TOOL_PREVIEW_BRUSH_GROUP_ID}`);
+ assert(brushPreviewGroup, 'Brush preview group not found');
+
+ const rectPreview = stage.findOne(`#${TOOL_PREVIEW_RECT_ID}`);
+ assert(rectPreview, 'Rect preview not found');
+
+ // No need to render the brush preview if the cursor position or color is missing
+ if (cursorPos && (tool === 'brush' || tool === 'eraser')) {
+ // Update the fill circle
+ const brushPreviewFill = brushPreviewGroup.findOne(`#${TOOL_PREVIEW_BRUSH_FILL_ID}`);
+ brushPreviewFill?.setAttrs({
+ x: cursorPos.x,
+ y: cursorPos.y,
+ radius: brushSize / 2,
+ fill: rgbaColorToString(brushColor),
+ globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out',
+ });
+
+ // Update the inner border of the brush preview
+ const brushPreviewInner = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_BRUSH_BORDER_INNER_ID}`);
+ brushPreviewInner?.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2 });
+
+ // Update the outer border of the brush preview
+ const brushPreviewOuter = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID}`);
+ brushPreviewOuter?.setAttrs({
+ x: cursorPos.x,
+ y: cursorPos.y,
+ radius: brushSize / 2 + 1,
+ });
+
+ brushPreviewGroup.visible(true);
+ } else {
+ brushPreviewGroup.visible(false);
+ }
+
+ if (cursorPos && lastMouseDownPos && tool === 'rect') {
+ const snappedPos = snapPosToStage(cursorPos, stage);
+ const rectPreview = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_RECT_ID}`);
+ rectPreview?.setAttrs({
+ x: Math.min(snappedPos.x, lastMouseDownPos.x),
+ y: Math.min(snappedPos.y, lastMouseDownPos.y),
+ width: Math.abs(snappedPos.x - lastMouseDownPos.x),
+ height: Math.abs(snappedPos.y - lastMouseDownPos.y),
+ });
+ rectPreview?.visible(true);
+ } else {
+ rectPreview?.visible(false);
+ }
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts
index 29f81fb799..2eed6a663b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts
@@ -1,3 +1,11 @@
+import {
+ CA_LAYER_NAME,
+ INITIAL_IMAGE_LAYER_NAME,
+ RASTER_LAYER_NAME,
+ RG_LAYER_LINE_NAME,
+ RG_LAYER_NAME,
+ RG_LAYER_RECT_NAME,
+} from 'features/controlLayers/konva/naming';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Vector2d } from 'konva/lib/types';
@@ -65,3 +73,33 @@ export const getIsMouseDown = (e: KonvaEventObject): boolean => e.ev
*/
export const getIsFocused = (stage: Konva.Stage): boolean => stage.container().contains(document.activeElement);
//#endregion
+
+//#region mapId
+/**
+ * Simple util to map an object to its id property. Serves as a minor optimization to avoid recreating a map callback
+ * every time we need to map an object to its id, which happens very often.
+ * @param object The object with an `id` property
+ * @returns The object's id property
+ */
+export const mapId = (object: { id: string }): string => object.id;
+//#endregion
+
+//#region konva selector callbacks
+/**
+ * Konva selection callback to select all renderable layers. This includes RG, CA II and Raster layers.
+ * This can be provided to the `find` or `findOne` konva node methods.
+ */
+export const selectRenderableLayers = (n: Konva.Node): boolean =>
+ n.name() === RG_LAYER_NAME ||
+ n.name() === CA_LAYER_NAME ||
+ n.name() === INITIAL_IMAGE_LAYER_NAME ||
+ n.name() === RASTER_LAYER_NAME;
+
+/**
+ * Konva selection callback to select RG mask objects. This includes lines and rects.
+ * This can be provided to the `find` or `findOne` konva node methods.
+ */
+export const selectVectorMaskObjects = (node: Konva.Node): boolean => {
+ return node.name() === RG_LAYER_LINE_NAME || node.name() === RG_LAYER_RECT_NAME;
+};
+//#endregion
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index 16069daecb..c9fe150969 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -50,23 +50,28 @@ import type {
AddEraserLineArg,
AddPointToLineArg,
AddRectShapeArg,
- BrushLine,
ControlAdapterLayer,
ControlLayersState,
- EllipseShape,
- EraserLine,
- ImageObject,
InitialImageLayer,
IPAdapterLayer,
Layer,
- PolygonShape,
RasterLayer,
- RectShape,
RegionalGuidanceLayer,
RgbaColor,
Tool,
} from './types';
-import { DEFAULT_RGBA_COLOR } from './types';
+import {
+ DEFAULT_RGBA_COLOR,
+ isCAOrIPALayer,
+ isControlAdapterLayer,
+ isInitialImageLayer,
+ isIPAdapterLayer,
+ isLine,
+ isRasterLayer,
+ isRegionalGuidanceLayer,
+ isRenderableLayer,
+ isRGOrRasterlayer,
+} from './types';
export const initialControlLayersState: ControlLayersState = {
_version: 3,
@@ -87,76 +92,31 @@ export const initialControlLayersState: ControlLayersState = {
},
};
-const isLine = (
- obj: BrushLine | EraserLine | RectShape | EllipseShape | PolygonShape | ImageObject
-): obj is BrushLine => obj.type === 'brush_line' || obj.type === 'eraser_line';
-export const isRegionalGuidanceLayer = (layer?: Layer): layer is RegionalGuidanceLayer =>
- layer?.type === 'regional_guidance_layer';
-export const isControlAdapterLayer = (layer?: Layer): layer is ControlAdapterLayer =>
- layer?.type === 'control_adapter_layer';
-export const isIPAdapterLayer = (layer?: Layer): layer is IPAdapterLayer => layer?.type === 'ip_adapter_layer';
-export const isInitialImageLayer = (layer?: Layer): layer is InitialImageLayer => layer?.type === 'initial_image_layer';
-export const isRasterLayer = (layer?: Layer): layer is RasterLayer => layer?.type === 'raster_layer';
-export const isRenderableLayer = (
- layer?: Layer
-): layer is RegionalGuidanceLayer | ControlAdapterLayer | InitialImageLayer =>
- layer?.type === 'regional_guidance_layer' ||
- layer?.type === 'control_adapter_layer' ||
- layer?.type === 'initial_image_layer' ||
- layer?.type === 'raster_layer';
+/**
+ * A selector that accepts a type guard and returns the first layer that matches the guard.
+ * Throws if the layer is not found or does not match the guard.
+ */
+export const selectLayerOrThrow = (
+ state: ControlLayersState,
+ layerId: string,
+ predicate: (layer: Layer) => layer is T
+): T => {
+ const layer = state.layers.find((l) => l.id === layerId);
+ assert(layer && predicate(layer));
+ return layer;
+};
-export const selectCALayerOrThrow = (state: ControlLayersState, layerId: string): ControlAdapterLayer => {
- const layer = state.layers.find((l) => l.id === layerId);
- assert(isControlAdapterLayer(layer));
- return layer;
-};
-export const selectIPALayerOrThrow = (state: ControlLayersState, layerId: string): IPAdapterLayer => {
- const layer = state.layers.find((l) => l.id === layerId);
- assert(isIPAdapterLayer(layer));
- return layer;
-};
-export const selectIILayerOrThrow = (state: ControlLayersState, layerId: string): InitialImageLayer => {
- const layer = state.layers.find((l) => l.id === layerId);
- assert(isInitialImageLayer(layer));
- return layer;
-};
-export const selectRasterLayerOrThrow = (state: ControlLayersState, layerId: string): RasterLayer => {
- const layer = state.layers.find((l) => l.id === layerId);
- assert(isRasterLayer(layer));
- return layer;
-};
-const selectCAOrIPALayerOrThrow = (
- state: ControlLayersState,
- layerId: string
-): ControlAdapterLayer | IPAdapterLayer => {
- const layer = state.layers.find((l) => l.id === layerId);
- assert(isControlAdapterLayer(layer) || isIPAdapterLayer(layer));
- return layer;
-};
-const selectRGLayerOrThrow = (state: ControlLayersState, layerId: string): RegionalGuidanceLayer => {
- const layer = state.layers.find((l) => l.id === layerId);
- assert(isRegionalGuidanceLayer(layer));
- return layer;
-};
-const selectRGOrRasterLayerOrThrow = (
- state: ControlLayersState,
- layerId: string
-): RegionalGuidanceLayer | RasterLayer => {
- const layer = state.layers.find((l) => l.id === layerId);
- assert(isRegionalGuidanceLayer(layer) || isRasterLayer(layer));
- return layer;
-};
export const selectRGLayerIPAdapterOrThrow = (
state: ControlLayersState,
layerId: string,
ipAdapterId: string
): IPAdapterConfigV2 => {
- const layer = state.layers.find((l) => l.id === layerId);
- assert(isRegionalGuidanceLayer(layer));
+ const layer = selectLayerOrThrow(state, layerId, isRegionalGuidanceLayer);
const ipAdapter = layer.ipAdapters.find((ipAdapter) => ipAdapter.id === ipAdapterId);
assert(ipAdapter);
return ipAdapter;
};
+
const getVectorMaskPreviewColor = (state: ControlLayersState): RgbColor => {
const rgLayers = state.layers.filter(isRegionalGuidanceLayer);
const lastColor = rgLayers[rgLayers.length - 1]?.previewColor;
@@ -222,6 +182,13 @@ export const controlLayersSlice = createSlice({
state.layers = state.layers.filter((l) => l.id !== action.payload);
state.selectedLayerId = state.layers[0]?.id ?? null;
},
+ layerOpacityChanged: (state, action: PayloadAction<{ layerId: string; opacity: number }>) => {
+ const { layerId, opacity } = action.payload;
+ const layer = state.layers.find((l) => l.id === layerId);
+ if (isControlAdapterLayer(layer) || isInitialImageLayer(layer) || isRasterLayer(layer)) {
+ layer.opacity = opacity;
+ }
+ },
layerMovedForward: (state, action: PayloadAction) => {
const cb = (l: Layer) => l.id === action.payload;
const [renderableLayers, ipAdapterLayers] = partition(state.layers, isRenderableLayer);
@@ -291,7 +258,7 @@ export const controlLayersSlice = createSlice({
},
caLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
const { layerId, imageDTO } = action.payload;
- const layer = selectCALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isControlAdapterLayer);
layer.bbox = null;
layer.bboxNeedsUpdate = true;
layer.isEnabled = true;
@@ -309,7 +276,7 @@ export const controlLayersSlice = createSlice({
},
caLayerProcessedImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
const { layerId, imageDTO } = action.payload;
- const layer = selectCALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isControlAdapterLayer);
layer.bbox = null;
layer.bboxNeedsUpdate = true;
layer.isEnabled = true;
@@ -323,7 +290,7 @@ export const controlLayersSlice = createSlice({
}>
) => {
const { layerId, modelConfig } = action.payload;
- const layer = selectCALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isControlAdapterLayer);
if (!modelConfig) {
layer.controlAdapter.model = null;
return;
@@ -347,7 +314,7 @@ export const controlLayersSlice = createSlice({
},
caLayerControlModeChanged: (state, action: PayloadAction<{ layerId: string; controlMode: ControlModeV2 }>) => {
const { layerId, controlMode } = action.payload;
- const layer = selectCALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isControlAdapterLayer);
assert(layer.controlAdapter.type === 'controlnet');
layer.controlAdapter.controlMode = controlMode;
},
@@ -356,7 +323,7 @@ export const controlLayersSlice = createSlice({
action: PayloadAction<{ layerId: string; processorConfig: ProcessorConfig | null }>
) => {
const { layerId, processorConfig } = action.payload;
- const layer = selectCALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isControlAdapterLayer);
layer.controlAdapter.processorConfig = processorConfig;
if (!processorConfig) {
layer.controlAdapter.processedImage = null;
@@ -364,20 +331,15 @@ export const controlLayersSlice = createSlice({
},
caLayerIsFilterEnabledChanged: (state, action: PayloadAction<{ layerId: string; isFilterEnabled: boolean }>) => {
const { layerId, isFilterEnabled } = action.payload;
- const layer = selectCALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isControlAdapterLayer);
layer.isFilterEnabled = isFilterEnabled;
},
- caLayerOpacityChanged: (state, action: PayloadAction<{ layerId: string; opacity: number }>) => {
- const { layerId, opacity } = action.payload;
- const layer = selectCALayerOrThrow(state, layerId);
- layer.opacity = opacity;
- },
caLayerProcessorPendingBatchIdChanged: (
state,
action: PayloadAction<{ layerId: string; batchId: string | null }>
) => {
const { layerId, batchId } = action.payload;
- const layer = selectCALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isControlAdapterLayer);
layer.controlAdapter.processorPendingBatchId = batchId;
},
//#endregion
@@ -403,12 +365,12 @@ export const controlLayersSlice = createSlice({
},
ipaLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
const { layerId, imageDTO } = action.payload;
- const layer = selectIPALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isIPAdapterLayer);
layer.ipAdapter.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null;
},
ipaLayerMethodChanged: (state, action: PayloadAction<{ layerId: string; method: IPMethodV2 }>) => {
const { layerId, method } = action.payload;
- const layer = selectIPALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isIPAdapterLayer);
layer.ipAdapter.method = method;
},
ipaLayerModelChanged: (
@@ -419,7 +381,7 @@ export const controlLayersSlice = createSlice({
}>
) => {
const { layerId, modelConfig } = action.payload;
- const layer = selectIPALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isIPAdapterLayer);
if (!modelConfig) {
layer.ipAdapter.model = null;
return;
@@ -431,7 +393,7 @@ export const controlLayersSlice = createSlice({
action: PayloadAction<{ layerId: string; clipVisionModel: CLIPVisionModelV2 }>
) => {
const { layerId, clipVisionModel } = action.payload;
- const layer = selectIPALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isIPAdapterLayer);
layer.ipAdapter.clipVisionModel = clipVisionModel;
},
//#endregion
@@ -439,7 +401,7 @@ export const controlLayersSlice = createSlice({
//#region CA or IPA Layers
caOrIPALayerWeightChanged: (state, action: PayloadAction<{ layerId: string; weight: number }>) => {
const { layerId, weight } = action.payload;
- const layer = selectCAOrIPALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isCAOrIPALayer);
if (layer.type === 'control_adapter_layer') {
layer.controlAdapter.weight = weight;
} else {
@@ -451,7 +413,7 @@ export const controlLayersSlice = createSlice({
action: PayloadAction<{ layerId: string; beginEndStepPct: [number, number] }>
) => {
const { layerId, beginEndStepPct } = action.payload;
- const layer = selectCAOrIPALayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isCAOrIPALayer);
if (layer.type === 'control_adapter_layer') {
layer.controlAdapter.beginEndStepPct = beginEndStepPct;
} else {
@@ -492,119 +454,23 @@ export const controlLayersSlice = createSlice({
},
rgLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string | null }>) => {
const { layerId, prompt } = action.payload;
- const layer = selectRGLayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isRegionalGuidanceLayer);
layer.positivePrompt = prompt;
},
rgLayerNegativePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string | null }>) => {
const { layerId, prompt } = action.payload;
- const layer = selectRGLayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isRegionalGuidanceLayer);
layer.negativePrompt = prompt;
},
rgLayerPreviewColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbColor }>) => {
const { layerId, color } = action.payload;
- const layer = selectRGLayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isRegionalGuidanceLayer);
layer.previewColor = color;
},
- brushLineAdded: {
- reducer: (
- state,
- action: PayloadAction<
- AddBrushLineArg & {
- lineUuid: string;
- }
- >
- ) => {
- const { layerId, points, lineUuid, color } = action.payload;
- const layer = selectRGOrRasterLayerOrThrow(state, layerId);
- layer.objects.push({
- id: getBrushLineId(layer.id, lineUuid),
- type: 'brush_line',
- // Points must be offset by the layer's x and y coordinates
- // TODO: Handle this in the event listener?
- points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y],
- strokeWidth: state.brushSize,
- color,
- });
- layer.bboxNeedsUpdate = true;
- if (layer.type === 'regional_guidance_layer') {
- layer.uploadedMaskImage = null;
- }
- },
- prepare: (payload: AddBrushLineArg) => ({
- payload: { ...payload, lineUuid: uuidv4() },
- }),
- },
- eraserLineAdded: {
- reducer: (
- state,
- action: PayloadAction<
- AddEraserLineArg & {
- lineUuid: string;
- }
- >
- ) => {
- const { layerId, points, lineUuid } = action.payload;
- const layer = selectRGOrRasterLayerOrThrow(state, layerId);
- layer.objects.push({
- id: getEraserLineId(layer.id, lineUuid),
- type: 'eraser_line',
- // Points must be offset by the layer's x and y coordinates
- // TODO: Handle this in the event listener?
- points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y],
- strokeWidth: state.brushSize,
- });
- layer.bboxNeedsUpdate = true;
- if (isRegionalGuidanceLayer(layer)) {
- layer.uploadedMaskImage = null;
- }
- },
- prepare: (payload: AddEraserLineArg) => ({
- payload: { ...payload, lineUuid: uuidv4() },
- }),
- },
- linePointsAdded: (state, action: PayloadAction) => {
- const { layerId, point } = action.payload;
- const layer = selectRGOrRasterLayerOrThrow(state, layerId);
- const lastLine = layer.objects.findLast(isLine);
- if (!lastLine || !isLine(lastLine)) {
- return;
- }
- // Points must be offset by the layer's x and y coordinates
- // TODO: Handle this in the event listener
- lastLine.points.push(point[0] - layer.x, point[1] - layer.y);
- layer.bboxNeedsUpdate = true;
- if (isRegionalGuidanceLayer(layer)) {
- layer.uploadedMaskImage = null;
- }
- },
- rectAdded: {
- reducer: (state, action: PayloadAction) => {
- const { layerId, rect, rectUuid, color } = action.payload;
- if (rect.height === 0 || rect.width === 0) {
- // Ignore zero-area rectangles
- return;
- }
- const layer = selectRGOrRasterLayerOrThrow(state, layerId);
- const id = getRectId(layer.id, rectUuid);
- layer.objects.push({
- type: 'rect_shape',
- id,
- x: rect.x - layer.x,
- y: rect.y - layer.y,
- width: rect.width,
- height: rect.height,
- color,
- });
- layer.bboxNeedsUpdate = true;
- if (isRegionalGuidanceLayer(layer)) {
- layer.uploadedMaskImage = null;
- }
- },
- prepare: (payload: AddRectShapeArg) => ({ payload: { ...payload, rectUuid: uuidv4() } }),
- },
+
rgLayerMaskImageUploaded: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO }>) => {
const { layerId, imageDTO } = action.payload;
- const layer = selectRGLayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isRegionalGuidanceLayer);
layer.uploadedMaskImage = imageDTOToImageWithDims(imageDTO);
},
rgLayerAutoNegativeChanged: (
@@ -612,17 +478,17 @@ export const controlLayersSlice = createSlice({
action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }>
) => {
const { layerId, autoNegative } = action.payload;
- const layer = selectRGLayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isRegionalGuidanceLayer);
layer.autoNegative = autoNegative;
},
rgLayerIPAdapterAdded: (state, action: PayloadAction<{ layerId: string; ipAdapter: IPAdapterConfigV2 }>) => {
const { layerId, ipAdapter } = action.payload;
- const layer = selectRGLayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isRegionalGuidanceLayer);
layer.ipAdapters.push(ipAdapter);
},
rgLayerIPAdapterDeleted: (state, action: PayloadAction<{ layerId: string; ipAdapterId: string }>) => {
const { layerId, ipAdapterId } = action.payload;
- const layer = selectRGLayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isRegionalGuidanceLayer);
layer.ipAdapters = layer.ipAdapters.filter((ipAdapter) => ipAdapter.id !== ipAdapterId);
},
rgLayerIPAdapterImageChanged: (
@@ -726,20 +592,15 @@ export const controlLayersSlice = createSlice({
},
iiLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
const { layerId, imageDTO } = action.payload;
- const layer = selectIILayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isInitialImageLayer);
layer.bbox = null;
layer.bboxNeedsUpdate = true;
layer.isEnabled = true;
layer.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null;
},
- iiLayerOpacityChanged: (state, action: PayloadAction<{ layerId: string; opacity: number }>) => {
- const { layerId, opacity } = action.payload;
- const layer = selectIILayerOrThrow(state, layerId);
- layer.opacity = opacity;
- },
iiLayerDenoisingStrengthChanged: (state, action: PayloadAction<{ layerId: string; denoisingStrength: number }>) => {
const { layerId, denoisingStrength } = action.payload;
- const layer = selectIILayerOrThrow(state, layerId);
+ const layer = selectLayerOrThrow(state, layerId, isInitialImageLayer);
layer.denoisingStrength = denoisingStrength;
},
//#endregion
@@ -765,10 +626,105 @@ export const controlLayersSlice = createSlice({
},
prepare: () => ({ payload: { layerId: uuidv4() } }),
},
- rasterLayerOpacityChanged: (state, action: PayloadAction<{ layerId: string; opacity: number }>) => {
- const { layerId, opacity } = action.payload;
- const layer = selectRasterLayerOrThrow(state, layerId);
- layer.opacity = opacity;
+ //#endregion
+
+ //#region Objects
+ brushLineAdded: {
+ reducer: (
+ state,
+ action: PayloadAction<
+ AddBrushLineArg & {
+ lineUuid: string;
+ }
+ >
+ ) => {
+ const { layerId, points, lineUuid, color } = action.payload;
+ const layer = selectLayerOrThrow(state, layerId, isRGOrRasterlayer);
+ layer.objects.push({
+ id: getBrushLineId(layer.id, lineUuid),
+ type: 'brush_line',
+ // Points must be offset by the layer's x and y coordinates
+ // TODO: Handle this in the event listener?
+ points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y],
+ strokeWidth: state.brushSize,
+ color,
+ });
+ layer.bboxNeedsUpdate = true;
+ if (layer.type === 'regional_guidance_layer') {
+ layer.uploadedMaskImage = null;
+ }
+ },
+ prepare: (payload: AddBrushLineArg) => ({
+ payload: { ...payload, lineUuid: uuidv4() },
+ }),
+ },
+ eraserLineAdded: {
+ reducer: (
+ state,
+ action: PayloadAction<
+ AddEraserLineArg & {
+ lineUuid: string;
+ }
+ >
+ ) => {
+ const { layerId, points, lineUuid } = action.payload;
+ const layer = selectLayerOrThrow(state, layerId, isRGOrRasterlayer);
+ layer.objects.push({
+ id: getEraserLineId(layer.id, lineUuid),
+ type: 'eraser_line',
+ // Points must be offset by the layer's x and y coordinates
+ // TODO: Handle this in the event listener?
+ points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y],
+ strokeWidth: state.brushSize,
+ });
+ layer.bboxNeedsUpdate = true;
+ if (isRegionalGuidanceLayer(layer)) {
+ layer.uploadedMaskImage = null;
+ }
+ },
+ prepare: (payload: AddEraserLineArg) => ({
+ payload: { ...payload, lineUuid: uuidv4() },
+ }),
+ },
+ linePointsAdded: (state, action: PayloadAction) => {
+ const { layerId, point } = action.payload;
+ const layer = selectLayerOrThrow(state, layerId, isRGOrRasterlayer);
+ const lastLine = layer.objects.findLast(isLine);
+ if (!lastLine || !isLine(lastLine)) {
+ return;
+ }
+ // Points must be offset by the layer's x and y coordinates
+ // TODO: Handle this in the event listener
+ lastLine.points.push(point[0] - layer.x, point[1] - layer.y);
+ layer.bboxNeedsUpdate = true;
+ if (isRegionalGuidanceLayer(layer)) {
+ layer.uploadedMaskImage = null;
+ }
+ },
+ rectAdded: {
+ reducer: (state, action: PayloadAction) => {
+ const { layerId, rect, rectUuid, color } = action.payload;
+ if (rect.height === 0 || rect.width === 0) {
+ // Ignore zero-area rectangles
+ return;
+ }
+ const layer = selectLayerOrThrow(state, layerId, isRGOrRasterlayer);
+ const id = getRectId(layer.id, rectUuid);
+ layer.objects.push({
+ type: 'rect_shape',
+ id,
+ x: rect.x - layer.x,
+ y: rect.y - layer.y,
+ width: rect.width,
+ height: rect.height,
+ color,
+ });
+ layer.bboxNeedsUpdate = true;
+ if (isRegionalGuidanceLayer(layer)) {
+ layer.uploadedMaskImage = null;
+ }
+ },
+ prepare: (payload: AddRectShapeArg) => ({ payload: { ...payload, rectUuid: uuidv4() } }),
},
//#endregion
@@ -898,6 +854,7 @@ export const {
layerBboxChanged,
layerReset,
layerDeleted,
+ layerOpacityChanged,
layerMovedForward,
layerMovedToFront,
layerMovedBackward,
@@ -913,7 +870,6 @@ export const {
caLayerControlModeChanged,
caLayerProcessorConfigChanged,
caLayerIsFilterEnabledChanged,
- caLayerOpacityChanged,
caLayerProcessorPendingBatchIdChanged,
// IPA Layers
ipaLayerAdded,
@@ -949,11 +905,9 @@ export const {
iiLayerAdded,
iiLayerRecalled,
iiLayerImageChanged,
- iiLayerOpacityChanged,
iiLayerDenoisingStrengthChanged,
// Raster layers
rasterLayerAdded,
- rasterLayerOpacityChanged,
// Globals
positivePromptChanged,
negativePromptChanged,
@@ -1053,6 +1007,15 @@ export const controlLayersUndoableConfig: UndoableOptions {
- return false;
+ // // Ignore all actions from other slices
+ // if (!action.type.startsWith(controlLayersSlice.name)) {
+ // return false;
+ // }
+ // // This action is triggered on state changes, including when we undo. If we do not ignore this action, when we
+ // // undo, this action triggers and empties the future states array. Therefore, we must ignore this action.
+ // if (layerBboxChanged.match(action)) {
+ // return false;
+ // }
+ return true;
},
};
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index ab40c25824..32ed9a674b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -25,7 +25,6 @@ import { z } from 'zod';
const zTool = z.enum(['brush', 'eraser', 'move', 'rect']);
export type Tool = z.infer;
const zDrawingTool = zTool.extract(['brush', 'eraser']);
-export type DrawingTool = z.infer;
const zPoints = z.array(z.number()).refine((points) => points.length % 2 === 0, {
message: 'Must have an even number of points',
@@ -118,6 +117,16 @@ const zImageObject = z.object({
});
export type ImageObject = z.infer;
+const zAnyLayerObject = z.discriminatedUnion('type', [
+ zImageObject,
+ zBrushLine,
+ zEraserline,
+ zRectShape,
+ zEllipseShape,
+ zPolygonShape,
+]);
+export type AnyLayerObject = z.infer;
+
const zLayerBase = z.object({
id: z.string(),
isEnabled: z.boolean().default(true),
@@ -140,9 +149,7 @@ const zRenderableLayerBase = zLayerBase.extend({
const zRasterLayer = zRenderableLayerBase.extend({
type: z.literal('raster_layer'),
opacity: zOpacity,
- objects: z.array(
- z.discriminatedUnion('type', [zImageObject, zBrushLine, zEraserline, zRectShape, zEllipseShape, zPolygonShape])
- ),
+ objects: z.array(zAnyLayerObject),
});
export type RasterLayer = z.infer;
@@ -213,6 +220,7 @@ const zRegionalGuidanceLayer = zRenderableLayerBase.extend({
autoNegative: zAutoNegative,
uploadedMaskImage: zImageWithDims.nullable(),
});
+// TODO(psyche): This doesn't migrate correctly!
const zRGLayer = z
.union([zOLD_RegionalGuidanceLayer, zRegionalGuidanceLayer])
.transform((val) => {
@@ -265,4 +273,46 @@ export type ControlLayersState = {
export type AddEraserLineArg = { layerId: string; points: [number, number, number, number] };
export type AddBrushLineArg = AddEraserLineArg & { color: RgbaColor };
export type AddPointToLineArg = { layerId: string; point: [number, number] };
-export type AddRectShapeArg = { layerId: string; rect: IRect; color: RgbaColor };
+export type AddRectShapeArg = { layerId: string; rect: IRect; color: RgbaColor }; //#region Type guards
+
+//#region Type guards
+export const isLine = (obj: AnyLayerObject): obj is BrushLine | EraserLine => {
+ return obj.type === 'brush_line' || obj.type === 'eraser_line';
+};
+export const isRegionalGuidanceLayer = (layer?: Layer): layer is RegionalGuidanceLayer => {
+ return layer?.type === 'regional_guidance_layer';
+};
+export const isControlAdapterLayer = (layer?: Layer): layer is ControlAdapterLayer => {
+ return layer?.type === 'control_adapter_layer';
+};
+export const isIPAdapterLayer = (layer?: Layer): layer is IPAdapterLayer => {
+ return layer?.type === 'ip_adapter_layer';
+};
+export const isInitialImageLayer = (layer?: Layer): layer is InitialImageLayer => {
+ return layer?.type === 'initial_image_layer';
+};
+export const isRasterLayer = (layer?: Layer): layer is RasterLayer => {
+ return layer?.type === 'raster_layer';
+};
+export const isRenderableLayer = (
+ layer?: Layer
+): layer is RegionalGuidanceLayer | ControlAdapterLayer | InitialImageLayer => {
+ return (
+ layer?.type === 'regional_guidance_layer' ||
+ layer?.type === 'control_adapter_layer' ||
+ layer?.type === 'initial_image_layer' ||
+ layer?.type === 'raster_layer'
+ );
+};
+export const isLayerWithOpacity = (layer?: Layer): layer is ControlAdapterLayer | InitialImageLayer | RasterLayer => {
+ return (
+ layer?.type === 'control_adapter_layer' || layer?.type === 'initial_image_layer' || layer?.type === 'raster_layer'
+ );
+};
+export const isCAOrIPALayer = (layer?: Layer): layer is ControlAdapterLayer | IPAdapterLayer => {
+ return isControlAdapterLayer(layer) || isIPAdapterLayer(layer);
+};
+export const isRGOrRasterlayer = (layer?: Layer): layer is RegionalGuidanceLayer | RasterLayer => {
+ return isRegionalGuidanceLayer(layer) || isRasterLayer(layer);
+};
+//#endregion
diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts
index a7934f72d2..aeb41c402c 100644
--- a/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts
+++ b/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts
@@ -7,14 +7,14 @@ import {
} from 'features/controlAdapters/store/controlAdaptersSlice';
import type { ControlAdaptersState } from 'features/controlAdapters/store/types';
import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
+import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
+import type { ControlLayersState } from 'features/controlLayers/store/types';
import {
isControlAdapterLayer,
isInitialImageLayer,
isIPAdapterLayer,
isRegionalGuidanceLayer,
- selectControlLayersSlice,
-} from 'features/controlLayers/store/controlLayersSlice';
-import type { ControlLayersState } from 'features/controlLayers/store/types';
+} from 'features/controlLayers/store/types';
import { selectDeleteImageModalSlice } from 'features/deleteImageModal/store/slice';
import { selectNodesSlice } from 'features/nodes/store/nodesSlice';
import type { NodesState } from 'features/nodes/store/types';
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addControlLayers.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addControlLayers.ts
index 4261318479..6adee17064 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addControlLayers.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addControlLayers.ts
@@ -4,15 +4,15 @@ import { deepClone } from 'common/util/deepClone';
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
import { RG_LAYER_NAME } from 'features/controlLayers/konva/naming';
-import { renderers } from 'features/controlLayers/konva/renderers';
+import { renderers } from 'features/controlLayers/konva/renderers/layers';
+import { rgLayerMaskImageUploaded } from 'features/controlLayers/store/controlLayersSlice';
+import type { InitialImageLayer, Layer, RegionalGuidanceLayer } from 'features/controlLayers/store/types';
import {
isControlAdapterLayer,
isInitialImageLayer,
isIPAdapterLayer,
isRegionalGuidanceLayer,
- rgLayerMaskImageUploaded,
-} from 'features/controlLayers/store/controlLayersSlice';
-import type { InitialImageLayer, Layer, RegionalGuidanceLayer } from 'features/controlLayers/store/types';
+} from 'features/controlLayers/store/types';
import type {
ControlNetConfigV2,
ImageWithDims,
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildGenerationTabGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildGenerationTabGraph.ts
index 9f03f58a69..288f0a944f 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildGenerationTabGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildGenerationTabGraph.ts
@@ -1,5 +1,5 @@
import type { RootState } from 'app/store/store';
-import { isInitialImageLayer, isRegionalGuidanceLayer } from 'features/controlLayers/store/controlLayersSlice';
+import { isInitialImageLayer, isRegionalGuidanceLayer } from 'features/controlLayers/store/types';
import { fetchModelConfigWithTypeGuard } from 'features/metadata/util/modelFetchingHelpers';
import {
CLIP_SKIP,