feat(ui): regional prompts spray n pray

Trying a lot of different things as I iterated, so this is smooshed into one big commit... too hard to split it now.

- Iterated on IP adapter handling and UI. Unfortunately there is an bug related to undo/redo. The IP adapter state is split across the `controlAdapters` slice and the `regionalPrompts` slice, but only the `regionalPrompts` slice supports undo/redo. If you delete the IP adapter and then undo/redo to a history state where it existed, you'll get an error. The fix is likely to merge the slices... Maybe there's a workaround.
- Iterated on UI. I think the layers are OK now.
- Removed ability to disable RP globally for now. It's enabled if you have enabled RP layers.
- Many minor tweaks and fixes.
This commit is contained in:
psychedelicious 2024-04-22 22:12:29 +10:00
parent 018845cda0
commit 6dcaf75b5f
29 changed files with 800 additions and 326 deletions

View File

@ -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)"
}
}

View File

@ -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<RgbColor> & {
withNumberInput?: boolean;
};
const colorPickerPointerStyles: NonNullable<ChakraProps['sx']> = {
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 (
<Flex sx={sx}>
<ColorfulRgbColorPicker color={color} onChange={onChange} style={colorPickerStyles} {...rest} />
{withNumberInput && (
<Flex gap={5}>
<FormControl gap={0}>
<FormLabel>{t('common.red')[0]}</FormLabel>
<CompositeNumberInput
value={color.r}
onChange={handleChangeR}
min={0}
max={255}
step={1}
w={numberInputWidth}
defaultValue={90}
/>
</FormControl>
<FormControl gap={0}>
<FormLabel>{t('common.green')[0]}</FormLabel>
<CompositeNumberInput
value={color.g}
onChange={handleChangeG}
min={0}
max={255}
step={1}
w={numberInputWidth}
defaultValue={90}
/>
</FormControl>
<FormControl gap={0}>
<FormLabel>{t('common.blue')[0]}</FormLabel>
<CompositeNumberInput
value={color.b}
onChange={handleChangeB}
min={0}
max={255}
step={1}
w={numberInputWidth}
defaultValue={255}
/>
</FormControl>
</Flex>
)}
</Flex>
);
};
export default memo(RgbColorPicker);

View File

@ -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'));
});
},
});

View File

@ -19,12 +19,14 @@ export const addIPAdapterToLinearGraph = async (
graph: NonNullableGraph,
baseNodeId: string
): Promise<void> => {
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

View File

@ -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',
},
});
}
}
};

View File

@ -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 (
<Flex w="full" p={2} justifyContent="space-between">
<Button
size="sm"
variant="ghost"
leftIcon={<PiPlusBold />}
onClick={addPositivePrompt}
isDisabled={!validActions.canAddPositivePrompt}
>
{t('common.positivePrompt')}
</Button>
<Button
size="sm"
variant="ghost"
leftIcon={<PiPlusBold />}
onClick={addNegativePrompt}
isDisabled={!validActions.canAddNegativePrompt}
>
{t('common.negativePrompt')}
</Button>
<Button size="sm" variant="ghost" leftIcon={<PiPlusBold />} onClick={addIPAdapter}>
{t('common.ipAdapter')}
</Button>
</Flex>
);
};

View File

@ -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 (
<FormControl>
<FormLabel>{t('regionalPrompts.brushSize')}</FormLabel>
<CompositeSlider
min={1}
max={300}
defaultValue={initialRegionalPromptsState.brushSize}
value={brushSize}
onChange={onChange}
/>
<CompositeNumberInput
min={1}
max={600}
defaultValue={initialRegionalPromptsState.brushSize}
value={brushSize}
onChange={onChange}
/>
<FormControl w="min-content">
<FormLabel m={0}>{t('regionalPrompts.brushSize')}</FormLabel>
<Popover isLazy>
<PopoverTrigger>
<CompositeNumberInput
min={1}
max={600}
defaultValue={initialRegionalPromptsState.brushSize}
value={brushSize}
onChange={onChange}
w={24}
format={formatPx}
/>
</PopoverTrigger>
<PopoverContent w={200} py={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider
min={1}
max={300}
defaultValue={initialRegionalPromptsState.brushSize}
value={brushSize}
onChange={onChange}
marks={marks}
/>
</PopoverBody>
</PopoverContent>
</Popover>
</FormControl>
);
});

