diff --git a/invokeai/frontend/web/src/common/components/IAIColorPicker.tsx b/invokeai/frontend/web/src/common/components/IAIColorPicker.tsx index 68ffa5369e..25b129f678 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 cb96923a99..9aab62da1f 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 1791f73cce..ec0b5fcffd 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 6b2d4d090b..4b057fcbfc 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 3f5e97cd18..ec19147172 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 e8422d5bde..8cea350e16 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 dac93406f4..e8f8eb8816 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 0d947ac3a5..9ba3017463 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 86409f0c40..54a30d44bc 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 3a9e49812a..e9cf9b4ab7 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 56276278f4..0c94b49e3d 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 6132263e7c..4a7c5aa99d 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 7e7af03fca..c3b25cdda2 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 3f9e3807b7..64de7b3a3e 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx @@ -7,13 +7,13 @@ import { useMouseEvents } from 'features/regionalPrompts/hooks/mouseEventHooks'; import { $cursorPosition, $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 +27,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) => { @@ -44,21 +44,21 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem 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,7 +130,7 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem if (!stage) { return; } - renderBrushPreview(stage, tool, selectedLayerIdColor, cursorPosition, state.brushSize); + renderToolPreview(stage, tool, selectedLayerIdColor, cursorPosition, state.brushSize); }, [stage, tool, cursorPosition, state.brushSize, selectedLayerIdColor]); useLayoutEffect(() => { @@ -138,8 +138,8 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem 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/hooks/layerStateHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts index f6be11c07d..46bb7b40d4 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 d4227b6600..d926233522 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts @@ -5,8 +5,8 @@ import { $isMouseDown, $isMouseOver, $tool, - rpLayerLineAdded, - rpLayerPointsAdded, + lineAdded, + pointsAddedToLastLine, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import type Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; @@ -64,7 +64,7 @@ export const useMouseEvents = () => { // const tool = getTool(); if (tool === 'brush' || tool === 'eraser') { dispatch( - rpLayerLineAdded({ + lineAdded({ layerId: selectedLayerId, points: [Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.x), Math.floor(pos.y)], tool, @@ -101,7 +101,7 @@ export const useMouseEvents = () => { } // const tool = getTool(); if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) { - dispatch(rpLayerPointsAdded({ layerId: selectedLayerId, point: [Math.floor(pos.x), Math.floor(pos.y)] })); + dispatch(pointsAddedToLastLine({ layerId: selectedLayerId, point: [Math.floor(pos.x), Math.floor(pos.y)] })); } }, [dispatch, selectedLayerId, tool] @@ -140,7 +140,7 @@ export const useMouseEvents = () => { } if (tool === 'brush' || tool === 'eraser') { dispatch( - rpLayerLineAdded({ + lineAdded({ 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 118d0e0a22..d326de6606 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts @@ -6,7 +6,7 @@ import type { ParameterAutoNegative } from 'features/parameters/types/parameterS 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'; @@ -15,63 +15,63 @@ type DrawingTool = 'brush' | 'eraser'; export type RPTool = DrawingTool | 'move'; -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 ImagePrompt = { + // TODO +}; type LayerBase = { id: string; -}; - -export type RegionalPromptLayer = LayerBase & { - isVisible: boolean; x: number; y: number; bbox: IRect | null; bboxNeedsUpdate: boolean; - hasEraserStrokes: boolean; - kind: 'regionalPromptLayer'; - objects: LayerObject[]; - positivePrompt: string; - negativePrompt: string; - color: RgbColor; - autoNegative: ParameterAutoNegative; + isVisible: boolean; }; -export type Layer = RegionalPromptLayer; +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 = VectorMaskLayer; type RegionalPromptsState = { _version: 1; selectedLayerId: string | null; layers: Layer[]; brushSize: number; - promptLayerOpacity: number; + brushColor: RgbaColor; + globalMaskLayerOpacity: number; isEnabled: boolean; }; @@ -79,13 +79,14 @@ 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 currently doesn't work 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', @@ -94,23 +95,27 @@ export const regionalPromptsSlice = createSlice({ //#region Meta Layer 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,10 +124,52 @@ 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; }, + allLayersDeleted: (state) => { + state.layers = []; + state.selectedLayerId = null; + }, layerMovedForward: (state, action: PayloadAction) => { const cb = (l: Layer) => l.id === action.payload; moveForward(state.layers, cb); @@ -143,70 +190,28 @@ export const regionalPromptsSlice = createSlice({ }, //#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 }>) => { + 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: { + lineAdded: { reducer: ( state, action: PayloadAction< @@ -217,20 +222,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 +244,10 @@ export const regionalPromptsSlice = createSlice({ meta: { uuid: uuidv4() }, }), }, - rpLayerPointsAdded: (state, action: PayloadAction<{ layerId: string; point: [number, number] }>) => { + pointsAddedToLastLine: (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,13 +258,13 @@ export const regionalPromptsSlice = createSlice({ layer.bboxNeedsUpdate = true; } }, - rpLayerAutoNegativeChanged: ( + 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; } }, @@ -268,8 +273,11 @@ export const regionalPromptsSlice = createSlice({ 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; @@ -282,22 +290,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; } @@ -319,21 +327,21 @@ export const { layerMovedToFront, allLayersDeleted, // Regional Prompt layer actions - rpLayerAutoNegativeChanged, - rpLayerBboxChanged, - rpLayerColorChanged, - rpLayerIsVisibleToggled, - rpLayerLineAdded, - rpLayerNegativePromptChanged, - rpLayerPointsAdded, - rpLayerPositivePromptChanged, - rpLayerReset, - rpLayerSelected, - rpLayerTranslated, + maskLayerAutoNegativeChanged, + layerBboxChanged, + maskLayerPreviewColorChanged, + layerVisibilityToggled, + lineAdded, + maskLayerNegativePromptChanged, + pointsAddedToLastLine, + maskLayerPositivePromptChanged, + layerReset, + layerSelected, + layerTranslated, // General actions isEnabledChanged, brushSizeChanged, - promptLayerOpacityChanged, + globalMaskLayerOpacityChanged, } = regionalPromptsSlice.actions; export const selectRegionalPromptsSlice = (state: RootState) => state.regionalPrompts; @@ -349,22 +357,23 @@ 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'; +export const TOOL_PREVIEW_LAYER_ID = 'tool_preview_layer'; +export const BRUSH_FILL_ID = 'brush_fill'; +export const BRUSH_BORDER_INNER_ID = 'brush_border_inner'; +export const BRUSH_BORDER_OUTER_ID = 'brush_border_outer'; // 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 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}`; +export const getVectorMaskLayerObjectGroupId = (layerId: string, groupId: string) => + `${layerId}.objectGroup_${groupId}`; +export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`; export const regionalPromptsPersistConfig: PersistConfig = { name: regionalPromptsSlice.name, @@ -380,12 +389,12 @@ export const redoRegionalPrompts = createAction(`${regionalPromptsSlice.name}/re // These actions are _individually_ grouped together as single undoable actions const undoableGroupByMatcher = isAnyOf( brushSizeChanged, - promptLayerOpacityChanged, + globalMaskLayerOpacityChanged, isEnabledChanged, - rpLayerPositivePromptChanged, - rpLayerNegativePromptChanged, - rpLayerTranslated, - rpLayerColorChanged + maskLayerPositivePromptChanged, + maskLayerNegativePromptChanged, + layerTranslated, + maskLayerPreviewColorChanged ); const LINE_1 = 'LINE_1'; @@ -396,13 +405,13 @@ export const regionalPromptsUndoableConfig: UndoableOptions { - // Lines are started with `rpLayerLineAdded` and may have any number of subsequent `rpLayerPointsAdded` events. + // Lines are started with `lineAdded` and may have any number of subsequent `pointsAddedToLastLine` 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 (lineAdded.match(action)) { return history.group === LINE_1 ? LINE_2 : LINE_1; } - if (rpLayerPointsAdded.match(action)) { + if (pointsAddedToLastLine.match(action)) { if (history.group === LINE_1 || history.group === LINE_2) { return history.group; } @@ -419,7 +428,7 @@ export const regionalPromptsUndoableConfig: UndoableOptions> => { 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 2842c64673..31d9948dd8 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts @@ -1,20 +1,21 @@ import { getStore } from 'app/store/nanostores/store'; import { rgbColorToString } from 'features/canvas/util/colorToString'; import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks'; -import type { Layer, RegionalPromptLayer, RPTool } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import type { Layer, RPTool, VectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { $isMouseOver, $tool, - 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, + BRUSH_BORDER_INNER_ID, + BRUSH_BORDER_OUTER_ID, + BRUSH_FILL_ID, + getLayerBboxId, + getVectorMaskLayerObjectGroupId, + isVectorMaskLayer, + LAYER_BBOX_NAME, + TOOL_PREVIEW_LAYER_ID, + VECTOR_MASK_LAYER_LINE_NAME, + VECTOR_MASK_LAYER_NAME, + VECTOR_MASK_LAYER_OBJECT_GROUP_NAME, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { getKonvaLayerBbox } from 'features/regionalPrompts/util/bbox'; import Konva from 'konva'; @@ -26,11 +27,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; @@ -46,37 +49,44 @@ const getIsSelected = (layerId?: string | null) => { * @param cursorPos The cursor position. * @param brushSize The brush size. */ -export const renderBrushPreview = ( +export const renderToolPreview = ( stage: Konva.Stage, tool: RPTool, color: RgbColor | null, cursorPos: 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 (tool === 'move') { + stage.container().style.cursor = 'default'; + } else if (layerCount === 0) { + // We have no layers, so we should not render any tool + stage.container().style.cursor = 'default'; + } else { + stage.container().style.cursor = 'none'; + } // Create the layer if it doesn't exist - let layer = stage.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`); + let layer = stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`); if (!layer) { // Initialize the brush preview layer & add to the stage - layer = new Konva.Layer({ id: BRUSH_PREVIEW_LAYER_ID, visible: tool !== 'move', listening: false }); + layer = new Konva.Layer({ id: TOOL_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 stage.on('mousemove', (e) => { e.target .getStage() - ?.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`) + ?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`) ?.visible($tool.get() !== 'move'); }); 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}`) + ?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`) ?.visible($tool.get() !== 'move'); }); } @@ -95,10 +105,10 @@ export const renderBrushPreview = ( } // Create and/or update the fill circle - let fill = layer.findOne(`#${BRUSH_PREVIEW_FILL_ID}`); + let fill = layer.findOne(`#${BRUSH_FILL_ID}`); if (!fill) { fill = new Konva.Circle({ - id: BRUSH_PREVIEW_FILL_ID, + id: BRUSH_FILL_ID, listening: false, strokeEnabled: false, }); @@ -113,12 +123,12 @@ export const renderBrushPreview = ( }); // Create and/or update the inner border of the brush preview - let borderInner = layer.findOne(`#${BRUSH_PREVIEW_BORDER_INNER_ID}`); + let borderInner = layer.findOne(`#${BRUSH_BORDER_INNER_ID}`); if (!borderInner) { borderInner = new Konva.Circle({ - id: BRUSH_PREVIEW_BORDER_INNER_ID, + id: BRUSH_BORDER_INNER_ID, listening: false, - stroke: BRUSH_PREVIEW_BORDER_INNER_COLOR, + stroke: BRUSH_BORDER_INNER_COLOR, strokeWidth: 1, strokeEnabled: true, }); @@ -127,12 +137,12 @@ export const renderBrushPreview = ( 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}`); + let borderOuter = layer.findOne(`#${BRUSH_BORDER_OUTER_ID}`); if (!borderOuter) { borderOuter = new Konva.Circle({ - id: BRUSH_PREVIEW_BORDER_OUTER_ID, + id: BRUSH_BORDER_OUTER_ID, listening: false, - stroke: BRUSH_PREVIEW_BORDER_OUTER_COLOR, + stroke: BRUSH_BORDER_OUTER_COLOR, strokeWidth: 1, strokeEnabled: true, }); @@ -145,22 +155,20 @@ export const renderBrushPreview = ( }); }; -const renderRPLayer = ( +const renderVectorMaskLayer = ( stage: Konva.Stage, - rpLayer: RegionalPromptLayer, - rpLayerIndex: number, - selectedLayerIdId: string | null, + vmLayer: VectorMaskLayer, + vmLayerIndex: number, tool: RPTool, - layerOpacity: number, 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, }); @@ -168,7 +176,7 @@ const renderRPLayer = ( // Create a `dragmove` listener for this layer if (onLayerPosChanged) { konvaLayer.on('dragend', function (e) { - onLayerPosChanged(rpLayer.id, Math.floor(e.target.x()), Math.floor(e.target.y())); + onLayerPosChanged(vmLayer.id, Math.floor(e.target.x()), Math.floor(e.target.y())); }); } @@ -192,8 +200,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); @@ -201,84 +209,92 @@ 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: Math.floor(rpLayer.x), - y: Math.floor(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(`.${VECTOR_MASK_LAYER_LINE_NAME}`)) { 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}`); - let konvaObject = 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); + } - 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. - 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 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; + } } } - if (groupNeedsCache) { - konvaObjectGroup.cache(); + // 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(); + } + + // Updating group opacity does not require re-caching + if (konvaObjectGroup.opacity() !== vmLayer.previewColor.a) { + konvaObjectGroup.opacity(vmLayer.previewColor.a); } }; @@ -286,7 +302,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 @@ -294,15 +309,13 @@ const renderRPLayer = ( export const renderLayers = ( stage: Konva.Stage, reduxLayers: Layer[], - selectedLayerIdId: string | null, - layerOpacity: number, tool: RPTool, 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(); } @@ -311,8 +324,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); } } }; @@ -335,7 +348,7 @@ export const renderBbox = ( ) => { // 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}`)) { + for (const bboxRect of stage.find(`.${LAYER_BBOX_NAME}`)) { bboxRect.visible(false); bboxRect.listening(false); } @@ -351,7 +364,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); @@ -363,11 +376,11 @@ export const renderBbox = ( 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 e3aa26e79a..d9ec1a6e54 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})` : ''}