diff --git a/invokeai/app/invocations/mask.py b/invokeai/app/invocations/mask.py index 64c6b0702cc..6f54660847a 100644 --- a/invokeai/app/invocations/mask.py +++ b/invokeai/app/invocations/mask.py @@ -88,3 +88,33 @@ def invoke(self, context: InvocationContext) -> MaskOutput: height=inverted.shape[1], width=inverted.shape[2], ) + + +@invocation( + "image_mask_to_tensor", + title="Image Mask to Tensor", + tags=["conditioning"], + category="conditioning", + version="1.0.0", +) +class ImageMaskToTensorInvocation(BaseInvocation, WithMetadata): + """Convert a mask image to a tensor. Converts the image to grayscale and uses thresholding at the specified value.""" + + image: ImageField = InputField(description="The mask image to convert.") + cutoff: int = InputField(ge=0, le=255, description="Cutoff (<)", default=128) + invert: bool = InputField(default=False, description="Whether to invert the mask.") + + def invoke(self, context: InvocationContext) -> MaskOutput: + image = context.images.get_pil(self.image.image_name, mode="L") + + mask = torch.zeros((1, image.height, image.width), dtype=torch.bool) + if self.invert: + mask[0] = torch.tensor(np.array(image)[:, :] >= self.cutoff, dtype=torch.bool) + else: + mask[0] = torch.tensor(np.array(image)[:, :] < self.cutoff, dtype=torch.bool) + + return MaskOutput( + mask=TensorField(tensor_name=context.tensors.save(mask)), + height=mask.shape[1], + width=mask.shape[2], + ) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 5dfa8a1731e..c78e7a5fce2 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1522,6 +1522,7 @@ "autoNegative": "Auto Negative", "toggleVisibility": "Toggle Layer Visibility", "resetRegion": "Reset Region", - "debugLayers": "Debug Layers" + "debugLayers": "Debug Layers", + "rectangle": "Rectangle" } } diff --git a/invokeai/frontend/web/src/common/components/IAIColorPicker.tsx b/invokeai/frontend/web/src/common/components/IAIColorPicker.tsx index 68ffa5369e4..25b129f6782 100644 --- a/invokeai/frontend/web/src/common/components/IAIColorPicker.tsx +++ b/invokeai/frontend/web/src/common/components/IAIColorPicker.tsx @@ -26,7 +26,7 @@ const sx: ChakraProps['sx'] = { const colorPickerStyles: CSSProperties = { width: '100%' }; -const numberInputWidth: ChakraProps['w'] = '4.2rem'; +const numberInputWidth: ChakraProps['w'] = '3.5rem'; const IAIColorPicker = (props: IAIColorPickerProps) => { const { color, onChange, withNumberInput, ...rest } = props; @@ -41,7 +41,7 @@ const IAIColorPicker = (props: IAIColorPickerProps) => { {withNumberInput && ( - {t('common.red')} + {t('common.red')[0]} { /> - {t('common.green')} + {t('common.green')[0]} { /> - {t('common.blue')} + {t('common.blue')[0]} { /> - {t('common.alpha')} + {t('common.alpha')[0]} & { - withNumberInput?: boolean; -}; - -const colorPickerPointerStyles: NonNullable = { - width: 6, - height: 6, - borderColor: 'base.100', -}; - -const sx: ChakraProps['sx'] = { - '.react-colorful__hue-pointer': colorPickerPointerStyles, - '.react-colorful__saturation-pointer': colorPickerPointerStyles, - '.react-colorful__alpha-pointer': colorPickerPointerStyles, - gap: 5, - flexDir: 'column', -}; - -const colorPickerStyles: CSSProperties = { width: '100%' }; - -const numberInputWidth: ChakraProps['w'] = '4.2rem'; - -const RgbColorPicker = (props: RgbColorPickerProps) => { - const { color, onChange, withNumberInput, ...rest } = props; - const { t } = useTranslation(); - const handleChangeR = useCallback((r: number) => onChange({ ...color, r }), [color, onChange]); - const handleChangeG = useCallback((g: number) => onChange({ ...color, g }), [color, onChange]); - const handleChangeB = useCallback((b: number) => onChange({ ...color, b }), [color, onChange]); - return ( - - - {withNumberInput && ( - - - {t('common.red')} - - - - {t('common.green')} - - - - {t('common.blue')} - - - - )} - - ); -}; - -export default memo(RgbColorPicker); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts index cb96923a996..9aab62da1f9 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts @@ -11,7 +11,7 @@ import { PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX, PROMPT_REGION_POSITIVE_COND_PREFIX, } from 'features/nodes/util/graph/constants'; -import { isRPLayer } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { isVectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { getRegionalPromptLayerBlobs } from 'features/regionalPrompts/util/getLayerBlobs'; import { size } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; @@ -23,12 +23,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull return; } const { dispatch } = getStore(); - // TODO: Handle non-SDXL const isSDXL = state.generation.model?.base === 'sdxl'; const layers = state.regionalPrompts.present.layers - .filter(isRPLayer) // We only want the prompt region layers - .filter((l) => l.isVisible) // Only visible layers are rendered on the canvas - .filter((l) => l.negativePrompt || l.positivePrompt); // Only layers with prompts get added to the graph + // Only support vector mask layers now + // TODO: Image masks + .filter(isVectorMaskLayer) + // Only visible layers are rendered on the canvas + .filter((l) => l.isVisible) + // Only layers with prompts get added to the graph + .filter((l) => { + const hasTextPrompt = l.textPrompt && (l.textPrompt.positive || l.textPrompt.negative); + const hasAtLeastOneImagePrompt = l.imagePrompts.length > 0; + return hasTextPrompt || hasAtLeastOneImagePrompt; + }); const layerIds = layers.map((l) => l.id); const blobs = await getRegionalPromptLayerBlobs(layerIds); @@ -123,19 +130,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull }; graph.nodes[maskToTensorNode.id] = maskToTensorNode; - if (layer.positivePrompt) { + if (layer.textPrompt?.positive) { // The main positive conditioning node const regionalPositiveCondNode: S['SDXLCompelPromptInvocation'] | S['CompelInvocation'] = isSDXL ? { type: 'sdxl_compel_prompt', id: `${PROMPT_REGION_POSITIVE_COND_PREFIX}_${layer.id}`, - prompt: layer.positivePrompt, - style: layer.positivePrompt, // TODO: Should we put the positive prompt in both fields? + prompt: layer.textPrompt.positive, + style: layer.textPrompt.positive, // TODO: Should we put the positive prompt in both fields? } : { type: 'compel', id: `${PROMPT_REGION_POSITIVE_COND_PREFIX}_${layer.id}`, - prompt: layer.positivePrompt, + prompt: layer.textPrompt.positive, }; graph.nodes[regionalPositiveCondNode.id] = regionalPositiveCondNode; @@ -162,19 +169,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull } } - if (layer.negativePrompt) { + if (layer.textPrompt?.negative) { // The main negative conditioning node const regionalNegativeCondNode: S['SDXLCompelPromptInvocation'] | S['CompelInvocation'] = isSDXL ? { type: 'sdxl_compel_prompt', id: `${PROMPT_REGION_NEGATIVE_COND_PREFIX}_${layer.id}`, - prompt: layer.negativePrompt, - style: layer.negativePrompt, + prompt: layer.textPrompt.negative, + style: layer.textPrompt.negative, } : { type: 'compel', id: `${PROMPT_REGION_NEGATIVE_COND_PREFIX}_${layer.id}`, - prompt: layer.negativePrompt, + prompt: layer.textPrompt.negative, }; graph.nodes[regionalNegativeCondNode.id] = regionalNegativeCondNode; @@ -202,7 +209,7 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull } // If we are using the "invert" auto-negative setting, we need to add an additional negative conditioning node - if (layer.autoNegative === 'invert' && layer.positivePrompt) { + if (layer.autoNegative === 'invert' && layer.textPrompt?.positive) { // We re-use the mask image, but invert it when converting to tensor const invertTensorMaskNode: S['InvertTensorMaskInvocation'] = { id: `${PROMPT_REGION_INVERT_TENSOR_MASK_PREFIX}_${layer.id}`, @@ -228,13 +235,13 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull ? { type: 'sdxl_compel_prompt', id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`, - prompt: layer.positivePrompt, - style: layer.positivePrompt, + prompt: layer.textPrompt.positive, + style: layer.textPrompt.positive, } : { type: 'compel', id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`, - prompt: layer.positivePrompt, + prompt: layer.textPrompt.positive, }; graph.nodes[regionalPositiveCondInvertedNode.id] = regionalPositiveCondInvertedNode; // Connect the inverted mask to the conditioning diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx index 1791f73cce2..ec0b5fcffd4 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx @@ -8,7 +8,7 @@ export const AddLayerButton = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const onClick = useCallback(() => { - dispatch(layerAdded('regionalPromptLayer')); + dispatch(layerAdded('vector_mask_layer')); }, [dispatch]); return ; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/PromptLayerOpacity.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/GlobalMaskLayerOpacity.tsx similarity index 62% rename from invokeai/frontend/web/src/features/regionalPrompts/components/PromptLayerOpacity.tsx rename to invokeai/frontend/web/src/features/regionalPrompts/components/GlobalMaskLayerOpacity.tsx index 6b2d4d090b7..4b057fcbfcc 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/PromptLayerOpacity.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/GlobalMaskLayerOpacity.tsx @@ -1,19 +1,19 @@ import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { + globalMaskLayerOpacityChanged, initialRegionalPromptsState, - promptLayerOpacityChanged, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -export const PromptLayerOpacity = memo(() => { +export const GlobalMaskLayerOpacity = memo(() => { const dispatch = useAppDispatch(); const { t } = useTranslation(); - const promptLayerOpacity = useAppSelector((s) => s.regionalPrompts.present.promptLayerOpacity); + const globalMaskLayerOpacity = useAppSelector((s) => s.regionalPrompts.present.globalMaskLayerOpacity); const onChange = useCallback( (v: number) => { - dispatch(promptLayerOpacityChanged(v)); + dispatch(globalMaskLayerOpacityChanged(v)); }, [dispatch] ); @@ -24,20 +24,20 @@ export const PromptLayerOpacity = memo(() => { min={0.25} max={1} step={0.01} - value={promptLayerOpacity} - defaultValue={initialRegionalPromptsState.promptLayerOpacity} + value={globalMaskLayerOpacity} + defaultValue={initialRegionalPromptsState.globalMaskLayerOpacity} onChange={onChange} /> ); }); -PromptLayerOpacity.displayName = 'PromptLayerOpacity'; +GlobalMaskLayerOpacity.displayName = 'GlobalMaskLayerOpacity'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerActionsButtonGroup.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerActionsButtonGroup.tsx index 3f5e97cd18a..ec191471725 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerActionsButtonGroup.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerActionsButtonGroup.tsx @@ -1,6 +1,6 @@ import { ButtonGroup, IconButton } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import { layerDeleted, rpLayerReset } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { layerDeleted, layerReset } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiTrashSimpleBold } from 'react-icons/pi'; @@ -14,7 +14,7 @@ export const RPLayerActionsButtonGroup = memo(({ layerId }: Props) => { dispatch(layerDeleted(layerId)); }, [dispatch, layerId]); const resetLayer = useCallback(() => { - dispatch(rpLayerReset(layerId)); + dispatch(layerReset(layerId)); }, [dispatch, layerId]); return ( diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCombobox.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCombobox.tsx index e8422d5bde7..8cea350e161 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCombobox.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCombobox.tsx @@ -4,8 +4,8 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { isParameterAutoNegative } from 'features/parameters/types/parameterSchemas'; import { - isRPLayer, - rpLayerAutoNegativeChanged, + isVectorMaskLayer, + maskLayerAutoNegativeChanged, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useMemo } from 'react'; @@ -26,7 +26,7 @@ const useAutoNegative = (layerId: string) => { () => createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); + assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); return layer.autoNegative; }), [layerId] @@ -45,7 +45,7 @@ export const RPLayerAutoNegativeCombobox = memo(({ layerId }: Props) => { if (!isParameterAutoNegative(v?.value)) { return; } - dispatch(rpLayerAutoNegativeChanged({ layerId, autoNegative: v.value })); + dispatch(maskLayerAutoNegativeChanged({ layerId, autoNegative: v.value })); }, [dispatch, layerId] ); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerColorPicker.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerColorPicker.tsx index dac93406f49..e8f8eb88160 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerColorPicker.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerColorPicker.tsx @@ -1,14 +1,14 @@ import { IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import RgbColorPicker from 'common/components/RgbColorPicker'; +import IAIColorPicker from 'common/components/IAIColorPicker'; import { - isRPLayer, - rpLayerColorChanged, + isVectorMaskLayer, + maskLayerPreviewColorChanged, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useMemo } from 'react'; -import type { RgbColor } from 'react-colorful'; +import type { RgbaColor } from 'react-colorful'; import { useTranslation } from 'react-i18next'; import { PiEyedropperBold } from 'react-icons/pi'; import { assert } from 'tsafe'; @@ -23,16 +23,16 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => { () => createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); - return layer.color; + assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an vector mask layer`); + return layer.previewColor; }), [layerId] ); const color = useAppSelector(selectColor); const dispatch = useAppDispatch(); const onColorChange = useCallback( - (color: RgbColor) => { - dispatch(rpLayerColorChanged({ layerId, color })); + (color: RgbaColor) => { + dispatch(maskLayerPreviewColorChanged({ layerId, color })); }, [dispatch, layerId] ); @@ -49,7 +49,7 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => { - + diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx index 0d947ac3a5a..9ba30174634 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx @@ -8,7 +8,7 @@ import { RPLayerMenu } from 'features/regionalPrompts/components/RPLayerMenu'; import { RPLayerNegativePrompt } from 'features/regionalPrompts/components/RPLayerNegativePrompt'; import { RPLayerPositivePrompt } from 'features/regionalPrompts/components/RPLayerPositivePrompt'; import { RPLayerVisibilityToggle } from 'features/regionalPrompts/components/RPLayerVisibilityToggle'; -import { isRPLayer, rpLayerSelected } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { isVectorMaskLayer, layerSelected } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback } from 'react'; import { assert } from 'tsafe'; @@ -21,24 +21,32 @@ export const RPLayerListItem = memo(({ layerId }: Props) => { const selectedLayerId = useAppSelector((s) => s.regionalPrompts.present.selectedLayerId); const color = useAppSelector((s) => { const layer = s.regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); - return rgbColorToString(layer.color); + assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); + return rgbColorToString(layer.previewColor); + }); + const hasTextPrompt = useAppSelector((s) => { + const layer = s.regionalPrompts.present.layers.find((l) => l.id === layerId); + assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); + return layer.textPrompt !== null; }); const onClickCapture = useCallback(() => { // Must be capture so that the layer is selected before deleting/resetting/etc - dispatch(rpLayerSelected(layerId)); + dispatch(layerSelected(layerId)); }, [dispatch, layerId]); return ( - + @@ -47,8 +55,8 @@ export const RPLayerListItem = memo(({ layerId }: Props) => { - - + {hasTextPrompt && } + {hasTextPrompt && } ); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerMenu.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerMenu.tsx index 86409f0c40f..54a30d44bcb 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerMenu.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerMenu.tsx @@ -2,13 +2,13 @@ import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { - isRPLayer, + isVectorMaskLayer, layerDeleted, layerMovedBackward, layerMovedForward, layerMovedToBack, layerMovedToFront, - rpLayerReset, + layerReset, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useMemo } from 'react'; @@ -33,7 +33,7 @@ export const RPLayerMenu = memo(({ layerId }: Props) => { () => createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); + assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); const layerIndex = regionalPrompts.present.layers.findIndex((l) => l.id === layerId); const layerCount = regionalPrompts.present.layers.length; return { @@ -59,7 +59,7 @@ export const RPLayerMenu = memo(({ layerId }: Props) => { dispatch(layerMovedToBack(layerId)); }, [dispatch, layerId]); const resetLayer = useCallback(() => { - dispatch(rpLayerReset(layerId)); + dispatch(layerReset(layerId)); }, [dispatch, layerId]); const deleteLayer = useCallback(() => { dispatch(layerDeleted(layerId)); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerNegativePrompt.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerNegativePrompt.tsx index 3a9e49812a4..e9cf9b4ab7e 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerNegativePrompt.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerNegativePrompt.tsx @@ -4,8 +4,8 @@ import { PromptOverlayButtonWrapper } from 'features/parameters/components/Promp import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton'; import { PromptPopover } from 'features/prompt/PromptPopover'; import { usePrompt } from 'features/prompt/usePrompt'; -import { useLayerNegativePrompt } from 'features/regionalPrompts/hooks/layerStateHooks'; -import { rpLayerNegativePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { useMaskLayerTextPrompt } from 'features/regionalPrompts/hooks/layerStateHooks'; +import { maskLayerNegativePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useRef } from 'react'; import type { HotkeyCallback } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -16,18 +16,18 @@ type Props = { }; export const RPLayerNegativePrompt = memo((props: Props) => { - const prompt = useLayerNegativePrompt(props.layerId); + const textPrompt = useMaskLayerTextPrompt(props.layerId); const dispatch = useAppDispatch(); const textareaRef = useRef(null); const { t } = useTranslation(); const _onChange = useCallback( (v: string) => { - dispatch(rpLayerNegativePromptChanged({ layerId: props.layerId, prompt: v })); + dispatch(maskLayerNegativePromptChanged({ layerId: props.layerId, prompt: v })); }, [dispatch, props.layerId] ); const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({ - prompt, + prompt: textPrompt.negative, textareaRef, onChange: _onChange, }); @@ -48,7 +48,7 @@ export const RPLayerNegativePrompt = memo((props: Props) => { id="prompt" name="prompt" ref={textareaRef} - value={prompt} + value={textPrompt.negative} placeholder={t('parameters.negativePromptPlaceholder')} onChange={onChange} onKeyDown={onKeyDown} diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPositivePrompt.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPositivePrompt.tsx index 56276278f43..0c94b49e3de 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPositivePrompt.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPositivePrompt.tsx @@ -4,8 +4,8 @@ import { PromptOverlayButtonWrapper } from 'features/parameters/components/Promp import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton'; import { PromptPopover } from 'features/prompt/PromptPopover'; import { usePrompt } from 'features/prompt/usePrompt'; -import { useLayerPositivePrompt } from 'features/regionalPrompts/hooks/layerStateHooks'; -import { rpLayerPositivePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { useMaskLayerTextPrompt } from 'features/regionalPrompts/hooks/layerStateHooks'; +import { maskLayerPositivePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useRef } from 'react'; import type { HotkeyCallback } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -16,18 +16,18 @@ type Props = { }; export const RPLayerPositivePrompt = memo((props: Props) => { - const prompt = useLayerPositivePrompt(props.layerId); + const textPrompt = useMaskLayerTextPrompt(props.layerId); const dispatch = useAppDispatch(); const textareaRef = useRef(null); const { t } = useTranslation(); const _onChange = useCallback( (v: string) => { - dispatch(rpLayerPositivePromptChanged({ layerId: props.layerId, prompt: v })); + dispatch(maskLayerPositivePromptChanged({ layerId: props.layerId, prompt: v })); }, [dispatch, props.layerId] ); const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({ - prompt, + prompt: textPrompt.positive, textareaRef, onChange: _onChange, }); @@ -48,7 +48,7 @@ export const RPLayerPositivePrompt = memo((props: Props) => { id="prompt" name="prompt" ref={textareaRef} - value={prompt} + value={textPrompt.positive} placeholder={t('parameters.positivePromptPlaceholder')} onChange={onChange} onKeyDown={onKeyDown} diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerVisibilityToggle.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerVisibilityToggle.tsx index 6132263e7cd..4a7c5aa99d7 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerVisibilityToggle.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerVisibilityToggle.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useLayerIsVisible } from 'features/regionalPrompts/hooks/layerStateHooks'; -import { rpLayerIsVisibleToggled } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { layerVisibilityToggled } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiEyeBold, PiEyeClosedBold } from 'react-icons/pi'; @@ -15,7 +15,7 @@ export const RPLayerVisibilityToggle = memo(({ layerId }: Props) => { const dispatch = useAppDispatch(); const isVisible = useLayerIsVisible(layerId); const onClick = useCallback(() => { - dispatch(rpLayerIsVisibleToggled(layerId)); + dispatch(layerVisibilityToggled(layerId)); }, [dispatch, layerId]); return ( diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx index 7e7af03fca5..c3b25cdda2d 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx @@ -7,18 +7,18 @@ import { AddLayerButton } from 'features/regionalPrompts/components/AddLayerButt import { BrushSize } from 'features/regionalPrompts/components/BrushSize'; import { DebugLayersButton } from 'features/regionalPrompts/components/DebugLayersButton'; import { DeleteAllLayersButton } from 'features/regionalPrompts/components/DeleteAllLayersButton'; -import { PromptLayerOpacity } from 'features/regionalPrompts/components/PromptLayerOpacity'; +import { GlobalMaskLayerOpacity } from 'features/regionalPrompts/components/GlobalMaskLayerOpacity'; import { RPEnabledSwitch } from 'features/regionalPrompts/components/RPEnabledSwitch'; import { RPLayerListItem } from 'features/regionalPrompts/components/RPLayerListItem'; import { StageComponent } from 'features/regionalPrompts/components/StageComponent'; import { ToolChooser } from 'features/regionalPrompts/components/ToolChooser'; import { UndoRedoButtonGroup } from 'features/regionalPrompts/components/UndoRedoButtonGroup'; -import { isRPLayer, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { isVectorMaskLayer, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo } from 'react'; const selectRPLayerIdsReversed = createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => regionalPrompts.present.layers - .filter(isRPLayer) + .filter(isVectorMaskLayer) .map((l) => l.id) .reverse() ); @@ -38,7 +38,7 @@ export const RegionalPromptsEditor = memo(() => { - + {rpLayerIdsReversed.map((id) => ( diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx index 3f9e3807b71..3f2465234e0 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx @@ -6,14 +6,15 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useMouseEvents } from 'features/regionalPrompts/hooks/mouseEventHooks'; import { $cursorPosition, + $lastMouseDownPos, $tool, - isRPLayer, - rpLayerBboxChanged, - rpLayerSelected, - rpLayerTranslated, + isVectorMaskLayer, + layerBboxChanged, + layerSelected, + layerTranslated, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { renderBbox, renderBrushPreview, renderLayers } from 'features/regionalPrompts/util/renderers'; +import { renderBbox, renderLayers, renderToolPreview } from 'features/regionalPrompts/util/renderers'; import Konva from 'konva'; import type { IRect } from 'konva/lib/types'; import { atom } from 'nanostores'; @@ -27,8 +28,8 @@ const selectSelectedLayerColor = createMemoizedSelector(selectRegionalPromptsSli if (!layer) { return null; } - assert(isRPLayer(layer), `Layer ${regionalPrompts.present.selectedLayerId} is not an RP layer`); - return layer.color; + assert(isVectorMaskLayer(layer), `Layer ${regionalPrompts.present.selectedLayerId} is not an RP layer`); + return layer.previewColor; }); const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElement | null) => { @@ -40,25 +41,26 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem const tool = useStore($tool); const { onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave } = useMouseEvents(); const cursorPosition = useStore($cursorPosition); + const lastMouseDownPos = useStore($lastMouseDownPos); const selectedLayerIdColor = useAppSelector(selectSelectedLayerColor); const onLayerPosChanged = useCallback( (layerId: string, x: number, y: number) => { - dispatch(rpLayerTranslated({ layerId, x, y })); + dispatch(layerTranslated({ layerId, x, y })); }, [dispatch] ); const onBboxChanged = useCallback( (layerId: string, bbox: IRect | null) => { - dispatch(rpLayerBboxChanged({ layerId, bbox })); + dispatch(layerBboxChanged({ layerId, bbox })); }, [dispatch] ); const onBboxMouseDown = useCallback( (layerId: string) => { - dispatch(rpLayerSelected(layerId)); + dispatch(layerSelected(layerId)); }, [dispatch] ); @@ -130,16 +132,16 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem if (!stage) { return; } - renderBrushPreview(stage, tool, selectedLayerIdColor, cursorPosition, state.brushSize); - }, [stage, tool, cursorPosition, state.brushSize, selectedLayerIdColor]); + renderToolPreview(stage, tool, selectedLayerIdColor, cursorPosition, lastMouseDownPos, state.brushSize); + }, [stage, tool, selectedLayerIdColor, cursorPosition, lastMouseDownPos, state.brushSize]); useLayoutEffect(() => { log.trace('Rendering layers'); if (!stage) { return; } - renderLayers(stage, state.layers, state.selectedLayerId, state.promptLayerOpacity, tool, onLayerPosChanged); - }, [onLayerPosChanged, stage, state.layers, state.promptLayerOpacity, tool, state.selectedLayerId]); + renderLayers(stage, state.layers, tool, onLayerPosChanged); + }, [stage, state.layers, tool, onLayerPosChanged]); useLayoutEffect(() => { log.trace('Rendering bbox'); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx index 524a3fe0c0d..816f10f34d5 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx @@ -1,45 +1,67 @@ import { ButtonGroup, IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; import { $tool } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { useCallback } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; -import { PiArrowsOutCardinalBold, PiEraserBold, PiPaintBrushBold } from 'react-icons/pi'; +import { PiArrowsOutCardinalBold, PiEraserBold, PiPaintBrushBold, PiRectangleBold } from 'react-icons/pi'; export const ToolChooser: React.FC = () => { const { t } = useTranslation(); + const isDisabled = useAppSelector((s) => s.regionalPrompts.present.layers.length === 0); const tool = useStore($tool); + const setToolToBrush = useCallback(() => { $tool.set('brush'); }, []); + useHotkeys('b', setToolToBrush, { enabled: !isDisabled }, [isDisabled]); const setToolToEraser = useCallback(() => { $tool.set('eraser'); }, []); + useHotkeys('e', setToolToEraser, { enabled: !isDisabled }, [isDisabled]); + const setToolToRect = useCallback(() => { + $tool.set('rect'); + }, []); + useHotkeys('u', setToolToRect, { enabled: !isDisabled }, [isDisabled]); const setToolToMove = useCallback(() => { $tool.set('move'); }, []); + useHotkeys('v', setToolToMove, { enabled: !isDisabled }, [isDisabled]); return ( } variant={tool === 'brush' ? 'solid' : 'outline'} onClick={setToolToBrush} + isDisabled={isDisabled} /> } variant={tool === 'eraser' ? 'solid' : 'outline'} onClick={setToolToEraser} + isDisabled={isDisabled} + /> + } + variant={tool === 'rect' ? 'solid' : 'outline'} + onClick={setToolToRect} + isDisabled={isDisabled} /> } variant={tool === 'move' ? 'solid' : 'outline'} onClick={setToolToMove} + isDisabled={isDisabled} /> ); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx index caabe4aad94..bb8f9cfd6e1 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx @@ -1,7 +1,7 @@ /* eslint-disable i18next/no-literal-string */ import { ButtonGroup, IconButton } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { redoRegionalPrompts, undoRegionalPrompts } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { redo, undo } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; @@ -12,30 +12,33 @@ export const UndoRedoButtonGroup = memo(() => { const dispatch = useAppDispatch(); const mayUndo = useAppSelector((s) => s.regionalPrompts.past.length > 0); - const undo = useCallback(() => { - dispatch(undoRegionalPrompts()); + const handleUndo = useCallback(() => { + dispatch(undo()); }, [dispatch]); - useHotkeys(['meta+z', 'ctrl+z'], undo, { enabled: mayUndo, preventDefault: true }, [mayUndo, undo]); + useHotkeys(['meta+z', 'ctrl+z'], handleUndo, { enabled: mayUndo, preventDefault: true }, [mayUndo, handleUndo]); const mayRedo = useAppSelector((s) => s.regionalPrompts.future.length > 0); - const redo = useCallback(() => { - dispatch(redoRegionalPrompts()); + const handleRedo = useCallback(() => { + dispatch(redo()); }, [dispatch]); - useHotkeys(['meta+shift+z', 'ctrl+shift+z'], redo, { enabled: mayRedo, preventDefault: true }, [mayRedo, redo]); + useHotkeys(['meta+shift+z', 'ctrl+shift+z'], handleRedo, { enabled: mayRedo, preventDefault: true }, [ + mayRedo, + handleRedo, + ]); return ( } isDisabled={!mayUndo} /> } isDisabled={!mayRedo} /> diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts index f6be11c07de..46bb7b40d44 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts @@ -1,35 +1,22 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; -import { isRPLayer, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { isVectorMaskLayer, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { useMemo } from 'react'; import { assert } from 'tsafe'; -export const useLayerPositivePrompt = (layerId: string) => { +export const useMaskLayerTextPrompt = (layerId: string) => { const selectLayer = useMemo( () => createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); - return layer.positivePrompt; + assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); + assert(layer.textPrompt !== null, `Layer ${layerId} does not have a text prompt`); + return layer.textPrompt; }), [layerId] ); - const prompt = useAppSelector(selectLayer); - return prompt; -}; - -export const useLayerNegativePrompt = (layerId: string) => { - const selectLayer = useMemo( - () => - createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { - const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); - return layer.negativePrompt; - }), - [layerId] - ); - const prompt = useAppSelector(selectLayer); - return prompt; + const textPrompt = useAppSelector(selectLayer); + return textPrompt; }; export const useLayerIsVisible = (layerId: string) => { @@ -37,7 +24,7 @@ export const useLayerIsVisible = (layerId: string) => { () => createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); + assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); return layer.isVisible; }), [layerId] diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts index 7e845a664b8..7ce11ccf289 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts @@ -1,13 +1,14 @@ import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import getScaledCursorPosition from 'features/canvas/util/getScaledCursorPosition'; import { $cursorPosition, $isMouseDown, $isMouseOver, + $lastMouseDownPos, $tool, - rpLayerLineAdded, - rpLayerPointsAdded, + maskLayerLineAdded, + maskLayerPointsAdded, + maskLayerRectAdded, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import type Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; @@ -17,8 +18,25 @@ const getIsFocused = (stage: Konva.Stage) => { return stage.container().contains(document.activeElement); }; +export const getScaledFlooredCursorPosition = (stage: Konva.Stage) => { + const pointerPosition = stage.getPointerPosition(); + + const stageTransform = stage.getAbsoluteTransform().copy(); + + if (!pointerPosition || !stageTransform) { + return; + } + + const scaledCursorPosition = stageTransform.invert().point(pointerPosition); + + return { + x: Math.floor(scaledCursorPosition.x), + y: Math.floor(scaledCursorPosition.y), + }; +}; + const syncCursorPos = (stage: Konva.Stage) => { - const pos = getScaledCursorPosition(stage); + const pos = getScaledFlooredCursorPosition(stage); if (!pos) { return null; } @@ -42,12 +60,19 @@ export const useMouseEvents = () => { return; } $isMouseDown.set(true); + $lastMouseDownPos.set(pos); if (!selectedLayerId) { return; } // const tool = getTool(); if (tool === 'brush' || tool === 'eraser') { - dispatch(rpLayerLineAdded({ layerId: selectedLayerId, points: [pos.x, pos.y, pos.x, pos.y], tool })); + dispatch( + maskLayerLineAdded({ + layerId: selectedLayerId, + points: [Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.x), Math.floor(pos.y)], + tool, + }) + ); } }, [dispatch, selectedLayerId, tool] @@ -59,12 +84,26 @@ export const useMouseEvents = () => { if (!stage) { return; } - // const tool = getTool(); - if ((tool === 'brush' || tool === 'eraser') && $isMouseDown.get()) { - $isMouseDown.set(false); + $isMouseDown.set(false); + const pos = $cursorPosition.get(); + const lastPos = $lastMouseDownPos.get(); + const tool = $tool.get(); + if (pos && lastPos && selectedLayerId && tool === 'rect') { + dispatch( + maskLayerRectAdded({ + layerId: selectedLayerId, + rect: { + x: Math.min(pos.x, lastPos.x), + y: Math.min(pos.y, lastPos.y), + width: Math.abs(pos.x - lastPos.x), + height: Math.abs(pos.y - lastPos.y), + }, + }) + ); } + $lastMouseDownPos.set(null); }, - [tool] + [dispatch, selectedLayerId] ); const onMouseMove = useCallback( @@ -77,9 +116,8 @@ export const useMouseEvents = () => { if (!pos || !selectedLayerId) { return; } - // const tool = getTool(); if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) { - dispatch(rpLayerPointsAdded({ layerId: selectedLayerId, point: [pos.x, pos.y] })); + dispatch(maskLayerPointsAdded({ layerId: selectedLayerId, point: [Math.floor(pos.x), Math.floor(pos.y)] })); } }, [dispatch, selectedLayerId, tool] @@ -117,7 +155,13 @@ export const useMouseEvents = () => { return; } if (tool === 'brush' || tool === 'eraser') { - dispatch(rpLayerLineAdded({ layerId: selectedLayerId, points: [pos.x, pos.y, pos.x, pos.y], tool })); + dispatch( + maskLayerLineAdded({ + layerId: selectedLayerId, + points: [Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.x), Math.floor(pos.y)], + tool, + }) + ); } } }, diff --git a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts index 118d0e0a220..ee773e0858c 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts @@ -1,77 +1,77 @@ import type { PayloadAction, UnknownAction } from '@reduxjs/toolkit'; -import { createAction, createSlice, isAnyOf } from '@reduxjs/toolkit'; +import { createSlice, isAnyOf } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { moveBackward, moveForward, moveToBack, moveToFront } from 'common/util/arrayUtils'; import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas'; import type { IRect, Vector2d } from 'konva/lib/types'; import { isEqual } from 'lodash-es'; import { atom } from 'nanostores'; -import type { RgbColor } from 'react-colorful'; +import type { RgbaColor } from 'react-colorful'; import type { UndoableOptions } from 'redux-undo'; import { assert } from 'tsafe'; import { v4 as uuidv4 } from 'uuid'; type DrawingTool = 'brush' | 'eraser'; -export type RPTool = DrawingTool | 'move'; +export type Tool = DrawingTool | 'move' | 'rect'; -type LayerObjectBase = { +type VectorMaskLine = { id: string; -}; - -type ImageObject = LayerObjectBase & { - kind: 'image'; - imageName: string; - x: number; - y: number; - width: number; - height: number; -}; - -type LineObject = LayerObjectBase & { - kind: 'line'; + kind: 'vector_mask_line'; tool: DrawingTool; strokeWidth: number; points: number[]; }; -type FillRectObject = LayerObjectBase & { - kind: 'fillRect'; +type VectorMaskRect = { + id: string; + kind: 'vector_mask_rect'; x: number; y: number; width: number; height: number; }; -type LayerObject = ImageObject | LineObject | FillRectObject; +type TextPrompt = { + positive: string; + negative: string; +}; -type LayerBase = { - id: string; +type ImagePrompt = { + // TODO }; -export type RegionalPromptLayer = LayerBase & { - isVisible: boolean; +type LayerBase = { + id: string; x: number; y: number; bbox: IRect | null; bboxNeedsUpdate: boolean; - hasEraserStrokes: boolean; - kind: 'regionalPromptLayer'; - objects: LayerObject[]; - positivePrompt: string; - negativePrompt: string; - color: RgbColor; + isVisible: boolean; +}; + +type MaskLayerBase = LayerBase & { + textPrompt: TextPrompt | null; // Up to one text prompt per mask + imagePrompts: ImagePrompt[]; // Any number of image prompts + previewColor: RgbaColor; autoNegative: ParameterAutoNegative; + needsPixelBbox: boolean; // Needs the slower pixel-based bbox calculation - set to true when an there is an eraser object +}; + +export type VectorMaskLayer = MaskLayerBase & { + kind: 'vector_mask_layer'; + objects: (VectorMaskLine | VectorMaskRect)[]; }; -export type Layer = RegionalPromptLayer; +export type Layer = VectorMaskLayer; type RegionalPromptsState = { _version: 1; selectedLayerId: string | null; layers: Layer[]; brushSize: number; - promptLayerOpacity: number; + brushColor: RgbaColor; + globalMaskLayerOpacity: number; isEnabled: boolean; }; @@ -79,38 +79,43 @@ export const initialRegionalPromptsState: RegionalPromptsState = { _version: 1, selectedLayerId: null, brushSize: 100, + brushColor: { r: 255, g: 0, b: 0, a: 1 }, layers: [], - promptLayerOpacity: 0.5, // This currently doesn't work + globalMaskLayerOpacity: 0.5, // this globally changes all mask layers' opacity isEnabled: false, }; -const isLine = (obj: LayerObject): obj is LineObject => obj.kind === 'line'; -export const isRPLayer = (layer?: Layer): layer is RegionalPromptLayer => layer?.kind === 'regionalPromptLayer'; +const isLine = (obj: VectorMaskLine | VectorMaskRect): obj is VectorMaskLine => obj.kind === 'vector_mask_line'; +export const isVectorMaskLayer = (layer?: Layer): layer is VectorMaskLayer => layer?.kind === 'vector_mask_layer'; export const regionalPromptsSlice = createSlice({ name: 'regionalPrompts', initialState: initialRegionalPromptsState, reducers: { - //#region Meta Layer + //#region All Layers layerAdded: { reducer: (state, action: PayloadAction) => { - if (action.payload === 'regionalPromptLayer') { - const lastColor = state.layers[state.layers.length - 1]?.color; + const kind = action.payload; + if (action.payload === 'vector_mask_layer') { + const lastColor = state.layers[state.layers.length - 1]?.previewColor; const color = LayerColors.next(lastColor); - const layer: RegionalPromptLayer = { - id: getRPLayerId(action.meta.uuid), + const layer: VectorMaskLayer = { + id: getVectorMaskLayerId(action.meta.uuid), + kind, isVisible: true, bbox: null, - kind: action.payload, - positivePrompt: '', - negativePrompt: '', + bboxNeedsUpdate: false, objects: [], - color, + previewColor: color, x: 0, y: 0, autoNegative: 'off', - bboxNeedsUpdate: false, - hasEraserStrokes: false, + needsPixelBbox: false, + textPrompt: { + positive: '', + negative: '', + }, + imagePrompts: [], }; state.layers.push(layer); state.selectedLayerId = layer.id; @@ -119,6 +124,44 @@ export const regionalPromptsSlice = createSlice({ }, prepare: (payload: Layer['kind']) => ({ payload, meta: { uuid: uuidv4() } }), }, + layerSelected: (state, action: PayloadAction) => { + const layer = state.layers.find((l) => l.id === action.payload); + if (layer) { + state.selectedLayerId = layer.id; + } + }, + layerVisibilityToggled: (state, action: PayloadAction) => { + const layer = state.layers.find((l) => l.id === action.payload); + if (layer) { + layer.isVisible = !layer.isVisible; + } + }, + layerTranslated: (state, action: PayloadAction<{ layerId: string; x: number; y: number }>) => { + const { layerId, x, y } = action.payload; + const layer = state.layers.find((l) => l.id === layerId); + if (layer) { + layer.x = x; + layer.y = y; + } + }, + layerBboxChanged: (state, action: PayloadAction<{ layerId: string; bbox: IRect | null }>) => { + const { layerId, bbox } = action.payload; + const layer = state.layers.find((l) => l.id === layerId); + if (layer) { + layer.bbox = bbox; + layer.bboxNeedsUpdate = false; + } + }, + layerReset: (state, action: PayloadAction) => { + const layer = state.layers.find((l) => l.id === action.payload); + if (layer) { + layer.objects = []; + layer.bbox = null; + layer.isVisible = true; + layer.needsPixelBbox = false; + layer.bboxNeedsUpdate = false; + } + }, layerDeleted: (state, action: PayloadAction) => { state.layers = state.layers.filter((l) => l.id !== action.payload); state.selectedLayerId = state.layers[0]?.id ?? null; @@ -141,72 +184,35 @@ export const regionalPromptsSlice = createSlice({ // Because the layers are in reverse order, moving to the back is equivalent to moving to the front moveToFront(state.layers, cb); }, - //#endregion - //#region RP Layers - rpLayerSelected: (state, action: PayloadAction) => { - const layer = state.layers.find((l) => l.id === action.payload); - if (isRPLayer(layer)) { - state.selectedLayerId = layer.id; - } - }, - rpLayerIsVisibleToggled: (state, action: PayloadAction) => { - const layer = state.layers.find((l) => l.id === action.payload); - if (isRPLayer(layer)) { - layer.isVisible = !layer.isVisible; - } - }, - rpLayerReset: (state, action: PayloadAction) => { - const layer = state.layers.find((l) => l.id === action.payload); - if (isRPLayer(layer)) { - layer.objects = []; - layer.bbox = null; - layer.isVisible = true; - layer.hasEraserStrokes = false; - layer.bboxNeedsUpdate = false; - } - }, - rpLayerTranslated: (state, action: PayloadAction<{ layerId: string; x: number; y: number }>) => { - const { layerId, x, y } = action.payload; - const layer = state.layers.find((l) => l.id === layerId); - if (isRPLayer(layer)) { - layer.x = x; - layer.y = y; - } - }, - rpLayerBboxChanged: (state, action: PayloadAction<{ layerId: string; bbox: IRect | null }>) => { - const { layerId, bbox } = action.payload; - const layer = state.layers.find((l) => l.id === layerId); - if (isRPLayer(layer)) { - layer.bbox = bbox; - layer.bboxNeedsUpdate = false; - } - }, allLayersDeleted: (state) => { state.layers = []; state.selectedLayerId = null; }, - rpLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { + //#endregion + + //#region Mask Layers + maskLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { const { layerId, prompt } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (isRPLayer(layer)) { - layer.positivePrompt = prompt; + if (layer && layer.textPrompt) { + layer.textPrompt.positive = prompt; } }, - rpLayerNegativePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { + maskLayerNegativePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { const { layerId, prompt } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (isRPLayer(layer)) { - layer.negativePrompt = prompt; + if (layer && layer.textPrompt) { + layer.textPrompt.negative = prompt; } }, - rpLayerColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbColor }>) => { + maskLayerPreviewColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbaColor }>) => { const { layerId, color } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (isRPLayer(layer)) { - layer.color = color; + if (layer) { + layer.previewColor = color; } }, - rpLayerLineAdded: { + maskLayerLineAdded: { reducer: ( state, action: PayloadAction< @@ -217,20 +223,20 @@ export const regionalPromptsSlice = createSlice({ ) => { const { layerId, points, tool } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (isRPLayer(layer)) { - const lineId = getRPLayerLineId(layer.id, action.meta.uuid); + if (layer) { + const lineId = getVectorMaskLayerLineId(layer.id, action.meta.uuid); layer.objects.push({ - kind: 'line', + kind: 'vector_mask_line', tool: tool, id: lineId, // Points must be offset by the layer's x and y coordinates - // TODO: Handle this in the event listener + // 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 (!layer.hasEraserStrokes && tool === 'eraser') { - layer.hasEraserStrokes = true; + if (!layer.needsPixelBbox && tool === 'eraser') { + layer.needsPixelBbox = true; } } }, @@ -239,10 +245,10 @@ export const regionalPromptsSlice = createSlice({ meta: { uuid: uuidv4() }, }), }, - rpLayerPointsAdded: (state, action: PayloadAction<{ layerId: string; point: [number, number] }>) => { + maskLayerPointsAdded: (state, action: PayloadAction<{ layerId: string; point: [number, number] }>) => { const { layerId, point } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (isRPLayer(layer)) { + if (layer) { const lastLine = layer.objects.findLast(isLine); if (!lastLine) { return; @@ -253,27 +259,66 @@ export const regionalPromptsSlice = createSlice({ layer.bboxNeedsUpdate = true; } }, - rpLayerAutoNegativeChanged: ( + maskLayerRectAdded: { + reducer: (state, action: PayloadAction<{ layerId: string; rect: IRect }, string, { uuid: string }>) => { + const { layerId, rect } = action.payload; + if (rect.height === 0 || rect.width === 0) { + // Ignore zero-area rectangles + return; + } + const layer = state.layers.find((l) => l.id === layerId); + if (layer) { + const id = getVectorMaskLayerRectId(layer.id, action.meta.uuid); + layer.objects.push({ + kind: 'vector_mask_rect', + id, + x: rect.x - layer.x, + y: rect.y - layer.y, + width: rect.width, + height: rect.height, + }); + layer.bboxNeedsUpdate = true; + } + }, + prepare: (payload: { layerId: string; rect: IRect }) => ({ payload, meta: { uuid: uuidv4() } }), + }, + maskLayerAutoNegativeChanged: ( state, action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }> ) => { const { layerId, autoNegative } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (isRPLayer(layer)) { + if (layer) { layer.autoNegative = autoNegative; } }, //#endregion + //#region General brushSizeChanged: (state, action: PayloadAction) => { state.brushSize = action.payload; }, - promptLayerOpacityChanged: (state, action: PayloadAction) => { - state.promptLayerOpacity = action.payload; + globalMaskLayerOpacityChanged: (state, action: PayloadAction) => { + state.globalMaskLayerOpacity = action.payload; + for (const layer of state.layers) { + layer.previewColor.a = action.payload; + } }, isEnabledChanged: (state, action: PayloadAction) => { state.isEnabled = action.payload; }, + undo: (state) => { + // Invalidate the bbox for all layers to prevent stale bboxes + for (const layer of state.layers) { + layer.bboxNeedsUpdate = true; + } + }, + redo: (state) => { + // Invalidate the bbox for all layers to prevent stale bboxes + for (const layer of state.layers) { + layer.bboxNeedsUpdate = true; + } + }, //#endregion }, }); @@ -282,22 +327,22 @@ export const regionalPromptsSlice = createSlice({ * This class is used to cycle through a set of colors for the prompt region layers. */ class LayerColors { - static COLORS: RgbColor[] = [ - { r: 123, g: 159, b: 237 }, // rgb(123, 159, 237) - { r: 106, g: 222, b: 106 }, // rgb(106, 222, 106) - { r: 250, g: 225, b: 80 }, // rgb(250, 225, 80) - { r: 233, g: 137, b: 81 }, // rgb(233, 137, 81) - { r: 229, g: 96, b: 96 }, // rgb(229, 96, 96) - { r: 226, g: 122, b: 210 }, // rgb(226, 122, 210) - { r: 167, g: 116, b: 234 }, // rgb(167, 116, 234) + static COLORS: RgbaColor[] = [ + { r: 123, g: 159, b: 237, a: 1 }, // rgb(123, 159, 237) + { r: 106, g: 222, b: 106, a: 1 }, // rgb(106, 222, 106) + { r: 250, g: 225, b: 80, a: 1 }, // rgb(250, 225, 80) + { r: 233, g: 137, b: 81, a: 1 }, // rgb(233, 137, 81) + { r: 229, g: 96, b: 96, a: 1 }, // rgb(229, 96, 96) + { r: 226, g: 122, b: 210, a: 1 }, // rgb(226, 122, 210) + { r: 167, g: 116, b: 234, a: 1 }, // rgb(167, 116, 234) ]; static i = this.COLORS.length - 1; /** * Get the next color in the sequence. If a known color is provided, the next color will be the one after it. */ - static next(currentColor?: RgbColor): RgbColor { + static next(currentColor?: RgbaColor): RgbaColor { if (currentColor) { - const i = this.COLORS.findIndex((c) => isEqual(c, currentColor)); + const i = this.COLORS.findIndex((c) => isEqual(c, { ...currentColor, a: 1 })); if (i !== -1) { this.i = i; } @@ -310,30 +355,33 @@ class LayerColors { } export const { - // Meta layer actions + // All layer actions layerAdded, layerDeleted, layerMovedBackward, layerMovedForward, layerMovedToBack, layerMovedToFront, + layerReset, + layerSelected, + layerTranslated, + layerBboxChanged, + layerVisibilityToggled, allLayersDeleted, - // Regional Prompt layer actions - rpLayerAutoNegativeChanged, - rpLayerBboxChanged, - rpLayerColorChanged, - rpLayerIsVisibleToggled, - rpLayerLineAdded, - rpLayerNegativePromptChanged, - rpLayerPointsAdded, - rpLayerPositivePromptChanged, - rpLayerReset, - rpLayerSelected, - rpLayerTranslated, + // Mask layer actions + maskLayerAutoNegativeChanged, + maskLayerPreviewColorChanged, + maskLayerLineAdded, + maskLayerNegativePromptChanged, + maskLayerPointsAdded, + maskLayerPositivePromptChanged, + maskLayerRectAdded, // General actions isEnabledChanged, brushSizeChanged, - promptLayerOpacityChanged, + globalMaskLayerOpacityChanged, + undo, + redo, } = regionalPromptsSlice.actions; export const selectRegionalPromptsSlice = (state: RootState) => state.regionalPrompts; @@ -345,26 +393,32 @@ const migrateRegionalPromptsState = (state: any): any => { export const $isMouseDown = atom(false); export const $isMouseOver = atom(false); -export const $tool = atom('brush'); +export const $lastMouseDownPos = atom(null); +export const $tool = atom('brush'); export const $cursorPosition = atom(null); -// IDs for singleton layers and objects -export const BRUSH_PREVIEW_LAYER_ID = 'brushPreviewLayer'; -export const BRUSH_PREVIEW_FILL_ID = 'brushPreviewFill'; -export const BRUSH_PREVIEW_BORDER_INNER_ID = 'brushPreviewBorderInner'; -export const BRUSH_PREVIEW_BORDER_OUTER_ID = 'brushPreviewBorderOuter'; +// IDs for singleton Konva layers and objects +export const TOOL_PREVIEW_LAYER_ID = 'tool_preview_layer'; +export const TOOL_PREVIEW_BRUSH_GROUP_ID = 'tool_preview_layer.brush_group'; +export const TOOL_PREVIEW_BRUSH_FILL_ID = 'tool_preview_layer.brush_fill'; +export const TOOL_PREVIEW_BRUSH_BORDER_INNER_ID = 'tool_preview_layer.brush_border_inner'; +export const TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID = 'tool_preview_layer.brush_border_outer'; +export const TOOL_PREVIEW_RECT_ID = 'tool_preview_layer.rect'; // Names (aka classes) for Konva layers and objects -export const REGIONAL_PROMPT_LAYER_NAME = 'regionalPromptLayer'; -export const REGIONAL_PROMPT_LAYER_LINE_NAME = 'regionalPromptLayerLine'; -export const REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME = 'regionalPromptLayerObjectGroup'; -export const REGIONAL_PROMPT_LAYER_BBOX_NAME = 'regionalPromptLayerBbox'; +export const VECTOR_MASK_LAYER_NAME = 'vector_mask_layer'; +export const VECTOR_MASK_LAYER_LINE_NAME = 'vector_mask_layer.line'; +export const VECTOR_MASK_LAYER_OBJECT_GROUP_NAME = 'vector_mask_layer.object_group'; +export const VECTOR_MASK_LAYER_RECT_NAME = 'vector_mask_layer.rect'; +export const LAYER_BBOX_NAME = 'layer.bbox'; // Getters for non-singleton layer and object IDs -const getRPLayerId = (layerId: string) => `rp_layer_${layerId}`; -const getRPLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`; -export const getRPLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`; -export const getPRLayerBboxId = (layerId: string) => `${layerId}.bbox`; +const getVectorMaskLayerId = (layerId: string) => `${VECTOR_MASK_LAYER_NAME}_${layerId}`; +const getVectorMaskLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`; +const getVectorMaskLayerRectId = (layerId: string, lineId: string) => `${layerId}.rect_${lineId}`; +export const getVectorMaskLayerObjectGroupId = (layerId: string, groupId: string) => + `${layerId}.objectGroup_${groupId}`; +export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`; export const regionalPromptsPersistConfig: PersistConfig = { name: regionalPromptsSlice.name, @@ -373,36 +427,33 @@ export const regionalPromptsPersistConfig: PersistConfig = persistDenylist: [], }; -// Payload-less actions for `redux-undo` -export const undoRegionalPrompts = createAction(`${regionalPromptsSlice.name}/undo`); -export const redoRegionalPrompts = createAction(`${regionalPromptsSlice.name}/redo`); - // These actions are _individually_ grouped together as single undoable actions const undoableGroupByMatcher = isAnyOf( + layerTranslated, brushSizeChanged, - promptLayerOpacityChanged, + globalMaskLayerOpacityChanged, isEnabledChanged, - rpLayerPositivePromptChanged, - rpLayerNegativePromptChanged, - rpLayerTranslated, - rpLayerColorChanged + maskLayerPositivePromptChanged, + maskLayerNegativePromptChanged, + maskLayerPreviewColorChanged ); +// These are used to group actions into logical lines below (hate typos) const LINE_1 = 'LINE_1'; const LINE_2 = 'LINE_2'; export const regionalPromptsUndoableConfig: UndoableOptions = { limit: 64, - undoType: undoRegionalPrompts.type, - redoType: redoRegionalPrompts.type, + undoType: regionalPromptsSlice.actions.undo.type, + redoType: regionalPromptsSlice.actions.redo.type, groupBy: (action, state, history) => { - // Lines are started with `rpLayerLineAdded` and may have any number of subsequent `rpLayerPointsAdded` events. + // Lines are started with `maskLayerLineAdded` and may have any number of subsequent `maskLayerPointsAdded` events. // We can use a double-buffer-esque trick to group each "logical" line as a single undoable action, without grouping // separate logical lines as a single undo action. - if (rpLayerLineAdded.match(action)) { + if (maskLayerLineAdded.match(action)) { return history.group === LINE_1 ? LINE_2 : LINE_1; } - if (rpLayerPointsAdded.match(action)) { + if (maskLayerPointsAdded.match(action)) { if (history.group === LINE_1 || history.group === LINE_2) { return history.group; } @@ -414,12 +465,12 @@ export const regionalPromptsUndoableConfig: UndoableOptions { // Ignore all actions from other slices - if (!action.type.startsWith('regionalPrompts/')) { + if (!action.type.startsWith(regionalPromptsSlice.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 (rpLayerBboxChanged.match(action)) { + if (layerBboxChanged.match(action)) { return false; } return true; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/bbox.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/bbox.ts index 1955ad331f1..9d661f37151 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/bbox.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/bbox.ts @@ -1,6 +1,6 @@ import openBase64ImageInTab from 'common/util/openBase64ImageInTab'; import { imageDataToDataURL } from 'features/canvas/util/blobToDataURL'; -import { REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { VECTOR_MASK_LAYER_OBJECT_GROUP_NAME } from 'features/regionalPrompts/store/regionalPromptsSlice'; import Konva from 'konva'; import type { Layer as KonvaLayerType } from 'konva/lib/Layer'; import type { IRect } from 'konva/lib/types'; @@ -80,7 +80,7 @@ export const getKonvaLayerBbox = (layer: KonvaLayerType, preview: boolean = fals offscreenStage.add(layerClone); for (const child of layerClone.getChildren()) { - if (child.name() === REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME) { + if (child.name() === VECTOR_MASK_LAYER_OBJECT_GROUP_NAME) { // We need to cache the group to ensure it composites out eraser strokes correctly child.cache(); } else { diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts index 64e71474b2c..941c84af6db 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts @@ -1,7 +1,7 @@ import { getStore } from 'app/store/nanostores/store'; import openBase64ImageInTab from 'common/util/openBase64ImageInTab'; import { blobToDataURL } from 'features/canvas/util/blobToDataURL'; -import { REGIONAL_PROMPT_LAYER_NAME } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { VECTOR_MASK_LAYER_NAME } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { renderLayers } from 'features/regionalPrompts/util/renderers'; import Konva from 'konva'; import { assert } from 'tsafe'; @@ -18,12 +18,11 @@ export const getRegionalPromptLayerBlobs = async ( ): Promise> => { const state = getStore().getState(); const reduxLayers = state.regionalPrompts.present.layers; - const selectedLayerIdId = state.regionalPrompts.present.selectedLayerId; const container = document.createElement('div'); const stage = new Konva.Stage({ container, width: state.generation.width, height: state.generation.height }); - renderLayers(stage, reduxLayers, selectedLayerIdId, 1, 'brush'); + renderLayers(stage, reduxLayers, 'brush'); - const konvaLayers = stage.find(`.${REGIONAL_PROMPT_LAYER_NAME}`); + const konvaLayers = stage.find(`.${VECTOR_MASK_LAYER_NAME}`); const blobs: Record = {}; // First remove all layers @@ -51,7 +50,10 @@ export const getRegionalPromptLayerBlobs = async ( if (preview) { const base64 = await blobToDataURL(blob); openBase64ImageInTab([ - { base64, caption: `${reduxLayer.id}: ${reduxLayer.positivePrompt} / ${reduxLayer.negativePrompt}` }, + { + base64, + caption: `${reduxLayer.id}: ${reduxLayer.textPrompt?.positive} / ${reduxLayer.textPrompt?.negative}`, + }, ]); } layer.remove(); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts index 54c6730724c..af00af8c85f 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts @@ -1,18 +1,24 @@ import { getStore } from 'app/store/nanostores/store'; import { rgbColorToString } from 'features/canvas/util/colorToString'; -import getScaledCursorPosition from 'features/canvas/util/getScaledCursorPosition'; -import type { Layer, RegionalPromptLayer, RPTool } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks'; +import type { Layer, Tool, VectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { - BRUSH_PREVIEW_BORDER_INNER_ID, - BRUSH_PREVIEW_BORDER_OUTER_ID, - BRUSH_PREVIEW_FILL_ID, - BRUSH_PREVIEW_LAYER_ID, - getPRLayerBboxId, - getRPLayerObjectGroupId, - REGIONAL_PROMPT_LAYER_BBOX_NAME, - REGIONAL_PROMPT_LAYER_LINE_NAME, - REGIONAL_PROMPT_LAYER_NAME, - REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME, + $isMouseOver, + $tool, + getLayerBboxId, + getVectorMaskLayerObjectGroupId, + isVectorMaskLayer, + LAYER_BBOX_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, + VECTOR_MASK_LAYER_LINE_NAME, + VECTOR_MASK_LAYER_NAME, + VECTOR_MASK_LAYER_OBJECT_GROUP_NAME, + VECTOR_MASK_LAYER_RECT_NAME, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { getKonvaLayerBbox } from 'features/regionalPrompts/util/bbox'; import Konva from 'konva'; @@ -24,11 +30,13 @@ import { v4 as uuidv4 } from 'uuid'; const BBOX_SELECTED_STROKE = 'rgba(78, 190, 255, 1)'; const BBOX_NOT_SELECTED_STROKE = 'rgba(255, 255, 255, 0.353)'; const BBOX_NOT_SELECTED_MOUSEOVER_STROKE = 'rgba(255, 255, 255, 0.661)'; -const BRUSH_PREVIEW_BORDER_INNER_COLOR = 'rgba(0,0,0,1)'; -const BRUSH_PREVIEW_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)'; +const BRUSH_BORDER_INNER_COLOR = 'rgba(0,0,0,1)'; +const BRUSH_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)'; + const GET_CLIENT_RECT_CONFIG = { skipTransform: true }; const mapId = (object: { id: string }) => object.id; + const getIsSelected = (layerId?: string | null) => { if (!layerId) { return false; @@ -36,134 +44,186 @@ const getIsSelected = (layerId?: string | null) => { return layerId === getStore().getState().regionalPrompts.present.selectedLayerId; }; +const selectVectorMaskObjects = (node: Konva.Node) => { + return node.name() === VECTOR_MASK_LAYER_LINE_NAME || node.name() === VECTOR_MASK_LAYER_RECT_NAME; +}; + /** * Renders the brush preview for the selected tool. * @param stage The konva stage to render on. * @param tool The selected tool. * @param color The selected layer's color. * @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 renderBrushPreview = ( +export const renderToolPreview = ( stage: Konva.Stage, - tool: RPTool, + tool: Tool, color: RgbColor | null, cursorPos: Vector2d | null, + lastMouseDownPos: Vector2d | null, brushSize: number ) => { - const layerCount = stage.find(`.${REGIONAL_PROMPT_LAYER_NAME}`).length; + const layerCount = stage.find(`.${VECTOR_MASK_LAYER_NAME}`).length; // Update the stage's pointer style - stage.container().style.cursor = tool === 'move' || layerCount === 0 ? 'default' : 'none'; + if (layerCount === 0) { + // We have no layers, so we should not render any tool + stage.container().style.cursor = 'default'; + } 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 use the brush preview + stage.container().style.cursor = 'none'; + } + + let toolPreviewLayer = stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`); // Create the layer if it doesn't exist - let layer = stage.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`); - if (!layer) { + if (!toolPreviewLayer) { // Initialize the brush preview layer & add to the stage - layer = new Konva.Layer({ id: BRUSH_PREVIEW_LAYER_ID, visible: tool !== 'move', listening: false }); - stage.add(layer); - // The brush preview is hidden and shown as the mouse leaves and enters the stage + toolPreviewLayer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, visible: tool !== 'move', listening: false }); + stage.add(toolPreviewLayer); + + // Add handlers to show/hide the brush preview layer + stage.on('mousemove', (e) => { + const tool = $tool.get(); + e.target + .getStage() + ?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`) + ?.visible(tool === 'brush' || tool === 'eraser'); + }); stage.on('mouseleave', (e) => { - e.target.getStage()?.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`)?.visible(false); + e.target.getStage()?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false); }); stage.on('mouseenter', (e) => { - e.target.getStage()?.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`)?.visible(true); + const tool = $tool.get(); + e.target + .getStage() + ?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`) + ?.visible(tool === 'brush' || tool === 'eraser'); + }); + + // 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 + const rectPreview = new Konva.Rect({ id: TOOL_PREVIEW_RECT_ID, listening: false, stroke: 'white', strokeWidth: 1 }); + toolPreviewLayer.add(rectPreview); } - if (!layer.visible()) { - // Rely on the mouseenter and mouseleave events as a "first pass" for brush preview visibility. If it is not visible - // inside this render function, we do not want to make it visible again... + if (!$isMouseOver.get() || layerCount === 0) { + // We can bail early if the mouse isn't over the stage or there are no layers + toolPreviewLayer.visible(false); return; } - // ...but we may want to hide it if it is visible, when using the move tool or when there are no layers - layer.visible(tool !== 'move' && layerCount > 0); + toolPreviewLayer.visible(true); - // No need to render the brush preview if the cursor position or color is missing - if (!cursorPos || !color) { - return; - } + const brushPreviewGroup = stage.findOne(`#${TOOL_PREVIEW_BRUSH_GROUP_ID}`); + assert(brushPreviewGroup, 'Brush preview group not found'); - // Create and/or update the fill circle - let fill = layer.findOne(`#${BRUSH_PREVIEW_FILL_ID}`); - if (!fill) { - fill = new Konva.Circle({ - id: BRUSH_PREVIEW_FILL_ID, - listening: false, - strokeEnabled: false, + 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 && color && (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: rgbColorToString(color), + globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out', }); - layer.add(fill); - } - fill.setAttrs({ - x: cursorPos.x, - y: cursorPos.y, - radius: brushSize / 2, - fill: rgbColorToString(color), - globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out', - }); - // Create and/or update the inner border of the brush preview - let borderInner = layer.findOne(`#${BRUSH_PREVIEW_BORDER_INNER_ID}`); - if (!borderInner) { - borderInner = new Konva.Circle({ - id: BRUSH_PREVIEW_BORDER_INNER_ID, - listening: false, - stroke: BRUSH_PREVIEW_BORDER_INNER_COLOR, - strokeWidth: 1, - strokeEnabled: true, + // 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, }); - layer.add(borderInner); + + brushPreviewGroup.visible(true); + } else { + brushPreviewGroup.visible(false); } - borderInner.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2 }); - // Create and/or update the outer border of the brush preview - let borderOuter = layer.findOne(`#${BRUSH_PREVIEW_BORDER_OUTER_ID}`); - if (!borderOuter) { - borderOuter = new Konva.Circle({ - id: BRUSH_PREVIEW_BORDER_OUTER_ID, - listening: false, - stroke: BRUSH_PREVIEW_BORDER_OUTER_COLOR, - strokeWidth: 1, - strokeEnabled: true, + if (cursorPos && lastMouseDownPos && tool === 'rect') { + const rectPreview = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_RECT_ID}`); + rectPreview?.setAttrs({ + x: Math.min(cursorPos.x, lastMouseDownPos.x), + y: Math.min(cursorPos.y, lastMouseDownPos.y), + width: Math.abs(cursorPos.x - lastMouseDownPos.x), + height: Math.abs(cursorPos.y - lastMouseDownPos.y), }); - layer.add(borderOuter); + rectPreview?.visible(true); + } else { + rectPreview?.visible(false); } - borderOuter.setAttrs({ - x: cursorPos.x, - y: cursorPos.y, - radius: brushSize / 2 + 1, - }); }; -const renderRPLayer = ( +const renderVectorMaskLayer = ( stage: Konva.Stage, - rpLayer: RegionalPromptLayer, - rpLayerIndex: number, - selectedLayerIdId: string | null, - tool: RPTool, - layerOpacity: number, + vmLayer: VectorMaskLayer, + vmLayerIndex: number, + tool: Tool, onLayerPosChanged?: (layerId: string, x: number, y: number) => void ) => { - let konvaLayer = stage.findOne(`#${rpLayer.id}`); + let konvaLayer = stage.findOne(`#${vmLayer.id}`); if (!konvaLayer) { // This layer hasn't been added to the konva state yet konvaLayer = new Konva.Layer({ - id: rpLayer.id, - name: REGIONAL_PROMPT_LAYER_NAME, + id: vmLayer.id, + name: VECTOR_MASK_LAYER_NAME, draggable: true, + dragDistance: 0, }); // Create a `dragmove` listener for this layer if (onLayerPosChanged) { konvaLayer.on('dragend', function (e) { - onLayerPosChanged(rpLayer.id, e.target.x(), e.target.y()); + onLayerPosChanged(vmLayer.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 = getScaledCursorPosition(stage); + const cursorPos = getScaledFlooredCursorPosition(stage); if (!cursorPos) { return this.getAbsolutePosition(); } @@ -181,8 +241,8 @@ const renderRPLayer = ( // The object group holds all of the layer's objects (e.g. lines and rects) const konvaObjectGroup = new Konva.Group({ - id: getRPLayerObjectGroupId(rpLayer.id, uuidv4()), - name: REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME, + id: getVectorMaskLayerObjectGroupId(vmLayer.id, uuidv4()), + name: VECTOR_MASK_LAYER_OBJECT_GROUP_NAME, listening: false, }); konvaLayer.add(konvaObjectGroup); @@ -190,84 +250,112 @@ const renderRPLayer = ( stage.add(konvaLayer); // When a layer is added, it ends up on top of the brush preview - we need to move the preview back to the top. - stage.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`)?.moveToTop(); + stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.moveToTop(); } // 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: rpLayer.x, - y: rpLayer.y, - // There are rpLayers.length layers, plus a brush preview layer rendered on top of them, so the zIndex works - // out to be the layerIndex. If more layers are added, this may no longer be true. - zIndex: rpLayerIndex, + x: Math.floor(vmLayer.x), + y: Math.floor(vmLayer.y), + // We have a konva layer for each redux layer, plus a brush preview layer, which should always be on top. We can + // therefore use the index of the redux layer as the zIndex for konva layers. If more layers are added to the + // stage, this may no longer be work. + zIndex: vmLayerIndex, }); - const color = rgbColorToString(rpLayer.color); + // Convert the color to a string, stripping the alpha - the object group will handle opacity. + const rgbColor = rgbColorToString(vmLayer.previewColor); - const konvaObjectGroup = konvaLayer.findOne(`.${REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME}`); - assert(konvaObjectGroup, `Object group not found for layer ${rpLayer.id}`); + const konvaObjectGroup = konvaLayer.findOne(`.${VECTOR_MASK_LAYER_OBJECT_GROUP_NAME}`); + assert(konvaObjectGroup, `Object group not found for layer ${vmLayer.id}`); // We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required. let groupNeedsCache = false; - if (konvaObjectGroup.opacity() !== layerOpacity) { - konvaObjectGroup.opacity(layerOpacity); - } - - // Remove deleted objects - const objectIds = rpLayer.objects.map(mapId); - for (const objectNode of konvaLayer.find(`.${REGIONAL_PROMPT_LAYER_LINE_NAME}`)) { + const objectIds = vmLayer.objects.map(mapId); + for (const objectNode of konvaObjectGroup.find(selectVectorMaskObjects)) { if (!objectIds.includes(objectNode.id())) { objectNode.destroy(); groupNeedsCache = true; } } - for (const reduxObject of rpLayer.objects) { - // TODO: Handle rects, images, etc - if (reduxObject.kind !== 'line') { - continue; - } + for (const reduxObject of vmLayer.objects) { + if (reduxObject.kind === 'vector_mask_line') { + let vectorMaskLine = stage.findOne(`#${reduxObject.id}`); + + // Create the line if it doesn't exist + if (!vectorMaskLine) { + vectorMaskLine = new Konva.Line({ + id: reduxObject.id, + key: reduxObject.id, + name: VECTOR_MASK_LAYER_LINE_NAME, + strokeWidth: reduxObject.strokeWidth, + tension: 0, + lineCap: 'round', + lineJoin: 'round', + shadowForStrokeEnabled: false, + globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out', + listening: false, + }); + konvaObjectGroup.add(vectorMaskLine); + } - let konvaObject = stage.findOne(`#${reduxObject.id}`); - - if (!konvaObject) { - // This object hasn't been added to the konva state yet. - konvaObject = new Konva.Line({ - id: reduxObject.id, - key: reduxObject.id, - name: REGIONAL_PROMPT_LAYER_LINE_NAME, - strokeWidth: reduxObject.strokeWidth, - tension: 0, - lineCap: 'round', - lineJoin: 'round', - shadowForStrokeEnabled: false, - globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out', - listening: false, - }); - konvaObjectGroup.add(konvaObject); + // 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 (vectorMaskLine.points().length !== reduxObject.points.length) { + vectorMaskLine.points(reduxObject.points); + groupNeedsCache = true; + } + // Only update the color if it has changed. + if (vectorMaskLine.stroke() !== rgbColor) { + vectorMaskLine.stroke(rgbColor); + groupNeedsCache = true; + } + } else if (reduxObject.kind === 'vector_mask_rect') { + let konvaObject = stage.findOne(`#${reduxObject.id}`); + if (!konvaObject) { + konvaObject = new Konva.Rect({ + id: reduxObject.id, + key: reduxObject.id, + name: VECTOR_MASK_LAYER_RECT_NAME, + x: reduxObject.x, + y: reduxObject.y, + width: reduxObject.width, + height: reduxObject.height, + listening: false, + }); + konvaObjectGroup.add(konvaObject); + } + // Only update the color if it has changed. + if (konvaObject.fill() !== rgbColor) { + konvaObject.fill(rgbColor); + groupNeedsCache = true; + } } + } - // Only update the points if they have changed. The point values are never mutated, they are only added to the array. - if (konvaObject.points().length !== reduxObject.points.length) { - konvaObject.points(reduxObject.points); - groupNeedsCache = true; - } - // Only update the color if it has changed. - if (konvaObject.stroke() !== color) { - konvaObject.stroke(color); - groupNeedsCache = true; - } - // Only update layer visibility if it has changed. - if (konvaLayer.visible() !== rpLayer.isVisible) { - konvaLayer.visible(rpLayer.isVisible); - groupNeedsCache = true; + // Only update layer visibility if it has changed. + if (konvaLayer.visible() !== vmLayer.isVisible) { + konvaLayer.visible(vmLayer.isVisible); + groupNeedsCache = true; + } + + if (konvaObjectGroup.children.length > 0) { + // If we have objects, we need to cache the group to apply the layer opacity... + if (groupNeedsCache) { + // ...but only if we've done something that needs the cache. + konvaObjectGroup.cache(); } + } else { + // No children - clear the cache to reset the previous pixel data + konvaObjectGroup.clearCache(); } - if (groupNeedsCache) { - konvaObjectGroup.cache(); + // Updating group opacity does not require re-caching + if (konvaObjectGroup.opacity() !== vmLayer.previewColor.a) { + konvaObjectGroup.opacity(vmLayer.previewColor.a); } }; @@ -275,7 +363,6 @@ const renderRPLayer = ( * Renders the layers on the stage. * @param stage The konva stage to render on. * @param reduxLayers Array of the layers from the redux store. - * @param selectedLayerIdId The selected layer id. * @param layerOpacity The opacity of the layer. * @param onLayerPosChanged Callback for when the layer's position changes. This is optional to allow for offscreen rendering. * @returns @@ -283,15 +370,13 @@ const renderRPLayer = ( export const renderLayers = ( stage: Konva.Stage, reduxLayers: Layer[], - selectedLayerIdId: string | null, - layerOpacity: number, - tool: RPTool, + tool: Tool, onLayerPosChanged?: (layerId: string, x: number, y: number) => void ) => { const reduxLayerIds = reduxLayers.map(mapId); // Remove un-rendered layers - for (const konvaLayer of stage.find(`.${REGIONAL_PROMPT_LAYER_NAME}`)) { + for (const konvaLayer of stage.find(`.${VECTOR_MASK_LAYER_NAME}`)) { if (!reduxLayerIds.includes(konvaLayer.id())) { konvaLayer.destroy(); } @@ -300,8 +385,8 @@ export const renderLayers = ( for (let layerIndex = 0; layerIndex < reduxLayers.length; layerIndex++) { const reduxLayer = reduxLayers[layerIndex]; assert(reduxLayer, `Layer at index ${layerIndex} is undefined`); - if (reduxLayer.kind === 'regionalPromptLayer') { - renderRPLayer(stage, reduxLayer, layerIndex, selectedLayerIdId, tool, layerOpacity, onLayerPosChanged); + if (isVectorMaskLayer(reduxLayer)) { + renderVectorMaskLayer(stage, reduxLayer, layerIndex, tool, onLayerPosChanged); } } }; @@ -318,16 +403,17 @@ export const renderBbox = ( stage: Konva.Stage, reduxLayers: Layer[], selectedLayerId: string | null, - tool: RPTool, + tool: Tool, onBboxChanged: (layerId: string, bbox: IRect | null) => void, onBboxMouseDown: (layerId: string) => 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') { - for (const bboxRect of stage.find(`.${REGIONAL_PROMPT_LAYER_BBOX_NAME}`)) { - bboxRect.visible(false); - bboxRect.listening(false); - } return; } @@ -340,7 +426,7 @@ export const renderBbox = ( // We only need to recalculate the bbox if the layer has changed and it has objects if (reduxLayer.bboxNeedsUpdate && reduxLayer.objects.length) { // We only need to use the pixel-perfect bounding box if the layer has eraser strokes - bbox = reduxLayer.hasEraserStrokes + bbox = reduxLayer.needsPixelBbox ? getKonvaLayerBbox(konvaLayer) : konvaLayer.getClientRect(GET_CLIENT_RECT_CONFIG); @@ -349,14 +435,14 @@ export const renderBbox = ( } if (!bbox) { - return; + continue; } - let rect = konvaLayer.findOne(`.${REGIONAL_PROMPT_LAYER_BBOX_NAME}`); + let rect = konvaLayer.findOne(`.${LAYER_BBOX_NAME}`); if (!rect) { rect = new Konva.Rect({ - id: getPRLayerBboxId(reduxLayer.id), - name: REGIONAL_PROMPT_LAYER_BBOX_NAME, + id: getLayerBboxId(reduxLayer.id), + name: LAYER_BBOX_NAME, strokeWidth: 1, }); rect.on('mousedown', function () { diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx index e3aa26e79a5..d9ec1a6e541 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx @@ -1,18 +1,30 @@ import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay'; import { RegionalPromptsEditor } from 'features/regionalPrompts/components/RegionalPromptsEditor'; +import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; +const selectValidLayerCount = createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { + if (!regionalPrompts.present.isEnabled) { + return 0; + } + const validLayers = regionalPrompts.present.layers + .filter((l) => l.isVisible) + .filter((l) => { + const hasTextPrompt = l.textPrompt && (l.textPrompt.positive || l.textPrompt.negative); + const hasAtLeastOneImagePrompt = l.imagePrompts.length > 0; + return hasTextPrompt || hasAtLeastOneImagePrompt; + }); + + return validLayers.length; +}); + const TextToImageTab = () => { const { t } = useTranslation(); - const noOfRPLayers = useAppSelector((s) => { - if (!s.regionalPrompts.present.isEnabled) { - return 0; - } - return s.regionalPrompts.present.layers.filter((l) => l.kind === 'regionalPromptLayer' && l.isVisible).length; - }); + const validLayerCount = useAppSelector(selectValidLayerCount); return ( @@ -20,7 +32,7 @@ const TextToImageTab = () => { {t('common.viewer')} {t('regionalPrompts.regionalPrompts')} - {noOfRPLayers > 0 ? ` (${noOfRPLayers})` : ''} + {validLayerCount > 0 ? ` (${validLayerCount})` : ''}