diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index cecb7a523e2..a591e654a7b 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -25,7 +25,7 @@ "typegen": "node scripts/typegen.js", "preview": "vite preview", "lint:knip": "knip", - "lint:dpdm": "dpdm --no-warning --no-tree --transform --exit-code circular:1 src/main.tsx", + "lint:dpdm": "dpdm --no-warning --no-tree --transform --exit-code circular:0 src/main.tsx", "lint:eslint": "eslint --max-warnings=0 .", "lint:prettier": "prettier --check .", "lint:tsc": "tsc --noEmit", diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index c78e7a5fce2..759d24842fb 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -84,6 +84,8 @@ "direction": "Direction", "ipAdapter": "IP Adapter", "t2iAdapter": "T2I Adapter", + "positivePrompt": "Positive Prompt", + "negativePrompt": "Negative Prompt", "discordLabel": "Discord", "dontAskMeAgain": "Don't ask me again", "error": "Error", @@ -1518,11 +1520,16 @@ "brushSize": "Brush Size", "regionalPrompts": "Regional Prompts BETA", "enableRegionalPrompts": "Enable $t(regionalPrompts.regionalPrompts)", - "layerOpacity": "Layer Opacity", + "globalMaskOpacity": "Global Mask Opacity", "autoNegative": "Auto Negative", "toggleVisibility": "Toggle Layer Visibility", + "deletePrompt": "Delete Prompt", "resetRegion": "Reset Region", "debugLayers": "Debug Layers", - "rectangle": "Rectangle" + "rectangle": "Rectangle", + "maskPreviewColor": "Mask Preview Color", + "addPositivePrompt": "Add $t(common.positivePrompt)", + "addNegativePrompt": "Add $t(common.negativePrompt)", + "addIPAdapter": "Add $t(common.ipAdapter)" } } diff --git a/invokeai/frontend/web/src/common/components/RgbColorPicker.tsx b/invokeai/frontend/web/src/common/components/RgbColorPicker.tsx new file mode 100644 index 00000000000..ecb9405a3d6 --- /dev/null +++ b/invokeai/frontend/web/src/common/components/RgbColorPicker.tsx @@ -0,0 +1,84 @@ +import type { ChakraProps } from '@invoke-ai/ui-library'; +import { CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import type { CSSProperties } from 'react'; +import { memo, useCallback } from 'react'; +import { RgbColorPicker as ColorfulRgbColorPicker } from 'react-colorful'; +import type { ColorPickerBaseProps, RgbColor } from 'react-colorful/dist/types'; +import { useTranslation } from 'react-i18next'; + +type RgbColorPickerProps = ColorPickerBaseProps & { + 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'] = '3.5rem'; + +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')[0]} + + + + {t('common.green')[0]} + + + + {t('common.blue')[0]} + + + + )} + + ); +}; + +export default memo(RgbColorPicker); diff --git a/invokeai/frontend/web/src/features/controlAdapters/store/controlAdaptersSlice.ts b/invokeai/frontend/web/src/features/controlAdapters/store/controlAdaptersSlice.ts index 9a1ce5e9842..3e335f4cc32 100644 --- a/invokeai/frontend/web/src/features/controlAdapters/store/controlAdaptersSlice.ts +++ b/invokeai/frontend/web/src/features/controlAdapters/store/controlAdaptersSlice.ts @@ -6,6 +6,7 @@ import { deepClone } from 'common/util/deepClone'; import { buildControlAdapter } from 'features/controlAdapters/util/buildControlAdapter'; import { buildControlAdapterProcessor } from 'features/controlAdapters/util/buildControlAdapterProcessor'; import { zModelIdentifierField } from 'features/nodes/types/common'; +import { maskLayerIPAdapterAdded } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { merge, uniq } from 'lodash-es'; import type { ControlNetModelConfig, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types'; import { socketInvocationError } from 'services/events/actions'; @@ -382,6 +383,10 @@ export const controlAdaptersSlice = createSlice({ builder.addCase(socketInvocationError, (state) => { state.pendingControlImages = []; }); + + builder.addCase(maskLayerIPAdapterAdded, (state, action) => { + caAdapter.addOne(state, buildControlAdapter(action.meta.uuid, 'ip_adapter')); + }); }, }); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addIPAdapterToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addIPAdapterToLinearGraph.ts index 568b24ccfdc..0a90622e044 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/addIPAdapterToLinearGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/addIPAdapterToLinearGraph.ts @@ -19,12 +19,14 @@ export const addIPAdapterToLinearGraph = async ( graph: NonNullableGraph, baseNodeId: string ): Promise => { - const validIPAdapters = selectValidIPAdapters(state.controlAdapters).filter(({ model, controlImage, isEnabled }) => { - const hasModel = Boolean(model); - const doesBaseMatch = model?.base === state.generation.model?.base; - const hasControlImage = controlImage; - return isEnabled && hasModel && doesBaseMatch && hasControlImage; - }); + const validIPAdapters = selectValidIPAdapters(state.controlAdapters) + .filter(({ model, controlImage, isEnabled }) => { + const hasModel = Boolean(model); + const doesBaseMatch = model?.base === state.generation.model?.base; + const hasControlImage = controlImage; + return isEnabled && hasModel && doesBaseMatch && hasControlImage; + }) + .filter((ca) => !state.regionalPrompts.present.layers.some((l) => l.ipAdapterIds.includes(ca.id))); if (validIPAdapters.length) { // Even though denoise_latents' ip adapter input is collection or scalar, keep it simple and always use a collect 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 9aab62da1f9..01c0a8dbf8c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts @@ -1,6 +1,8 @@ import { getStore } from 'app/store/nanostores/store'; import type { RootState } from 'app/store/store'; +import { selectAllIPAdapters } from 'features/controlAdapters/store/controlAdaptersSlice'; import { + IP_ADAPTER_COLLECT, NEGATIVE_CONDITIONING, NEGATIVE_CONDITIONING_COLLECT, POSITIVE_CONDITIONING, @@ -13,9 +15,9 @@ import { } from 'features/nodes/util/graph/constants'; import { isVectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { getRegionalPromptLayerBlobs } from 'features/regionalPrompts/util/getLayerBlobs'; -import { size } from 'lodash-es'; +import { size, sumBy } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; -import type { CollectInvocation, Edge, NonNullableGraph, S } from 'services/api/types'; +import type { CollectInvocation, Edge, IPAdapterInvocation, NonNullableGraph, S } from 'services/api/types'; import { assert } from 'tsafe'; export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNullableGraph, denoiseNodeId: string) => { @@ -32,9 +34,9 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull .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 hasTextPrompt = Boolean(l.positivePrompt || l.negativePrompt); + const hasIPAdapter = l.ipAdapterIds.length !== 0; + return hasTextPrompt || hasIPAdapter; }); const layerIds = layers.map((l) => l.id); @@ -103,6 +105,22 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull }, }); + if (!graph.nodes[IP_ADAPTER_COLLECT] && sumBy(layers, (l) => l.ipAdapterIds.length) > 0) { + const ipAdapterCollectNode: CollectInvocation = { + id: IP_ADAPTER_COLLECT, + type: 'collect', + is_intermediate: true, + }; + graph.nodes[IP_ADAPTER_COLLECT] = ipAdapterCollectNode; + graph.edges.push({ + source: { node_id: IP_ADAPTER_COLLECT, field: 'collection' }, + destination: { + node_id: denoiseNodeId, + field: 'ip_adapter', + }, + }); + } + // Upload the blobs to the backend, add each to graph // TODO: Store the uploaded image names in redux to reuse them, so long as the layer hasn't otherwise changed. This // would be a great perf win - not only would we skip re-uploading the same image, but we'd be able to use the node @@ -130,19 +148,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull }; graph.nodes[maskToTensorNode.id] = maskToTensorNode; - if (layer.textPrompt?.positive) { + if (layer.positivePrompt) { // 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.textPrompt.positive, - style: layer.textPrompt.positive, // TODO: Should we put the positive prompt in both fields? + prompt: layer.positivePrompt, + style: layer.positivePrompt, // TODO: Should we put the positive prompt in both fields? } : { type: 'compel', id: `${PROMPT_REGION_POSITIVE_COND_PREFIX}_${layer.id}`, - prompt: layer.textPrompt.positive, + prompt: layer.positivePrompt, }; graph.nodes[regionalPositiveCondNode.id] = regionalPositiveCondNode; @@ -169,19 +187,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull } } - if (layer.textPrompt?.negative) { + if (layer.negativePrompt) { // 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.textPrompt.negative, - style: layer.textPrompt.negative, + prompt: layer.negativePrompt, + style: layer.negativePrompt, } : { type: 'compel', id: `${PROMPT_REGION_NEGATIVE_COND_PREFIX}_${layer.id}`, - prompt: layer.textPrompt.negative, + prompt: layer.negativePrompt, }; graph.nodes[regionalNegativeCondNode.id] = regionalNegativeCondNode; @@ -209,7 +227,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.textPrompt?.positive) { + if (layer.autoNegative === 'invert' && layer.positivePrompt) { // 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}`, @@ -235,13 +253,13 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull ? { type: 'sdxl_compel_prompt', id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`, - prompt: layer.textPrompt.positive, - style: layer.textPrompt.positive, + prompt: layer.positivePrompt, + style: layer.positivePrompt, } : { type: 'compel', id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`, - prompt: layer.textPrompt.positive, + prompt: layer.positivePrompt, }; graph.nodes[regionalPositiveCondInvertedNode.id] = regionalPositiveCondInvertedNode; // Connect the inverted mask to the conditioning @@ -264,5 +282,47 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull } } } + + for (const ipAdapterId of layer.ipAdapterIds) { + const ipAdapter = selectAllIPAdapters(state.controlAdapters).find((ca) => ca.id === ipAdapterId); + console.log(ipAdapter); + if (!ipAdapter?.model) { + return; + } + const { id, weight, model, clipVisionModel, method, beginStepPct, endStepPct, controlImage } = ipAdapter; + + assert(controlImage, 'IP Adapter image is required'); + + const ipAdapterNode: IPAdapterInvocation = { + id: `ip_adapter_${id}`, + type: 'ip_adapter', + is_intermediate: true, + weight: weight, + method: method, + ip_adapter_model: model, + clip_vision_model: clipVisionModel, + begin_step_percent: beginStepPct, + end_step_percent: endStepPct, + image: { + image_name: controlImage, + }, + }; + + graph.nodes[ipAdapterNode.id] = ipAdapterNode; + + // Connect the mask to the conditioning + graph.edges.push({ + source: { node_id: maskToTensorNode.id, field: 'mask' }, + destination: { node_id: ipAdapterNode.id, field: 'mask' }, + }); + + graph.edges.push({ + source: { node_id: ipAdapterNode.id, field: 'ip_adapter' }, + destination: { + node_id: IP_ADAPTER_COLLECT, + field: 'item', + }, + }); + } } }; diff --git a/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts b/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts index 31f83e76a07..a18cc7f86d3 100644 --- a/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts +++ b/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts @@ -200,6 +200,4 @@ export const isParameterLoRAWeight = (val: unknown): val is ParameterLoRAWeight // #region Regional Prompts AutoNegative const zAutoNegative = z.enum(['off', 'invert']); export type ParameterAutoNegative = z.infer; -export const isParameterAutoNegative = (val: unknown): val is ParameterAutoNegative => - zAutoNegative.safeParse(val).success; // #endregion diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/AddPromptButtons.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/AddPromptButtons.tsx new file mode 100644 index 00000000000..2f1d4e8f4d4 --- /dev/null +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/AddPromptButtons.tsx @@ -0,0 +1,70 @@ +import { Button, Flex } from '@invoke-ai/ui-library'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { + isVectorMaskLayer, + maskLayerIPAdapterAdded, + maskLayerNegativePromptChanged, + maskLayerPositivePromptChanged, + selectRegionalPromptsSlice, +} from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiPlusBold } from 'react-icons/pi'; +import { assert } from 'tsafe'; +type AddPromptButtonProps = { + layerId: string; +}; + +export const AddPromptButtons = ({ layerId }: AddPromptButtonProps) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const selectValidActions = useMemo( + () => + createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { + const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); + assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); + return { + canAddPositivePrompt: layer.positivePrompt === null, + canAddNegativePrompt: layer.negativePrompt === null, + }; + }), + [layerId] + ); + const validActions = useAppSelector(selectValidActions); + const addPositivePrompt = useCallback(() => { + dispatch(maskLayerPositivePromptChanged({ layerId, prompt: '' })); + }, [dispatch, layerId]); + const addNegativePrompt = useCallback(() => { + dispatch(maskLayerNegativePromptChanged({ layerId, prompt: '' })); + }, [dispatch, layerId]); + const addIPAdapter = useCallback(() => { + dispatch(maskLayerIPAdapterAdded(layerId)); + }, [dispatch, layerId]); + + return ( + + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/BrushSize.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/BrushSize.tsx index 03491abe952..e06e259f6e0 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/BrushSize.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/BrushSize.tsx @@ -1,9 +1,22 @@ -import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { + CompositeNumberInput, + CompositeSlider, + FormControl, + FormLabel, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, +} from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { brushSizeChanged, initialRegionalPromptsState } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +const marks = [0, 100, 200, 300]; +const formatPx = (v: number | string) => `${v} px`; + export const BrushSize = memo(() => { const dispatch = useAppDispatch(); const { t } = useTranslation(); @@ -15,22 +28,34 @@ export const BrushSize = memo(() => { [dispatch] ); return ( - - {t('regionalPrompts.brushSize')} - - + + {t('regionalPrompts.brushSize')} + + + + + + + + + + + ); }); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/DebugLayersButton.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/DebugLayersButton.tsx deleted file mode 100644 index 5c117d520c0..00000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/DebugLayersButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { IconButton } from '@invoke-ai/ui-library'; -import { getRegionalPromptLayerBlobs } from 'features/regionalPrompts/util/getLayerBlobs'; -import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiBugBold } from 'react-icons/pi'; - -const debugBlobs = () => { - getRegionalPromptLayerBlobs(undefined, true); -}; -export const DebugLayersButton = memo(() => { - const { t } = useTranslation(); - return ( - } - onClick={debugBlobs} - /> - ); -}); - -DebugLayersButton.displayName = 'DebugLayersButton'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/GlobalMaskLayerOpacity.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/GlobalMaskLayerOpacity.tsx index 4b057fcbfcc..8386f522a2e 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/GlobalMaskLayerOpacity.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/GlobalMaskLayerOpacity.tsx @@ -1,4 +1,14 @@ -import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { + CompositeNumberInput, + CompositeSlider, + FormControl, + FormLabel, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, +} from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { globalMaskLayerOpacityChanged, @@ -7,35 +17,52 @@ import { import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +const marks = [0, 25, 50, 75, 100]; +const formatPct = (v: number | string) => `${v} %`; + export const GlobalMaskLayerOpacity = memo(() => { const dispatch = useAppDispatch(); const { t } = useTranslation(); - const globalMaskLayerOpacity = useAppSelector((s) => s.regionalPrompts.present.globalMaskLayerOpacity); + const globalMaskLayerOpacity = useAppSelector((s) => + Math.round(s.regionalPrompts.present.globalMaskLayerOpacity * 100) + ); const onChange = useCallback( (v: number) => { - dispatch(globalMaskLayerOpacityChanged(v)); + dispatch(globalMaskLayerOpacityChanged(v / 100)); }, [dispatch] ); return ( - - {t('regionalPrompts.layerOpacity')} - - + + {t('regionalPrompts.globalMaskOpacity')} + + + + + + + + + + + ); }); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPEnabledSwitch.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPEnabledSwitch.tsx deleted file mode 100644 index 0dd945228c0..00000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPEnabledSwitch.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { isEnabledChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import type { ChangeEvent } from 'react'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; - -export const RPEnabledSwitch = memo(() => { - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - const isEnabled = useAppSelector((s) => s.regionalPrompts.present.isEnabled); - const onChange = useCallback( - (e: ChangeEvent) => { - dispatch(isEnabledChanged(e.target.checked)); - }, - [dispatch] - ); - - return ( - - {t('regionalPrompts.enableRegionalPrompts')} - - - ); -}); - -RPEnabledSwitch.displayName = 'RPEnabledSwitch'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerActionsButtonGroup.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerActionsButtonGroup.tsx deleted file mode 100644 index ec191471725..00000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerActionsButtonGroup.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ButtonGroup, IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -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'; - -type Props = { layerId: string }; - -export const RPLayerActionsButtonGroup = memo(({ layerId }: Props) => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const deleteLayer = useCallback(() => { - dispatch(layerDeleted(layerId)); - }, [dispatch, layerId]); - const resetLayer = useCallback(() => { - dispatch(layerReset(layerId)); - }, [dispatch, layerId]); - return ( - - } - onClick={resetLayer} - /> - } - onClick={deleteLayer} - /> - - ); -}); - -RPLayerActionsButtonGroup.displayName = 'RPLayerActionsButtonGroup'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCombobox.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCheckbox.tsx similarity index 59% rename from invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCombobox.tsx rename to invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCheckbox.tsx index 8cea350e161..454c30a6d22 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCombobox.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCheckbox.tsx @@ -1,22 +1,16 @@ -import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library'; -import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { isParameterAutoNegative } from 'features/parameters/types/parameterSchemas'; import { isVectorMaskLayer, maskLayerAutoNegativeChanged, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import type { ChangeEvent } from 'react'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { assert } from 'tsafe'; -const options: ComboboxOption[] = [ - { label: 'Off', value: 'off' }, - { label: 'Invert', value: 'invert' }, -]; - type Props = { layerId: string; }; @@ -35,29 +29,23 @@ const useAutoNegative = (layerId: string) => { return autoNegative; }; -export const RPLayerAutoNegativeCombobox = memo(({ layerId }: Props) => { - const dispatch = useAppDispatch(); +export const RPLayerAutoNegativeCheckbox = memo(({ layerId }: Props) => { const { t } = useTranslation(); + const dispatch = useAppDispatch(); const autoNegative = useAutoNegative(layerId); - - const onChange = useCallback( - (v) => { - if (!isParameterAutoNegative(v?.value)) { - return; - } - dispatch(maskLayerAutoNegativeChanged({ layerId, autoNegative: v.value })); + const onChange = useCallback( + (e: ChangeEvent) => { + dispatch(maskLayerAutoNegativeChanged({ layerId, autoNegative: e.target.checked ? 'invert' : 'off' })); }, [dispatch, layerId] ); - const value = useMemo(() => options.find((o) => o.value === autoNegative), [autoNegative]); - return ( - + {t('regionalPrompts.autoNegative')} - + ); }); -RPLayerAutoNegativeCombobox.displayName = 'RPLayerAutoNegativeCombobox'; +RPLayerAutoNegativeCheckbox.displayName = 'RPLayerAutoNegativeCheckbox'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerColorPicker.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerColorPicker.tsx index e8f8eb88160..851cd8b13f0 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerColorPicker.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerColorPicker.tsx @@ -1,16 +1,16 @@ -import { IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library'; +import { Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, Tooltip } from '@invoke-ai/ui-library'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIColorPicker from 'common/components/IAIColorPicker'; +import RgbColorPicker from 'common/components/RgbColorPicker'; +import { rgbColorToString } from 'features/canvas/util/colorToString'; import { isVectorMaskLayer, maskLayerPreviewColorChanged, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useMemo } from 'react'; -import type { RgbaColor } from 'react-colorful'; +import type { RgbColor } from 'react-colorful'; import { useTranslation } from 'react-i18next'; -import { PiEyedropperBold } from 'react-icons/pi'; import { assert } from 'tsafe'; type Props = { @@ -31,7 +31,7 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => { const color = useAppSelector(selectColor); const dispatch = useAppDispatch(); const onColorChange = useCallback( - (color: RgbaColor) => { + (color: RgbColor) => { dispatch(maskLayerPreviewColorChanged({ layerId, color })); }, [dispatch, layerId] @@ -39,17 +39,25 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => { return ( - } - /> + + + + + - + diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerDeleteButton.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerDeleteButton.tsx new file mode 100644 index 00000000000..237b7100626 --- /dev/null +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerDeleteButton.tsx @@ -0,0 +1,28 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { layerDeleted } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiTrashSimpleBold } from 'react-icons/pi'; + +type Props = { layerId: string }; + +export const RPLayerDeleteButton = memo(({ layerId }: Props) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const deleteLayer = useCallback(() => { + dispatch(layerDeleted(layerId)); + }, [dispatch, layerId]); + return ( + } + onClick={deleteLayer} + /> + ); +}); + +RPLayerDeleteButton.displayName = 'RPLayerDeleteButton'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerIPAdapterList.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerIPAdapterList.tsx new file mode 100644 index 00000000000..c5d1ca62e98 --- /dev/null +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerIPAdapterList.tsx @@ -0,0 +1,34 @@ +import { Flex } from '@invoke-ai/ui-library'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import ControlAdapterConfig from 'features/controlAdapters/components/ControlAdapterConfig'; +import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { memo, useMemo } from 'react'; +import { assert } from 'tsafe'; + +type Props = { + layerId: string; +}; + +export const RPLayerIPAdapterList = memo(({ layerId }: Props) => { + const selectIPAdapterIds = useMemo( + () => + createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { + const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); + assert(layer, `Layer ${layerId} not found`); + return layer.ipAdapterIds; + }), + [layerId] + ); + const ipAdapterIds = useAppSelector(selectIPAdapterIds); + + return ( + + {ipAdapterIds.map((id, index) => ( + + ))} + + ); +}); + +RPLayerIPAdapterList.displayName = 'RPLayerIPAdapterList'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx index 9ba30174634..67f2897fade 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx @@ -1,34 +1,51 @@ -import { Flex, Spacer } from '@invoke-ai/ui-library'; +import { Badge, Flex, Spacer } from '@invoke-ai/ui-library'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { rgbColorToString } from 'features/canvas/util/colorToString'; -import { RPLayerActionsButtonGroup } from 'features/regionalPrompts/components/RPLayerActionsButtonGroup'; -import { RPLayerAutoNegativeCombobox } from 'features/regionalPrompts/components/RPLayerAutoNegativeCombobox'; import { RPLayerColorPicker } from 'features/regionalPrompts/components/RPLayerColorPicker'; +import { RPLayerDeleteButton } from 'features/regionalPrompts/components/RPLayerDeleteButton'; +import { RPLayerIPAdapterList } from 'features/regionalPrompts/components/RPLayerIPAdapterList'; import { RPLayerMenu } from 'features/regionalPrompts/components/RPLayerMenu'; import { RPLayerNegativePrompt } from 'features/regionalPrompts/components/RPLayerNegativePrompt'; import { RPLayerPositivePrompt } from 'features/regionalPrompts/components/RPLayerPositivePrompt'; +import RPLayerSettingsPopover from 'features/regionalPrompts/components/RPLayerSettingsPopover'; import { RPLayerVisibilityToggle } from 'features/regionalPrompts/components/RPLayerVisibilityToggle'; -import { isVectorMaskLayer, layerSelected } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { memo, useCallback } from 'react'; +import { + isVectorMaskLayer, + layerSelected, + selectRegionalPromptsSlice, +} from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { assert } from 'tsafe'; +import { AddPromptButtons } from './AddPromptButtons'; + type Props = { layerId: string; }; export const RPLayerListItem = memo(({ layerId }: Props) => { + const { t } = useTranslation(); const dispatch = useAppDispatch(); - const selectedLayerId = useAppSelector((s) => s.regionalPrompts.present.selectedLayerId); - const color = 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 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 selector = useMemo( + () => + createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { + const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); + assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); + return { + color: rgbColorToString(layer.previewColor), + hasPositivePrompt: layer.positivePrompt !== null, + hasNegativePrompt: layer.negativePrompt !== null, + hasIPAdapters: layer.ipAdapterIds.length > 0, + isSelected: layerId === regionalPrompts.present.selectedLayerId, + autoNegative: layer.autoNegative, + }; + }), + [layerId] + ); + const { autoNegative, color, hasPositivePrompt, hasNegativePrompt, hasIPAdapters, isSelected } = + useAppSelector(selector); const onClickCapture = useCallback(() => { // Must be capture so that the layer is selected before deleting/resetting/etc dispatch(layerSelected(layerId)); @@ -37,26 +54,31 @@ export const RPLayerListItem = memo(({ layerId }: Props) => { - - - - + + + - - + {autoNegative === 'invert' && ( + + {t('regionalPrompts.autoNegative')} + + )} + + + - {hasTextPrompt && } - {hasTextPrompt && } + + {hasPositivePrompt && } + {hasNegativePrompt && } + {hasIPAdapters && } ); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerMenu.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerMenu.tsx index 54a30d44bcb..f3c5317e109 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerMenu.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerMenu.tsx @@ -9,6 +9,9 @@ import { layerMovedToBack, layerMovedToFront, layerReset, + maskLayerIPAdapterAdded, + maskLayerNegativePromptChanged, + maskLayerPositivePromptChanged, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useMemo } from 'react'; @@ -20,6 +23,7 @@ import { PiArrowLineUpBold, PiArrowUpBold, PiDotsThreeVerticalBold, + PiPlusBold, PiTrashSimpleBold, } from 'react-icons/pi'; import { assert } from 'tsafe'; @@ -37,6 +41,8 @@ export const RPLayerMenu = memo(({ layerId }: Props) => { const layerIndex = regionalPrompts.present.layers.findIndex((l) => l.id === layerId); const layerCount = regionalPrompts.present.layers.length; return { + canAddPositivePrompt: layer.positivePrompt === null, + canAddNegativePrompt: layer.negativePrompt === null, canMoveForward: layerIndex < layerCount - 1, canMoveBackward: layerIndex > 0, canMoveToFront: layerIndex < layerCount - 1, @@ -46,6 +52,15 @@ export const RPLayerMenu = memo(({ layerId }: Props) => { [layerId] ); const validActions = useAppSelector(selectValidActions); + const addPositivePrompt = useCallback(() => { + dispatch(maskLayerPositivePromptChanged({ layerId, prompt: '' })); + }, [dispatch, layerId]); + const addNegativePrompt = useCallback(() => { + dispatch(maskLayerNegativePromptChanged({ layerId, prompt: '' })); + }, [dispatch, layerId]); + const addIPAdapter = useCallback(() => { + dispatch(maskLayerIPAdapterAdded(layerId)); + }, [dispatch, layerId]); const moveForward = useCallback(() => { dispatch(layerMovedForward(layerId)); }, [dispatch, layerId]); @@ -68,6 +83,16 @@ export const RPLayerMenu = memo(({ layerId }: Props) => { } /> + }> + {t('regionalPrompts.addPositivePrompt')} + + }> + {t('regionalPrompts.addNegativePrompt')} + + }> + {t('regionalPrompts.addIPAdapter')} + + }> {t('regionalPrompts.moveToFront')} diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerNegativePrompt.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerNegativePrompt.tsx index e9cf9b4ab7e..382b698b8fb 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerNegativePrompt.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerNegativePrompt.tsx @@ -4,42 +4,32 @@ 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 { useMaskLayerTextPrompt } from 'features/regionalPrompts/hooks/layerStateHooks'; +import { RPLayerPromptDeleteButton } from 'features/regionalPrompts/components/RPLayerPromptDeleteButton'; +import { useLayerNegativePrompt } 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'; import { useTranslation } from 'react-i18next'; type Props = { layerId: string; }; -export const RPLayerNegativePrompt = memo((props: Props) => { - const textPrompt = useMaskLayerTextPrompt(props.layerId); +export const RPLayerNegativePrompt = memo(({ layerId }: Props) => { + const prompt = useLayerNegativePrompt(layerId); const dispatch = useAppDispatch(); const textareaRef = useRef(null); const { t } = useTranslation(); const _onChange = useCallback( (v: string) => { - dispatch(maskLayerNegativePromptChanged({ layerId: props.layerId, prompt: v })); + dispatch(maskLayerNegativePromptChanged({ layerId, prompt: v })); }, - [dispatch, props.layerId] + [dispatch, layerId] ); - const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({ - prompt: textPrompt.negative, + const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown } = usePrompt({ + prompt, textareaRef, onChange: _onChange, }); - const focus: HotkeyCallback = useCallback( - (e) => { - onFocus(); - e.preventDefault(); - }, - [onFocus] - ); - - useHotkeys('alt+a', focus, []); return ( @@ -48,7 +38,7 @@ export const RPLayerNegativePrompt = memo((props: Props) => { id="prompt" name="prompt" ref={textareaRef} - value={textPrompt.negative} + value={prompt} placeholder={t('parameters.negativePromptPlaceholder')} onChange={onChange} onKeyDown={onKeyDown} @@ -57,6 +47,7 @@ export const RPLayerNegativePrompt = memo((props: Props) => { fontSize="sm" /> + diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPositivePrompt.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPositivePrompt.tsx index 0c94b49e3de..595a44e83e4 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPositivePrompt.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPositivePrompt.tsx @@ -4,42 +4,32 @@ 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 { useMaskLayerTextPrompt } from 'features/regionalPrompts/hooks/layerStateHooks'; +import { RPLayerPromptDeleteButton } from 'features/regionalPrompts/components/RPLayerPromptDeleteButton'; +import { useLayerPositivePrompt } 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'; import { useTranslation } from 'react-i18next'; type Props = { layerId: string; }; -export const RPLayerPositivePrompt = memo((props: Props) => { - const textPrompt = useMaskLayerTextPrompt(props.layerId); +export const RPLayerPositivePrompt = memo(({ layerId }: Props) => { + const prompt = useLayerPositivePrompt(layerId); const dispatch = useAppDispatch(); const textareaRef = useRef(null); const { t } = useTranslation(); const _onChange = useCallback( (v: string) => { - dispatch(maskLayerPositivePromptChanged({ layerId: props.layerId, prompt: v })); + dispatch(maskLayerPositivePromptChanged({ layerId, prompt: v })); }, - [dispatch, props.layerId] + [dispatch, layerId] ); - const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({ - prompt: textPrompt.positive, + const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown } = usePrompt({ + prompt, textareaRef, onChange: _onChange, }); - const focus: HotkeyCallback = useCallback( - (e) => { - onFocus(); - e.preventDefault(); - }, - [onFocus] - ); - - useHotkeys('alt+a', focus, []); return ( @@ -48,7 +38,7 @@ export const RPLayerPositivePrompt = memo((props: Props) => { id="prompt" name="prompt" ref={textareaRef} - value={textPrompt.positive} + value={prompt} placeholder={t('parameters.positivePromptPlaceholder')} onChange={onChange} onKeyDown={onKeyDown} @@ -57,6 +47,7 @@ export const RPLayerPositivePrompt = memo((props: Props) => { minH={28} /> + diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPromptDeleteButton.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPromptDeleteButton.tsx new file mode 100644 index 00000000000..7448e3a035e --- /dev/null +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPromptDeleteButton.tsx @@ -0,0 +1,38 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { + maskLayerNegativePromptChanged, + maskLayerPositivePromptChanged, +} from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiTrashSimpleBold } from 'react-icons/pi'; + +type Props = { + layerId: string; + polarity: 'positive' | 'negative'; +}; + +export const RPLayerPromptDeleteButton = memo(({ layerId, polarity }: Props) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const onClick = useCallback(() => { + if (polarity === 'positive') { + dispatch(maskLayerPositivePromptChanged({ layerId, prompt: null })); + } else { + dispatch(maskLayerNegativePromptChanged({ layerId, prompt: null })); + } + }, [dispatch, layerId, polarity]); + return ( + + } + onClick={onClick} + /> + + ); +}); + +RPLayerPromptDeleteButton.displayName = 'RPLayerPromptDeleteButton'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerSettingsPopover.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerSettingsPopover.tsx new file mode 100644 index 00000000000..a9a450c3aab --- /dev/null +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerSettingsPopover.tsx @@ -0,0 +1,53 @@ +import type { FormLabelProps } from '@invoke-ai/ui-library'; +import { + Flex, + FormControlGroup, + IconButton, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, +} from '@invoke-ai/ui-library'; +import { RPLayerAutoNegativeCheckbox } from 'features/regionalPrompts/components/RPLayerAutoNegativeCheckbox'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiGearSixBold } from 'react-icons/pi'; + +type Props = { + layerId: string; +}; + +const formLabelProps: FormLabelProps = { + flexGrow: 1, + minW: 32, +}; + +const RPLayerSettingsPopover = ({ layerId }: Props) => { + const { t } = useTranslation(); + + return ( + + + } + /> + + + + + + + + + + + + + ); +}; + +export default memo(RPLayerSettingsPopover); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerVisibilityToggle.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerVisibilityToggle.tsx index 4a7c5aa99d7..4f9e5e84b48 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerVisibilityToggle.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerVisibilityToggle.tsx @@ -4,7 +4,7 @@ import { useLayerIsVisible } from 'features/regionalPrompts/hooks/layerStateHook import { layerVisibilityToggled } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiEyeBold, PiEyeClosedBold } from 'react-icons/pi'; +import { PiCheckBold } from 'react-icons/pi'; type Props = { layerId: string; @@ -23,9 +23,10 @@ export const RPLayerVisibilityToggle = memo(({ layerId }: Props) => { size="sm" aria-label={t('regionalPrompts.toggleVisibility')} tooltip={t('regionalPrompts.toggleVisibility')} - variant={isVisible ? 'outline' : 'ghost'} - icon={isVisible ? : } + variant="outline" + icon={isVisible ? : undefined} onClick={onClick} + colorScheme="base" /> ); }); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx index c3b25cdda2d..fbf1bfec498 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx @@ -5,10 +5,8 @@ import { useAppSelector } from 'app/store/storeHooks'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { AddLayerButton } from 'features/regionalPrompts/components/AddLayerButton'; import { BrushSize } from 'features/regionalPrompts/components/BrushSize'; -import { DebugLayersButton } from 'features/regionalPrompts/components/DebugLayersButton'; import { DeleteAllLayersButton } from 'features/regionalPrompts/components/DeleteAllLayersButton'; 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'; @@ -29,16 +27,16 @@ 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 3f2465234e0..8d9f12710b4 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx @@ -21,6 +21,9 @@ import { atom } from 'nanostores'; import { useCallback, useLayoutEffect } from 'react'; import { assert } from 'tsafe'; +// This will log warnings when layers > 5 - maybe use `import.meta.env.MODE === 'development'` instead? +Konva.showWarnings = false; + const log = logger('regionalPrompts'); const $stage = atom(null); const selectSelectedLayerColor = createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { @@ -132,16 +135,32 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem if (!stage) { return; } - renderToolPreview(stage, tool, selectedLayerIdColor, cursorPosition, lastMouseDownPos, state.brushSize); - }, [stage, tool, selectedLayerIdColor, cursorPosition, lastMouseDownPos, state.brushSize]); + renderToolPreview( + stage, + tool, + selectedLayerIdColor, + state.globalMaskLayerOpacity, + cursorPosition, + lastMouseDownPos, + state.brushSize + ); + }, [ + stage, + tool, + selectedLayerIdColor, + state.globalMaskLayerOpacity, + cursorPosition, + lastMouseDownPos, + state.brushSize, + ]); useLayoutEffect(() => { log.trace('Rendering layers'); if (!stage) { return; } - renderLayers(stage, state.layers, tool, onLayerPosChanged); - }, [stage, state.layers, tool, onLayerPosChanged]); + renderLayers(stage, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged); + }, [stage, state.layers, state.globalMaskLayerOpacity, 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 46bb7b40d44..8ca830e2281 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts @@ -4,19 +4,34 @@ import { isVectorMaskLayer, selectRegionalPromptsSlice } from 'features/regional import { useMemo } from 'react'; import { assert } from 'tsafe'; -export const useMaskLayerTextPrompt = (layerId: string) => { +export const useLayerPositivePrompt = (layerId: string) => { const selectLayer = useMemo( () => createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); 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; + assert(layer.positivePrompt !== null, `Layer ${layerId} does not have a positive prompt`); + return layer.positivePrompt; }), [layerId] ); - const textPrompt = useAppSelector(selectLayer); - return textPrompt; + 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(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); + assert(layer.negativePrompt !== null, `Layer ${layerId} does not have a negative prompt`); + return layer.negativePrompt; + }), + [layerId] + ); + const prompt = useAppSelector(selectLayer); + return prompt; }; export const useLayerIsVisible = (layerId: string) => { diff --git a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts index ee773e0858c..0cc3103fe44 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts @@ -2,11 +2,12 @@ import type { PayloadAction, UnknownAction } 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 { controlAdapterRemoved } from 'features/controlAdapters/store/controlAdaptersSlice'; import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas'; import type { IRect, Vector2d } from 'konva/lib/types'; import { isEqual } from 'lodash-es'; import { atom } from 'nanostores'; -import type { RgbaColor } from 'react-colorful'; +import type { RgbColor } from 'react-colorful'; import type { UndoableOptions } from 'redux-undo'; import { assert } from 'tsafe'; import { v4 as uuidv4 } from 'uuid'; @@ -17,7 +18,7 @@ export type Tool = DrawingTool | 'move' | 'rect'; type VectorMaskLine = { id: string; - kind: 'vector_mask_line'; + type: 'vector_mask_line'; tool: DrawingTool; strokeWidth: number; points: number[]; @@ -25,22 +26,13 @@ type VectorMaskLine = { type VectorMaskRect = { id: string; - kind: 'vector_mask_rect'; + type: 'vector_mask_rect'; x: number; y: number; width: number; height: number; }; -type TextPrompt = { - positive: string; - negative: string; -}; - -type ImagePrompt = { - // TODO -}; - type LayerBase = { id: string; x: number; @@ -51,15 +43,16 @@ type LayerBase = { }; type MaskLayerBase = LayerBase & { - textPrompt: TextPrompt | null; // Up to one text prompt per mask - imagePrompts: ImagePrompt[]; // Any number of image prompts - previewColor: RgbaColor; + positivePrompt: string | null; + negativePrompt: string | null; // Up to one text prompt per mask + ipAdapterIds: string[]; // Any number of image prompts + previewColor: RgbColor; 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'; + type: 'vector_mask_layer'; objects: (VectorMaskLine | VectorMaskRect)[]; }; @@ -70,7 +63,6 @@ type RegionalPromptsState = { selectedLayerId: string | null; layers: Layer[]; brushSize: number; - brushColor: RgbaColor; globalMaskLayerOpacity: number; isEnabled: boolean; }; @@ -79,14 +71,13 @@ export const initialRegionalPromptsState: RegionalPromptsState = { _version: 1, selectedLayerId: null, brushSize: 100, - brushColor: { r: 255, g: 0, b: 0, a: 1 }, layers: [], globalMaskLayerOpacity: 0.5, // this globally changes all mask layers' opacity - isEnabled: false, + isEnabled: true, }; -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'; +const isLine = (obj: VectorMaskLine | VectorMaskRect): obj is VectorMaskLine => obj.type === 'vector_mask_line'; +export const isVectorMaskLayer = (layer?: Layer): layer is VectorMaskLayer => layer?.type === 'vector_mask_layer'; export const regionalPromptsSlice = createSlice({ name: 'regionalPrompts', @@ -94,35 +85,33 @@ export const regionalPromptsSlice = createSlice({ reducers: { //#region All Layers layerAdded: { - reducer: (state, action: PayloadAction) => { + reducer: (state, action: PayloadAction) => { 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 previewColor = LayerColors.next(lastColor); const layer: VectorMaskLayer = { id: getVectorMaskLayerId(action.meta.uuid), - kind, + type: kind, isVisible: true, bbox: null, bboxNeedsUpdate: false, objects: [], - previewColor: color, + previewColor, x: 0, y: 0, - autoNegative: 'off', + autoNegative: 'invert', needsPixelBbox: false, - textPrompt: { - positive: '', - negative: '', - }, - imagePrompts: [], + positivePrompt: null, + negativePrompt: null, + ipAdapterIds: [], }; state.layers.push(layer); state.selectedLayerId = layer.id; return; } }, - prepare: (payload: Layer['kind']) => ({ payload, meta: { uuid: uuidv4() } }), + prepare: (payload: Layer['type']) => ({ payload, meta: { uuid: uuidv4() } }), }, layerSelected: (state, action: PayloadAction) => { const layer = state.layers.find((l) => l.id === action.payload); @@ -191,21 +180,30 @@ export const regionalPromptsSlice = createSlice({ //#endregion //#region Mask Layers - maskLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { + maskLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string | null }>) => { const { layerId, prompt } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (layer && layer.textPrompt) { - layer.textPrompt.positive = prompt; + if (layer) { + layer.positivePrompt = prompt; } }, - maskLayerNegativePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { + maskLayerNegativePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string | null }>) => { const { layerId, prompt } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (layer && layer.textPrompt) { - layer.textPrompt.negative = prompt; + if (layer) { + layer.negativePrompt = prompt; } }, - maskLayerPreviewColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbaColor }>) => { + maskLayerIPAdapterAdded: { + reducer: (state, action: PayloadAction) => { + const layer = state.layers.find((l) => l.id === action.payload); + if (layer) { + layer.ipAdapterIds.push(action.meta.uuid); + } + }, + prepare: (payload: string) => ({ payload, meta: { uuid: uuidv4() } }), + }, + maskLayerPreviewColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbColor }>) => { const { layerId, color } = action.payload; const layer = state.layers.find((l) => l.id === layerId); if (layer) { @@ -226,7 +224,7 @@ export const regionalPromptsSlice = createSlice({ if (layer) { const lineId = getVectorMaskLayerLineId(layer.id, action.meta.uuid); layer.objects.push({ - kind: 'vector_mask_line', + type: 'vector_mask_line', tool: tool, id: lineId, // Points must be offset by the layer's x and y coordinates @@ -270,7 +268,7 @@ export const regionalPromptsSlice = createSlice({ if (layer) { const id = getVectorMaskLayerRectId(layer.id, action.meta.uuid); layer.objects.push({ - kind: 'vector_mask_rect', + type: 'vector_mask_rect', id, x: rect.x - layer.x, y: rect.y - layer.y, @@ -300,9 +298,6 @@ export const regionalPromptsSlice = createSlice({ }, 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; @@ -321,28 +316,35 @@ export const regionalPromptsSlice = createSlice({ }, //#endregion }, + extraReducers(builder) { + builder.addCase(controlAdapterRemoved, (state, action) => { + for (const layer of state.layers) { + layer.ipAdapterIds = layer.ipAdapterIds.filter((id) => id !== action.payload.id); + } + }); + }, }); /** * This class is used to cycle through a set of colors for the prompt region layers. */ class LayerColors { - 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 COLORS: RgbColor[] = [ + { r: 121, g: 157, b: 219 }, // rgb(121, 157, 219) + { r: 131, g: 214, b: 131 }, // rgb(131, 214, 131) + { r: 250, g: 225, b: 80 }, // rgb(250, 225, 80) + { r: 220, g: 144, b: 101 }, // rgb(220, 144, 101) + { r: 224, g: 117, b: 117 }, // rgb(224, 117, 117) + { r: 213, g: 139, b: 202 }, // rgb(213, 139, 202) + { r: 161, g: 120, b: 214 }, // rgb(161, 120, 214) ]; 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?: RgbaColor): RgbaColor { + static next(currentColor?: RgbColor): RgbColor { if (currentColor) { - const i = this.COLORS.findIndex((c) => isEqual(c, { ...currentColor, a: 1 })); + const i = this.COLORS.findIndex((c) => isEqual(c, currentColor)); if (i !== -1) { this.i = i; } @@ -369,15 +371,15 @@ export const { layerVisibilityToggled, allLayersDeleted, // Mask layer actions - maskLayerAutoNegativeChanged, - maskLayerPreviewColorChanged, maskLayerLineAdded, - maskLayerNegativePromptChanged, maskLayerPointsAdded, - maskLayerPositivePromptChanged, maskLayerRectAdded, + maskLayerNegativePromptChanged, + maskLayerPositivePromptChanged, + maskLayerIPAdapterAdded, + maskLayerAutoNegativeChanged, + maskLayerPreviewColorChanged, // General actions - isEnabledChanged, brushSizeChanged, globalMaskLayerOpacityChanged, undo, @@ -432,7 +434,6 @@ const undoableGroupByMatcher = isAnyOf( layerTranslated, brushSizeChanged, globalMaskLayerOpacityChanged, - isEnabledChanged, maskLayerPositivePromptChanged, maskLayerNegativePromptChanged, maskLayerPreviewColorChanged diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/bbox.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/bbox.ts index 9d661f37151..3182cbca7e6 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/bbox.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/bbox.ts @@ -6,6 +6,8 @@ import type { Layer as KonvaLayerType } from 'konva/lib/Layer'; import type { IRect } from 'konva/lib/types'; import { assert } from 'tsafe'; +const GET_CLIENT_RECT_CONFIG = { skipTransform: true }; + type Extents = { minX: number; minY: number; @@ -54,10 +56,9 @@ const getImageDataBbox = (imageData: ImageData): Extents | null => { /** * Get the bounding box of a regional prompt konva layer. This function has special handling for regional prompt layers. * @param layer The konva layer to get the bounding box of. - * @param filterChildren Optional filter function to exclude certain children from the bounding box calculation. Defaults to including all children. * @param preview Whether to open a new tab displaying the rendered layer, which is used to calculate the bbox. */ -export const getKonvaLayerBbox = (layer: KonvaLayerType, preview: boolean = false): IRect | null => { +export const getLayerBboxPixels = (layer: KonvaLayerType, preview: boolean = false): IRect | null => { // To calculate the layer's bounding box, we must first export it to a pixel array, then do some math. // // Though it is relatively fast, we can't use Konva's `getClientRect`. It programmatically determines the rect @@ -82,6 +83,7 @@ export const getKonvaLayerBbox = (layer: KonvaLayerType, preview: boolean = fals for (const child of layerClone.getChildren()) { if (child.name() === VECTOR_MASK_LAYER_OBJECT_GROUP_NAME) { // We need to cache the group to ensure it composites out eraser strokes correctly + child.opacity(1); child.cache(); } else { // Filter out unwanted children. @@ -112,11 +114,21 @@ export const getKonvaLayerBbox = (layer: KonvaLayerType, preview: boolean = fals // Correct the bounding box to be relative to the layer's position. const correctedLayerBbox = { - x: layerBbox.minX - stage.x() + layerRect.x - layer.x(), - y: layerBbox.minY - stage.y() + layerRect.y - layer.y(), + x: layerBbox.minX - Math.floor(stage.x()) + layerRect.x - Math.floor(layer.x()), + y: layerBbox.minY - Math.floor(stage.y()) + layerRect.y - Math.floor(layer.y()), width: layerBbox.maxX - layerBbox.minX, height: layerBbox.maxY - layerBbox.minY, }; return correctedLayerBbox; }; + +export const getLayerBboxFast = (layer: KonvaLayerType): IRect | null => { + const bbox = layer.getClientRect(GET_CLIENT_RECT_CONFIG); + return { + x: Math.floor(bbox.x), + y: Math.floor(bbox.y), + width: Math.floor(bbox.width), + height: Math.floor(bbox.height), + }; +}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts index 941c84af6db..aa61a9a4066 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts @@ -20,7 +20,7 @@ export const getRegionalPromptLayerBlobs = async ( const reduxLayers = state.regionalPrompts.present.layers; const container = document.createElement('div'); const stage = new Konva.Stage({ container, width: state.generation.width, height: state.generation.height }); - renderLayers(stage, reduxLayers, 'brush'); + renderLayers(stage, reduxLayers, 1, 'brush'); const konvaLayers = stage.find(`.${VECTOR_MASK_LAYER_NAME}`); const blobs: Record = {}; @@ -52,7 +52,7 @@ export const getRegionalPromptLayerBlobs = async ( openBase64ImageInTab([ { base64, - caption: `${reduxLayer.id}: ${reduxLayer.textPrompt?.positive} / ${reduxLayer.textPrompt?.negative}`, + caption: `${reduxLayer.id}: ${reduxLayer.positivePrompt} / ${reduxLayer.negativePrompt}`, }, ]); } diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts index af00af8c85f..a802f4ee83b 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts @@ -1,5 +1,5 @@ import { getStore } from 'app/store/nanostores/store'; -import { rgbColorToString } from 'features/canvas/util/colorToString'; +import { rgbaColorToString, rgbColorToString } from 'features/canvas/util/colorToString'; import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks'; import type { Layer, Tool, VectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { @@ -20,7 +20,7 @@ import { VECTOR_MASK_LAYER_OBJECT_GROUP_NAME, VECTOR_MASK_LAYER_RECT_NAME, } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { getKonvaLayerBbox } from 'features/regionalPrompts/util/bbox'; +import { getLayerBboxFast, getLayerBboxPixels } from 'features/regionalPrompts/util/bbox'; import Konva from 'konva'; import type { IRect, Vector2d } from 'konva/lib/types'; import type { RgbColor } from 'react-colorful'; @@ -33,8 +33,6 @@ const BBOX_NOT_SELECTED_MOUSEOVER_STROKE = 'rgba(255, 255, 255, 0.661)'; 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) => { @@ -61,6 +59,7 @@ export const renderToolPreview = ( stage: Konva.Stage, tool: Tool, color: RgbColor | null, + globalMaskLayerOpacity: number, cursorPos: Vector2d | null, lastMouseDownPos: Vector2d | null, brushSize: number @@ -161,7 +160,7 @@ export const renderToolPreview = ( x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2, - fill: rgbColorToString(color), + fill: rgbaColorToString({ ...color, a: globalMaskLayerOpacity }), globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out', }); @@ -200,6 +199,7 @@ const renderVectorMaskLayer = ( stage: Konva.Stage, vmLayer: VectorMaskLayer, vmLayerIndex: number, + globalMaskLayerOpacity: number, tool: Tool, onLayerPosChanged?: (layerId: string, x: number, y: number) => void ) => { @@ -282,7 +282,7 @@ const renderVectorMaskLayer = ( } for (const reduxObject of vmLayer.objects) { - if (reduxObject.kind === 'vector_mask_line') { + if (reduxObject.type === 'vector_mask_line') { let vectorMaskLine = stage.findOne(`#${reduxObject.id}`); // Create the line if it doesn't exist @@ -313,7 +313,7 @@ const renderVectorMaskLayer = ( vectorMaskLine.stroke(rgbColor); groupNeedsCache = true; } - } else if (reduxObject.kind === 'vector_mask_rect') { + } else if (reduxObject.type === 'vector_mask_rect') { let konvaObject = stage.findOne(`#${reduxObject.id}`); if (!konvaObject) { konvaObject = new Konva.Rect({ @@ -354,8 +354,8 @@ const renderVectorMaskLayer = ( } // Updating group opacity does not require re-caching - if (konvaObjectGroup.opacity() !== vmLayer.previewColor.a) { - konvaObjectGroup.opacity(vmLayer.previewColor.a); + if (konvaObjectGroup.opacity() !== globalMaskLayerOpacity) { + konvaObjectGroup.opacity(globalMaskLayerOpacity); } }; @@ -370,6 +370,7 @@ const renderVectorMaskLayer = ( export const renderLayers = ( stage: Konva.Stage, reduxLayers: Layer[], + globalMaskLayerOpacity: number, tool: Tool, onLayerPosChanged?: (layerId: string, x: number, y: number) => void ) => { @@ -386,7 +387,7 @@ export const renderLayers = ( const reduxLayer = reduxLayers[layerIndex]; assert(reduxLayer, `Layer at index ${layerIndex} is undefined`); if (isVectorMaskLayer(reduxLayer)) { - renderVectorMaskLayer(stage, reduxLayer, layerIndex, tool, onLayerPosChanged); + renderVectorMaskLayer(stage, reduxLayer, layerIndex, globalMaskLayerOpacity, tool, onLayerPosChanged); } } }; @@ -426,9 +427,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.needsPixelBbox - ? getKonvaLayerBbox(konvaLayer) - : konvaLayer.getClientRect(GET_CLIENT_RECT_CONFIG); + bbox = reduxLayer.needsPixelBbox ? getLayerBboxPixels(konvaLayer) : getLayerBboxFast(konvaLayer); // Update the layer's bbox in the redux store onBboxChanged(reduxLayer.id, bbox); diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/ControlSettingsAccordion/ControlSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/ControlSettingsAccordion/ControlSettingsAccordion.tsx index ec81b0b211f..36448c8909b 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/ControlSettingsAccordion/ControlSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ControlSettingsAccordion/ControlSettingsAccordion.tsx @@ -13,51 +13,60 @@ import { selectValidIPAdapters, selectValidT2IAdapters, } from 'features/controlAdapters/store/controlAdaptersSlice'; +import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { Fragment, memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiPlusBold } from 'react-icons/pi'; -const selector = createMemoizedSelector(selectControlAdaptersSlice, (controlAdapters) => { - const badges: string[] = []; - let isError = false; +const selector = createMemoizedSelector( + [selectControlAdaptersSlice, selectRegionalPromptsSlice], + (controlAdapters, regionalPrompts) => { + const badges: string[] = []; + let isError = false; - const enabledIPAdapterCount = selectAllIPAdapters(controlAdapters).filter((ca) => ca.isEnabled).length; - const validIPAdapterCount = selectValidIPAdapters(controlAdapters).length; - if (enabledIPAdapterCount > 0) { - badges.push(`${enabledIPAdapterCount} IP`); - } - if (enabledIPAdapterCount > validIPAdapterCount) { - isError = true; - } + const enabledIPAdapterCount = selectAllIPAdapters(controlAdapters) + .filter((ca) => !regionalPrompts.present.layers.some((l) => l.ipAdapterIds.includes(ca.id))) + .filter((ca) => ca.isEnabled).length; - const enabledControlNetCount = selectAllControlNets(controlAdapters).filter((ca) => ca.isEnabled).length; - const validControlNetCount = selectValidControlNets(controlAdapters).length; - if (enabledControlNetCount > 0) { - badges.push(`${enabledControlNetCount} ControlNet`); - } - if (enabledControlNetCount > validControlNetCount) { - isError = true; - } + const validIPAdapterCount = selectValidIPAdapters(controlAdapters).length; + if (enabledIPAdapterCount > 0) { + badges.push(`${enabledIPAdapterCount} IP`); + } + if (enabledIPAdapterCount > validIPAdapterCount) { + isError = true; + } - const enabledT2IAdapterCount = selectAllT2IAdapters(controlAdapters).filter((ca) => ca.isEnabled).length; - const validT2IAdapterCount = selectValidT2IAdapters(controlAdapters).length; - if (enabledT2IAdapterCount > 0) { - badges.push(`${enabledT2IAdapterCount} T2I`); - } - if (enabledT2IAdapterCount > validT2IAdapterCount) { - isError = true; - } + const enabledControlNetCount = selectAllControlNets(controlAdapters).filter((ca) => ca.isEnabled).length; + const validControlNetCount = selectValidControlNets(controlAdapters).length; + if (enabledControlNetCount > 0) { + badges.push(`${enabledControlNetCount} ControlNet`); + } + if (enabledControlNetCount > validControlNetCount) { + isError = true; + } - const controlAdapterIds = selectControlAdapterIds(controlAdapters); + const enabledT2IAdapterCount = selectAllT2IAdapters(controlAdapters).filter((ca) => ca.isEnabled).length; + const validT2IAdapterCount = selectValidT2IAdapters(controlAdapters).length; + if (enabledT2IAdapterCount > 0) { + badges.push(`${enabledT2IAdapterCount} T2I`); + } + if (enabledT2IAdapterCount > validT2IAdapterCount) { + isError = true; + } - return { - controlAdapterIds, - badges, - isError, // TODO: Add some visual indicator that the control adapters are in an error state - }; -}); + const controlAdapterIds = selectControlAdapterIds(controlAdapters).filter( + (id) => !regionalPrompts.present.layers.some((l) => l.ipAdapterIds.includes(id)) + ); + + return { + controlAdapterIds, + badges, + isError, // TODO: Add some visual indicator that the control adapters are in an error state + }; + } +); export const ControlSettingsAccordion: React.FC = memo(() => { const { t } = useTranslation(); 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 d9ec1a6e541..8021a3a9e50 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx @@ -14,8 +14,8 @@ const selectValidLayerCount = createSelector(selectRegionalPromptsSlice, (region 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; + const hasTextPrompt = Boolean(l.positivePrompt || l.negativePrompt); + const hasAtLeastOneImagePrompt = l.ipAdapterIds.length > 0; return hasTextPrompt || hasAtLeastOneImagePrompt; });