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,