View File

@ -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 (
<FormControl>
<FormLabel>{t('regionalPrompts.layerOpacity')}</FormLabel>
<CompositeSlider
min={0.25}
max={1}
step={0.01}
value={globalMaskLayerOpacity}
defaultValue={initialRegionalPromptsState.globalMaskLayerOpacity}
onChange={onChange}
/>
<CompositeNumberInput
min={0.25}
max={1}
step={0.01}
value={globalMaskLayerOpacity}
defaultValue={initialRegionalPromptsState.globalMaskLayerOpacity}
onChange={onChange}
/>
<FormControl w="min-content">
<FormLabel m={0}>{t('regionalPrompts.globalMaskOpacity')}</FormLabel>
<Popover isLazy>
<PopoverTrigger>
<CompositeNumberInput
min={0}
max={100}
step={1}
value={globalMaskLayerOpacity}
defaultValue={initialRegionalPromptsState.globalMaskLayerOpacity * 100}
onChange={onChange}
w={24}
format={formatPct}
/>
</PopoverTrigger>
<PopoverContent w={200} py={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider
min={0}
max={100}
step={1}
value={globalMaskLayerOpacity}
defaultValue={initialRegionalPromptsState.globalMaskLayerOpacity * 100}
onChange={onChange}
marks={marks}
/>
</PopoverBody>
</PopoverContent>
</Popover>
</FormControl>
);
});

View File

@ -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 (
<ButtonGroup isAttached={false}>
<IconButton
size="sm"
aria-label={t('regionalPrompts.resetRegion')}
tooltip={t('regionalPrompts.resetRegion')}
icon={<PiArrowCounterClockwiseBold />}
onClick={resetLayer}
/>
<IconButton
size="sm"
colorScheme="error"
aria-label={t('common.delete')}
tooltip={t('common.delete')}
icon={<PiTrashSimpleBold />}
onClick={deleteLayer}
/>
</ButtonGroup>
);
});
RPLayerActionsButtonGroup.displayName = 'RPLayerActionsButtonGroup';

View File

@ -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<ComboboxOnChange>(
(v) => {
if (!isParameterAutoNegative(v?.value)) {
return;
}
dispatch(maskLayerAutoNegativeChanged({ layerId, autoNegative: v.value }));
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(maskLayerAutoNegativeChanged({ layerId, autoNegative: e.target.checked ? 'invert' : 'off' }));
},
[dispatch, layerId]
);
const value = useMemo(() => options.find((o) => o.value === autoNegative), [autoNegative]);
return (
<FormControl flexGrow={0} gap={2} w="min-content">
<FormControl gap={2}>
<FormLabel m={0}>{t('regionalPrompts.autoNegative')}</FormLabel>
<Combobox value={value} options={options} onChange={onChange} isSearchable={false} sx={{ w: '5.2rem' }} />
<Checkbox size="md" isChecked={autoNegative === 'invert'} onChange={onChange} />
</FormControl>
);
});
RPLayerAutoNegativeCombobox.displayName = 'RPLayerAutoNegativeCombobox';
RPLayerAutoNegativeCheckbox.displayName = 'RPLayerAutoNegativeCheckbox';

View File

@ -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 (
<Popover isLazy>
<PopoverTrigger>
<IconButton
tooltip={t('unifiedCanvas.colorPicker')}
aria-label={t('unifiedCanvas.colorPicker')}
size="sm"
borderRadius="base"
icon={<PiEyedropperBold />}
/>
<span>
<Tooltip label={t('regionalPrompts.maskPreviewColor')}>
<Flex
as="button"
aria-label={t('regionalPrompts.maskPreviewColor')}
borderRadius="base"
borderWidth={1}
bg={rgbColorToString(color)}
w={8}
h={8}
cursor="pointer"
tabIndex={-1}
/>
</Tooltip>
</span>
</PopoverTrigger>
<PopoverContent>
<PopoverBody minH={64}>
<IAIColorPicker color={color} onChange={onColorChange} withNumberInput />
<RgbColorPicker color={color} onChange={onColorChange} withNumberInput />
</PopoverBody>
</PopoverContent>
</Popover>

View File

@ -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 (
<IconButton
size="sm"
colorScheme="error"
aria-label={t('common.delete')}
tooltip={t('common.delete')}
icon={<PiTrashSimpleBold />}
onClick={deleteLayer}
/>
);
});
RPLayerDeleteButton.displayName = 'RPLayerDeleteButton';

