refactor(ui): revise regional prompts state to support prompt-less mask layers

This structure is more adaptable to future features like IP-Adapter-only regions, controlnet layers, image masks, etc.
This commit is contained in:
psychedelicious 2024-04-20 22:10:49 +10:00 committed by Kent Keirsey
parent a00e703144
commit 58d3a9e7d4
22 changed files with 426 additions and 472 deletions

View File

@ -26,7 +26,7 @@ const sx: ChakraProps['sx'] = {
const colorPickerStyles: CSSProperties = { width: '100%' }; const colorPickerStyles: CSSProperties = { width: '100%' };
const numberInputWidth: ChakraProps['w'] = '4.2rem'; const numberInputWidth: ChakraProps['w'] = '3.5rem';
const IAIColorPicker = (props: IAIColorPickerProps) => { const IAIColorPicker = (props: IAIColorPickerProps) => {
const { color, onChange, withNumberInput, ...rest } = props; const { color, onChange, withNumberInput, ...rest } = props;
@ -41,7 +41,7 @@ const IAIColorPicker = (props: IAIColorPickerProps) => {
{withNumberInput && ( {withNumberInput && (
<Flex gap={5}> <Flex gap={5}>
<FormControl gap={0}> <FormControl gap={0}>
<FormLabel>{t('common.red')}</FormLabel> <FormLabel>{t('common.red')[0]}</FormLabel>
<CompositeNumberInput <CompositeNumberInput
value={color.r} value={color.r}
onChange={handleChangeR} onChange={handleChangeR}
@ -53,7 +53,7 @@ const IAIColorPicker = (props: IAIColorPickerProps) => {
/> />
</FormControl> </FormControl>
<FormControl gap={0}> <FormControl gap={0}>
<FormLabel>{t('common.green')}</FormLabel> <FormLabel>{t('common.green')[0]}</FormLabel>
<CompositeNumberInput <CompositeNumberInput
value={color.g} value={color.g}
onChange={handleChangeG} onChange={handleChangeG}
@ -65,7 +65,7 @@ const IAIColorPicker = (props: IAIColorPickerProps) => {
/> />
</FormControl> </FormControl>
<FormControl gap={0}> <FormControl gap={0}>
<FormLabel>{t('common.blue')}</FormLabel> <FormLabel>{t('common.blue')[0]}</FormLabel>
<CompositeNumberInput <CompositeNumberInput
value={color.b} value={color.b}
onChange={handleChangeB} onChange={handleChangeB}
@ -77,7 +77,7 @@ const IAIColorPicker = (props: IAIColorPickerProps) => {
/> />
</FormControl> </FormControl>
<FormControl gap={0}> <FormControl gap={0}>
<FormLabel>{t('common.alpha')}</FormLabel> <FormLabel>{t('common.alpha')[0]}</FormLabel>
<CompositeNumberInput <CompositeNumberInput
value={color.a} value={color.a}
onChange={handleChangeA} onChange={handleChangeA}

View File

@ -1,84 +0,0 @@
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'] = '4.2rem';
const RgbColorPicker = (props: RgbColorPickerProps) => {
const { color, onChange, withNumberInput, ...rest } = props;
const { t } = useTranslation();
const handleChangeR = useCallback((r: number) => onChange({ ...color, r }), [color, onChange]);
const handleChangeG = useCallback((g: number) => onChange({ ...color, g }), [color, onChange]);
const handleChangeB = useCallback((b: number) => onChange({ ...color, b }), [color, onChange]);
return (
<Flex sx={sx}>
<ColorfulRgbColorPicker color={color} onChange={onChange} style={colorPickerStyles} {...rest} />
{withNumberInput && (
<Flex gap={5}>
<FormControl gap={0}>
<FormLabel>{t('common.red')}</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')}</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')}</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

@ -11,7 +11,7 @@ import {
PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX, PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX,
PROMPT_REGION_POSITIVE_COND_PREFIX, PROMPT_REGION_POSITIVE_COND_PREFIX,
} from 'features/nodes/util/graph/constants'; } from 'features/nodes/util/graph/constants';
import { isRPLayer } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { isVectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { getRegionalPromptLayerBlobs } from 'features/regionalPrompts/util/getLayerBlobs'; import { getRegionalPromptLayerBlobs } from 'features/regionalPrompts/util/getLayerBlobs';
import { size } from 'lodash-es'; import { size } from 'lodash-es';
import { imagesApi } from 'services/api/endpoints/images'; import { imagesApi } from 'services/api/endpoints/images';
@ -23,12 +23,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
return; return;
} }
const { dispatch } = getStore(); const { dispatch } = getStore();
// TODO: Handle non-SDXL
const isSDXL = state.generation.model?.base === 'sdxl'; const isSDXL = state.generation.model?.base === 'sdxl';
const layers = state.regionalPrompts.present.layers const layers = state.regionalPrompts.present.layers
.filter(isRPLayer) // We only want the prompt region layers // Only support vector mask layers now
.filter((l) => l.isVisible) // Only visible layers are rendered on the canvas // TODO: Image masks
.filter((l) => l.negativePrompt || l.positivePrompt); // Only layers with prompts get added to the graph .filter(isVectorMaskLayer)
// Only visible layers are rendered on the canvas
.filter((l) => l.isVisible)
// Only layers with prompts get added to the graph
.filter((l) => {
const hasTextPrompt = l.textPrompt && (l.textPrompt.positive || l.textPrompt.negative);
const hasAtLeastOneImagePrompt = l.imagePrompts.length > 0;
return hasTextPrompt || hasAtLeastOneImagePrompt;
});
const layerIds = layers.map((l) => l.id); const layerIds = layers.map((l) => l.id);
const blobs = await getRegionalPromptLayerBlobs(layerIds); const blobs = await getRegionalPromptLayerBlobs(layerIds);
@ -123,19 +130,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
}; };
graph.nodes[maskToTensorNode.id] = maskToTensorNode; graph.nodes[maskToTensorNode.id] = maskToTensorNode;
if (layer.positivePrompt) { if (layer.textPrompt?.positive) {
// The main positive conditioning node // The main positive conditioning node
const regionalPositiveCondNode: S['SDXLCompelPromptInvocation'] | S['CompelInvocation'] = isSDXL const regionalPositiveCondNode: S['SDXLCompelPromptInvocation'] | S['CompelInvocation'] = isSDXL
? { ? {
type: 'sdxl_compel_prompt', type: 'sdxl_compel_prompt',
id: `${PROMPT_REGION_POSITIVE_COND_PREFIX}_${layer.id}`, id: `${PROMPT_REGION_POSITIVE_COND_PREFIX}_${layer.id}`,
prompt: layer.positivePrompt, prompt: layer.textPrompt.positive,
style: layer.positivePrompt, // TODO: Should we put the positive prompt in both fields? style: layer.textPrompt.positive, // TODO: Should we put the positive prompt in both fields?
} }
: { : {
type: 'compel', type: 'compel',
id: `${PROMPT_REGION_POSITIVE_COND_PREFIX}_${layer.id}`, id: `${PROMPT_REGION_POSITIVE_COND_PREFIX}_${layer.id}`,
prompt: layer.positivePrompt, prompt: layer.textPrompt.positive,
}; };
graph.nodes[regionalPositiveCondNode.id] = regionalPositiveCondNode; graph.nodes[regionalPositiveCondNode.id] = regionalPositiveCondNode;
@ -162,19 +169,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
} }
} }
if (layer.negativePrompt) { if (layer.textPrompt?.negative) {
// The main negative conditioning node // The main negative conditioning node
const regionalNegativeCondNode: S['SDXLCompelPromptInvocation'] | S['CompelInvocation'] = isSDXL const regionalNegativeCondNode: S['SDXLCompelPromptInvocation'] | S['CompelInvocation'] = isSDXL
? { ? {
type: 'sdxl_compel_prompt', type: 'sdxl_compel_prompt',
id: `${PROMPT_REGION_NEGATIVE_COND_PREFIX}_${layer.id}`, id: `${PROMPT_REGION_NEGATIVE_COND_PREFIX}_${layer.id}`,
prompt: layer.negativePrompt, prompt: layer.textPrompt.negative,
style: layer.negativePrompt, style: layer.textPrompt.negative,
} }
: { : {
type: 'compel', type: 'compel',
id: `${PROMPT_REGION_NEGATIVE_COND_PREFIX}_${layer.id}`, id: `${PROMPT_REGION_NEGATIVE_COND_PREFIX}_${layer.id}`,
prompt: layer.negativePrompt, prompt: layer.textPrompt.negative,
}; };
graph.nodes[regionalNegativeCondNode.id] = regionalNegativeCondNode; graph.nodes[regionalNegativeCondNode.id] = regionalNegativeCondNode;
@ -202,7 +209,7 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
} }
// If we are using the "invert" auto-negative setting, we need to add an additional negative conditioning node // If we are using the "invert" auto-negative setting, we need to add an additional negative conditioning node
if (layer.autoNegative === 'invert' && layer.positivePrompt) { if (layer.autoNegative === 'invert' && layer.textPrompt?.positive) {
// We re-use the mask image, but invert it when converting to tensor // We re-use the mask image, but invert it when converting to tensor
const invertTensorMaskNode: S['InvertTensorMaskInvocation'] = { const invertTensorMaskNode: S['InvertTensorMaskInvocation'] = {
id: `${PROMPT_REGION_INVERT_TENSOR_MASK_PREFIX}_${layer.id}`, id: `${PROMPT_REGION_INVERT_TENSOR_MASK_PREFIX}_${layer.id}`,
@ -228,13 +235,13 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
? { ? {
type: 'sdxl_compel_prompt', type: 'sdxl_compel_prompt',
id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`, id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`,
prompt: layer.positivePrompt, prompt: layer.textPrompt.positive,
style: layer.positivePrompt, style: layer.textPrompt.positive,
} }
: { : {
type: 'compel', type: 'compel',
id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`, id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`,
prompt: layer.positivePrompt, prompt: layer.textPrompt.positive,
}; };
graph.nodes[regionalPositiveCondInvertedNode.id] = regionalPositiveCondInvertedNode; graph.nodes[regionalPositiveCondInvertedNode.id] = regionalPositiveCondInvertedNode;
// Connect the inverted mask to the conditioning // Connect the inverted mask to the conditioning

View File

@ -8,7 +8,7 @@ export const AddLayerButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const onClick = useCallback(() => { const onClick = useCallback(() => {
dispatch(layerAdded('regionalPromptLayer')); dispatch(layerAdded('vector_mask_layer'));
}, [dispatch]); }, [dispatch]);
return <Button onClick={onClick}>{t('regionalPrompts.addLayer')}</Button>; return <Button onClick={onClick}>{t('regionalPrompts.addLayer')}</Button>;

View File

@ -1,19 +1,19 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library'; import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { import {
globalMaskLayerOpacityChanged,
initialRegionalPromptsState, initialRegionalPromptsState,
promptLayerOpacityChanged,
} from 'features/regionalPrompts/store/regionalPromptsSlice'; } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export const PromptLayerOpacity = memo(() => { export const GlobalMaskLayerOpacity = memo(() => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const promptLayerOpacity = useAppSelector((s) => s.regionalPrompts.present.promptLayerOpacity); const globalMaskLayerOpacity = useAppSelector((s) => s.regionalPrompts.present.globalMaskLayerOpacity);
const onChange = useCallback( const onChange = useCallback(
(v: number) => { (v: number) => {
dispatch(promptLayerOpacityChanged(v)); dispatch(globalMaskLayerOpacityChanged(v));
}, },
[dispatch] [dispatch]
); );
@ -24,20 +24,20 @@ export const PromptLayerOpacity = memo(() => {
min={0.25} min={0.25}
max={1} max={1}
step={0.01} step={0.01}
value={promptLayerOpacity} value={globalMaskLayerOpacity}
defaultValue={initialRegionalPromptsState.promptLayerOpacity} defaultValue={initialRegionalPromptsState.globalMaskLayerOpacity}
onChange={onChange} onChange={onChange}
/> />
<CompositeNumberInput <CompositeNumberInput
min={0.25} min={0.25}
max={1} max={1}
step={0.01} step={0.01}
value={promptLayerOpacity} value={globalMaskLayerOpacity}
defaultValue={initialRegionalPromptsState.promptLayerOpacity} defaultValue={initialRegionalPromptsState.globalMaskLayerOpacity}
onChange={onChange} onChange={onChange}
/> />
</FormControl> </FormControl>
); );
}); });
PromptLayerOpacity.displayName = 'PromptLayerOpacity'; GlobalMaskLayerOpacity.displayName = 'GlobalMaskLayerOpacity';

View File

@ -1,6 +1,6 @@
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library'; import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { layerDeleted, rpLayerReset } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { layerDeleted, layerReset } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiTrashSimpleBold } from 'react-icons/pi'; import { PiArrowCounterClockwiseBold, PiTrashSimpleBold } from 'react-icons/pi';
@ -14,7 +14,7 @@ export const RPLayerActionsButtonGroup = memo(({ layerId }: Props) => {
dispatch(layerDeleted(layerId)); dispatch(layerDeleted(layerId));
}, [dispatch, layerId]); }, [dispatch, layerId]);
const resetLayer = useCallback(() => { const resetLayer = useCallback(() => {
dispatch(rpLayerReset(layerId)); dispatch(layerReset(layerId));
}, [dispatch, layerId]); }, [dispatch, layerId]);
return ( return (
<ButtonGroup isAttached={false}> <ButtonGroup isAttached={false}>

View File

@ -4,8 +4,8 @@ import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { isParameterAutoNegative } from 'features/parameters/types/parameterSchemas'; import { isParameterAutoNegative } from 'features/parameters/types/parameterSchemas';
import { import {
isRPLayer, isVectorMaskLayer,
rpLayerAutoNegativeChanged, maskLayerAutoNegativeChanged,
selectRegionalPromptsSlice, selectRegionalPromptsSlice,
} from 'features/regionalPrompts/store/regionalPromptsSlice'; } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
@ -26,7 +26,7 @@ const useAutoNegative = (layerId: string) => {
() => () =>
createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { createSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
return layer.autoNegative; return layer.autoNegative;
}), }),
[layerId] [layerId]
@ -45,7 +45,7 @@ export const RPLayerAutoNegativeCombobox = memo(({ layerId }: Props) => {
if (!isParameterAutoNegative(v?.value)) { if (!isParameterAutoNegative(v?.value)) {
return; return;
} }
dispatch(rpLayerAutoNegativeChanged({ layerId, autoNegative: v.value })); dispatch(maskLayerAutoNegativeChanged({ layerId, autoNegative: v.value }));
}, },
[dispatch, layerId] [dispatch, layerId]
); );

View File

@ -1,14 +1,14 @@
import { IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library'; import { IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import RgbColorPicker from 'common/components/RgbColorPicker'; import IAIColorPicker from 'common/components/IAIColorPicker';
import { import {
isRPLayer, isVectorMaskLayer,
rpLayerColorChanged, maskLayerPreviewColorChanged,
selectRegionalPromptsSlice, selectRegionalPromptsSlice,
} from 'features/regionalPrompts/store/regionalPromptsSlice'; } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import type { RgbColor } from 'react-colorful'; import type { RgbaColor } from 'react-colorful';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiEyedropperBold } from 'react-icons/pi'; import { PiEyedropperBold } from 'react-icons/pi';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
@ -23,16 +23,16 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => {
() => () =>
createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an vector mask layer`);
return layer.color; return layer.previewColor;
}), }),
[layerId] [layerId]
); );
const color = useAppSelector(selectColor); const color = useAppSelector(selectColor);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const onColorChange = useCallback( const onColorChange = useCallback(
(color: RgbColor) => { (color: RgbaColor) => {
dispatch(rpLayerColorChanged({ layerId, color })); dispatch(maskLayerPreviewColorChanged({ layerId, color }));
}, },
[dispatch, layerId] [dispatch, layerId]
); );
@ -49,7 +49,7 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent> <PopoverContent>
<PopoverBody minH={64}> <PopoverBody minH={64}>
<RgbColorPicker color={color} onChange={onColorChange} withNumberInput /> <IAIColorPicker color={color} onChange={onColorChange} withNumberInput />
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@ -8,7 +8,7 @@ import { RPLayerMenu } from 'features/regionalPrompts/components/RPLayerMenu';
import { RPLayerNegativePrompt } from 'features/regionalPrompts/components/RPLayerNegativePrompt'; import { RPLayerNegativePrompt } from 'features/regionalPrompts/components/RPLayerNegativePrompt';
import { RPLayerPositivePrompt } from 'features/regionalPrompts/components/RPLayerPositivePrompt'; import { RPLayerPositivePrompt } from 'features/regionalPrompts/components/RPLayerPositivePrompt';
import { RPLayerVisibilityToggle } from 'features/regionalPrompts/components/RPLayerVisibilityToggle'; import { RPLayerVisibilityToggle } from 'features/regionalPrompts/components/RPLayerVisibilityToggle';
import { isRPLayer, rpLayerSelected } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { isVectorMaskLayer, layerSelected } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
@ -21,24 +21,32 @@ export const RPLayerListItem = memo(({ layerId }: Props) => {
const selectedLayerId = useAppSelector((s) => s.regionalPrompts.present.selectedLayerId); const selectedLayerId = useAppSelector((s) => s.regionalPrompts.present.selectedLayerId);
const color = useAppSelector((s) => { const color = useAppSelector((s) => {
const layer = s.regionalPrompts.present.layers.find((l) => l.id === layerId); const layer = s.regionalPrompts.present.layers.find((l) => l.id === layerId);
assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
return rgbColorToString(layer.color); return rgbColorToString(layer.previewColor);
});
const hasTextPrompt = useAppSelector((s) => {
const layer = s.regionalPrompts.present.layers.find((l) => l.id === layerId);
assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
return layer.textPrompt !== null;
}); });
const onClickCapture = useCallback(() => { const onClickCapture = useCallback(() => {
// Must be capture so that the layer is selected before deleting/resetting/etc // Must be capture so that the layer is selected before deleting/resetting/etc
dispatch(rpLayerSelected(layerId)); dispatch(layerSelected(layerId));
}, [dispatch, layerId]); }, [dispatch, layerId]);
return ( return (
<Flex <Flex
gap={2} gap={2}
onClickCapture={onClickCapture} onClickCapture={onClickCapture}
bg={color} bg={color}
px={2}
borderRadius="base" borderRadius="base"
p="1px" borderTop="1px"
ps={2} borderBottom="1px"
borderColor="base.800"
opacity={selectedLayerId === layerId ? 1 : 0.5} opacity={selectedLayerId === layerId ? 1 : 0.5}
cursor="pointer"
> >
<Flex flexDir="column" gap={2} w="full" bg="base.850" borderRadius="base" p={2}> <Flex flexDir="column" gap={2} w="full" bg="base.850" p={2}>
<Flex gap={2} alignItems="center"> <Flex gap={2} alignItems="center">
<RPLayerMenu layerId={layerId} /> <RPLayerMenu layerId={layerId} />
<RPLayerColorPicker layerId={layerId} /> <RPLayerColorPicker layerId={layerId} />
@ -47,8 +55,8 @@ export const RPLayerListItem = memo(({ layerId }: Props) => {
<RPLayerAutoNegativeCombobox layerId={layerId} /> <RPLayerAutoNegativeCombobox layerId={layerId} />
<RPLayerActionsButtonGroup layerId={layerId} /> <RPLayerActionsButtonGroup layerId={layerId} />
</Flex> </Flex>
<RPLayerPositivePrompt layerId={layerId} /> {hasTextPrompt && <RPLayerPositivePrompt layerId={layerId} />}
<RPLayerNegativePrompt layerId={layerId} /> {hasTextPrompt && <RPLayerNegativePrompt layerId={layerId} />}
</Flex> </Flex>
</Flex> </Flex>
); );

View File

@ -2,13 +2,13 @@ import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { import {
isRPLayer, isVectorMaskLayer,
layerDeleted, layerDeleted,
layerMovedBackward, layerMovedBackward,
layerMovedForward, layerMovedForward,
layerMovedToBack, layerMovedToBack,
layerMovedToFront, layerMovedToFront,
rpLayerReset, layerReset,
selectRegionalPromptsSlice, selectRegionalPromptsSlice,
} from 'features/regionalPrompts/store/regionalPromptsSlice'; } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
@ -33,7 +33,7 @@ export const RPLayerMenu = memo(({ layerId }: Props) => {
() => () =>
createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
const layerIndex = regionalPrompts.present.layers.findIndex((l) => l.id === layerId); const layerIndex = regionalPrompts.present.layers.findIndex((l) => l.id === layerId);
const layerCount = regionalPrompts.present.layers.length; const layerCount = regionalPrompts.present.layers.length;
return { return {
@ -59,7 +59,7 @@ export const RPLayerMenu = memo(({ layerId }: Props) => {
dispatch(layerMovedToBack(layerId)); dispatch(layerMovedToBack(layerId));
}, [dispatch, layerId]); }, [dispatch, layerId]);
const resetLayer = useCallback(() => { const resetLayer = useCallback(() => {
dispatch(rpLayerReset(layerId)); dispatch(layerReset(layerId));
}, [dispatch, layerId]); }, [dispatch, layerId]);
const deleteLayer = useCallback(() => { const deleteLayer = useCallback(() => {
dispatch(layerDeleted(layerId)); dispatch(layerDeleted(layerId));

View File

@ -4,8 +4,8 @@ import { PromptOverlayButtonWrapper } from 'features/parameters/components/Promp
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton'; import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover'; import { PromptPopover } from 'features/prompt/PromptPopover';
import { usePrompt } from 'features/prompt/usePrompt'; import { usePrompt } from 'features/prompt/usePrompt';
import { useLayerNegativePrompt } from 'features/regionalPrompts/hooks/layerStateHooks'; import { useMaskLayerTextPrompt } from 'features/regionalPrompts/hooks/layerStateHooks';
import { rpLayerNegativePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { maskLayerNegativePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo, useCallback, useRef } from 'react'; import { memo, useCallback, useRef } from 'react';
import type { HotkeyCallback } from 'react-hotkeys-hook'; import type { HotkeyCallback } from 'react-hotkeys-hook';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@ -16,18 +16,18 @@ type Props = {
}; };
export const RPLayerNegativePrompt = memo((props: Props) => { export const RPLayerNegativePrompt = memo((props: Props) => {
const prompt = useLayerNegativePrompt(props.layerId); const textPrompt = useMaskLayerTextPrompt(props.layerId);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const _onChange = useCallback( const _onChange = useCallback(
(v: string) => { (v: string) => {
dispatch(rpLayerNegativePromptChanged({ layerId: props.layerId, prompt: v })); dispatch(maskLayerNegativePromptChanged({ layerId: props.layerId, prompt: v }));
}, },
[dispatch, props.layerId] [dispatch, props.layerId]
); );
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({ const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({
prompt, prompt: textPrompt.negative,
textareaRef, textareaRef,
onChange: _onChange, onChange: _onChange,
}); });
@ -48,7 +48,7 @@ export const RPLayerNegativePrompt = memo((props: Props) => {
id="prompt" id="prompt"
name="prompt" name="prompt"
ref={textareaRef} ref={textareaRef}
value={prompt} value={textPrompt.negative}
placeholder={t('parameters.negativePromptPlaceholder')} placeholder={t('parameters.negativePromptPlaceholder')}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}

View File

@ -4,8 +4,8 @@ import { PromptOverlayButtonWrapper } from 'features/parameters/components/Promp
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton'; import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover'; import { PromptPopover } from 'features/prompt/PromptPopover';
import { usePrompt } from 'features/prompt/usePrompt'; import { usePrompt } from 'features/prompt/usePrompt';
import { useLayerPositivePrompt } from 'features/regionalPrompts/hooks/layerStateHooks'; import { useMaskLayerTextPrompt } from 'features/regionalPrompts/hooks/layerStateHooks';
import { rpLayerPositivePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { maskLayerPositivePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo, useCallback, useRef } from 'react'; import { memo, useCallback, useRef } from 'react';
import type { HotkeyCallback } from 'react-hotkeys-hook'; import type { HotkeyCallback } from 'react-hotkeys-hook';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@ -16,18 +16,18 @@ type Props = {
}; };
export const RPLayerPositivePrompt = memo((props: Props) => { export const RPLayerPositivePrompt = memo((props: Props) => {
const prompt = useLayerPositivePrompt(props.layerId); const textPrompt = useMaskLayerTextPrompt(props.layerId);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const _onChange = useCallback( const _onChange = useCallback(
(v: string) => { (v: string) => {
dispatch(rpLayerPositivePromptChanged({ layerId: props.layerId, prompt: v })); dispatch(maskLayerPositivePromptChanged({ layerId: props.layerId, prompt: v }));
}, },
[dispatch, props.layerId] [dispatch, props.layerId]
); );
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({ const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({
prompt, prompt: textPrompt.positive,
textareaRef, textareaRef,
onChange: _onChange, onChange: _onChange,
}); });
@ -48,7 +48,7 @@ export const RPLayerPositivePrompt = memo((props: Props) => {
id="prompt" id="prompt"
name="prompt" name="prompt"
ref={textareaRef} ref={textareaRef}
value={prompt} value={textPrompt.positive}
placeholder={t('parameters.positivePromptPlaceholder')} placeholder={t('parameters.positivePromptPlaceholder')}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}

View File

@ -1,7 +1,7 @@
import { IconButton } from '@invoke-ai/ui-library'; import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { useLayerIsVisible } from 'features/regionalPrompts/hooks/layerStateHooks'; import { useLayerIsVisible } from 'features/regionalPrompts/hooks/layerStateHooks';
import { rpLayerIsVisibleToggled } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { layerVisibilityToggled } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiEyeBold, PiEyeClosedBold } from 'react-icons/pi'; import { PiEyeBold, PiEyeClosedBold } from 'react-icons/pi';
@ -15,7 +15,7 @@ export const RPLayerVisibilityToggle = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isVisible = useLayerIsVisible(layerId); const isVisible = useLayerIsVisible(layerId);
const onClick = useCallback(() => { const onClick = useCallback(() => {
dispatch(rpLayerIsVisibleToggled(layerId)); dispatch(layerVisibilityToggled(layerId));
}, [dispatch, layerId]); }, [dispatch, layerId]);
return ( return (

View File

@ -7,18 +7,18 @@ import { AddLayerButton } from 'features/regionalPrompts/components/AddLayerButt
import { BrushSize } from 'features/regionalPrompts/components/BrushSize'; import { BrushSize } from 'features/regionalPrompts/components/BrushSize';
import { DebugLayersButton } from 'features/regionalPrompts/components/DebugLayersButton'; import { DebugLayersButton } from 'features/regionalPrompts/components/DebugLayersButton';
import { DeleteAllLayersButton } from 'features/regionalPrompts/components/DeleteAllLayersButton'; import { DeleteAllLayersButton } from 'features/regionalPrompts/components/DeleteAllLayersButton';
import { PromptLayerOpacity } from 'features/regionalPrompts/components/PromptLayerOpacity'; import { GlobalMaskLayerOpacity } from 'features/regionalPrompts/components/GlobalMaskLayerOpacity';
import { RPEnabledSwitch } from 'features/regionalPrompts/components/RPEnabledSwitch'; import { RPEnabledSwitch } from 'features/regionalPrompts/components/RPEnabledSwitch';
import { RPLayerListItem } from 'features/regionalPrompts/components/RPLayerListItem'; import { RPLayerListItem } from 'features/regionalPrompts/components/RPLayerListItem';
import { StageComponent } from 'features/regionalPrompts/components/StageComponent'; import { StageComponent } from 'features/regionalPrompts/components/StageComponent';
import { ToolChooser } from 'features/regionalPrompts/components/ToolChooser'; import { ToolChooser } from 'features/regionalPrompts/components/ToolChooser';
import { UndoRedoButtonGroup } from 'features/regionalPrompts/components/UndoRedoButtonGroup'; import { UndoRedoButtonGroup } from 'features/regionalPrompts/components/UndoRedoButtonGroup';
import { isRPLayer, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { isVectorMaskLayer, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo } from 'react'; import { memo } from 'react';
const selectRPLayerIdsReversed = createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => const selectRPLayerIdsReversed = createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) =>
regionalPrompts.present.layers regionalPrompts.present.layers
.filter(isRPLayer) .filter(isVectorMaskLayer)
.map((l) => l.id) .map((l) => l.id)
.reverse() .reverse()
); );
@ -38,7 +38,7 @@ export const RegionalPromptsEditor = memo(() => {
</Flex> </Flex>
<RPEnabledSwitch /> <RPEnabledSwitch />
<BrushSize /> <BrushSize />
<PromptLayerOpacity /> <GlobalMaskLayerOpacity />
<ScrollableContent> <ScrollableContent>
<Flex flexDir="column" gap={2}> <Flex flexDir="column" gap={2}>
{rpLayerIdsReversed.map((id) => ( {rpLayerIdsReversed.map((id) => (

View File

@ -7,13 +7,13 @@ import { useMouseEvents } from 'features/regionalPrompts/hooks/mouseEventHooks';
import { import {
$cursorPosition, $cursorPosition,
$tool, $tool,
isRPLayer, isVectorMaskLayer,
rpLayerBboxChanged, layerBboxChanged,
rpLayerSelected, layerSelected,
rpLayerTranslated, layerTranslated,
selectRegionalPromptsSlice, selectRegionalPromptsSlice,
} from 'features/regionalPrompts/store/regionalPromptsSlice'; } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { renderBbox, renderBrushPreview, renderLayers } from 'features/regionalPrompts/util/renderers'; import { renderBbox, renderLayers,renderToolPreview } from 'features/regionalPrompts/util/renderers';
import Konva from 'konva'; import Konva from 'konva';
import type { IRect } from 'konva/lib/types'; import type { IRect } from 'konva/lib/types';
import { atom } from 'nanostores'; import { atom } from 'nanostores';
@ -27,8 +27,8 @@ const selectSelectedLayerColor = createMemoizedSelector(selectRegionalPromptsSli
if (!layer) { if (!layer) {
return null; return null;
} }
assert(isRPLayer(layer), `Layer ${regionalPrompts.present.selectedLayerId} is not an RP layer`); assert(isVectorMaskLayer(layer), `Layer ${regionalPrompts.present.selectedLayerId} is not an RP layer`);
return layer.color; return layer.previewColor;
}); });
const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElement | null) => { const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElement | null) => {
@ -44,21 +44,21 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem
const onLayerPosChanged = useCallback( const onLayerPosChanged = useCallback(
(layerId: string, x: number, y: number) => { (layerId: string, x: number, y: number) => {
dispatch(rpLayerTranslated({ layerId, x, y })); dispatch(layerTranslated({ layerId, x, y }));
}, },
[dispatch] [dispatch]
); );
const onBboxChanged = useCallback( const onBboxChanged = useCallback(
(layerId: string, bbox: IRect | null) => { (layerId: string, bbox: IRect | null) => {
dispatch(rpLayerBboxChanged({ layerId, bbox })); dispatch(layerBboxChanged({ layerId, bbox }));
}, },
[dispatch] [dispatch]
); );
const onBboxMouseDown = useCallback( const onBboxMouseDown = useCallback(
(layerId: string) => { (layerId: string) => {
dispatch(rpLayerSelected(layerId)); dispatch(layerSelected(layerId));
}, },
[dispatch] [dispatch]
); );
@ -130,7 +130,7 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem
if (!stage) { if (!stage) {
return; return;
} }
renderBrushPreview(stage, tool, selectedLayerIdColor, cursorPosition, state.brushSize); renderToolPreview(stage, tool, selectedLayerIdColor, cursorPosition, state.brushSize);
}, [stage, tool, cursorPosition, state.brushSize, selectedLayerIdColor]); }, [stage, tool, cursorPosition, state.brushSize, selectedLayerIdColor]);
useLayoutEffect(() => { useLayoutEffect(() => {
@ -138,8 +138,8 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem
if (!stage) { if (!stage) {
return; return;
} }
renderLayers(stage, state.layers, state.selectedLayerId, state.promptLayerOpacity, tool, onLayerPosChanged); renderLayers(stage, state.layers, tool, onLayerPosChanged);
}, [onLayerPosChanged, stage, state.layers, state.promptLayerOpacity, tool, state.selectedLayerId]); }, [stage, state.layers, tool, onLayerPosChanged]);
useLayoutEffect(() => { useLayoutEffect(() => {
log.trace('Rendering bbox'); log.trace('Rendering bbox');

View File

@ -1,35 +1,22 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { isRPLayer, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { isVectorMaskLayer, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
export const useLayerPositivePrompt = (layerId: string) => { export const useMaskLayerTextPrompt = (layerId: string) => {
const selectLayer = useMemo( const selectLayer = useMemo(
() => () =>
createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { createSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
return layer.positivePrompt; assert(layer.textPrompt !== null, `Layer ${layerId} does not have a text prompt`);
return layer.textPrompt;
}), }),
[layerId] [layerId]
); );
const prompt = useAppSelector(selectLayer); const textPrompt = useAppSelector(selectLayer);
return prompt; return textPrompt;
};
export const useLayerNegativePrompt = (layerId: string) => {
const selectLayer = useMemo(
() =>
createSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`);
return layer.negativePrompt;
}),
[layerId]
);
const prompt = useAppSelector(selectLayer);
return prompt;
}; };
export const useLayerIsVisible = (layerId: string) => { export const useLayerIsVisible = (layerId: string) => {
@ -37,7 +24,7 @@ export const useLayerIsVisible = (layerId: string) => {
() => () =>
createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { createSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
assert(isRPLayer(layer), `Layer ${layerId} not found or not an RP layer`); assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
return layer.isVisible; return layer.isVisible;
}), }),
[layerId] [layerId]

