From 5a166a19c437d941f5fab033062e55ad625ee971 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 15:10:25 +1000 Subject: [PATCH 01/12] fix(ui): bbox rendered slightly too small --- .../web/src/features/regionalPrompts/util/renderers.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts index 54c6730724c..40aaef553ce 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts @@ -382,10 +382,10 @@ export const renderBbox = ( rect.setAttrs({ visible: true, listening: true, - x: bbox.x, - y: bbox.y, - width: bbox.width, - height: bbox.height, + x: bbox.x - 1, + y: bbox.y - 1, + width: bbox.width + 2, + height: bbox.height + 2, stroke: reduxLayer.id === selectedLayerId ? BBOX_SELECTED_STROKE : BBOX_NOT_SELECTED_STROKE, }); } From cc4e6dd6b503fe141a85fc77ee117d3f67c4a790 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 15:55:28 +1000 Subject: [PATCH 02/12] fix(ui): fix missing bbox when a layer is empty --- .../frontend/web/src/features/regionalPrompts/util/renderers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts index 40aaef553ce..a7d222f6641 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts @@ -349,7 +349,7 @@ export const renderBbox = ( } if (!bbox) { - return; + continue; } let rect = konvaLayer.findOne(`.${REGIONAL_PROMPT_LAYER_BBOX_NAME}`); From 9e68fb6a87ad4077a8bd7ff877f2b08036e56dfd Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 16:07:27 +1000 Subject: [PATCH 03/12] feat(ui): tool hotkeys for rp --- .../regionalPrompts/components/ToolChooser.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx index 524a3fe0c0d..2fc4a6c3806 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx @@ -2,41 +2,46 @@ import { ButtonGroup, IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; 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'; export const ToolChooser: React.FC = () => { const { t } = useTranslation(); const tool = useStore($tool); + const setToolToBrush = useCallback(() => { $tool.set('brush'); }, []); + useHotkeys('b', setToolToBrush, []); const setToolToEraser = useCallback(() => { $tool.set('eraser'); }, []); + useHotkeys('e', setToolToEraser, []); const setToolToMove = useCallback(() => { $tool.set('move'); }, []); + useHotkeys('v', setToolToMove, []); return ( } variant={tool === 'brush' ? 'solid' : 'outline'} onClick={setToolToBrush} /> } variant={tool === 'eraser' ? 'solid' : 'outline'} onClick={setToolToEraser} /> } variant={tool === 'move' ? 'solid' : 'outline'} onClick={setToolToMove} From 7fdf08e19765eeee8654d7248927772eff38b83f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 16:13:52 +1000 Subject: [PATCH 04/12] fix(ui): brush preview not visible after hotkey --- .../web/src/features/regionalPrompts/util/renderers.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts index a7d222f6641..0ed0dd09f3f 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts @@ -62,6 +62,9 @@ export const renderBrushPreview = ( 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 + stage.on('mousemove', (e) => { + e.target.getStage()?.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`)?.visible(true); + }); stage.on('mouseleave', (e) => { e.target.getStage()?.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`)?.visible(false); }); From 8541c70b8f83f5667a9ded99f156f4257b07b97a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 16:23:37 +1000 Subject: [PATCH 05/12] fix(ui): brush preview cursor jank --- .../features/regionalPrompts/util/renderers.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts index 0ed0dd09f3f..6916681c8ac 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts @@ -3,6 +3,8 @@ 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 { + $isMouseOver, + $tool, BRUSH_PREVIEW_BORDER_INNER_ID, BRUSH_PREVIEW_BORDER_OUTER_ID, BRUSH_PREVIEW_FILL_ID, @@ -63,19 +65,24 @@ export const renderBrushPreview = ( 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}`)?.visible(true); + e.target + .getStage() + ?.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`) + ?.visible($tool.get() !== 'move'); }); stage.on('mouseleave', (e) => { e.target.getStage()?.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`)?.visible(false); }); stage.on('mouseenter', (e) => { - e.target.getStage()?.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`)?.visible(true); + e.target + .getStage() + ?.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`) + ?.visible($tool.get() !== 'move'); }); } - 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()) { + layer.visible(false); return; } From 3c2dc3fe565f3ecd580a6c892c16011ef0420152 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 16:25:47 +1000 Subject: [PATCH 06/12] feat(ui): remove drag distance on layers --- .../frontend/web/src/features/regionalPrompts/util/renderers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts index 6916681c8ac..486eb31385a 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts @@ -162,6 +162,7 @@ const renderRPLayer = ( id: rpLayer.id, name: REGIONAL_PROMPT_LAYER_NAME, draggable: true, + dragDistance: 0, }); // Create a `dragmove` listener for this layer From 69707e6b73fe27202bbc35f93de1c76c35e79987 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 16:46:54 +1000 Subject: [PATCH 07/12] fix(ui): floor all pixel coords This prevents rendering objects with sub-pixel positioning, which looks soft --- .../regionalPrompts/hooks/mouseEventHooks.ts | 38 ++++++++++++++++--- .../regionalPrompts/util/renderers.ts | 10 ++--- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts index 7e845a664b8..d4227b66004 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts @@ -1,6 +1,5 @@ import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import getScaledCursorPosition from 'features/canvas/util/getScaledCursorPosition'; import { $cursorPosition, $isMouseDown, @@ -17,8 +16,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; } @@ -47,7 +63,13 @@ export const useMouseEvents = () => { } // const tool = getTool(); if (tool === 'brush' || tool === 'eraser') { - dispatch(rpLayerLineAdded({ layerId: selectedLayerId, points: [pos.x, pos.y, pos.x, pos.y], tool })); + dispatch( + rpLayerLineAdded({ + layerId: selectedLayerId, + points: [Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.x), Math.floor(pos.y)], + tool, + }) + ); } }, [dispatch, selectedLayerId, tool] @@ -79,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: [pos.x, pos.y] })); + dispatch(rpLayerPointsAdded({ layerId: selectedLayerId, point: [Math.floor(pos.x), Math.floor(pos.y)] })); } }, [dispatch, selectedLayerId, tool] @@ -117,7 +139,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( + rpLayerLineAdded({ + 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/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts index 486eb31385a..2842c646739 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts @@ -1,6 +1,6 @@ import { getStore } from 'app/store/nanostores/store'; import { rgbColorToString } from 'features/canvas/util/colorToString'; -import getScaledCursorPosition from 'features/canvas/util/getScaledCursorPosition'; +import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks'; import type { Layer, RegionalPromptLayer, RPTool } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { $isMouseOver, @@ -168,13 +168,13 @@ const renderRPLayer = ( // Create a `dragmove` listener for this layer if (onLayerPosChanged) { konvaLayer.on('dragend', function (e) { - onLayerPosChanged(rpLayer.id, e.target.x(), e.target.y()); + onLayerPosChanged(rpLayer.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(); } @@ -207,8 +207,8 @@ const renderRPLayer = ( // 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, + 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, From d986b3ddb3c5da070027f28bb0f206093aa54ef3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 21:58:16 +1000 Subject: [PATCH 08/12] feat(nodes): image mask to tensor invocation Thanks @JPPhoto! --- invokeai/app/invocations/mask.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) 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], + ) From 1d9d86c12b27d8eb7b4da410423fffce8f22a058 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 22:10:49 +1000 Subject: [PATCH 09/12] refactor(ui): revise regional prompts state to support prompt-less mask layers This structure is more adaptable to future features like IP-Adapter-only regions, controlnet layers, image masks, etc. --- .../src/common/components/IAIColorPicker.tsx | 10 +- .../src/common/components/RgbColorPicker.tsx | 84 ----- .../util/graph/addRegionalPromptsToGraph.ts | 41 ++- .../components/AddLayerButton.tsx | 2 +- ...Opacity.tsx => GlobalMaskLayerOpacity.tsx} | 18 +- .../components/RPLayerActionsButtonGroup.tsx | 4 +- .../RPLayerAutoNegativeCombobox.tsx | 8 +- .../components/RPLayerColorPicker.tsx | 18 +- .../components/RPLayerListItem.tsx | 26 +- .../components/RPLayerMenu.tsx | 8 +- .../components/RPLayerNegativePrompt.tsx | 12 +- .../components/RPLayerPositivePrompt.tsx | 12 +- .../components/RPLayerVisibilityToggle.tsx | 4 +- .../components/RegionalPromptsEditor.tsx | 8 +- .../components/StageComponent.tsx | 26 +- .../regionalPrompts/hooks/layerStateHooks.ts | 29 +- .../regionalPrompts/hooks/mouseEventHooks.ts | 10 +- .../store/regionalPromptsSlice.ts | 309 +++++++++--------- .../src/features/regionalPrompts/util/bbox.ts | 4 +- .../regionalPrompts/util/getLayerBlobs.ts | 12 +- .../regionalPrompts/util/renderers.ts | 223 +++++++------ .../ui/components/tabs/TextToImageTab.tsx | 26 +- 22 files changed, 424 insertions(+), 470 deletions(-) delete mode 100644 invokeai/frontend/web/src/common/components/RgbColorPicker.tsx rename invokeai/frontend/web/src/features/regionalPrompts/components/{PromptLayerOpacity.tsx => GlobalMaskLayerOpacity.tsx} (62%) 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..64de7b3a3e5 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 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 d4227b66004..d9262335220 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 118d0e0a220..d326de6606a 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 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,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,94 +124,94 @@ export const regionalPromptsSlice = createSlice({ }, prepare: (payload: Layer['kind']) => ({ payload, meta: { uuid: uuidv4() } }), }, - layerDeleted: (state, action: PayloadAction) => { - state.layers = state.layers.filter((l) => l.id !== action.payload); - state.selectedLayerId = state.layers[0]?.id ?? null; - }, - layerMovedForward: (state, action: PayloadAction) => { - const cb = (l: Layer) => l.id === action.payload; - moveForward(state.layers, cb); - }, - layerMovedToFront: (state, action: PayloadAction) => { - const cb = (l: Layer) => l.id === action.payload; - // Because the layers are in reverse order, moving to the front is equivalent to moving to the back - moveToBack(state.layers, cb); - }, - layerMovedBackward: (state, action: PayloadAction) => { - const cb = (l: Layer) => l.id === action.payload; - moveBackward(state.layers, cb); - }, - layerMovedToBack: (state, action: PayloadAction) => { - const cb = (l: Layer) => l.id === action.payload; - // 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) => { + layerSelected: (state, action: PayloadAction) => { const layer = state.layers.find((l) => l.id === action.payload); - if (isRPLayer(layer)) { + if (layer) { state.selectedLayerId = layer.id; } }, - rpLayerIsVisibleToggled: (state, action: PayloadAction) => { + layerVisibilityToggled: (state, action: PayloadAction) => { const layer = state.layers.find((l) => l.id === action.payload); - if (isRPLayer(layer)) { + if (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 }>) => { + 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 (isRPLayer(layer)) { + if (layer) { layer.x = x; layer.y = y; } }, - rpLayerBboxChanged: (state, action: PayloadAction<{ layerId: string; bbox: IRect | null }>) => { + layerBboxChanged: (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)) { + 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; }, - rpLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { + layerMovedForward: (state, action: PayloadAction) => { + const cb = (l: Layer) => l.id === action.payload; + moveForward(state.layers, cb); + }, + layerMovedToFront: (state, action: PayloadAction) => { + const cb = (l: Layer) => l.id === action.payload; + // Because the layers are in reverse order, moving to the front is equivalent to moving to the back + moveToBack(state.layers, cb); + }, + layerMovedBackward: (state, action: PayloadAction) => { + const cb = (l: Layer) => l.id === action.payload; + moveBackward(state.layers, cb); + }, + layerMovedToBack: (state, action: PayloadAction) => { + const cb = (l: Layer) => l.id === action.payload; + // 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 + 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 2842c646739..31d9948dd8e 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}`); + + // 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; + } } + } - // 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); } }; @@ -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 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})` : ''} From 37f38349e42a0115da6cd5c6e048f20b53dd9319 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 22:19:29 +1000 Subject: [PATCH 10/12] tidy(ui): clean up action names --- .../regionalPrompts/hooks/mouseEventHooks.ts | 10 ++--- .../store/regionalPromptsSlice.ts | 40 +++++++++---------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts index d9262335220..f511656a67e 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, - lineAdded, - pointsAddedToLastLine, + maskLayerLineAdded, + maskLayerPointsAdded, } 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( - lineAdded({ + maskLayerLineAdded({ 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(pointsAddedToLastLine({ layerId: selectedLayerId, point: [Math.floor(pos.x), Math.floor(pos.y)] })); + dispatch(maskLayerPointsAdded({ 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( - lineAdded({ + 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 d326de6606a..0c2de490c36 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts @@ -92,7 +92,7 @@ export const regionalPromptsSlice = createSlice({ name: 'regionalPrompts', initialState: initialRegionalPromptsState, reducers: { - //#region Meta Layer + //#region Any Layers layerAdded: { reducer: (state, action: PayloadAction) => { const kind = action.payload; @@ -166,10 +166,6 @@ export const regionalPromptsSlice = createSlice({ 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); @@ -188,8 +184,12 @@ 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); }, + allLayersDeleted: (state) => { + state.layers = []; + state.selectedLayerId = null; + }, //#endregion - //#region RP Layers + //#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); @@ -211,7 +211,7 @@ export const regionalPromptsSlice = createSlice({ layer.previewColor = color; } }, - lineAdded: { + maskLayerLineAdded: { reducer: ( state, action: PayloadAction< @@ -244,7 +244,7 @@ export const regionalPromptsSlice = createSlice({ meta: { uuid: uuidv4() }, }), }, - pointsAddedToLastLine: (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 (layer) { @@ -318,26 +318,26 @@ class LayerColors { } export const { - // Meta layer actions + // Any layer actions layerAdded, layerDeleted, layerMovedBackward, layerMovedForward, layerMovedToBack, layerMovedToFront, + layerReset, + layerSelected, + layerTranslated, + layerBboxChanged, + layerVisibilityToggled, allLayersDeleted, - // Regional Prompt layer actions + // Vector mask layer actions maskLayerAutoNegativeChanged, - layerBboxChanged, maskLayerPreviewColorChanged, - layerVisibilityToggled, - lineAdded, + maskLayerLineAdded, maskLayerNegativePromptChanged, - pointsAddedToLastLine, + maskLayerPointsAdded, maskLayerPositivePromptChanged, - layerReset, - layerSelected, - layerTranslated, // General actions isEnabledChanged, brushSizeChanged, @@ -405,13 +405,13 @@ export const regionalPromptsUndoableConfig: UndoableOptions { - // Lines are started with `lineAdded` and may have any number of subsequent `pointsAddedToLastLine` 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 (lineAdded.match(action)) { + if (maskLayerLineAdded.match(action)) { return history.group === LINE_1 ? LINE_2 : LINE_1; } - if (pointsAddedToLastLine.match(action)) { + if (maskLayerPointsAdded.match(action)) { if (history.group === LINE_1 || history.group === LINE_2) { return history.group; } From b0d5299c347baf2655418b3229e6903ef4b853ec Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 23:21:32 +1000 Subject: [PATCH 11/12] feat(ui): rects on regional prompt UI --- invokeai/frontend/web/public/locales/en.json | 3 +- .../components/StageComponent.tsx | 8 +- .../components/ToolChooser.tsx | 25 +- .../components/UndoRedoButtonGroup.tsx | 18 +- .../regionalPrompts/hooks/mouseEventHooks.ts | 26 ++- .../store/regionalPromptsSlice.ts | 80 +++++-- .../regionalPrompts/util/renderers.ts | 214 +++++++++++------- 7 files changed, 257 insertions(+), 117 deletions(-) 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/features/regionalPrompts/components/StageComponent.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx index 64de7b3a3e5..3f2465234e0 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx @@ -6,6 +6,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useMouseEvents } from 'features/regionalPrompts/hooks/mouseEventHooks'; import { $cursorPosition, + $lastMouseDownPos, $tool, isVectorMaskLayer, layerBboxChanged, @@ -13,7 +14,7 @@ import { layerTranslated, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { renderBbox, renderLayers,renderToolPreview } 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'; @@ -40,6 +41,7 @@ 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( @@ -130,8 +132,8 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem if (!stage) { return; } - renderToolPreview(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'); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx index 2fc4a6c3806..816f10f34d5 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx @@ -1,27 +1,33 @@ 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, []); + useHotkeys('b', setToolToBrush, { enabled: !isDisabled }, [isDisabled]); const setToolToEraser = useCallback(() => { $tool.set('eraser'); }, []); - useHotkeys('e', setToolToEraser, []); + 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, []); + useHotkeys('v', setToolToMove, { enabled: !isDisabled }, [isDisabled]); return ( @@ -31,6 +37,7 @@ export const ToolChooser: React.FC = () => { icon={} variant={tool === 'brush' ? 'solid' : 'outline'} onClick={setToolToBrush} + isDisabled={isDisabled} /> { icon={} variant={tool === 'eraser' ? 'solid' : 'outline'} onClick={setToolToEraser} + isDisabled={isDisabled} + /> + } + variant={tool === 'rect' ? 'solid' : 'outline'} + onClick={setToolToRect} + isDisabled={isDisabled} /> { icon={} 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..12437966625 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,30 @@ 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, undo]); 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, redo]); return ( } isDisabled={!mayUndo} /> } isDisabled={!mayRedo} /> diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts index f511656a67e..7ce11ccf289 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts @@ -4,9 +4,11 @@ import { $cursorPosition, $isMouseDown, $isMouseOver, + $lastMouseDownPos, $tool, maskLayerLineAdded, maskLayerPointsAdded, + maskLayerRectAdded, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import type Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; @@ -58,6 +60,7 @@ export const useMouseEvents = () => { return; } $isMouseDown.set(true); + $lastMouseDownPos.set(pos); if (!selectedLayerId) { return; } @@ -81,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( @@ -99,7 +116,6 @@ export const useMouseEvents = () => { if (!pos || !selectedLayerId) { return; } - // const tool = getTool(); if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) { dispatch(maskLayerPointsAdded({ layerId: selectedLayerId, point: [Math.floor(pos.x), Math.floor(pos.y)] })); } diff --git a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts index 0c2de490c36..ee773e0858c 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts @@ -1,5 +1,5 @@ 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'; @@ -13,7 +13,7 @@ import { v4 as uuidv4 } from 'uuid'; type DrawingTool = 'brush' | 'eraser'; -export type RPTool = DrawingTool | 'move'; +export type Tool = DrawingTool | 'move' | 'rect'; type VectorMaskLine = { id: string; @@ -81,7 +81,7 @@ export const initialRegionalPromptsState: RegionalPromptsState = { brushSize: 100, brushColor: { r: 255, g: 0, b: 0, a: 1 }, layers: [], - globalMaskLayerOpacity: 0.5, // This currently doesn't work + globalMaskLayerOpacity: 0.5, // this globally changes all mask layers' opacity isEnabled: false, }; @@ -92,7 +92,7 @@ export const regionalPromptsSlice = createSlice({ name: 'regionalPrompts', initialState: initialRegionalPromptsState, reducers: { - //#region Any Layers + //#region All Layers layerAdded: { reducer: (state, action: PayloadAction) => { const kind = action.payload; @@ -189,6 +189,7 @@ export const regionalPromptsSlice = createSlice({ state.selectedLayerId = null; }, //#endregion + //#region Mask Layers maskLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { const { layerId, prompt } = action.payload; @@ -258,6 +259,29 @@ export const regionalPromptsSlice = createSlice({ layer.bboxNeedsUpdate = true; } }, + 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 }> @@ -269,6 +293,7 @@ export const regionalPromptsSlice = createSlice({ } }, //#endregion + //#region General brushSizeChanged: (state, action: PayloadAction) => { state.brushSize = action.payload; @@ -282,6 +307,18 @@ export const regionalPromptsSlice = createSlice({ 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 }, }); @@ -318,7 +355,7 @@ class LayerColors { } export const { - // Any layer actions + // All layer actions layerAdded, layerDeleted, layerMovedBackward, @@ -331,17 +368,20 @@ export const { layerBboxChanged, layerVisibilityToggled, allLayersDeleted, - // Vector mask layer actions + // Mask layer actions maskLayerAutoNegativeChanged, maskLayerPreviewColorChanged, maskLayerLineAdded, maskLayerNegativePromptChanged, maskLayerPointsAdded, maskLayerPositivePromptChanged, + maskLayerRectAdded, // General actions isEnabledChanged, brushSizeChanged, globalMaskLayerOpacityChanged, + undo, + redo, } = regionalPromptsSlice.actions; export const selectRegionalPromptsSlice = (state: RootState) => state.regionalPrompts; @@ -353,24 +393,29 @@ 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 +// IDs for singleton Konva layers and objects 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'; +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 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 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`; @@ -382,28 +427,25 @@ 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, globalMaskLayerOpacityChanged, isEnabledChanged, maskLayerPositivePromptChanged, maskLayerNegativePromptChanged, - layerTranslated, 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 `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 @@ -423,7 +465,7 @@ 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 diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts index 31d9948dd8e..af00af8c85f 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts @@ -1,21 +1,24 @@ import { getStore } from 'app/store/nanostores/store'; import { rgbColorToString } from 'features/canvas/util/colorToString'; import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks'; -import type { Layer, RPTool, VectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import type { Layer, Tool, VectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { $isMouseOver, $tool, - BRUSH_BORDER_INNER_ID, - BRUSH_BORDER_OUTER_ID, - BRUSH_FILL_ID, 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'; @@ -41,125 +44,163 @@ 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 renderToolPreview = ( stage: Konva.Stage, - tool: RPTool, + tool: Tool, color: RgbColor | null, cursorPos: Vector2d | null, + lastMouseDownPos: Vector2d | null, brushSize: number ) => { const layerCount = stage.find(`.${VECTOR_MASK_LAYER_NAME}`).length; // Update the stage's pointer style - if (tool === 'move') { - stage.container().style.cursor = 'default'; - } else if (layerCount === 0) { + 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(`#${TOOL_PREVIEW_LAYER_ID}`); - if (!layer) { + if (!toolPreviewLayer) { // Initialize the brush preview layer & add to the stage - 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 + 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.get() !== 'move'); + ?.visible(tool === 'brush' || tool === 'eraser'); }); stage.on('mouseleave', (e) => { e.target.getStage()?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false); }); stage.on('mouseenter', (e) => { + const tool = $tool.get(); e.target .getStage() ?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`) - ?.visible($tool.get() !== 'move'); + ?.visible(tool === 'brush' || tool === 'eraser'); }); - } - - if (!$isMouseOver.get()) { - layer.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); - - // No need to render the brush preview if the cursor position or color is missing - if (!cursorPos || !color) { - return; - } - // Create and/or update the fill circle - let fill = layer.findOne(`#${BRUSH_FILL_ID}`); - if (!fill) { - fill = new Konva.Circle({ - id: BRUSH_FILL_ID, + // 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, }); - 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_BORDER_INNER_ID}`); - if (!borderInner) { - borderInner = new Konva.Circle({ - id: BRUSH_BORDER_INNER_ID, + 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, }); - layer.add(borderInner); - } - 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_BORDER_OUTER_ID}`); - if (!borderOuter) { - borderOuter = new Konva.Circle({ - id: BRUSH_BORDER_OUTER_ID, + 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, }); - layer.add(borderOuter); + 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 (!$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; + } + + toolPreviewLayer.visible(true); + + const brushPreviewGroup = stage.findOne(`#${TOOL_PREVIEW_BRUSH_GROUP_ID}`); + assert(brushPreviewGroup, 'Brush preview group not found'); + + const rectPreview = stage.findOne(`#${TOOL_PREVIEW_RECT_ID}`); + assert(rectPreview, 'Rect preview not found'); + + // No need to render the brush preview if the cursor position or color is missing + if (cursorPos && 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', + }); + + // Update the inner border of the brush preview + const brushPreviewInner = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_BRUSH_BORDER_INNER_ID}`); + brushPreviewInner?.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2 }); + + // Update the outer border of the brush preview + const brushPreviewOuter = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID}`); + brushPreviewOuter?.setAttrs({ + x: cursorPos.x, + y: cursorPos.y, + radius: brushSize / 2 + 1, + }); + + brushPreviewGroup.visible(true); + } else { + brushPreviewGroup.visible(false); + } + + if (cursorPos && lastMouseDownPos && tool === 'rect') { + const 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), + }); + rectPreview?.visible(true); + } else { + rectPreview?.visible(false); } - borderOuter.setAttrs({ - x: cursorPos.x, - y: cursorPos.y, - radius: brushSize / 2 + 1, - }); }; const renderVectorMaskLayer = ( stage: Konva.Stage, vmLayer: VectorMaskLayer, vmLayerIndex: number, - tool: RPTool, + tool: Tool, onLayerPosChanged?: (layerId: string, x: number, y: number) => void ) => { let konvaLayer = stage.findOne(`#${vmLayer.id}`); @@ -233,7 +274,7 @@ const renderVectorMaskLayer = ( let groupNeedsCache = false; const objectIds = vmLayer.objects.map(mapId); - for (const objectNode of konvaObjectGroup.find(`.${VECTOR_MASK_LAYER_LINE_NAME}`)) { + for (const objectNode of konvaObjectGroup.find(selectVectorMaskObjects)) { if (!objectIds.includes(objectNode.id())) { objectNode.destroy(); groupNeedsCache = true; @@ -272,6 +313,26 @@ const renderVectorMaskLayer = ( 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; + } } } @@ -309,7 +370,7 @@ const renderVectorMaskLayer = ( export const renderLayers = ( stage: Konva.Stage, reduxLayers: Layer[], - tool: RPTool, + tool: Tool, onLayerPosChanged?: (layerId: string, x: number, y: number) => void ) => { const reduxLayerIds = reduxLayers.map(mapId); @@ -342,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(`.${LAYER_BBOX_NAME}`)) { - bboxRect.visible(false); - bboxRect.listening(false); - } return; } @@ -406,10 +468,10 @@ export const renderBbox = ( rect.setAttrs({ visible: true, listening: true, - x: bbox.x - 1, - y: bbox.y - 1, - width: bbox.width + 2, - height: bbox.height + 2, + x: bbox.x, + y: bbox.y, + width: bbox.width, + height: bbox.height, stroke: reduxLayer.id === selectedLayerId ? BBOX_SELECTED_STROKE : BBOX_NOT_SELECTED_STROKE, }); } From a5ff233914263528040b3a2347d38c668665de1e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 20 Apr 2024 23:27:59 +1000 Subject: [PATCH 12/12] fix(ui): hotkeys dependency array --- .../regionalPrompts/components/UndoRedoButtonGroup.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx index 12437966625..bb8f9cfd6e1 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx @@ -15,13 +15,16 @@ export const UndoRedoButtonGroup = memo(() => { const handleUndo = useCallback(() => { dispatch(undo()); }, [dispatch]); - useHotkeys(['meta+z', 'ctrl+z'], handleUndo, { 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 handleRedo = useCallback(() => { dispatch(redo()); }, [dispatch]); - useHotkeys(['meta+shift+z', 'ctrl+shift+z'], handleRedo, { enabled: mayRedo, preventDefault: true }, [mayRedo, redo]); + useHotkeys(['meta+shift+z', 'ctrl+shift+z'], handleRedo, { enabled: mayRedo, preventDefault: true }, [ + mayRedo, + handleRedo, + ]); return (