View File

@ -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 (
<Flex w="full" flexDir="column" gap={2}>
{ipAdapterIds.map((id, index) => (
<ControlAdapterConfig key={id} id={id} number={index + 1} />
))}
</Flex>
);
});
RPLayerIPAdapterList.displayName = 'RPLayerIPAdapterList';

View File

@ -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) => {
<Flex
gap={2}
onClickCapture={onClickCapture}
bg={color}
px={2}
bg={isSelected ? color : 'base.800'}
ps={2}
borderRadius="base"
borderTop="1px"
borderBottom="1px"
borderColor="base.800"
opacity={selectedLayerId === layerId ? 1 : 0.5}
pe="1px"
py="1px"
cursor="pointer"
>
<Flex flexDir="column" gap={2} w="full" bg="base.850" p={2}>
<Flex gap={2} alignItems="center">
<RPLayerMenu layerId={layerId} />
<RPLayerColorPicker layerId={layerId} />
<Flex flexDir="column" gap={2} w="full" bg="base.850" p={2} borderRadius="base">
<Flex gap={3} alignItems="center">
<RPLayerVisibilityToggle layerId={layerId} />
<RPLayerColorPicker layerId={layerId} />
<Spacer />
<RPLayerAutoNegativeCombobox layerId={layerId} />
<RPLayerActionsButtonGroup layerId={layerId} />
{autoNegative === 'invert' && (
<Badge color="base.300" bg="transparent" borderWidth={1}>
{t('regionalPrompts.autoNegative')}
</Badge>
)}
<RPLayerDeleteButton layerId={layerId} />
<RPLayerSettingsPopover layerId={layerId} />
<RPLayerMenu layerId={layerId} />
</Flex>
{hasTextPrompt && <RPLayerPositivePrompt layerId={layerId} />}
{hasTextPrompt && <RPLayerNegativePrompt layerId={layerId} />}
<AddPromptButtons layerId={layerId} />
{hasPositivePrompt && <RPLayerPositivePrompt layerId={layerId} />}
{hasNegativePrompt && <RPLayerNegativePrompt layerId={layerId} />}
{hasIPAdapters && <RPLayerIPAdapterList layerId={layerId} />}
</Flex>
</Flex>
);

View File

@ -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) => {
<Menu>
<MenuButton as={IconButton} aria-label="Layer menu" size="sm" icon={<PiDotsThreeVerticalBold />} />
<MenuList>
<MenuItem onClick={addPositivePrompt} isDisabled={!validActions.canAddPositivePrompt} icon={<PiPlusBold />}>
{t('regionalPrompts.addPositivePrompt')}
</MenuItem>
<MenuItem onClick={addNegativePrompt} isDisabled={!validActions.canAddNegativePrompt} icon={<PiPlusBold />}>
{t('regionalPrompts.addNegativePrompt')}
</MenuItem>
<MenuItem onClick={addIPAdapter} icon={<PiPlusBold />}>
{t('regionalPrompts.addIPAdapter')}
</MenuItem>
<MenuDivider />
<MenuItem onClick={moveToFront} isDisabled={!validActions.canMoveToFront} icon={<PiArrowLineUpBold />}>
{t('regionalPrompts.moveToFront')}
</MenuItem>

View File

@ -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<HTMLTextAreaElement>(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 (
<PromptPopover isOpen={isOpen} onClose={onClose} onSelect={onSelect} width={textareaRef.current?.clientWidth}>
@ -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"
/>
<PromptOverlayButtonWrapper>
<RPLayerPromptDeleteButton layerId={layerId} polarity="negative" />
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
</PromptOverlayButtonWrapper>
</Box>

View File

@ -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<HTMLTextAreaElement>(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 (
<PromptPopover isOpen={isOpen} onClose={onClose} onSelect={onSelect} width={textareaRef.current?.clientWidth}>
@ -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}
/>
<PromptOverlayButtonWrapper>
<RPLayerPromptDeleteButton layerId={layerId} polarity="positive" />
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
</PromptOverlayButtonWrapper>
</Box>

View File

@ -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 (
<Tooltip label={t('regionalPrompts.deletePrompt')}>
<IconButton
variant="promptOverlay"
aria-label={t('regionalPrompts.deletePrompt')}
icon={<PiTrashSimpleBold />}
onClick={onClick}
/>
</Tooltip>
);
});
RPLayerPromptDeleteButton.displayName = 'RPLayerPromptDeleteButton';

View File

@ -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 (
<Popover isLazy>
<PopoverTrigger>
<IconButton
tooltip={t('common.settingsLabel')}
aria-label={t('common.settingsLabel')}
size="sm"
icon={<PiGearSixBold />}
/>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverBody>
<Flex direction="column" gap={2}>
<FormControlGroup formLabelProps={formLabelProps}>
<RPLayerAutoNegativeCheckbox layerId={layerId} />
</FormControlGroup>
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default memo(RPLayerSettingsPopover);

View File

@ -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 ? <PiEyeBold /> : <PiEyeClosedBold />}
variant="outline"
icon={isVisible ? <PiCheckBold /> : undefined}
onClick={onClick}
colorScheme="base"
/>
);
});