View File

@ -5,8 +5,8 @@ import {
$isMouseDown, $isMouseDown,
$isMouseOver, $isMouseOver,
$tool, $tool,
rpLayerLineAdded, lineAdded,
rpLayerPointsAdded, pointsAddedToLastLine,
} from 'features/regionalPrompts/store/regionalPromptsSlice'; } from 'features/regionalPrompts/store/regionalPromptsSlice';
import type Konva from 'konva'; import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
@ -64,7 +64,7 @@ export const useMouseEvents = () => {
// const tool = getTool(); // const tool = getTool();
if (tool === 'brush' || tool === 'eraser') { if (tool === 'brush' || tool === 'eraser') {
dispatch( dispatch(
rpLayerLineAdded({ lineAdded({
layerId: selectedLayerId, layerId: selectedLayerId,
points: [Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.x), Math.floor(pos.y)], points: [Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.x), Math.floor(pos.y)],
tool, tool,
@ -101,7 +101,7 @@ export const useMouseEvents = () => {
} }
// const tool = getTool(); // const tool = getTool();
if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) { if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) {
dispatch(rpLayerPointsAdded({ layerId: selectedLayerId, point: [Math.floor(pos.x), Math.floor(pos.y)] })); dispatch(pointsAddedToLastLine({ layerId: selectedLayerId, point: [Math.floor(pos.x), Math.floor(pos.y)] }));
} }
}, },
[dispatch, selectedLayerId, tool] [dispatch, selectedLayerId, tool]
@ -140,7 +140,7 @@ export const useMouseEvents = () => {
} }
if (tool === 'brush' || tool === 'eraser') { if (tool === 'brush' || tool === 'eraser') {
dispatch( dispatch(
rpLayerLineAdded({ lineAdded({
layerId: selectedLayerId, layerId: selectedLayerId,
points: [Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.x), Math.floor(pos.y)], points: [Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.x), Math.floor(pos.y)],
tool, tool,

View File

@ -6,7 +6,7 @@ import type { ParameterAutoNegative } from 'features/parameters/types/parameterS
import type { IRect, Vector2d } from 'konva/lib/types'; import type { IRect, Vector2d } from 'konva/lib/types';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { atom } from 'nanostores'; import { atom } from 'nanostores';
import type { RgbColor } from 'react-colorful'; import type { RgbaColor } from 'react-colorful';
import type { UndoableOptions } from 'redux-undo'; import type { UndoableOptions } from 'redux-undo';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -15,63 +15,63 @@ type DrawingTool = 'brush' | 'eraser';
export type RPTool = DrawingTool | 'move'; export type RPTool = DrawingTool | 'move';
type LayerObjectBase = { type VectorMaskLine = {
id: string; id: string;
}; kind: 'vector_mask_line';
type ImageObject = LayerObjectBase & {
kind: 'image';
imageName: string;
x: number;
y: number;
width: number;
height: number;
};
type LineObject = LayerObjectBase & {
kind: 'line';
tool: DrawingTool; tool: DrawingTool;
strokeWidth: number; strokeWidth: number;
points: number[]; points: number[];
}; };
type FillRectObject = LayerObjectBase & { type VectorMaskRect = {
kind: 'fillRect'; id: string;
kind: 'vector_mask_rect';
x: number; x: number;
y: number; y: number;
width: number; width: number;
height: number; height: number;
}; };
type LayerObject = ImageObject | LineObject | FillRectObject; type TextPrompt = {
positive: string;
negative: string;
};
type ImagePrompt = {
// TODO
};
type LayerBase = { type LayerBase = {
id: string; id: string;
};
export type RegionalPromptLayer = LayerBase & {
isVisible: boolean;
x: number; x: number;
y: number; y: number;
bbox: IRect | null; bbox: IRect | null;
bboxNeedsUpdate: boolean; bboxNeedsUpdate: boolean;
hasEraserStrokes: boolean; isVisible: boolean;
kind: 'regionalPromptLayer';
objects: LayerObject[];
positivePrompt: string;
negativePrompt: string;
color: RgbColor;
autoNegative: ParameterAutoNegative;
}; };
export type Layer = RegionalPromptLayer; type MaskLayerBase = LayerBase & {
textPrompt: TextPrompt | null; // Up to one text prompt per mask
imagePrompts: ImagePrompt[]; // Any number of image prompts
previewColor: RgbaColor;
autoNegative: ParameterAutoNegative;
needsPixelBbox: boolean; // Needs the slower pixel-based bbox calculation - set to true when an there is an eraser object
};
export type VectorMaskLayer = MaskLayerBase & {
kind: 'vector_mask_layer';
objects: (VectorMaskLine | VectorMaskRect)[];
};
export type Layer = VectorMaskLayer;
type RegionalPromptsState = { type RegionalPromptsState = {
_version: 1; _version: 1;
selectedLayerId: string | null; selectedLayerId: string | null;
layers: Layer[]; layers: Layer[];
brushSize: number; brushSize: number;
promptLayerOpacity: number; brushColor: RgbaColor;
globalMaskLayerOpacity: number;
isEnabled: boolean; isEnabled: boolean;
}; };
@ -79,13 +79,14 @@ export const initialRegionalPromptsState: RegionalPromptsState = {
_version: 1, _version: 1,
selectedLayerId: null, selectedLayerId: null,
brushSize: 100, brushSize: 100,
brushColor: { r: 255, g: 0, b: 0, a: 1 },
layers: [], layers: [],
promptLayerOpacity: 0.5, // This currently doesn't work globalMaskLayerOpacity: 0.5, // This currently doesn't work
isEnabled: false, isEnabled: false,
}; };
const isLine = (obj: LayerObject): obj is LineObject => obj.kind === 'line'; const isLine = (obj: VectorMaskLine | VectorMaskRect): obj is VectorMaskLine => obj.kind === 'vector_mask_line';
export const isRPLayer = (layer?: Layer): layer is RegionalPromptLayer => layer?.kind === 'regionalPromptLayer'; export const isVectorMaskLayer = (layer?: Layer): layer is VectorMaskLayer => layer?.kind === 'vector_mask_layer';
export const regionalPromptsSlice = createSlice({ export const regionalPromptsSlice = createSlice({
name: 'regionalPrompts', name: 'regionalPrompts',
@ -94,23 +95,27 @@ export const regionalPromptsSlice = createSlice({
//#region Meta Layer //#region Meta Layer
layerAdded: { layerAdded: {
reducer: (state, action: PayloadAction<Layer['kind'], string, { uuid: string }>) => { reducer: (state, action: PayloadAction<Layer['kind'], string, { uuid: string }>) => {
if (action.payload === 'regionalPromptLayer') { const kind = action.payload;
const lastColor = state.layers[state.layers.length - 1]?.color; if (action.payload === 'vector_mask_layer') {
const lastColor = state.layers[state.layers.length - 1]?.previewColor;
const color = LayerColors.next(lastColor); const color = LayerColors.next(lastColor);
const layer: RegionalPromptLayer = { const layer: VectorMaskLayer = {
id: getRPLayerId(action.meta.uuid), id: getVectorMaskLayerId(action.meta.uuid),
kind,
isVisible: true, isVisible: true,
bbox: null, bbox: null,
kind: action.payload, bboxNeedsUpdate: false,
positivePrompt: '',
negativePrompt: '',
objects: [], objects: [],
color, previewColor: color,
x: 0, x: 0,
y: 0, y: 0,
autoNegative: 'off', autoNegative: 'off',
bboxNeedsUpdate: false, needsPixelBbox: false,
hasEraserStrokes: false, textPrompt: {
positive: '',
negative: '',
},
imagePrompts: [],
}; };
state.layers.push(layer); state.layers.push(layer);
state.selectedLayerId = layer.id; state.selectedLayerId = layer.id;
@ -119,10 +124,52 @@ export const regionalPromptsSlice = createSlice({
}, },
prepare: (payload: Layer['kind']) => ({ payload, meta: { uuid: uuidv4() } }), prepare: (payload: Layer['kind']) => ({ payload, meta: { uuid: uuidv4() } }),
}, },
layerSelected: (state, action: PayloadAction<string>) => {
const layer = state.layers.find((l) => l.id === action.payload);
if (layer) {
state.selectedLayerId = layer.id;
}
},
layerVisibilityToggled: (state, action: PayloadAction<string>) => {
const layer = state.layers.find((l) => l.id === action.payload);
if (layer) {
layer.isVisible = !layer.isVisible;
}
},
layerTranslated: (state, action: PayloadAction<{ layerId: string; x: number; y: number }>) => {
const { layerId, x, y } = action.payload;
const layer = state.layers.find((l) => l.id === layerId);
if (layer) {
layer.x = x;
layer.y = y;
}
},
layerBboxChanged: (state, action: PayloadAction<{ layerId: string; bbox: IRect | null }>) => {
const { layerId, bbox } = action.payload;
const layer = state.layers.find((l) => l.id === layerId);
if (layer) {
layer.bbox = bbox;
layer.bboxNeedsUpdate = false;
}
},
layerReset: (state, action: PayloadAction<string>) => {
const layer = state.layers.find((l) => l.id === action.payload);
if (layer) {
layer.objects = [];
layer.bbox = null;
layer.isVisible = true;
layer.needsPixelBbox = false;
layer.bboxNeedsUpdate = false;
}
},
layerDeleted: (state, action: PayloadAction<string>) => { layerDeleted: (state, action: PayloadAction<string>) => {
state.layers = state.layers.filter((l) => l.id !== action.payload); state.layers = state.layers.filter((l) => l.id !== action.payload);
state.selectedLayerId = state.layers[0]?.id ?? null; state.selectedLayerId = state.layers[0]?.id ?? null;
}, },
allLayersDeleted: (state) => {
state.layers = [];
state.selectedLayerId = null;
},
layerMovedForward: (state, action: PayloadAction<string>) => { layerMovedForward: (state, action: PayloadAction<string>) => {
const cb = (l: Layer) => l.id === action.payload; const cb = (l: Layer) => l.id === action.payload;
moveForward(state.layers, cb); moveForward(state.layers, cb);
@ -143,70 +190,28 @@ export const regionalPromptsSlice = createSlice({
}, },
//#endregion //#endregion
//#region RP Layers //#region RP Layers
rpLayerSelected: (state, action: PayloadAction<string>) => { maskLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => {
const layer = state.layers.find((l) => l.id === action.payload);
if (isRPLayer(layer)) {
state.selectedLayerId = layer.id;
}
},
rpLayerIsVisibleToggled: (state, action: PayloadAction<string>) => {
const layer = state.layers.find((l) => l.id === action.payload);
if (isRPLayer(layer)) {
layer.isVisible = !layer.isVisible;
}
},
rpLayerReset: (state, action: PayloadAction<string>) => {
const layer = state.layers.find((l) => l.id === action.payload);
if (isRPLayer(layer)) {
layer.objects = [];
layer.bbox = null;
layer.isVisible = true;
layer.hasEraserStrokes = false;
layer.bboxNeedsUpdate = false;
}
},
rpLayerTranslated: (state, action: PayloadAction<{ layerId: string; x: number; y: number }>) => {
const { layerId, x, y } = action.payload;
const layer = state.layers.find((l) => l.id === layerId);
if (isRPLayer(layer)) {
layer.x = x;
layer.y = y;
}
},
rpLayerBboxChanged: (state, action: PayloadAction<{ layerId: string; bbox: IRect | null }>) => {
const { layerId, bbox } = action.payload;
const layer = state.layers.find((l) => l.id === layerId);
if (isRPLayer(layer)) {
layer.bbox = bbox;
layer.bboxNeedsUpdate = false;
}
},
allLayersDeleted: (state) => {
state.layers = [];
state.selectedLayerId = null;
},
rpLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => {
const { layerId, prompt } = action.payload; const { layerId, prompt } = action.payload;
const layer = state.layers.find((l) => l.id === layerId); const layer = state.layers.find((l) => l.id === layerId);
if (isRPLayer(layer)) { if (layer && layer.textPrompt) {
layer.positivePrompt = prompt; layer.textPrompt.positive = prompt;
} }
}, },
rpLayerNegativePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { maskLayerNegativePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => {
const { layerId, prompt } = action.payload; const { layerId, prompt } = action.payload;
const layer = state.layers.find((l) => l.id === layerId); const layer = state.layers.find((l) => l.id === layerId);
if (isRPLayer(layer)) { if (layer && layer.textPrompt) {
layer.negativePrompt = prompt; layer.textPrompt.negative = prompt;
} }
}, },
rpLayerColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbColor }>) => { maskLayerPreviewColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbaColor }>) => {
const { layerId, color } = action.payload; const { layerId, color } = action.payload;
const layer = state.layers.find((l) => l.id === layerId); const layer = state.layers.find((l) => l.id === layerId);
if (isRPLayer(layer)) { if (layer) {
layer.color = color; layer.previewColor = color;
} }
}, },
rpLayerLineAdded: { lineAdded: {
reducer: ( reducer: (
state, state,
action: PayloadAction< action: PayloadAction<
@ -217,20 +222,20 @@ export const regionalPromptsSlice = createSlice({
) => { ) => {
const { layerId, points, tool } = action.payload; const { layerId, points, tool } = action.payload;
const layer = state.layers.find((l) => l.id === layerId); const layer = state.layers.find((l) => l.id === layerId);
if (isRPLayer(layer)) { if (layer) {
const lineId = getRPLayerLineId(layer.id, action.meta.uuid); const lineId = getVectorMaskLayerLineId(layer.id, action.meta.uuid);
layer.objects.push({ layer.objects.push({
kind: 'line', kind: 'vector_mask_line',
tool: tool, tool: tool,
id: lineId, id: lineId,
// Points must be offset by the layer's x and y coordinates // Points must be offset by the layer's x and y coordinates
// TODO: Handle this in the event listener // TODO: Handle this in the event listener?
points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y], points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y],
strokeWidth: state.brushSize, strokeWidth: state.brushSize,
}); });
layer.bboxNeedsUpdate = true; layer.bboxNeedsUpdate = true;
if (!layer.hasEraserStrokes && tool === 'eraser') { if (!layer.needsPixelBbox && tool === 'eraser') {
layer.hasEraserStrokes = true; layer.needsPixelBbox = true;
} }
} }
}, },
@ -239,10 +244,10 @@ export const regionalPromptsSlice = createSlice({
meta: { uuid: uuidv4() }, meta: { uuid: uuidv4() },
}), }),
}, },
rpLayerPointsAdded: (state, action: PayloadAction<{ layerId: string; point: [number, number] }>) => { pointsAddedToLastLine: (state, action: PayloadAction<{ layerId: string; point: [number, number] }>) => {
const { layerId, point } = action.payload; const { layerId, point } = action.payload;
const layer = state.layers.find((l) => l.id === layerId); const layer = state.layers.find((l) => l.id === layerId);
if (isRPLayer(layer)) { if (layer) {
const lastLine = layer.objects.findLast(isLine); const lastLine = layer.objects.findLast(isLine);
if (!lastLine) { if (!lastLine) {
return; return;
@ -253,13 +258,13 @@ export const regionalPromptsSlice = createSlice({
layer.bboxNeedsUpdate = true; layer.bboxNeedsUpdate = true;
} }
}, },
rpLayerAutoNegativeChanged: ( maskLayerAutoNegativeChanged: (
state, state,
action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }> action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }>
) => { ) => {
const { layerId, autoNegative } = action.payload; const { layerId, autoNegative } = action.payload;
const layer = state.layers.find((l) => l.id === layerId); const layer = state.layers.find((l) => l.id === layerId);
if (isRPLayer(layer)) { if (layer) {
layer.autoNegative = autoNegative; layer.autoNegative = autoNegative;
} }
}, },
@ -268,8 +273,11 @@ export const regionalPromptsSlice = createSlice({
brushSizeChanged: (state, action: PayloadAction<number>) => { brushSizeChanged: (state, action: PayloadAction<number>) => {
state.brushSize = action.payload; state.brushSize = action.payload;
}, },
promptLayerOpacityChanged: (state, action: PayloadAction<number>) => { globalMaskLayerOpacityChanged: (state, action: PayloadAction<number>) => {
state.promptLayerOpacity = action.payload; state.globalMaskLayerOpacity = action.payload;
for (const layer of state.layers) {
layer.previewColor.a = action.payload;
}
}, },
isEnabledChanged: (state, action: PayloadAction<boolean>) => { isEnabledChanged: (state, action: PayloadAction<boolean>) => {
state.isEnabled = action.payload; state.isEnabled = action.payload;
@ -282,22 +290,22 @@ export const regionalPromptsSlice = createSlice({
* This class is used to cycle through a set of colors for the prompt region layers. * This class is used to cycle through a set of colors for the prompt region layers.
*/ */
class LayerColors { class LayerColors {
static COLORS: RgbColor[] = [ static COLORS: RgbaColor[] = [
{ r: 123, g: 159, b: 237 }, // rgb(123, 159, 237) { r: 123, g: 159, b: 237, a: 1 }, // rgb(123, 159, 237)
{ r: 106, g: 222, b: 106 }, // rgb(106, 222, 106) { r: 106, g: 222, b: 106, a: 1 }, // rgb(106, 222, 106)
{ r: 250, g: 225, b: 80 }, // rgb(250, 225, 80) { r: 250, g: 225, b: 80, a: 1 }, // rgb(250, 225, 80)
{ r: 233, g: 137, b: 81 }, // rgb(233, 137, 81) { r: 233, g: 137, b: 81, a: 1 }, // rgb(233, 137, 81)
{ r: 229, g: 96, b: 96 }, // rgb(229, 96, 96) { r: 229, g: 96, b: 96, a: 1 }, // rgb(229, 96, 96)
{ r: 226, g: 122, b: 210 }, // rgb(226, 122, 210) { r: 226, g: 122, b: 210, a: 1 }, // rgb(226, 122, 210)
{ r: 167, g: 116, b: 234 }, // rgb(167, 116, 234) { r: 167, g: 116, b: 234, a: 1 }, // rgb(167, 116, 234)
]; ];
static i = this.COLORS.length - 1; 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. * Get the next color in the sequence. If a known color is provided, the next color will be the one after it.
*/ */
static next(currentColor?: RgbColor): RgbColor { static next(currentColor?: RgbaColor): RgbaColor {
if (currentColor) { if (currentColor) {
const i = this.COLORS.findIndex((c) => isEqual(c, currentColor)); const i = this.COLORS.findIndex((c) => isEqual(c, { ...currentColor, a: 1 }));
if (i !== -1) { if (i !== -1) {
this.i = i; this.i = i;
} }
@ -319,21 +327,21 @@ export const {
layerMovedToFront, layerMovedToFront,
allLayersDeleted, allLayersDeleted,
// Regional Prompt layer actions // Regional Prompt layer actions
rpLayerAutoNegativeChanged, maskLayerAutoNegativeChanged,
rpLayerBboxChanged, layerBboxChanged,
rpLayerColorChanged, maskLayerPreviewColorChanged,
rpLayerIsVisibleToggled, layerVisibilityToggled,
rpLayerLineAdded, lineAdded,
rpLayerNegativePromptChanged, maskLayerNegativePromptChanged,
rpLayerPointsAdded, pointsAddedToLastLine,
rpLayerPositivePromptChanged, maskLayerPositivePromptChanged,
rpLayerReset, layerReset,
rpLayerSelected, layerSelected,
rpLayerTranslated, layerTranslated,
// General actions // General actions
isEnabledChanged, isEnabledChanged,
brushSizeChanged, brushSizeChanged,
promptLayerOpacityChanged, globalMaskLayerOpacityChanged,
} = regionalPromptsSlice.actions; } = regionalPromptsSlice.actions;
export const selectRegionalPromptsSlice = (state: RootState) => state.regionalPrompts; export const selectRegionalPromptsSlice = (state: RootState) => state.regionalPrompts;
@ -349,22 +357,23 @@ export const $tool = atom<RPTool>('brush');
export const $cursorPosition = atom<Vector2d | null>(null); export const $cursorPosition = atom<Vector2d | null>(null);
// IDs for singleton layers and objects // IDs for singleton layers and objects
export const BRUSH_PREVIEW_LAYER_ID = 'brushPreviewLayer'; export const TOOL_PREVIEW_LAYER_ID = 'tool_preview_layer';
export const BRUSH_PREVIEW_FILL_ID = 'brushPreviewFill'; export const BRUSH_FILL_ID = 'brush_fill';
export const BRUSH_PREVIEW_BORDER_INNER_ID = 'brushPreviewBorderInner'; export const BRUSH_BORDER_INNER_ID = 'brush_border_inner';
export const BRUSH_PREVIEW_BORDER_OUTER_ID = 'brushPreviewBorderOuter'; export const BRUSH_BORDER_OUTER_ID = 'brush_border_outer';
// Names (aka classes) for Konva layers and objects // Names (aka classes) for Konva layers and objects
export const REGIONAL_PROMPT_LAYER_NAME = 'regionalPromptLayer'; export const VECTOR_MASK_LAYER_NAME = 'vector_mask_layer';
export const REGIONAL_PROMPT_LAYER_LINE_NAME = 'regionalPromptLayerLine'; export const VECTOR_MASK_LAYER_LINE_NAME = 'vector_mask_layer.line';
export const REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME = 'regionalPromptLayerObjectGroup'; export const VECTOR_MASK_LAYER_OBJECT_GROUP_NAME = 'vector_mask_layer.object_group';
export const REGIONAL_PROMPT_LAYER_BBOX_NAME = 'regionalPromptLayerBbox'; export const LAYER_BBOX_NAME = 'layer.bbox';
// Getters for non-singleton layer and object IDs // Getters for non-singleton layer and object IDs
const getRPLayerId = (layerId: string) => `rp_layer_${layerId}`; const getVectorMaskLayerId = (layerId: string) => `${VECTOR_MASK_LAYER_NAME}_${layerId}`;
const getRPLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`; const getVectorMaskLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`;
export const getRPLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`; export const getVectorMaskLayerObjectGroupId = (layerId: string, groupId: string) =>
export const getPRLayerBboxId = (layerId: string) => `${layerId}.bbox`; `${layerId}.objectGroup_${groupId}`;
export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`;
export const regionalPromptsPersistConfig: PersistConfig<RegionalPromptsState> = { export const regionalPromptsPersistConfig: PersistConfig<RegionalPromptsState> = {
name: regionalPromptsSlice.name, name: regionalPromptsSlice.name,
@ -380,12 +389,12 @@ export const redoRegionalPrompts = createAction(`${regionalPromptsSlice.name}/re
// These actions are _individually_ grouped together as single undoable actions // These actions are _individually_ grouped together as single undoable actions
const undoableGroupByMatcher = isAnyOf( const undoableGroupByMatcher = isAnyOf(
brushSizeChanged, brushSizeChanged,
promptLayerOpacityChanged, globalMaskLayerOpacityChanged,
isEnabledChanged, isEnabledChanged,
rpLayerPositivePromptChanged, maskLayerPositivePromptChanged,
rpLayerNegativePromptChanged, maskLayerNegativePromptChanged,
rpLayerTranslated, layerTranslated,
rpLayerColorChanged maskLayerPreviewColorChanged
); );
const LINE_1 = 'LINE_1'; const LINE_1 = 'LINE_1';
@ -396,13 +405,13 @@ export const regionalPromptsUndoableConfig: UndoableOptions<RegionalPromptsState
undoType: undoRegionalPrompts.type, undoType: undoRegionalPrompts.type,
redoType: redoRegionalPrompts.type, redoType: redoRegionalPrompts.type,
groupBy: (action, state, history) => { groupBy: (action, state, history) => {
// Lines are started with `rpLayerLineAdded` and may have any number of subsequent `rpLayerPointsAdded` events. // Lines are started with `lineAdded` and may have any number of subsequent `pointsAddedToLastLine` events.
// We can use a double-buffer-esque trick to group each "logical" line as a single undoable action, without grouping // We can use a double-buffer-esque trick to group each "logical" line as a single undoable action, without grouping
// separate logical lines as a single undo action. // separate logical lines as a single undo action.
if (rpLayerLineAdded.match(action)) { if (lineAdded.match(action)) {
return history.group === LINE_1 ? LINE_2 : LINE_1; return history.group === LINE_1 ? LINE_2 : LINE_1;
} }
if (rpLayerPointsAdded.match(action)) { if (pointsAddedToLastLine.match(action)) {
if (history.group === LINE_1 || history.group === LINE_2) { if (history.group === LINE_1 || history.group === LINE_2) {
return history.group; return history.group;
} }
@ -419,7 +428,7 @@ export const regionalPromptsUndoableConfig: UndoableOptions<RegionalPromptsState
} }
// This action is triggered on state changes, including when we undo. If we do not ignore this action, when we // This action is triggered on state changes, including when we undo. If we do not ignore this action, when we
// undo, this action triggers and empties the future states array. Therefore, we must ignore this action. // undo, this action triggers and empties the future states array. Therefore, we must ignore this action.
if (rpLayerBboxChanged.match(action)) { if (layerBboxChanged.match(action)) {
return false; return false;
} }
return true; return true;

View File

@ -1,6 +1,6 @@
import openBase64ImageInTab from 'common/util/openBase64ImageInTab'; import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { imageDataToDataURL } from 'features/canvas/util/blobToDataURL'; import { imageDataToDataURL } from 'features/canvas/util/blobToDataURL';
import { REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { VECTOR_MASK_LAYER_OBJECT_GROUP_NAME } from 'features/regionalPrompts/store/regionalPromptsSlice';
import Konva from 'konva'; import Konva from 'konva';
import type { Layer as KonvaLayerType } from 'konva/lib/Layer'; import type { Layer as KonvaLayerType } from 'konva/lib/Layer';
import type { IRect } from 'konva/lib/types'; import type { IRect } from 'konva/lib/types';
@ -80,7 +80,7 @@ export const getKonvaLayerBbox = (layer: KonvaLayerType, preview: boolean = fals
offscreenStage.add(layerClone); offscreenStage.add(layerClone);
for (const child of layerClone.getChildren()) { for (const child of layerClone.getChildren()) {
if (child.name() === REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME) { if (child.name() === VECTOR_MASK_LAYER_OBJECT_GROUP_NAME) {
// We need to cache the group to ensure it composites out eraser strokes correctly // We need to cache the group to ensure it composites out eraser strokes correctly
child.cache(); child.cache();
} else { } else {

View File

@ -1,7 +1,7 @@
import { getStore } from 'app/store/nanostores/store'; import { getStore } from 'app/store/nanostores/store';
import openBase64ImageInTab from 'common/util/openBase64ImageInTab'; import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { blobToDataURL } from 'features/canvas/util/blobToDataURL'; import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
import { REGIONAL_PROMPT_LAYER_NAME } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { VECTOR_MASK_LAYER_NAME } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { renderLayers } from 'features/regionalPrompts/util/renderers'; import { renderLayers } from 'features/regionalPrompts/util/renderers';
import Konva from 'konva'; import Konva from 'konva';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
@ -18,12 +18,11 @@ export const getRegionalPromptLayerBlobs = async (
): Promise<Record<string, Blob>> => { ): Promise<Record<string, Blob>> => {
const state = getStore().getState(); const state = getStore().getState();
const reduxLayers = state.regionalPrompts.present.layers; const reduxLayers = state.regionalPrompts.present.layers;
const selectedLayerIdId = state.regionalPrompts.present.selectedLayerId;
const container = document.createElement('div'); const container = document.createElement('div');
const stage = new Konva.Stage({ container, width: state.generation.width, height: state.generation.height }); const stage = new Konva.Stage({ container, width: state.generation.width, height: state.generation.height });
renderLayers(stage, reduxLayers, selectedLayerIdId, 1, 'brush'); renderLayers(stage, reduxLayers, 'brush');
const konvaLayers = stage.find<Konva.Layer>(`.${REGIONAL_PROMPT_LAYER_NAME}`); const konvaLayers = stage.find<Konva.Layer>(`.${VECTOR_MASK_LAYER_NAME}`);
const blobs: Record<string, Blob> = {}; const blobs: Record<string, Blob> = {};
// First remove all layers // First remove all layers
@ -51,7 +50,10 @@ export const getRegionalPromptLayerBlobs = async (
if (preview) { if (preview) {
const base64 = await blobToDataURL(blob); const base64 = await blobToDataURL(blob);
openBase64ImageInTab([ openBase64ImageInTab([
{ base64, caption: `${reduxLayer.id}: ${reduxLayer.positivePrompt} / ${reduxLayer.negativePrompt}` }, {
base64,
caption: `${reduxLayer.id}: ${reduxLayer.textPrompt?.positive} / ${reduxLayer.textPrompt?.negative}`,
},
]); ]);
} }
layer.remove(); layer.remove();

View File

@ -1,20 +1,21 @@
import { getStore } from 'app/store/nanostores/store'; import { getStore } from 'app/store/nanostores/store';
import { rgbColorToString } from 'features/canvas/util/colorToString'; import { rgbColorToString } from 'features/canvas/util/colorToString';
import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks'; import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks';
import type { Layer, RegionalPromptLayer, RPTool } from 'features/regionalPrompts/store/regionalPromptsSlice'; import type { Layer, RPTool, VectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { import {
$isMouseOver, $isMouseOver,
$tool, $tool,
BRUSH_PREVIEW_BORDER_INNER_ID, BRUSH_BORDER_INNER_ID,
BRUSH_PREVIEW_BORDER_OUTER_ID, BRUSH_BORDER_OUTER_ID,
BRUSH_PREVIEW_FILL_ID, BRUSH_FILL_ID,
BRUSH_PREVIEW_LAYER_ID, getLayerBboxId,
getPRLayerBboxId, getVectorMaskLayerObjectGroupId,
getRPLayerObjectGroupId, isVectorMaskLayer,
REGIONAL_PROMPT_LAYER_BBOX_NAME, LAYER_BBOX_NAME,
REGIONAL_PROMPT_LAYER_LINE_NAME, TOOL_PREVIEW_LAYER_ID,
REGIONAL_PROMPT_LAYER_NAME, VECTOR_MASK_LAYER_LINE_NAME,
REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME, VECTOR_MASK_LAYER_NAME,
VECTOR_MASK_LAYER_OBJECT_GROUP_NAME,
} from 'features/regionalPrompts/store/regionalPromptsSlice'; } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { getKonvaLayerBbox } from 'features/regionalPrompts/util/bbox'; import { getKonvaLayerBbox } from 'features/regionalPrompts/util/bbox';
import Konva from 'konva'; import Konva from 'konva';
@ -26,11 +27,13 @@ import { v4 as uuidv4 } from 'uuid';
const BBOX_SELECTED_STROKE = 'rgba(78, 190, 255, 1)'; const BBOX_SELECTED_STROKE = 'rgba(78, 190, 255, 1)';
const BBOX_NOT_SELECTED_STROKE = 'rgba(255, 255, 255, 0.353)'; const BBOX_NOT_SELECTED_STROKE = 'rgba(255, 255, 255, 0.353)';
const BBOX_NOT_SELECTED_MOUSEOVER_STROKE = 'rgba(255, 255, 255, 0.661)'; const BBOX_NOT_SELECTED_MOUSEOVER_STROKE = 'rgba(255, 255, 255, 0.661)';
const BRUSH_PREVIEW_BORDER_INNER_COLOR = 'rgba(0,0,0,1)'; const BRUSH_BORDER_INNER_COLOR = 'rgba(0,0,0,1)';
const BRUSH_PREVIEW_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)'; const BRUSH_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)';
const GET_CLIENT_RECT_CONFIG = { skipTransform: true }; const GET_CLIENT_RECT_CONFIG = { skipTransform: true };
const mapId = (object: { id: string }) => object.id; const mapId = (object: { id: string }) => object.id;
const getIsSelected = (layerId?: string | null) => { const getIsSelected = (layerId?: string | null) => {
if (!layerId) { if (!layerId) {
return false; return false;
@ -46,37 +49,44 @@ const getIsSelected = (layerId?: string | null) => {
* @param cursorPos The cursor position. * @param cursorPos The cursor position.
* @param brushSize The brush size. * @param brushSize The brush size.
*/ */
export const renderBrushPreview = ( export const renderToolPreview = (
stage: Konva.Stage, stage: Konva.Stage,
tool: RPTool, tool: RPTool,
color: RgbColor | null, color: RgbColor | null,
cursorPos: Vector2d | null, cursorPos: Vector2d | null,
brushSize: number brushSize: number
) => { ) => {
const layerCount = stage.find(`.${REGIONAL_PROMPT_LAYER_NAME}`).length; const layerCount = stage.find(`.${VECTOR_MASK_LAYER_NAME}`).length;
// Update the stage's pointer style // Update the stage's pointer style
stage.container().style.cursor = tool === 'move' || layerCount === 0 ? 'default' : 'none'; if (tool === 'move') {
stage.container().style.cursor = 'default';
} else if (layerCount === 0) {
// We have no layers, so we should not render any tool
stage.container().style.cursor = 'default';
} else {
stage.container().style.cursor = 'none';
}
// Create the layer if it doesn't exist // Create the layer if it doesn't exist
let layer = stage.findOne<Konva.Layer>(`#${BRUSH_PREVIEW_LAYER_ID}`); let layer = stage.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`);
if (!layer) { if (!layer) {
// Initialize the brush preview layer & add to the stage // Initialize the brush preview layer & add to the stage
layer = new Konva.Layer({ id: BRUSH_PREVIEW_LAYER_ID, visible: tool !== 'move', listening: false }); layer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, visible: tool !== 'move', listening: false });
stage.add(layer); stage.add(layer);
// The brush preview is hidden and shown as the mouse leaves and enters the stage // The brush preview is hidden and shown as the mouse leaves and enters the stage
stage.on('mousemove', (e) => { stage.on('mousemove', (e) => {
e.target e.target
.getStage() .getStage()
?.findOne<Konva.Layer>(`#${BRUSH_PREVIEW_LAYER_ID}`) ?.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)
?.visible($tool.get() !== 'move'); ?.visible($tool.get() !== 'move');
}); });
stage.on('mouseleave', (e) => { stage.on('mouseleave', (e) => {
e.target.getStage()?.findOne<Konva.Layer>(`#${BRUSH_PREVIEW_LAYER_ID}`)?.visible(false); e.target.getStage()?.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false);
}); });
stage.on('mouseenter', (e) => { stage.on('mouseenter', (e) => {
e.target e.target
.getStage() .getStage()
?.findOne<Konva.Layer>(`#${BRUSH_PREVIEW_LAYER_ID}`) ?.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)
?.visible($tool.get() !== 'move'); ?.visible($tool.get() !== 'move');
}); });
} }
@ -95,10 +105,10 @@ export const renderBrushPreview = (
} }
// Create and/or update the fill circle // Create and/or update the fill circle
let fill = layer.findOne<Konva.Circle>(`#${BRUSH_PREVIEW_FILL_ID}`); let fill = layer.findOne<Konva.Circle>(`#${BRUSH_FILL_ID}`);
if (!fill) { if (!fill) {
fill = new Konva.Circle({ fill = new Konva.Circle({
id: BRUSH_PREVIEW_FILL_ID, id: BRUSH_FILL_ID,
listening: false, listening: false,
strokeEnabled: false, strokeEnabled: false,
}); });
@ -113,12 +123,12 @@ export const renderBrushPreview = (
}); });
// Create and/or update the inner border of the brush preview // Create and/or update the inner border of the brush preview
let borderInner = layer.findOne<Konva.Circle>(`#${BRUSH_PREVIEW_BORDER_INNER_ID}`); let borderInner = layer.findOne<Konva.Circle>(`#${BRUSH_BORDER_INNER_ID}`);
if (!borderInner) { if (!borderInner) {
borderInner = new Konva.Circle({ borderInner = new Konva.Circle({
id: BRUSH_PREVIEW_BORDER_INNER_ID, id: BRUSH_BORDER_INNER_ID,
listening: false, listening: false,
stroke: BRUSH_PREVIEW_BORDER_INNER_COLOR, stroke: BRUSH_BORDER_INNER_COLOR,
strokeWidth: 1, strokeWidth: 1,
strokeEnabled: true, strokeEnabled: true,
}); });
@ -127,12 +137,12 @@ export const renderBrushPreview = (
borderInner.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2 }); borderInner.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2 });
// Create and/or update the outer border of the brush preview // Create and/or update the outer border of the brush preview
let borderOuter = layer.findOne<Konva.Circle>(`#${BRUSH_PREVIEW_BORDER_OUTER_ID}`); let borderOuter = layer.findOne<Konva.Circle>(`#${BRUSH_BORDER_OUTER_ID}`);
if (!borderOuter) { if (!borderOuter) {
borderOuter = new Konva.Circle({ borderOuter = new Konva.Circle({
id: BRUSH_PREVIEW_BORDER_OUTER_ID, id: BRUSH_BORDER_OUTER_ID,
listening: false, listening: false,
stroke: BRUSH_PREVIEW_BORDER_OUTER_COLOR, stroke: BRUSH_BORDER_OUTER_COLOR,
strokeWidth: 1, strokeWidth: 1,
strokeEnabled: true, strokeEnabled: true,
}); });
@ -145,22 +155,20 @@ export const renderBrushPreview = (
}); });
}; };
const renderRPLayer = ( const renderVectorMaskLayer = (
stage: Konva.Stage, stage: Konva.Stage,
rpLayer: RegionalPromptLayer, vmLayer: VectorMaskLayer,
rpLayerIndex: number, vmLayerIndex: number,
selectedLayerIdId: string | null,
tool: RPTool, tool: RPTool,
layerOpacity: number,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void onLayerPosChanged?: (layerId: string, x: number, y: number) => void
) => { ) => {
let konvaLayer = stage.findOne<Konva.Layer>(`#${rpLayer.id}`); let konvaLayer = stage.findOne<Konva.Layer>(`#${vmLayer.id}`);
if (!konvaLayer) { if (!konvaLayer) {
// This layer hasn't been added to the konva state yet // This layer hasn't been added to the konva state yet
konvaLayer = new Konva.Layer({ konvaLayer = new Konva.Layer({
id: rpLayer.id, id: vmLayer.id,
name: REGIONAL_PROMPT_LAYER_NAME, name: VECTOR_MASK_LAYER_NAME,
draggable: true, draggable: true,
dragDistance: 0, dragDistance: 0,
}); });
@ -168,7 +176,7 @@ const renderRPLayer = (
// Create a `dragmove` listener for this layer // Create a `dragmove` listener for this layer
if (onLayerPosChanged) { if (onLayerPosChanged) {
konvaLayer.on('dragend', function (e) { konvaLayer.on('dragend', function (e) {
onLayerPosChanged(rpLayer.id, Math.floor(e.target.x()), Math.floor(e.target.y())); onLayerPosChanged(vmLayer.id, Math.floor(e.target.x()), Math.floor(e.target.y()));
}); });
} }
@ -192,8 +200,8 @@ const renderRPLayer = (
// The object group holds all of the layer's objects (e.g. lines and rects) // The object group holds all of the layer's objects (e.g. lines and rects)
const konvaObjectGroup = new Konva.Group({ const konvaObjectGroup = new Konva.Group({
id: getRPLayerObjectGroupId(rpLayer.id, uuidv4()), id: getVectorMaskLayerObjectGroupId(vmLayer.id, uuidv4()),
name: REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME, name: VECTOR_MASK_LAYER_OBJECT_GROUP_NAME,
listening: false, listening: false,
}); });
konvaLayer.add(konvaObjectGroup); konvaLayer.add(konvaObjectGroup);
@ -201,54 +209,47 @@ const renderRPLayer = (
stage.add(konvaLayer); stage.add(konvaLayer);
// When a layer is added, it ends up on top of the brush preview - we need to move the preview back to the top. // When a layer is added, it ends up on top of the brush preview - we need to move the preview back to the top.
stage.findOne<Konva.Layer>(`#${BRUSH_PREVIEW_LAYER_ID}`)?.moveToTop(); stage.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)?.moveToTop();
} }
// Update the layer's position and listening state // Update the layer's position and listening state
konvaLayer.setAttrs({ konvaLayer.setAttrs({
listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events
x: Math.floor(rpLayer.x), x: Math.floor(vmLayer.x),
y: Math.floor(rpLayer.y), y: Math.floor(vmLayer.y),
// There are rpLayers.length layers, plus a brush preview layer rendered on top of them, so the zIndex works // We have a konva layer for each redux layer, plus a brush preview layer, which should always be on top. We can
// out to be the layerIndex. If more layers are added, this may no longer be true. // therefore use the index of the redux layer as the zIndex for konva layers. If more layers are added to the
zIndex: rpLayerIndex, // stage, this may no longer be work.
zIndex: vmLayerIndex,
}); });
const color = rgbColorToString(rpLayer.color); // Convert the color to a string, stripping the alpha - the object group will handle opacity.
const rgbColor = rgbColorToString(vmLayer.previewColor);
const konvaObjectGroup = konvaLayer.findOne<Konva.Group>(`.${REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME}`); const konvaObjectGroup = konvaLayer.findOne<Konva.Group>(`.${VECTOR_MASK_LAYER_OBJECT_GROUP_NAME}`);
assert(konvaObjectGroup, `Object group not found for layer ${rpLayer.id}`); assert(konvaObjectGroup, `Object group not found for layer ${vmLayer.id}`);
// We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required. // We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required.
let groupNeedsCache = false; let groupNeedsCache = false;
if (konvaObjectGroup.opacity() !== layerOpacity) { const objectIds = vmLayer.objects.map(mapId);
konvaObjectGroup.opacity(layerOpacity); for (const objectNode of konvaObjectGroup.find(`.${VECTOR_MASK_LAYER_LINE_NAME}`)) {
}
// Remove deleted objects
const objectIds = rpLayer.objects.map(mapId);
for (const objectNode of konvaLayer.find(`.${REGIONAL_PROMPT_LAYER_LINE_NAME}`)) {
if (!objectIds.includes(objectNode.id())) { if (!objectIds.includes(objectNode.id())) {
objectNode.destroy(); objectNode.destroy();
groupNeedsCache = true; groupNeedsCache = true;
} }
} }
for (const reduxObject of rpLayer.objects) { for (const reduxObject of vmLayer.objects) {
// TODO: Handle rects, images, etc if (reduxObject.kind === 'vector_mask_line') {
if (reduxObject.kind !== 'line') { let vectorMaskLine = stage.findOne<Konva.Line>(`#${reduxObject.id}`);
continue;
}
let konvaObject = stage.findOne<Konva.Line>(`#${reduxObject.id}`); // Create the line if it doesn't exist
if (!vectorMaskLine) {
if (!konvaObject) { vectorMaskLine = new Konva.Line({
// This object hasn't been added to the konva state yet.
konvaObject = new Konva.Line({
id: reduxObject.id, id: reduxObject.id,
key: reduxObject.id, key: reduxObject.id,
name: REGIONAL_PROMPT_LAYER_LINE_NAME, name: VECTOR_MASK_LAYER_LINE_NAME,
strokeWidth: reduxObject.strokeWidth, strokeWidth: reduxObject.strokeWidth,
tension: 0, tension: 0,
lineCap: 'round', lineCap: 'round',
@ -257,36 +258,50 @@ const renderRPLayer = (
globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out', globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out',
listening: false, listening: false,
}); });
konvaObjectGroup.add(konvaObject); konvaObjectGroup.add(vectorMaskLine);
} }
// Only update the points if they have changed. The point values are never mutated, they are only added to the array. // Only update the points if they have changed. The point values are never mutated, they are only added to the
if (konvaObject.points().length !== reduxObject.points.length) { // array, so checking the length is sufficient to determine if we need to re-cache.
konvaObject.points(reduxObject.points); if (vectorMaskLine.points().length !== reduxObject.points.length) {
vectorMaskLine.points(reduxObject.points);
groupNeedsCache = true; groupNeedsCache = true;
} }
// Only update the color if it has changed. // Only update the color if it has changed.
if (konvaObject.stroke() !== color) { if (vectorMaskLine.stroke() !== rgbColor) {
konvaObject.stroke(color); vectorMaskLine.stroke(rgbColor);
groupNeedsCache = true; groupNeedsCache = true;
} }
// Only update layer visibility if it has changed.
if (konvaLayer.visible() !== rpLayer.isVisible) {
konvaLayer.visible(rpLayer.isVisible);
groupNeedsCache = true;
} }
} }
// Only update layer visibility if it has changed.
if (konvaLayer.visible() !== vmLayer.isVisible) {
konvaLayer.visible(vmLayer.isVisible);
groupNeedsCache = true;
}
if (konvaObjectGroup.children.length > 0) {
// If we have objects, we need to cache the group to apply the layer opacity...
if (groupNeedsCache) { if (groupNeedsCache) {
// ...but only if we've done something that needs the cache.
konvaObjectGroup.cache(); konvaObjectGroup.cache();
} }
} else {
// No children - clear the cache to reset the previous pixel data
konvaObjectGroup.clearCache();
}
// Updating group opacity does not require re-caching
if (konvaObjectGroup.opacity() !== vmLayer.previewColor.a) {
konvaObjectGroup.opacity(vmLayer.previewColor.a);
}
}; };
/** /**
* Renders the layers on the stage. * Renders the layers on the stage.
* @param stage The konva stage to render on. * @param stage The konva stage to render on.
* @param reduxLayers Array of the layers from the redux store. * @param reduxLayers Array of the layers from the redux store.
* @param selectedLayerIdId The selected layer id.
* @param layerOpacity The opacity of the layer. * @param layerOpacity The opacity of the layer.
* @param onLayerPosChanged Callback for when the layer's position changes. This is optional to allow for offscreen rendering. * @param onLayerPosChanged Callback for when the layer's position changes. This is optional to allow for offscreen rendering.
* @returns * @returns
@ -294,15 +309,13 @@ const renderRPLayer = (
export const renderLayers = ( export const renderLayers = (
stage: Konva.Stage, stage: Konva.Stage,
reduxLayers: Layer[], reduxLayers: Layer[],
selectedLayerIdId: string | null,
layerOpacity: number,
tool: RPTool, tool: RPTool,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void onLayerPosChanged?: (layerId: string, x: number, y: number) => void
) => { ) => {
const reduxLayerIds = reduxLayers.map(mapId); const reduxLayerIds = reduxLayers.map(mapId);
// Remove un-rendered layers // Remove un-rendered layers
for (const konvaLayer of stage.find<Konva.Layer>(`.${REGIONAL_PROMPT_LAYER_NAME}`)) { for (const konvaLayer of stage.find<Konva.Layer>(`.${VECTOR_MASK_LAYER_NAME}`)) {
if (!reduxLayerIds.includes(konvaLayer.id())) { if (!reduxLayerIds.includes(konvaLayer.id())) {
konvaLayer.destroy(); konvaLayer.destroy();
} }
@ -311,8 +324,8 @@ export const renderLayers = (
for (let layerIndex = 0; layerIndex < reduxLayers.length; layerIndex++) { for (let layerIndex = 0; layerIndex < reduxLayers.length; layerIndex++) {
const reduxLayer = reduxLayers[layerIndex]; const reduxLayer = reduxLayers[layerIndex];
assert(reduxLayer, `Layer at index ${layerIndex} is undefined`); assert(reduxLayer, `Layer at index ${layerIndex} is undefined`);
if (reduxLayer.kind === 'regionalPromptLayer') { if (isVectorMaskLayer(reduxLayer)) {
renderRPLayer(stage, reduxLayer, layerIndex, selectedLayerIdId, tool, layerOpacity, onLayerPosChanged); renderVectorMaskLayer(stage, reduxLayer, layerIndex, tool, onLayerPosChanged);
} }
} }
}; };
@ -335,7 +348,7 @@ export const renderBbox = (
) => { ) => {
// No selected layer or not using the move tool - nothing more to do here // No selected layer or not using the move tool - nothing more to do here
if (tool !== 'move') { if (tool !== 'move') {
for (const bboxRect of stage.find<Konva.Rect>(`.${REGIONAL_PROMPT_LAYER_BBOX_NAME}`)) { for (const bboxRect of stage.find<Konva.Rect>(`.${LAYER_BBOX_NAME}`)) {
bboxRect.visible(false); bboxRect.visible(false);
bboxRect.listening(false); bboxRect.listening(false);
} }
@ -351,7 +364,7 @@ export const renderBbox = (
// We only need to recalculate the bbox if the layer has changed and it has objects // We only need to recalculate the bbox if the layer has changed and it has objects
if (reduxLayer.bboxNeedsUpdate && reduxLayer.objects.length) { if (reduxLayer.bboxNeedsUpdate && reduxLayer.objects.length) {
// We only need to use the pixel-perfect bounding box if the layer has eraser strokes // We only need to use the pixel-perfect bounding box if the layer has eraser strokes
bbox = reduxLayer.hasEraserStrokes bbox = reduxLayer.needsPixelBbox
? getKonvaLayerBbox(konvaLayer) ? getKonvaLayerBbox(konvaLayer)
: konvaLayer.getClientRect(GET_CLIENT_RECT_CONFIG); : konvaLayer.getClientRect(GET_CLIENT_RECT_CONFIG);
@ -363,11 +376,11 @@ export const renderBbox = (
continue; continue;
} }
let rect = konvaLayer.findOne<Konva.Rect>(`.${REGIONAL_PROMPT_LAYER_BBOX_NAME}`); let rect = konvaLayer.findOne<Konva.Rect>(`.${LAYER_BBOX_NAME}`);
if (!rect) { if (!rect) {
rect = new Konva.Rect({ rect = new Konva.Rect({
id: getPRLayerBboxId(reduxLayer.id), id: getLayerBboxId(reduxLayer.id),
name: REGIONAL_PROMPT_LAYER_BBOX_NAME, name: LAYER_BBOX_NAME,
strokeWidth: 1, strokeWidth: 1,
}); });
rect.on('mousedown', function () { rect.on('mousedown', function () {

View File

@ -1,18 +1,30 @@
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay'; import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay';
import { RegionalPromptsEditor } from 'features/regionalPrompts/components/RegionalPromptsEditor'; import { RegionalPromptsEditor } from 'features/regionalPrompts/components/RegionalPromptsEditor';
import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo } from 'react'; import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const TextToImageTab = () => { const selectValidLayerCount = createSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
const { t } = useTranslation(); if (!regionalPrompts.present.isEnabled) {
const noOfRPLayers = useAppSelector((s) => {
if (!s.regionalPrompts.present.isEnabled) {
return 0; return 0;
} }
return s.regionalPrompts.present.layers.filter((l) => l.kind === 'regionalPromptLayer' && l.isVisible).length; const validLayers = regionalPrompts.present.layers
.filter((l) => l.isVisible)
.filter((l) => {
const hasTextPrompt = l.textPrompt && (l.textPrompt.positive || l.textPrompt.negative);
const hasAtLeastOneImagePrompt = l.imagePrompts.length > 0;
return hasTextPrompt || hasAtLeastOneImagePrompt;
}); });
return validLayers.length;
});
const TextToImageTab = () => {
const { t } = useTranslation();
const validLayerCount = useAppSelector(selectValidLayerCount);
return ( return (
<Box position="relative" w="full" h="full" p={2} borderRadius="base"> <Box position="relative" w="full" h="full" p={2} borderRadius="base">
<Tabs variant="line" isLazy={true} display="flex" flexDir="column" w="full" h="full"> <Tabs variant="line" isLazy={true} display="flex" flexDir="column" w="full" h="full">
@ -20,7 +32,7 @@ const TextToImageTab = () => {
<Tab>{t('common.viewer')}</Tab> <Tab>{t('common.viewer')}</Tab>
<Tab> <Tab>
{t('regionalPrompts.regionalPrompts')} {t('regionalPrompts.regionalPrompts')}
{noOfRPLayers > 0 ? ` (${noOfRPLayers})` : ''} {validLayerCount > 0 ? ` (${validLayerCount})` : ''}
</Tab> </Tab>
</TabList> </TabList>