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 numberInputWidth: ChakraProps['w'] = '4.2rem';
const numberInputWidth: ChakraProps['w'] = '3.5rem';
const IAIColorPicker = (props: IAIColorPickerProps) => {
const { color, onChange, withNumberInput, ...rest } = props;
@ -41,7 +41,7 @@ const IAIColorPicker = (props: IAIColorPickerProps) => {
{withNumberInput && (
<Flex gap={5}>
<FormControl gap={0}>
<FormLabel>{t('common.red')}</FormLabel>
<FormLabel>{t('common.red')[0]}</FormLabel>
<CompositeNumberInput
value={color.r}
onChange={handleChangeR}
@ -53,7 +53,7 @@ const IAIColorPicker = (props: IAIColorPickerProps) => {
/>
</FormControl>
<FormControl gap={0}>
<FormLabel>{t('common.green')}</FormLabel>
<FormLabel>{t('common.green')[0]}</FormLabel>
<CompositeNumberInput
value={color.g}
onChange={handleChangeG}
@ -65,7 +65,7 @@ const IAIColorPicker = (props: IAIColorPickerProps) => {
/>
</FormControl>
<FormControl gap={0}>
<FormLabel>{t('common.blue')}</FormLabel>
<FormLabel>{t('common.blue')[0]}</FormLabel>
<CompositeNumberInput
value={color.b}
onChange={handleChangeB}
@ -77,7 +77,7 @@ const IAIColorPicker = (props: IAIColorPickerProps) => {
/>
</FormControl>
<FormControl gap={0}>
<FormLabel>{t('common.alpha')}</FormLabel>
<FormLabel>{t('common.alpha')[0]}</FormLabel>
<CompositeNumberInput
value={color.a}
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_PREFIX,
} from 'features/nodes/util/graph/constants';
import { isRPLayer } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { isVectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { getRegionalPromptLayerBlobs } from 'features/regionalPrompts/util/getLayerBlobs';
import { size } from 'lodash-es';
import { imagesApi } from 'services/api/endpoints/images';
@ -23,12 +23,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
return;
}
const { dispatch } = getStore();
// TODO: Handle non-SDXL
const isSDXL = state.generation.model?.base === 'sdxl';
const layers = state.regionalPrompts.present.layers
.filter(isRPLayer) // We only want the prompt region layers
.filter((l) => l.isVisible) // Only visible layers are rendered on the canvas
.filter((l) => l.negativePrompt || l.positivePrompt); // Only layers with prompts get added to the graph
// Only support vector mask layers now
// TODO: Image masks
.filter(isVectorMaskLayer)
// Only visible layers are rendered on the canvas
.filter((l) => l.isVisible)
// Only layers with prompts get added to the graph
.filter((l) => {
const hasTextPrompt = l.textPrompt && (l.textPrompt.positive || l.textPrompt.negative);
const hasAtLeastOneImagePrompt = l.imagePrompts.length > 0;
return hasTextPrompt || hasAtLeastOneImagePrompt;
});
const layerIds = layers.map((l) => l.id);
const blobs = await getRegionalPromptLayerBlobs(layerIds);
@ -123,19 +130,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
};
graph.nodes[maskToTensorNode.id] = maskToTensorNode;
if (layer.positivePrompt) {
if (layer.textPrompt?.positive) {
// The main positive conditioning node
const regionalPositiveCondNode: S['SDXLCompelPromptInvocation'] | S['CompelInvocation'] = isSDXL
? {
type: 'sdxl_compel_prompt',
id: `${PROMPT_REGION_POSITIVE_COND_PREFIX}_${layer.id}`,
prompt: layer.positivePrompt,
style: layer.positivePrompt, // TODO: Should we put the positive prompt in both fields?
prompt: layer.textPrompt.positive,
style: layer.textPrompt.positive, // TODO: Should we put the positive prompt in both fields?
}
: {
type: 'compel',
id: `${PROMPT_REGION_POSITIVE_COND_PREFIX}_${layer.id}`,
prompt: layer.positivePrompt,
prompt: layer.textPrompt.positive,
};
graph.nodes[regionalPositiveCondNode.id] = regionalPositiveCondNode;
@ -162,19 +169,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
}
}
if (layer.negativePrompt) {
if (layer.textPrompt?.negative) {
// The main negative conditioning node
const regionalNegativeCondNode: S['SDXLCompelPromptInvocation'] | S['CompelInvocation'] = isSDXL
? {
type: 'sdxl_compel_prompt',
id: `${PROMPT_REGION_NEGATIVE_COND_PREFIX}_${layer.id}`,
prompt: layer.negativePrompt,
style: layer.negativePrompt,
prompt: layer.textPrompt.negative,
style: layer.textPrompt.negative,
}
: {
type: 'compel',
id: `${PROMPT_REGION_NEGATIVE_COND_PREFIX}_${layer.id}`,
prompt: layer.negativePrompt,
prompt: layer.textPrompt.negative,
};
graph.nodes[regionalNegativeCondNode.id] = regionalNegativeCondNode;
@ -202,7 +209,7 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
}
// If we are using the "invert" auto-negative setting, we need to add an additional negative conditioning node
if (layer.autoNegative === 'invert' && layer.positivePrompt) {
if (layer.autoNegative === 'invert' && layer.textPrompt?.positive) {
// We re-use the mask image, but invert it when converting to tensor
const invertTensorMaskNode: S['InvertTensorMaskInvocation'] = {
id: `${PROMPT_REGION_INVERT_TENSOR_MASK_PREFIX}_${layer.id}`,
@ -228,13 +235,13 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
? {
type: 'sdxl_compel_prompt',
id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`,
prompt: layer.positivePrompt,
style: layer.positivePrompt,
prompt: layer.textPrompt.positive,
style: layer.textPrompt.positive,
}
: {
type: 'compel',
id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`,
prompt: layer.positivePrompt,
prompt: layer.textPrompt.positive,
};
graph.nodes[regionalPositiveCondInvertedNode.id] = regionalPositiveCondInvertedNode;
// Connect the inverted mask to the conditioning

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
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 type { Layer as KonvaLayerType } from 'konva/lib/Layer';
import type { IRect } from 'konva/lib/types';
@ -80,7 +80,7 @@ export const getKonvaLayerBbox = (layer: KonvaLayerType, preview: boolean = fals
offscreenStage.add(layerClone);
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
child.cache();
} else {

View File

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

View File

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

View File

@ -1,18 +1,30 @@
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay';
import { RegionalPromptsEditor } from 'features/regionalPrompts/components/RegionalPromptsEditor';
import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const TextToImageTab = () => {
const { t } = useTranslation();
const noOfRPLayers = useAppSelector((s) => {
if (!s.regionalPrompts.present.isEnabled) {
const selectValidLayerCount = createSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
if (!regionalPrompts.present.isEnabled) {
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 (
<Box position="relative" w="full" h="full" p={2} borderRadius="base">
<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('regionalPrompts.regionalPrompts')}
{noOfRPLayers > 0 ? ` (${noOfRPLayers})` : ''}
{validLayerCount > 0 ? ` (${validLayerCount})` : ''}
</Tab>
</TabList>