View File

@ -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(() => {
<Flex gap={4} w="full" h="full">
<Flex flexDir="column" gap={4} minW={430}>
<Flex gap={3} w="full" justifyContent="space-between">
<DebugLayersButton />
<AddLayerButton />
<DeleteAllLayersButton />
<Spacer />
<UndoRedoButtonGroup />
<ToolChooser />
</Flex>
<RPEnabledSwitch />
<BrushSize />
<GlobalMaskLayerOpacity />
<Flex justifyContent="space-between">
<BrushSize />
<GlobalMaskLayerOpacity />
</Flex>
<ScrollableContent>
<Flex flexDir="column" gap={2}>
{rpLayerIdsReversed.map((id) => (

View File

@ -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<Konva.Stage | null>(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');

View File

@ -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) => {

View File

@ -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';
@ -32,15 +33,6 @@ type VectorMaskRect = {
height: number;
};
type TextPrompt = {
positive: string;
negative: string;
};
type ImagePrompt = {
// TODO
};
type LayerBase = {
id: string;
x: number;
@ -51,9 +43,10 @@ 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
};
@ -70,7 +63,6 @@ type RegionalPromptsState = {
selectedLayerId: string | null;
layers: Layer[];
brushSize: number;
brushColor: RgbaColor;
globalMaskLayerOpacity: number;
isEnabled: boolean;
};
@ -79,10 +71,9 @@ 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.type === 'vector_mask_line';
@ -98,7 +89,7 @@ export const regionalPromptsSlice = createSlice({
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),
type: kind,
@ -106,16 +97,14 @@ export const regionalPromptsSlice = createSlice({
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;
@ -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<string, string, { uuid: string }>) => {
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) {
@ -300,9 +298,6 @@ export const regionalPromptsSlice = createSlice({
},
globalMaskLayerOpacityChanged: (state, action: PayloadAction<number>) => {
state.globalMaskLayerOpacity = action.payload;
for (const layer of state.layers) {
layer.previewColor.a = action.payload;
}
},
isEnabledChanged: (state, action: PayloadAction<boolean>) => {
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,13 +371,14 @@ export const {
layerVisibilityToggled,
allLayersDeleted,
// Mask layer actions
maskLayerLineAdded,
maskLayerPointsAdded,
maskLayerRectAdded,
maskLayerNegativePromptChanged,
maskLayerPositivePromptChanged,
maskLayerIPAdapterAdded,
maskLayerAutoNegativeChanged,
maskLayerPreviewColorChanged,
maskLayerLineAdded,
maskLayerNegativePromptChanged,
maskLayerPointsAdded,
maskLayerPositivePromptChanged,
maskLayerRectAdded,
// General actions
isEnabledChanged,
brushSizeChanged,

View File

@ -6,6 +6,8 @@ import type { Layer as KonvaLayerType } from 'konva/lib/Layer';
import type { IRect } from 'konva/lib/types';
import { assert } from 'tsafe';
export 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),
};
};

View File

@ -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<Konva.Layer>(`.${VECTOR_MASK_LAYER_NAME}`);
const blobs: Record<string, Blob> = {};
@ -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}`,
},
]);
}

View File

@ -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
) => {
@ -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);

View File

@ -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();

View File

@ -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;
});