mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): regional prompts spray n pray
Trying a lot of different things as I iterated, so this is smooshed into one big commit... too hard to split it now. - Iterated on IP adapter handling and UI. Unfortunately there is an bug related to undo/redo. The IP adapter state is split across the `controlAdapters` slice and the `regionalPrompts` slice, but only the `regionalPrompts` slice supports undo/redo. If you delete the IP adapter and then undo/redo to a history state where it existed, you'll get an error. The fix is likely to merge the slices... Maybe there's a workaround. - Iterated on UI. I think the layers are OK now. - Removed ability to disable RP globally for now. It's enabled if you have enabled RP layers. - Many minor tweaks and fixes.
This commit is contained in:
parent
018845cda0
commit
6dcaf75b5f
@ -84,6 +84,8 @@
|
||||
"direction": "Direction",
|
||||
"ipAdapter": "IP Adapter",
|
||||
"t2iAdapter": "T2I Adapter",
|
||||
"positivePrompt": "Positive Prompt",
|
||||
"negativePrompt": "Negative Prompt",
|
||||
"discordLabel": "Discord",
|
||||
"dontAskMeAgain": "Don't ask me again",
|
||||
"error": "Error",
|
||||
@ -1518,11 +1520,16 @@
|
||||
"brushSize": "Brush Size",
|
||||
"regionalPrompts": "Regional Prompts BETA",
|
||||
"enableRegionalPrompts": "Enable $t(regionalPrompts.regionalPrompts)",
|
||||
"layerOpacity": "Layer Opacity",
|
||||
"globalMaskOpacity": "Global Mask Opacity",
|
||||
"autoNegative": "Auto Negative",
|
||||
"toggleVisibility": "Toggle Layer Visibility",
|
||||
"deletePrompt": "Delete Prompt",
|
||||
"resetRegion": "Reset Region",
|
||||
"debugLayers": "Debug Layers",
|
||||
"rectangle": "Rectangle"
|
||||
"rectangle": "Rectangle",
|
||||
"maskPreviewColor": "Mask Preview Color",
|
||||
"addPositivePrompt": "Add $t(common.positivePrompt)",
|
||||
"addNegativePrompt": "Add $t(common.negativePrompt)",
|
||||
"addIPAdapter": "Add $t(common.ipAdapter)"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,84 @@
|
||||
import type { ChakraProps } from '@invoke-ai/ui-library';
|
||||
import { CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { RgbColorPicker as ColorfulRgbColorPicker } from 'react-colorful';
|
||||
import type { ColorPickerBaseProps, RgbColor } from 'react-colorful/dist/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type RgbColorPickerProps = ColorPickerBaseProps<RgbColor> & {
|
||||
withNumberInput?: boolean;
|
||||
};
|
||||
|
||||
const colorPickerPointerStyles: NonNullable<ChakraProps['sx']> = {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderColor: 'base.100',
|
||||
};
|
||||
|
||||
const sx: ChakraProps['sx'] = {
|
||||
'.react-colorful__hue-pointer': colorPickerPointerStyles,
|
||||
'.react-colorful__saturation-pointer': colorPickerPointerStyles,
|
||||
'.react-colorful__alpha-pointer': colorPickerPointerStyles,
|
||||
gap: 5,
|
||||
flexDir: 'column',
|
||||
};
|
||||
|
||||
const colorPickerStyles: CSSProperties = { width: '100%' };
|
||||
|
||||
const numberInputWidth: ChakraProps['w'] = '3.5rem';
|
||||
|
||||
const RgbColorPicker = (props: RgbColorPickerProps) => {
|
||||
const { color, onChange, withNumberInput, ...rest } = props;
|
||||
const { t } = useTranslation();
|
||||
const handleChangeR = useCallback((r: number) => onChange({ ...color, r }), [color, onChange]);
|
||||
const handleChangeG = useCallback((g: number) => onChange({ ...color, g }), [color, onChange]);
|
||||
const handleChangeB = useCallback((b: number) => onChange({ ...color, b }), [color, onChange]);
|
||||
return (
|
||||
<Flex sx={sx}>
|
||||
<ColorfulRgbColorPicker color={color} onChange={onChange} style={colorPickerStyles} {...rest} />
|
||||
{withNumberInput && (
|
||||
<Flex gap={5}>
|
||||
<FormControl gap={0}>
|
||||
<FormLabel>{t('common.red')[0]}</FormLabel>
|
||||
<CompositeNumberInput
|
||||
value={color.r}
|
||||
onChange={handleChangeR}
|
||||
min={0}
|
||||
max={255}
|
||||
step={1}
|
||||
w={numberInputWidth}
|
||||
defaultValue={90}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl gap={0}>
|
||||
<FormLabel>{t('common.green')[0]}</FormLabel>
|
||||
<CompositeNumberInput
|
||||
value={color.g}
|
||||
onChange={handleChangeG}
|
||||
min={0}
|
||||
max={255}
|
||||
step={1}
|
||||
w={numberInputWidth}
|
||||
defaultValue={90}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl gap={0}>
|
||||
<FormLabel>{t('common.blue')[0]}</FormLabel>
|
||||
<CompositeNumberInput
|
||||
value={color.b}
|
||||
onChange={handleChangeB}
|
||||
min={0}
|
||||
max={255}
|
||||
step={1}
|
||||
w={numberInputWidth}
|
||||
defaultValue={255}
|
||||
/>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RgbColorPicker);
|
@ -6,6 +6,7 @@ import { deepClone } from 'common/util/deepClone';
|
||||
import { buildControlAdapter } from 'features/controlAdapters/util/buildControlAdapter';
|
||||
import { buildControlAdapterProcessor } from 'features/controlAdapters/util/buildControlAdapterProcessor';
|
||||
import { zModelIdentifierField } from 'features/nodes/types/common';
|
||||
import { maskLayerIPAdapterAdded } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { merge, uniq } from 'lodash-es';
|
||||
import type { ControlNetModelConfig, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types';
|
||||
import { socketInvocationError } from 'services/events/actions';
|
||||
@ -382,6 +383,10 @@ export const controlAdaptersSlice = createSlice({
|
||||
builder.addCase(socketInvocationError, (state) => {
|
||||
state.pendingControlImages = [];
|
||||
});
|
||||
|
||||
builder.addCase(maskLayerIPAdapterAdded, (state, action) => {
|
||||
caAdapter.addOne(state, buildControlAdapter(action.meta.uuid, 'ip_adapter'));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -19,12 +19,14 @@ export const addIPAdapterToLinearGraph = async (
|
||||
graph: NonNullableGraph,
|
||||
baseNodeId: string
|
||||
): Promise<void> => {
|
||||
const validIPAdapters = selectValidIPAdapters(state.controlAdapters).filter(({ model, controlImage, isEnabled }) => {
|
||||
const hasModel = Boolean(model);
|
||||
const doesBaseMatch = model?.base === state.generation.model?.base;
|
||||
const hasControlImage = controlImage;
|
||||
return isEnabled && hasModel && doesBaseMatch && hasControlImage;
|
||||
});
|
||||
const validIPAdapters = selectValidIPAdapters(state.controlAdapters)
|
||||
.filter(({ model, controlImage, isEnabled }) => {
|
||||
const hasModel = Boolean(model);
|
||||
const doesBaseMatch = model?.base === state.generation.model?.base;
|
||||
const hasControlImage = controlImage;
|
||||
return isEnabled && hasModel && doesBaseMatch && hasControlImage;
|
||||
})
|
||||
.filter((ca) => !state.regionalPrompts.present.layers.some((l) => l.ipAdapterIds.includes(ca.id)));
|
||||
|
||||
if (validIPAdapters.length) {
|
||||
// Even though denoise_latents' ip adapter input is collection or scalar, keep it simple and always use a collect
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { getStore } from 'app/store/nanostores/store';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import { selectAllIPAdapters } from 'features/controlAdapters/store/controlAdaptersSlice';
|
||||
import {
|
||||
IP_ADAPTER_COLLECT,
|
||||
NEGATIVE_CONDITIONING,
|
||||
NEGATIVE_CONDITIONING_COLLECT,
|
||||
POSITIVE_CONDITIONING,
|
||||
@ -13,9 +15,9 @@ import {
|
||||
} from 'features/nodes/util/graph/constants';
|
||||
import { isVectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { getRegionalPromptLayerBlobs } from 'features/regionalPrompts/util/getLayerBlobs';
|
||||
import { size } from 'lodash-es';
|
||||
import { size, sumBy } from 'lodash-es';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { CollectInvocation, Edge, NonNullableGraph, S } from 'services/api/types';
|
||||
import type { CollectInvocation, Edge, IPAdapterInvocation, NonNullableGraph, S } from 'services/api/types';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNullableGraph, denoiseNodeId: string) => {
|
||||
@ -32,9 +34,9 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
|
||||
.filter((l) => l.isVisible)
|
||||
// Only layers with prompts get added to the graph
|
||||
.filter((l) => {
|
||||
const hasTextPrompt = l.textPrompt && (l.textPrompt.positive || l.textPrompt.negative);
|
||||
const hasAtLeastOneImagePrompt = l.imagePrompts.length > 0;
|
||||
return hasTextPrompt || hasAtLeastOneImagePrompt;
|
||||
const hasTextPrompt = Boolean(l.positivePrompt || l.negativePrompt);
|
||||
const hasIPAdapter = l.ipAdapterIds.length !== 0;
|
||||
return hasTextPrompt || hasIPAdapter;
|
||||
});
|
||||
|
||||
const layerIds = layers.map((l) => l.id);
|
||||
@ -103,6 +105,22 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
|
||||
},
|
||||
});
|
||||
|
||||
if (!graph.nodes[IP_ADAPTER_COLLECT] && sumBy(layers, (l) => l.ipAdapterIds.length) > 0) {
|
||||
const ipAdapterCollectNode: CollectInvocation = {
|
||||
id: IP_ADAPTER_COLLECT,
|
||||
type: 'collect',
|
||||
is_intermediate: true,
|
||||
};
|
||||
graph.nodes[IP_ADAPTER_COLLECT] = ipAdapterCollectNode;
|
||||
graph.edges.push({
|
||||
source: { node_id: IP_ADAPTER_COLLECT, field: 'collection' },
|
||||
destination: {
|
||||
node_id: denoiseNodeId,
|
||||
field: 'ip_adapter',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Upload the blobs to the backend, add each to graph
|
||||
// TODO: Store the uploaded image names in redux to reuse them, so long as the layer hasn't otherwise changed. This
|
||||
// would be a great perf win - not only would we skip re-uploading the same image, but we'd be able to use the node
|
||||
@ -130,19 +148,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
|
||||
};
|
||||
graph.nodes[maskToTensorNode.id] = maskToTensorNode;
|
||||
|
||||
if (layer.textPrompt?.positive) {
|
||||
if (layer.positivePrompt) {
|
||||
// The main positive conditioning node
|
||||
const regionalPositiveCondNode: S['SDXLCompelPromptInvocation'] | S['CompelInvocation'] = isSDXL
|
||||
? {
|
||||
type: 'sdxl_compel_prompt',
|
||||
id: `${PROMPT_REGION_POSITIVE_COND_PREFIX}_${layer.id}`,
|
||||
prompt: layer.textPrompt.positive,
|
||||
style: layer.textPrompt.positive, // TODO: Should we put the positive prompt in both fields?
|
||||
prompt: layer.positivePrompt,
|
||||
style: layer.positivePrompt, // TODO: Should we put the positive prompt in both fields?
|
||||
}
|
||||
: {
|
||||
type: 'compel',
|
||||
id: `${PROMPT_REGION_POSITIVE_COND_PREFIX}_${layer.id}`,
|
||||
prompt: layer.textPrompt.positive,
|
||||
prompt: layer.positivePrompt,
|
||||
};
|
||||
graph.nodes[regionalPositiveCondNode.id] = regionalPositiveCondNode;
|
||||
|
||||
@ -169,19 +187,19 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
|
||||
}
|
||||
}
|
||||
|
||||
if (layer.textPrompt?.negative) {
|
||||
if (layer.negativePrompt) {
|
||||
// The main negative conditioning node
|
||||
const regionalNegativeCondNode: S['SDXLCompelPromptInvocation'] | S['CompelInvocation'] = isSDXL
|
||||
? {
|
||||
type: 'sdxl_compel_prompt',
|
||||
id: `${PROMPT_REGION_NEGATIVE_COND_PREFIX}_${layer.id}`,
|
||||
prompt: layer.textPrompt.negative,
|
||||
style: layer.textPrompt.negative,
|
||||
prompt: layer.negativePrompt,
|
||||
style: layer.negativePrompt,
|
||||
}
|
||||
: {
|
||||
type: 'compel',
|
||||
id: `${PROMPT_REGION_NEGATIVE_COND_PREFIX}_${layer.id}`,
|
||||
prompt: layer.textPrompt.negative,
|
||||
prompt: layer.negativePrompt,
|
||||
};
|
||||
graph.nodes[regionalNegativeCondNode.id] = regionalNegativeCondNode;
|
||||
|
||||
@ -209,7 +227,7 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
|
||||
}
|
||||
|
||||
// If we are using the "invert" auto-negative setting, we need to add an additional negative conditioning node
|
||||
if (layer.autoNegative === 'invert' && layer.textPrompt?.positive) {
|
||||
if (layer.autoNegative === 'invert' && layer.positivePrompt) {
|
||||
// We re-use the mask image, but invert it when converting to tensor
|
||||
const invertTensorMaskNode: S['InvertTensorMaskInvocation'] = {
|
||||
id: `${PROMPT_REGION_INVERT_TENSOR_MASK_PREFIX}_${layer.id}`,
|
||||
@ -235,13 +253,13 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
|
||||
? {
|
||||
type: 'sdxl_compel_prompt',
|
||||
id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`,
|
||||
prompt: layer.textPrompt.positive,
|
||||
style: layer.textPrompt.positive,
|
||||
prompt: layer.positivePrompt,
|
||||
style: layer.positivePrompt,
|
||||
}
|
||||
: {
|
||||
type: 'compel',
|
||||
id: `${PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX}_${layer.id}`,
|
||||
prompt: layer.textPrompt.positive,
|
||||
prompt: layer.positivePrompt,
|
||||
};
|
||||
graph.nodes[regionalPositiveCondInvertedNode.id] = regionalPositiveCondInvertedNode;
|
||||
// Connect the inverted mask to the conditioning
|
||||
@ -264,5 +282,47 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ipAdapterId of layer.ipAdapterIds) {
|
||||
const ipAdapter = selectAllIPAdapters(state.controlAdapters).find((ca) => ca.id === ipAdapterId);
|
||||
console.log(ipAdapter);
|
||||
if (!ipAdapter?.model) {
|
||||
return;
|
||||
}
|
||||
const { id, weight, model, clipVisionModel, method, beginStepPct, endStepPct, controlImage } = ipAdapter;
|
||||
|
||||
assert(controlImage, 'IP Adapter image is required');
|
||||
|
||||
const ipAdapterNode: IPAdapterInvocation = {
|
||||
id: `ip_adapter_${id}`,
|
||||
type: 'ip_adapter',
|
||||
is_intermediate: true,
|
||||
weight: weight,
|
||||
method: method,
|
||||
ip_adapter_model: model,
|
||||
clip_vision_model: clipVisionModel,
|
||||
begin_step_percent: beginStepPct,
|
||||
end_step_percent: endStepPct,
|
||||
image: {
|
||||
image_name: controlImage,
|
||||
},
|
||||
};
|
||||
|
||||
graph.nodes[ipAdapterNode.id] = ipAdapterNode;
|
||||
|
||||
// Connect the mask to the conditioning
|
||||
graph.edges.push({
|
||||
source: { node_id: maskToTensorNode.id, field: 'mask' },
|
||||
destination: { node_id: ipAdapterNode.id, field: 'mask' },
|
||||
});
|
||||
|
||||
graph.edges.push({
|
||||
source: { node_id: ipAdapterNode.id, field: 'ip_adapter' },
|
||||
destination: {
|
||||
node_id: IP_ADAPTER_COLLECT,
|
||||
field: 'item',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -0,0 +1,70 @@
|
||||
import { Button, Flex } from '@invoke-ai/ui-library';
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
isVectorMaskLayer,
|
||||
maskLayerIPAdapterAdded,
|
||||
maskLayerNegativePromptChanged,
|
||||
maskLayerPositivePromptChanged,
|
||||
selectRegionalPromptsSlice,
|
||||
} from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiPlusBold } from 'react-icons/pi';
|
||||
import { assert } from 'tsafe';
|
||||
type AddPromptButtonProps = {
|
||||
layerId: string;
|
||||
};
|
||||
|
||||
export const AddPromptButtons = ({ layerId }: AddPromptButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const selectValidActions = useMemo(
|
||||
() =>
|
||||
createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
|
||||
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
|
||||
assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
|
||||
return {
|
||||
canAddPositivePrompt: layer.positivePrompt === null,
|
||||
canAddNegativePrompt: layer.negativePrompt === null,
|
||||
};
|
||||
}),
|
||||
[layerId]
|
||||
);
|
||||
const validActions = useAppSelector(selectValidActions);
|
||||
const addPositivePrompt = useCallback(() => {
|
||||
dispatch(maskLayerPositivePromptChanged({ layerId, prompt: '' }));
|
||||
}, [dispatch, layerId]);
|
||||
const addNegativePrompt = useCallback(() => {
|
||||
dispatch(maskLayerNegativePromptChanged({ layerId, prompt: '' }));
|
||||
}, [dispatch, layerId]);
|
||||
const addIPAdapter = useCallback(() => {
|
||||
dispatch(maskLayerIPAdapterAdded(layerId));
|
||||
}, [dispatch, layerId]);
|
||||
|
||||
return (
|
||||
<Flex w="full" p={2} justifyContent="space-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<PiPlusBold />}
|
||||
onClick={addPositivePrompt}
|
||||
isDisabled={!validActions.canAddPositivePrompt}
|
||||
>
|
||||
{t('common.positivePrompt')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<PiPlusBold />}
|
||||
onClick={addNegativePrompt}
|
||||
isDisabled={!validActions.canAddNegativePrompt}
|
||||
>
|
||||
{t('common.negativePrompt')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" leftIcon={<PiPlusBold />} onClick={addIPAdapter}>
|
||||
{t('common.ipAdapter')}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
};
|
@ -1,9 +1,22 @@
|
||||
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
|
||||
import {
|
||||
CompositeNumberInput,
|
||||
CompositeSlider,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { brushSizeChanged, initialRegionalPromptsState } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const marks = [0, 100, 200, 300];
|
||||
const formatPx = (v: number | string) => `${v} px`;
|
||||
|
||||
export const BrushSize = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
@ -15,22 +28,34 @@ export const BrushSize = memo(() => {
|
||||
[dispatch]
|
||||
);
|
||||
return (
|
||||
<FormControl>
|
||||
<FormLabel>{t('regionalPrompts.brushSize')}</FormLabel>
|
||||
<CompositeSlider
|
||||
min={1}
|
||||
max={300}
|
||||
defaultValue={initialRegionalPromptsState.brushSize}
|
||||
value={brushSize}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<CompositeNumberInput
|
||||
min={1}
|
||||
max={600}
|
||||
defaultValue={initialRegionalPromptsState.brushSize}
|
||||
value={brushSize}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<FormControl w="min-content">
|
||||
<FormLabel m={0}>{t('regionalPrompts.brushSize')}</FormLabel>
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
<CompositeNumberInput
|
||||
min={1}
|
||||
max={600}
|
||||
defaultValue={initialRegionalPromptsState.brushSize}
|
||||
value={brushSize}
|
||||
onChange={onChange}
|
||||
w={24}
|
||||
format={formatPx}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w={200} py={2} px={4}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<CompositeSlider
|
||||
min={1}
|
||||
max={300}
|
||||
defaultValue={initialRegionalPromptsState.brushSize}
|
||||
value={brushSize}
|
||||
onChange={onChange}
|
||||
marks={marks}
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
|
@ -1,4 +1,14 @@
|
||||
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
|
||||
import {
|
||||
CompositeNumberInput,
|
||||
CompositeSlider,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
globalMaskLayerOpacityChanged,
|
||||
@ -7,35 +17,52 @@ import {
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const marks = [0, 25, 50, 75, 100];
|
||||
const formatPct = (v: number | string) => `${v} %`;
|
||||
|
||||
export const GlobalMaskLayerOpacity = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const globalMaskLayerOpacity = useAppSelector((s) => s.regionalPrompts.present.globalMaskLayerOpacity);
|
||||
const globalMaskLayerOpacity = useAppSelector((s) =>
|
||||
Math.round(s.regionalPrompts.present.globalMaskLayerOpacity * 100)
|
||||
);
|
||||
const onChange = useCallback(
|
||||
(v: number) => {
|
||||
dispatch(globalMaskLayerOpacityChanged(v));
|
||||
dispatch(globalMaskLayerOpacityChanged(v / 100));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
return (
|
||||
<FormControl>
|
||||
<FormLabel>{t('regionalPrompts.layerOpacity')}</FormLabel>
|
||||
<CompositeSlider
|
||||
min={0.25}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={globalMaskLayerOpacity}
|
||||
defaultValue={initialRegionalPromptsState.globalMaskLayerOpacity}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<CompositeNumberInput
|
||||
min={0.25}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={globalMaskLayerOpacity}
|
||||
defaultValue={initialRegionalPromptsState.globalMaskLayerOpacity}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<FormControl w="min-content">
|
||||
<FormLabel m={0}>{t('regionalPrompts.globalMaskOpacity')}</FormLabel>
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
<CompositeNumberInput
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={globalMaskLayerOpacity}
|
||||
defaultValue={initialRegionalPromptsState.globalMaskLayerOpacity * 100}
|
||||
onChange={onChange}
|
||||
w={24}
|
||||
format={formatPct}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w={200} py={2} px={4}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<CompositeSlider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={globalMaskLayerOpacity}
|
||||
defaultValue={initialRegionalPromptsState.globalMaskLayerOpacity * 100}
|
||||
onChange={onChange}
|
||||
marks={marks}
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
|
@ -1,40 +0,0 @@
|
||||
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { layerDeleted, layerReset } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowCounterClockwiseBold, PiTrashSimpleBold } from 'react-icons/pi';
|
||||
|
||||
type Props = { layerId: string };
|
||||
|
||||
export const RPLayerActionsButtonGroup = memo(({ layerId }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const deleteLayer = useCallback(() => {
|
||||
dispatch(layerDeleted(layerId));
|
||||
}, [dispatch, layerId]);
|
||||
const resetLayer = useCallback(() => {
|
||||
dispatch(layerReset(layerId));
|
||||
}, [dispatch, layerId]);
|
||||
return (
|
||||
<ButtonGroup isAttached={false}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={t('regionalPrompts.resetRegion')}
|
||||
tooltip={t('regionalPrompts.resetRegion')}
|
||||
icon={<PiArrowCounterClockwiseBold />}
|
||||
onClick={resetLayer}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
colorScheme="error"
|
||||
aria-label={t('common.delete')}
|
||||
tooltip={t('common.delete')}
|
||||
icon={<PiTrashSimpleBold />}
|
||||
onClick={deleteLayer}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
);
|
||||
});
|
||||
|
||||
RPLayerActionsButtonGroup.displayName = 'RPLayerActionsButtonGroup';
|
@ -1,22 +1,16 @@
|
||||
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
|
||||
import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
|
||||
import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { isParameterAutoNegative } from 'features/parameters/types/parameterSchemas';
|
||||
import {
|
||||
isVectorMaskLayer,
|
||||
maskLayerAutoNegativeChanged,
|
||||
selectRegionalPromptsSlice,
|
||||
} from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
const options: ComboboxOption[] = [
|
||||
{ label: 'Off', value: 'off' },
|
||||
{ label: 'Invert', value: 'invert' },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
layerId: string;
|
||||
};
|
||||
@ -35,29 +29,23 @@ const useAutoNegative = (layerId: string) => {
|
||||
return autoNegative;
|
||||
};
|
||||
|
||||
export const RPLayerAutoNegativeCombobox = memo(({ layerId }: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
export const RPLayerAutoNegativeCheckbox = memo(({ layerId }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const autoNegative = useAutoNegative(layerId);
|
||||
|
||||
const onChange = useCallback<ComboboxOnChange>(
|
||||
(v) => {
|
||||
if (!isParameterAutoNegative(v?.value)) {
|
||||
return;
|
||||
}
|
||||
dispatch(maskLayerAutoNegativeChanged({ layerId, autoNegative: v.value }));
|
||||
const onChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(maskLayerAutoNegativeChanged({ layerId, autoNegative: e.target.checked ? 'invert' : 'off' }));
|
||||
},
|
||||
[dispatch, layerId]
|
||||
);
|
||||
|
||||
const value = useMemo(() => options.find((o) => o.value === autoNegative), [autoNegative]);
|
||||
|
||||
return (
|
||||
<FormControl flexGrow={0} gap={2} w="min-content">
|
||||
<FormControl gap={2}>
|
||||
<FormLabel m={0}>{t('regionalPrompts.autoNegative')}</FormLabel>
|
||||
<Combobox value={value} options={options} onChange={onChange} isSearchable={false} sx={{ w: '5.2rem' }} />
|
||||
<Checkbox size="md" isChecked={autoNegative === 'invert'} onChange={onChange} />
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
|
||||
RPLayerAutoNegativeCombobox.displayName = 'RPLayerAutoNegativeCombobox';
|
||||
RPLayerAutoNegativeCheckbox.displayName = 'RPLayerAutoNegativeCheckbox';
|
@ -1,16 +1,16 @@
|
||||
import { IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library';
|
||||
import { Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import IAIColorPicker from 'common/components/IAIColorPicker';
|
||||
import RgbColorPicker from 'common/components/RgbColorPicker';
|
||||
import { rgbColorToString } from 'features/canvas/util/colorToString';
|
||||
import {
|
||||
isVectorMaskLayer,
|
||||
maskLayerPreviewColorChanged,
|
||||
selectRegionalPromptsSlice,
|
||||
} from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import type { RgbaColor } from 'react-colorful';
|
||||
import type { RgbColor } from 'react-colorful';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiEyedropperBold } from 'react-icons/pi';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
type Props = {
|
||||
@ -31,7 +31,7 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => {
|
||||
const color = useAppSelector(selectColor);
|
||||
const dispatch = useAppDispatch();
|
||||
const onColorChange = useCallback(
|
||||
(color: RgbaColor) => {
|
||||
(color: RgbColor) => {
|
||||
dispatch(maskLayerPreviewColorChanged({ layerId, color }));
|
||||
},
|
||||
[dispatch, layerId]
|
||||
@ -39,17 +39,25 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => {
|
||||
return (
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
tooltip={t('unifiedCanvas.colorPicker')}
|
||||
aria-label={t('unifiedCanvas.colorPicker')}
|
||||
size="sm"
|
||||
borderRadius="base"
|
||||
icon={<PiEyedropperBold />}
|
||||
/>
|
||||
<span>
|
||||
<Tooltip label={t('regionalPrompts.maskPreviewColor')}>
|
||||
<Flex
|
||||
as="button"
|
||||
aria-label={t('regionalPrompts.maskPreviewColor')}
|
||||
borderRadius="base"
|
||||
borderWidth={1}
|
||||
bg={rgbColorToString(color)}
|
||||
w={8}
|
||||
h={8}
|
||||
cursor="pointer"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverBody minH={64}>
|
||||
<IAIColorPicker color={color} onChange={onColorChange} withNumberInput />
|
||||
<RgbColorPicker color={color} onChange={onColorChange} withNumberInput />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
@ -0,0 +1,28 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { layerDeleted } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiTrashSimpleBold } from 'react-icons/pi';
|
||||
|
||||
type Props = { layerId: string };
|
||||
|
||||
export const RPLayerDeleteButton = memo(({ layerId }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const deleteLayer = useCallback(() => {
|
||||
dispatch(layerDeleted(layerId));
|
||||
}, [dispatch, layerId]);
|
||||
return (
|
||||
<IconButton
|
||||
size="sm"
|
||||
colorScheme="error"
|
||||
aria-label={t('common.delete')}
|
||||
tooltip={t('common.delete')}
|
||||
icon={<PiTrashSimpleBold />}
|
||||
onClick={deleteLayer}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
RPLayerDeleteButton.displayName = 'RPLayerDeleteButton';
|
@ -0,0 +1,34 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import ControlAdapterConfig from 'features/controlAdapters/components/ControlAdapterConfig';
|
||||
import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
type Props = {
|
||||
layerId: string;
|
||||
};
|
||||
|
||||
export const RPLayerIPAdapterList = memo(({ layerId }: Props) => {
|
||||
const selectIPAdapterIds = useMemo(
|
||||
() =>
|
||||
createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
|
||||
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
|
||||
assert(layer, `Layer ${layerId} not found`);
|
||||
return layer.ipAdapterIds;
|
||||
}),
|
||||
[layerId]
|
||||
);
|
||||
const ipAdapterIds = useAppSelector(selectIPAdapterIds);
|
||||
|
||||
return (
|
||||
<Flex w="full" flexDir="column" gap={2}>
|
||||
{ipAdapterIds.map((id, index) => (
|
||||
<ControlAdapterConfig key={id} id={id} number={index + 1} />
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
RPLayerIPAdapterList.displayName = 'RPLayerIPAdapterList';
|
@ -1,34 +1,51 @@
|
||||
import { Flex, Spacer } from '@invoke-ai/ui-library';
|
||||
import { Badge, Flex, Spacer } from '@invoke-ai/ui-library';
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { rgbColorToString } from 'features/canvas/util/colorToString';
|
||||
import { RPLayerActionsButtonGroup } from 'features/regionalPrompts/components/RPLayerActionsButtonGroup';
|
||||
import { RPLayerAutoNegativeCombobox } from 'features/regionalPrompts/components/RPLayerAutoNegativeCombobox';
|
||||
import { RPLayerColorPicker } from 'features/regionalPrompts/components/RPLayerColorPicker';
|
||||
import { RPLayerDeleteButton } from 'features/regionalPrompts/components/RPLayerDeleteButton';
|
||||
import { RPLayerIPAdapterList } from 'features/regionalPrompts/components/RPLayerIPAdapterList';
|
||||
import { RPLayerMenu } from 'features/regionalPrompts/components/RPLayerMenu';
|
||||
import { RPLayerNegativePrompt } from 'features/regionalPrompts/components/RPLayerNegativePrompt';
|
||||
import { RPLayerPositivePrompt } from 'features/regionalPrompts/components/RPLayerPositivePrompt';
|
||||
import RPLayerSettingsPopover from 'features/regionalPrompts/components/RPLayerSettingsPopover';
|
||||
import { RPLayerVisibilityToggle } from 'features/regionalPrompts/components/RPLayerVisibilityToggle';
|
||||
import { isVectorMaskLayer, layerSelected } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import {
|
||||
isVectorMaskLayer,
|
||||
layerSelected,
|
||||
selectRegionalPromptsSlice,
|
||||
} from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
import { AddPromptButtons } from './AddPromptButtons';
|
||||
|
||||
type Props = {
|
||||
layerId: string;
|
||||
};
|
||||
|
||||
export const RPLayerListItem = memo(({ layerId }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const selectedLayerId = useAppSelector((s) => s.regionalPrompts.present.selectedLayerId);
|
||||
const color = useAppSelector((s) => {
|
||||
const layer = s.regionalPrompts.present.layers.find((l) => l.id === layerId);
|
||||
assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
|
||||
return rgbColorToString(layer.previewColor);
|
||||
});
|
||||
const hasTextPrompt = useAppSelector((s) => {
|
||||
const layer = s.regionalPrompts.present.layers.find((l) => l.id === layerId);
|
||||
assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
|
||||
return layer.textPrompt !== null;
|
||||
});
|
||||
const selector = useMemo(
|
||||
() =>
|
||||
createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
|
||||
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
|
||||
assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
|
||||
return {
|
||||
color: rgbColorToString(layer.previewColor),
|
||||
hasPositivePrompt: layer.positivePrompt !== null,
|
||||
hasNegativePrompt: layer.negativePrompt !== null,
|
||||
hasIPAdapters: layer.ipAdapterIds.length > 0,
|
||||
isSelected: layerId === regionalPrompts.present.selectedLayerId,
|
||||
autoNegative: layer.autoNegative,
|
||||
};
|
||||
}),
|
||||
[layerId]
|
||||
);
|
||||
const { autoNegative, color, hasPositivePrompt, hasNegativePrompt, hasIPAdapters, isSelected } =
|
||||
useAppSelector(selector);
|
||||
const onClickCapture = useCallback(() => {
|
||||
// Must be capture so that the layer is selected before deleting/resetting/etc
|
||||
dispatch(layerSelected(layerId));
|
||||
@ -37,26 +54,31 @@ export const RPLayerListItem = memo(({ layerId }: Props) => {
|
||||
<Flex
|
||||
gap={2}
|
||||
onClickCapture={onClickCapture}
|
||||
bg={color}
|
||||
px={2}
|
||||
bg={isSelected ? color : 'base.800'}
|
||||
ps={2}
|
||||
borderRadius="base"
|
||||
borderTop="1px"
|
||||
borderBottom="1px"
|
||||
borderColor="base.800"
|
||||
opacity={selectedLayerId === layerId ? 1 : 0.5}
|
||||
pe="1px"
|
||||
py="1px"
|
||||
cursor="pointer"
|
||||
>
|
||||
<Flex flexDir="column" gap={2} w="full" bg="base.850" p={2}>
|
||||
<Flex gap={2} alignItems="center">
|
||||
<RPLayerMenu layerId={layerId} />
|
||||
<RPLayerColorPicker layerId={layerId} />
|
||||
<Flex flexDir="column" gap={2} w="full" bg="base.850" p={2} borderRadius="base">
|
||||
<Flex gap={3} alignItems="center">
|
||||
<RPLayerVisibilityToggle layerId={layerId} />
|
||||
<RPLayerColorPicker layerId={layerId} />
|
||||
<Spacer />
|
||||
<RPLayerAutoNegativeCombobox layerId={layerId} />
|
||||
<RPLayerActionsButtonGroup layerId={layerId} />
|
||||
{autoNegative === 'invert' && (
|
||||
<Badge color="base.300" bg="transparent" borderWidth={1}>
|
||||
{t('regionalPrompts.autoNegative')}
|
||||
</Badge>
|
||||
)}
|
||||
<RPLayerDeleteButton layerId={layerId} />
|
||||
<RPLayerSettingsPopover layerId={layerId} />
|
||||
<RPLayerMenu layerId={layerId} />
|
||||
</Flex>
|
||||
{hasTextPrompt && <RPLayerPositivePrompt layerId={layerId} />}
|
||||
{hasTextPrompt && <RPLayerNegativePrompt layerId={layerId} />}
|
||||
<AddPromptButtons layerId={layerId} />
|
||||
{hasPositivePrompt && <RPLayerPositivePrompt layerId={layerId} />}
|
||||
{hasNegativePrompt && <RPLayerNegativePrompt layerId={layerId} />}
|
||||
{hasIPAdapters && <RPLayerIPAdapterList layerId={layerId} />}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
@ -9,6 +9,9 @@ import {
|
||||
layerMovedToBack,
|
||||
layerMovedToFront,
|
||||
layerReset,
|
||||
maskLayerIPAdapterAdded,
|
||||
maskLayerNegativePromptChanged,
|
||||
maskLayerPositivePromptChanged,
|
||||
selectRegionalPromptsSlice,
|
||||
} from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
@ -20,6 +23,7 @@ import {
|
||||
PiArrowLineUpBold,
|
||||
PiArrowUpBold,
|
||||
PiDotsThreeVerticalBold,
|
||||
PiPlusBold,
|
||||
PiTrashSimpleBold,
|
||||
} from 'react-icons/pi';
|
||||
import { assert } from 'tsafe';
|
||||
@ -37,6 +41,8 @@ export const RPLayerMenu = memo(({ layerId }: Props) => {
|
||||
const layerIndex = regionalPrompts.present.layers.findIndex((l) => l.id === layerId);
|
||||
const layerCount = regionalPrompts.present.layers.length;
|
||||
return {
|
||||
canAddPositivePrompt: layer.positivePrompt === null,
|
||||
canAddNegativePrompt: layer.negativePrompt === null,
|
||||
canMoveForward: layerIndex < layerCount - 1,
|
||||
canMoveBackward: layerIndex > 0,
|
||||
canMoveToFront: layerIndex < layerCount - 1,
|
||||
@ -46,6 +52,15 @@ export const RPLayerMenu = memo(({ layerId }: Props) => {
|
||||
[layerId]
|
||||
);
|
||||
const validActions = useAppSelector(selectValidActions);
|
||||
const addPositivePrompt = useCallback(() => {
|
||||
dispatch(maskLayerPositivePromptChanged({ layerId, prompt: '' }));
|
||||
}, [dispatch, layerId]);
|
||||
const addNegativePrompt = useCallback(() => {
|
||||
dispatch(maskLayerNegativePromptChanged({ layerId, prompt: '' }));
|
||||
}, [dispatch, layerId]);
|
||||
const addIPAdapter = useCallback(() => {
|
||||
dispatch(maskLayerIPAdapterAdded(layerId));
|
||||
}, [dispatch, layerId]);
|
||||
const moveForward = useCallback(() => {
|
||||
dispatch(layerMovedForward(layerId));
|
||||
}, [dispatch, layerId]);
|
||||
@ -68,6 +83,16 @@ export const RPLayerMenu = memo(({ layerId }: Props) => {
|
||||
<Menu>
|
||||
<MenuButton as={IconButton} aria-label="Layer menu" size="sm" icon={<PiDotsThreeVerticalBold />} />
|
||||
<MenuList>
|
||||
<MenuItem onClick={addPositivePrompt} isDisabled={!validActions.canAddPositivePrompt} icon={<PiPlusBold />}>
|
||||
{t('regionalPrompts.addPositivePrompt')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={addNegativePrompt} isDisabled={!validActions.canAddNegativePrompt} icon={<PiPlusBold />}>
|
||||
{t('regionalPrompts.addNegativePrompt')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={addIPAdapter} icon={<PiPlusBold />}>
|
||||
{t('regionalPrompts.addIPAdapter')}
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem onClick={moveToFront} isDisabled={!validActions.canMoveToFront} icon={<PiArrowLineUpBold />}>
|
||||
{t('regionalPrompts.moveToFront')}
|
||||
</MenuItem>
|
||||
|
@ -4,42 +4,32 @@ import { PromptOverlayButtonWrapper } from 'features/parameters/components/Promp
|
||||
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
|
||||
import { PromptPopover } from 'features/prompt/PromptPopover';
|
||||
import { usePrompt } from 'features/prompt/usePrompt';
|
||||
import { useMaskLayerTextPrompt } from 'features/regionalPrompts/hooks/layerStateHooks';
|
||||
import { RPLayerPromptDeleteButton } from 'features/regionalPrompts/components/RPLayerPromptDeleteButton';
|
||||
import { useLayerNegativePrompt } from 'features/regionalPrompts/hooks/layerStateHooks';
|
||||
import { maskLayerNegativePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import type { HotkeyCallback } from 'react-hotkeys-hook';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type Props = {
|
||||
layerId: string;
|
||||
};
|
||||
|
||||
export const RPLayerNegativePrompt = memo((props: Props) => {
|
||||
const textPrompt = useMaskLayerTextPrompt(props.layerId);
|
||||
export const RPLayerNegativePrompt = memo(({ layerId }: Props) => {
|
||||
const prompt = useLayerNegativePrompt(layerId);
|
||||
const dispatch = useAppDispatch();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const _onChange = useCallback(
|
||||
(v: string) => {
|
||||
dispatch(maskLayerNegativePromptChanged({ layerId: props.layerId, prompt: v }));
|
||||
dispatch(maskLayerNegativePromptChanged({ layerId, prompt: v }));
|
||||
},
|
||||
[dispatch, props.layerId]
|
||||
[dispatch, layerId]
|
||||
);
|
||||
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({
|
||||
prompt: textPrompt.negative,
|
||||
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown } = usePrompt({
|
||||
prompt,
|
||||
textareaRef,
|
||||
onChange: _onChange,
|
||||
});
|
||||
const focus: HotkeyCallback = useCallback(
|
||||
(e) => {
|
||||
onFocus();
|
||||
e.preventDefault();
|
||||
},
|
||||
[onFocus]
|
||||
);
|
||||
|
||||
useHotkeys('alt+a', focus, []);
|
||||
|
||||
return (
|
||||
<PromptPopover isOpen={isOpen} onClose={onClose} onSelect={onSelect} width={textareaRef.current?.clientWidth}>
|
||||
@ -48,7 +38,7 @@ export const RPLayerNegativePrompt = memo((props: Props) => {
|
||||
id="prompt"
|
||||
name="prompt"
|
||||
ref={textareaRef}
|
||||
value={textPrompt.negative}
|
||||
value={prompt}
|
||||
placeholder={t('parameters.negativePromptPlaceholder')}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
@ -57,6 +47,7 @@ export const RPLayerNegativePrompt = memo((props: Props) => {
|
||||
fontSize="sm"
|
||||
/>
|
||||
<PromptOverlayButtonWrapper>
|
||||
<RPLayerPromptDeleteButton layerId={layerId} polarity="negative" />
|
||||
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
|
||||
</PromptOverlayButtonWrapper>
|
||||
</Box>
|
||||
|
@ -4,42 +4,32 @@ import { PromptOverlayButtonWrapper } from 'features/parameters/components/Promp
|
||||
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
|
||||
import { PromptPopover } from 'features/prompt/PromptPopover';
|
||||
import { usePrompt } from 'features/prompt/usePrompt';
|
||||
import { useMaskLayerTextPrompt } from 'features/regionalPrompts/hooks/layerStateHooks';
|
||||
import { RPLayerPromptDeleteButton } from 'features/regionalPrompts/components/RPLayerPromptDeleteButton';
|
||||
import { useLayerPositivePrompt } from 'features/regionalPrompts/hooks/layerStateHooks';
|
||||
import { maskLayerPositivePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import type { HotkeyCallback } from 'react-hotkeys-hook';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type Props = {
|
||||
layerId: string;
|
||||
};
|
||||
|
||||
export const RPLayerPositivePrompt = memo((props: Props) => {
|
||||
const textPrompt = useMaskLayerTextPrompt(props.layerId);
|
||||
export const RPLayerPositivePrompt = memo(({ layerId }: Props) => {
|
||||
const prompt = useLayerPositivePrompt(layerId);
|
||||
const dispatch = useAppDispatch();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const _onChange = useCallback(
|
||||
(v: string) => {
|
||||
dispatch(maskLayerPositivePromptChanged({ layerId: props.layerId, prompt: v }));
|
||||
dispatch(maskLayerPositivePromptChanged({ layerId, prompt: v }));
|
||||
},
|
||||
[dispatch, props.layerId]
|
||||
[dispatch, layerId]
|
||||
);
|
||||
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({
|
||||
prompt: textPrompt.positive,
|
||||
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown } = usePrompt({
|
||||
prompt,
|
||||
textareaRef,
|
||||
onChange: _onChange,
|
||||
});
|
||||
const focus: HotkeyCallback = useCallback(
|
||||
(e) => {
|
||||
onFocus();
|
||||
e.preventDefault();
|
||||
},
|
||||
[onFocus]
|
||||
);
|
||||
|
||||
useHotkeys('alt+a', focus, []);
|
||||
|
||||
return (
|
||||
<PromptPopover isOpen={isOpen} onClose={onClose} onSelect={onSelect} width={textareaRef.current?.clientWidth}>
|
||||
@ -48,7 +38,7 @@ export const RPLayerPositivePrompt = memo((props: Props) => {
|
||||
id="prompt"
|
||||
name="prompt"
|
||||
ref={textareaRef}
|
||||
value={textPrompt.positive}
|
||||
value={prompt}
|
||||
placeholder={t('parameters.positivePromptPlaceholder')}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
@ -57,6 +47,7 @@ export const RPLayerPositivePrompt = memo((props: Props) => {
|
||||
minH={28}
|
||||
/>
|
||||
<PromptOverlayButtonWrapper>
|
||||
<RPLayerPromptDeleteButton layerId={layerId} polarity="positive" />
|
||||
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
|
||||
</PromptOverlayButtonWrapper>
|
||||
</Box>
|
||||
|
@ -0,0 +1,38 @@
|
||||
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import {
|
||||
maskLayerNegativePromptChanged,
|
||||
maskLayerPositivePromptChanged,
|
||||
} from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiTrashSimpleBold } from 'react-icons/pi';
|
||||
|
||||
type Props = {
|
||||
layerId: string;
|
||||
polarity: 'positive' | 'negative';
|
||||
};
|
||||
|
||||
export const RPLayerPromptDeleteButton = memo(({ layerId, polarity }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(() => {
|
||||
if (polarity === 'positive') {
|
||||
dispatch(maskLayerPositivePromptChanged({ layerId, prompt: null }));
|
||||
} else {
|
||||
dispatch(maskLayerNegativePromptChanged({ layerId, prompt: null }));
|
||||
}
|
||||
}, [dispatch, layerId, polarity]);
|
||||
return (
|
||||
<Tooltip label={t('regionalPrompts.deletePrompt')}>
|
||||
<IconButton
|
||||
variant="promptOverlay"
|
||||
aria-label={t('regionalPrompts.deletePrompt')}
|
||||
icon={<PiTrashSimpleBold />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
RPLayerPromptDeleteButton.displayName = 'RPLayerPromptDeleteButton';
|
@ -0,0 +1,53 @@
|
||||
import type { FormLabelProps } from '@invoke-ai/ui-library';
|
||||
import {
|
||||
Flex,
|
||||
FormControlGroup,
|
||||
IconButton,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { RPLayerAutoNegativeCheckbox } from 'features/regionalPrompts/components/RPLayerAutoNegativeCheckbox';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiGearSixBold } from 'react-icons/pi';
|
||||
|
||||
type Props = {
|
||||
layerId: string;
|
||||
};
|
||||
|
||||
const formLabelProps: FormLabelProps = {
|
||||
flexGrow: 1,
|
||||
minW: 32,
|
||||
};
|
||||
|
||||
const RPLayerSettingsPopover = ({ layerId }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
tooltip={t('common.settingsLabel')}
|
||||
aria-label={t('common.settingsLabel')}
|
||||
size="sm"
|
||||
icon={<PiGearSixBold />}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<Flex direction="column" gap={2}>
|
||||
<FormControlGroup formLabelProps={formLabelProps}>
|
||||
<RPLayerAutoNegativeCheckbox layerId={layerId} />
|
||||
</FormControlGroup>
|
||||
</Flex>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RPLayerSettingsPopover);
|
@ -4,7 +4,7 @@ import { useLayerIsVisible } from 'features/regionalPrompts/hooks/layerStateHook
|
||||
import { layerVisibilityToggled } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiEyeBold, PiEyeClosedBold } from 'react-icons/pi';
|
||||
import { PiCheckBold } from 'react-icons/pi';
|
||||
|
||||
type Props = {
|
||||
layerId: string;
|
||||
@ -23,9 +23,10 @@ export const RPLayerVisibilityToggle = memo(({ layerId }: Props) => {
|
||||
size="sm"
|
||||
aria-label={t('regionalPrompts.toggleVisibility')}
|
||||
tooltip={t('regionalPrompts.toggleVisibility')}
|
||||
variant={isVisible ? 'outline' : 'ghost'}
|
||||
icon={isVisible ? <PiEyeBold /> : <PiEyeClosedBold />}
|
||||
variant="outline"
|
||||
icon={isVisible ? <PiCheckBold /> : undefined}
|
||||
onClick={onClick}
|
||||
colorScheme="base"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -5,10 +5,8 @@ import { useAppSelector } from 'app/store/storeHooks';
|
||||
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
|
||||
import { AddLayerButton } from 'features/regionalPrompts/components/AddLayerButton';
|
||||
import { BrushSize } from 'features/regionalPrompts/components/BrushSize';
|
||||
import { DebugLayersButton } from 'features/regionalPrompts/components/DebugLayersButton';
|
||||
import { DeleteAllLayersButton } from 'features/regionalPrompts/components/DeleteAllLayersButton';
|
||||
import { GlobalMaskLayerOpacity } from 'features/regionalPrompts/components/GlobalMaskLayerOpacity';
|
||||
import { RPEnabledSwitch } from 'features/regionalPrompts/components/RPEnabledSwitch';
|
||||
import { RPLayerListItem } from 'features/regionalPrompts/components/RPLayerListItem';
|
||||
import { StageComponent } from 'features/regionalPrompts/components/StageComponent';
|
||||
import { ToolChooser } from 'features/regionalPrompts/components/ToolChooser';
|
||||
@ -29,16 +27,16 @@ export const RegionalPromptsEditor = memo(() => {
|
||||
<Flex gap={4} w="full" h="full">
|
||||
<Flex flexDir="column" gap={4} minW={430}>
|
||||
<Flex gap={3} w="full" justifyContent="space-between">
|
||||
<DebugLayersButton />
|
||||
<AddLayerButton />
|
||||
<DeleteAllLayersButton />
|
||||
<Spacer />
|
||||
<UndoRedoButtonGroup />
|
||||
<ToolChooser />
|
||||
</Flex>
|
||||
<RPEnabledSwitch />
|
||||
<BrushSize />
|
||||
<GlobalMaskLayerOpacity />
|
||||
<Flex justifyContent="space-between">
|
||||
<BrushSize />
|
||||
<GlobalMaskLayerOpacity />
|
||||
</Flex>
|
||||
<ScrollableContent>
|
||||
<Flex flexDir="column" gap={2}>
|
||||
{rpLayerIdsReversed.map((id) => (
|
||||
|
@ -21,6 +21,9 @@ import { atom } from 'nanostores';
|
||||
import { useCallback, useLayoutEffect } from 'react';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
// This will log warnings when layers > 5 - maybe use `import.meta.env.MODE === 'development'` instead?
|
||||
Konva.showWarnings = false;
|
||||
|
||||
const log = logger('regionalPrompts');
|
||||
const $stage = atom<Konva.Stage | null>(null);
|
||||
const selectSelectedLayerColor = createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
|
||||
@ -132,16 +135,32 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
renderToolPreview(stage, tool, selectedLayerIdColor, cursorPosition, lastMouseDownPos, state.brushSize);
|
||||
}, [stage, tool, selectedLayerIdColor, cursorPosition, lastMouseDownPos, state.brushSize]);
|
||||
renderToolPreview(
|
||||
stage,
|
||||
tool,
|
||||
selectedLayerIdColor,
|
||||
state.globalMaskLayerOpacity,
|
||||
cursorPosition,
|
||||
lastMouseDownPos,
|
||||
state.brushSize
|
||||
);
|
||||
}, [
|
||||
stage,
|
||||
tool,
|
||||
selectedLayerIdColor,
|
||||
state.globalMaskLayerOpacity,
|
||||
cursorPosition,
|
||||
lastMouseDownPos,
|
||||
state.brushSize,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
log.trace('Rendering layers');
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
renderLayers(stage, state.layers, tool, onLayerPosChanged);
|
||||
}, [stage, state.layers, tool, onLayerPosChanged]);
|
||||
renderLayers(stage, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged);
|
||||
}, [stage, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
log.trace('Rendering bbox');
|
||||
|
@ -4,19 +4,34 @@ import { isVectorMaskLayer, selectRegionalPromptsSlice } from 'features/regional
|
||||
import { useMemo } from 'react';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
export const useMaskLayerTextPrompt = (layerId: string) => {
|
||||
export const useLayerPositivePrompt = (layerId: string) => {
|
||||
const selectLayer = useMemo(
|
||||
() =>
|
||||
createSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
|
||||
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
|
||||
assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
|
||||
assert(layer.textPrompt !== null, `Layer ${layerId} does not have a text prompt`);
|
||||
return layer.textPrompt;
|
||||
assert(layer.positivePrompt !== null, `Layer ${layerId} does not have a positive prompt`);
|
||||
return layer.positivePrompt;
|
||||
}),
|
||||
[layerId]
|
||||
);
|
||||
const textPrompt = useAppSelector(selectLayer);
|
||||
return textPrompt;
|
||||
const prompt = useAppSelector(selectLayer);
|
||||
return prompt;
|
||||
};
|
||||
|
||||
export const useLayerNegativePrompt = (layerId: string) => {
|
||||
const selectLayer = useMemo(
|
||||
() =>
|
||||
createSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
|
||||
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
|
||||
assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`);
|
||||
assert(layer.negativePrompt !== null, `Layer ${layerId} does not have a negative prompt`);
|
||||
return layer.negativePrompt;
|
||||
}),
|
||||
[layerId]
|
||||
);
|
||||
const prompt = useAppSelector(selectLayer);
|
||||
return prompt;
|
||||
};
|
||||
|
||||
export const useLayerIsVisible = (layerId: string) => {
|
||||
|
@ -2,11 +2,12 @@ import type { PayloadAction, UnknownAction } from '@reduxjs/toolkit';
|
||||
import { createSlice, isAnyOf } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import { moveBackward, moveForward, moveToBack, moveToFront } from 'common/util/arrayUtils';
|
||||
import { controlAdapterRemoved } from 'features/controlAdapters/store/controlAdaptersSlice';
|
||||
import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas';
|
||||
import type { IRect, Vector2d } from 'konva/lib/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { atom } from 'nanostores';
|
||||
import type { RgbaColor } from 'react-colorful';
|
||||
import type { RgbColor } from 'react-colorful';
|
||||
import type { UndoableOptions } from 'redux-undo';
|
||||
import { assert } from 'tsafe';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@ -32,15 +33,6 @@ type VectorMaskRect = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
type TextPrompt = {
|
||||
positive: string;
|
||||
negative: string;
|
||||
};
|
||||
|
||||
type ImagePrompt = {
|
||||
// TODO
|
||||
};
|
||||
|
||||
type LayerBase = {
|
||||
id: string;
|
||||
x: number;
|
||||
@ -51,9 +43,10 @@ type LayerBase = {
|
||||
};
|
||||
|
||||
type MaskLayerBase = LayerBase & {
|
||||
textPrompt: TextPrompt | null; // Up to one text prompt per mask
|
||||
imagePrompts: ImagePrompt[]; // Any number of image prompts
|
||||
previewColor: RgbaColor;
|
||||
positivePrompt: string | null;
|
||||
negativePrompt: string | null; // Up to one text prompt per mask
|
||||
ipAdapterIds: string[]; // Any number of image prompts
|
||||
previewColor: RgbColor;
|
||||
autoNegative: ParameterAutoNegative;
|
||||
needsPixelBbox: boolean; // Needs the slower pixel-based bbox calculation - set to true when an there is an eraser object
|
||||
};
|
||||
@ -70,7 +63,6 @@ type RegionalPromptsState = {
|
||||
selectedLayerId: string | null;
|
||||
layers: Layer[];
|
||||
brushSize: number;
|
||||
brushColor: RgbaColor;
|
||||
globalMaskLayerOpacity: number;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
@ -79,10 +71,9 @@ export const initialRegionalPromptsState: RegionalPromptsState = {
|
||||
_version: 1,
|
||||
selectedLayerId: null,
|
||||
brushSize: 100,
|
||||
brushColor: { r: 255, g: 0, b: 0, a: 1 },
|
||||
layers: [],
|
||||
globalMaskLayerOpacity: 0.5, // this globally changes all mask layers' opacity
|
||||
isEnabled: false,
|
||||
isEnabled: true,
|
||||
};
|
||||
|
||||
const isLine = (obj: VectorMaskLine | VectorMaskRect): obj is VectorMaskLine => obj.type === 'vector_mask_line';
|
||||
@ -98,7 +89,7 @@ export const regionalPromptsSlice = createSlice({
|
||||
const kind = action.payload;
|
||||
if (action.payload === 'vector_mask_layer') {
|
||||
const lastColor = state.layers[state.layers.length - 1]?.previewColor;
|
||||
const color = LayerColors.next(lastColor);
|
||||
const previewColor = LayerColors.next(lastColor);
|
||||
const layer: VectorMaskLayer = {
|
||||
id: getVectorMaskLayerId(action.meta.uuid),
|
||||
type: kind,
|
||||
@ -106,16 +97,14 @@ export const regionalPromptsSlice = createSlice({
|
||||
bbox: null,
|
||||
bboxNeedsUpdate: false,
|
||||
objects: [],
|
||||
previewColor: color,
|
||||
previewColor,
|
||||
x: 0,
|
||||
y: 0,
|
||||
autoNegative: 'off',
|
||||
autoNegative: 'invert',
|
||||
needsPixelBbox: false,
|
||||
textPrompt: {
|
||||
positive: '',
|
||||
negative: '',
|
||||
},
|
||||
imagePrompts: [],
|
||||
positivePrompt: null,
|
||||
negativePrompt: null,
|
||||
ipAdapterIds: [],
|
||||
};
|
||||
state.layers.push(layer);
|
||||
state.selectedLayerId = layer.id;
|
||||
@ -191,21 +180,30 @@ export const regionalPromptsSlice = createSlice({
|
||||
//#endregion
|
||||
|
||||
//#region Mask Layers
|
||||
maskLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => {
|
||||
maskLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string | null }>) => {
|
||||
const { layerId, prompt } = action.payload;
|
||||
const layer = state.layers.find((l) => l.id === layerId);
|
||||
if (layer && layer.textPrompt) {
|
||||
layer.textPrompt.positive = prompt;
|
||||
if (layer) {
|
||||
layer.positivePrompt = prompt;
|
||||
}
|
||||
},
|
||||
maskLayerNegativePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => {
|
||||
maskLayerNegativePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string | null }>) => {
|
||||
const { layerId, prompt } = action.payload;
|
||||
const layer = state.layers.find((l) => l.id === layerId);
|
||||
if (layer && layer.textPrompt) {
|
||||
layer.textPrompt.negative = prompt;
|
||||
if (layer) {
|
||||
layer.negativePrompt = prompt;
|
||||
}
|
||||
},
|
||||
maskLayerPreviewColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbaColor }>) => {
|
||||
maskLayerIPAdapterAdded: {
|
||||
reducer: (state, action: PayloadAction<string, string, { uuid: string }>) => {
|
||||
const layer = state.layers.find((l) => l.id === action.payload);
|
||||
if (layer) {
|
||||
layer.ipAdapterIds.push(action.meta.uuid);
|
||||
}
|
||||
},
|
||||
prepare: (payload: string) => ({ payload, meta: { uuid: uuidv4() } }),
|
||||
},
|
||||
maskLayerPreviewColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbColor }>) => {
|
||||
const { layerId, color } = action.payload;
|
||||
const layer = state.layers.find((l) => l.id === layerId);
|
||||
if (layer) {
|
||||
@ -300,9 +298,6 @@ export const regionalPromptsSlice = createSlice({
|
||||
},
|
||||
globalMaskLayerOpacityChanged: (state, action: PayloadAction<number>) => {
|
||||
state.globalMaskLayerOpacity = action.payload;
|
||||
for (const layer of state.layers) {
|
||||
layer.previewColor.a = action.payload;
|
||||
}
|
||||
},
|
||||
isEnabledChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.isEnabled = action.payload;
|
||||
@ -321,28 +316,35 @@ export const regionalPromptsSlice = createSlice({
|
||||
},
|
||||
//#endregion
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder.addCase(controlAdapterRemoved, (state, action) => {
|
||||
for (const layer of state.layers) {
|
||||
layer.ipAdapterIds = layer.ipAdapterIds.filter((id) => id !== action.payload.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* This class is used to cycle through a set of colors for the prompt region layers.
|
||||
*/
|
||||
class LayerColors {
|
||||
static COLORS: RgbaColor[] = [
|
||||
{ r: 123, g: 159, b: 237, a: 1 }, // rgb(123, 159, 237)
|
||||
{ r: 106, g: 222, b: 106, a: 1 }, // rgb(106, 222, 106)
|
||||
{ r: 250, g: 225, b: 80, a: 1 }, // rgb(250, 225, 80)
|
||||
{ r: 233, g: 137, b: 81, a: 1 }, // rgb(233, 137, 81)
|
||||
{ r: 229, g: 96, b: 96, a: 1 }, // rgb(229, 96, 96)
|
||||
{ r: 226, g: 122, b: 210, a: 1 }, // rgb(226, 122, 210)
|
||||
{ r: 167, g: 116, b: 234, a: 1 }, // rgb(167, 116, 234)
|
||||
static COLORS: RgbColor[] = [
|
||||
{ r: 121, g: 157, b: 219 }, // rgb(121, 157, 219)
|
||||
{ r: 131, g: 214, b: 131 }, // rgb(131, 214, 131)
|
||||
{ r: 250, g: 225, b: 80 }, // rgb(250, 225, 80)
|
||||
{ r: 220, g: 144, b: 101 }, // rgb(220, 144, 101)
|
||||
{ r: 224, g: 117, b: 117 }, // rgb(224, 117, 117)
|
||||
{ r: 213, g: 139, b: 202 }, // rgb(213, 139, 202)
|
||||
{ r: 161, g: 120, b: 214 }, // rgb(161, 120, 214)
|
||||
];
|
||||
static i = this.COLORS.length - 1;
|
||||
/**
|
||||
* Get the next color in the sequence. If a known color is provided, the next color will be the one after it.
|
||||
*/
|
||||
static next(currentColor?: RgbaColor): RgbaColor {
|
||||
static next(currentColor?: RgbColor): RgbColor {
|
||||
if (currentColor) {
|
||||
const i = this.COLORS.findIndex((c) => isEqual(c, { ...currentColor, a: 1 }));
|
||||
const i = this.COLORS.findIndex((c) => isEqual(c, currentColor));
|
||||
if (i !== -1) {
|
||||
this.i = i;
|
||||
}
|
||||
@ -369,13 +371,14 @@ export const {
|
||||
layerVisibilityToggled,
|
||||
allLayersDeleted,
|
||||
// Mask layer actions
|
||||
maskLayerLineAdded,
|
||||
maskLayerPointsAdded,
|
||||
maskLayerRectAdded,
|
||||
maskLayerNegativePromptChanged,
|
||||
maskLayerPositivePromptChanged,
|
||||
maskLayerIPAdapterAdded,
|
||||
maskLayerAutoNegativeChanged,
|
||||
maskLayerPreviewColorChanged,
|
||||
maskLayerLineAdded,
|
||||
maskLayerNegativePromptChanged,
|
||||
maskLayerPointsAdded,
|
||||
maskLayerPositivePromptChanged,
|
||||
maskLayerRectAdded,
|
||||
// General actions
|
||||
isEnabledChanged,
|
||||
brushSizeChanged,
|
||||
|
@ -6,6 +6,8 @@ import type { Layer as KonvaLayerType } from 'konva/lib/Layer';
|
||||
import type { IRect } from 'konva/lib/types';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
export const GET_CLIENT_RECT_CONFIG = { skipTransform: true };
|
||||
|
||||
type Extents = {
|
||||
minX: number;
|
||||
minY: number;
|
||||
@ -54,10 +56,9 @@ const getImageDataBbox = (imageData: ImageData): Extents | null => {
|
||||
/**
|
||||
* Get the bounding box of a regional prompt konva layer. This function has special handling for regional prompt layers.
|
||||
* @param layer The konva layer to get the bounding box of.
|
||||
* @param filterChildren Optional filter function to exclude certain children from the bounding box calculation. Defaults to including all children.
|
||||
* @param preview Whether to open a new tab displaying the rendered layer, which is used to calculate the bbox.
|
||||
*/
|
||||
export const getKonvaLayerBbox = (layer: KonvaLayerType, preview: boolean = false): IRect | null => {
|
||||
export const getLayerBboxPixels = (layer: KonvaLayerType, preview: boolean = false): IRect | null => {
|
||||
// To calculate the layer's bounding box, we must first export it to a pixel array, then do some math.
|
||||
//
|
||||
// Though it is relatively fast, we can't use Konva's `getClientRect`. It programmatically determines the rect
|
||||
@ -82,6 +83,7 @@ export const getKonvaLayerBbox = (layer: KonvaLayerType, preview: boolean = fals
|
||||
for (const child of layerClone.getChildren()) {
|
||||
if (child.name() === VECTOR_MASK_LAYER_OBJECT_GROUP_NAME) {
|
||||
// We need to cache the group to ensure it composites out eraser strokes correctly
|
||||
child.opacity(1);
|
||||
child.cache();
|
||||
} else {
|
||||
// Filter out unwanted children.
|
||||
@ -112,11 +114,21 @@ export const getKonvaLayerBbox = (layer: KonvaLayerType, preview: boolean = fals
|
||||
|
||||
// Correct the bounding box to be relative to the layer's position.
|
||||
const correctedLayerBbox = {
|
||||
x: layerBbox.minX - stage.x() + layerRect.x - layer.x(),
|
||||
y: layerBbox.minY - stage.y() + layerRect.y - layer.y(),
|
||||
x: layerBbox.minX - Math.floor(stage.x()) + layerRect.x - Math.floor(layer.x()),
|
||||
y: layerBbox.minY - Math.floor(stage.y()) + layerRect.y - Math.floor(layer.y()),
|
||||
width: layerBbox.maxX - layerBbox.minX,
|
||||
height: layerBbox.maxY - layerBbox.minY,
|
||||
};
|
||||
|
||||
return correctedLayerBbox;
|
||||
};
|
||||
|
||||
export const getLayerBboxFast = (layer: KonvaLayerType): IRect | null => {
|
||||
const bbox = layer.getClientRect(GET_CLIENT_RECT_CONFIG);
|
||||
return {
|
||||
x: Math.floor(bbox.x),
|
||||
y: Math.floor(bbox.y),
|
||||
width: Math.floor(bbox.width),
|
||||
height: Math.floor(bbox.height),
|
||||
};
|
||||
};
|
||||
|
@ -20,7 +20,7 @@ export const getRegionalPromptLayerBlobs = async (
|
||||
const reduxLayers = state.regionalPrompts.present.layers;
|
||||
const container = document.createElement('div');
|
||||
const stage = new Konva.Stage({ container, width: state.generation.width, height: state.generation.height });
|
||||
renderLayers(stage, reduxLayers, 'brush');
|
||||
renderLayers(stage, reduxLayers, 1, 'brush');
|
||||
|
||||
const konvaLayers = stage.find<Konva.Layer>(`.${VECTOR_MASK_LAYER_NAME}`);
|
||||
const blobs: Record<string, Blob> = {};
|
||||
@ -52,7 +52,7 @@ export const getRegionalPromptLayerBlobs = async (
|
||||
openBase64ImageInTab([
|
||||
{
|
||||
base64,
|
||||
caption: `${reduxLayer.id}: ${reduxLayer.textPrompt?.positive} / ${reduxLayer.textPrompt?.negative}`,
|
||||
caption: `${reduxLayer.id}: ${reduxLayer.positivePrompt} / ${reduxLayer.negativePrompt}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { getStore } from 'app/store/nanostores/store';
|
||||
import { rgbColorToString } from 'features/canvas/util/colorToString';
|
||||
import { rgbaColorToString,rgbColorToString } from 'features/canvas/util/colorToString';
|
||||
import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks';
|
||||
import type { Layer, Tool, VectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import {
|
||||
@ -20,7 +20,7 @@ import {
|
||||
VECTOR_MASK_LAYER_OBJECT_GROUP_NAME,
|
||||
VECTOR_MASK_LAYER_RECT_NAME,
|
||||
} from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { getKonvaLayerBbox } from 'features/regionalPrompts/util/bbox';
|
||||
import { getLayerBboxFast, getLayerBboxPixels } from 'features/regionalPrompts/util/bbox';
|
||||
import Konva from 'konva';
|
||||
import type { IRect, Vector2d } from 'konva/lib/types';
|
||||
import type { RgbColor } from 'react-colorful';
|
||||
@ -33,8 +33,6 @@ const BBOX_NOT_SELECTED_MOUSEOVER_STROKE = 'rgba(255, 255, 255, 0.661)';
|
||||
const BRUSH_BORDER_INNER_COLOR = 'rgba(0,0,0,1)';
|
||||
const BRUSH_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)';
|
||||
|
||||
const GET_CLIENT_RECT_CONFIG = { skipTransform: true };
|
||||
|
||||
const mapId = (object: { id: string }) => object.id;
|
||||
|
||||
const getIsSelected = (layerId?: string | null) => {
|
||||
@ -61,6 +59,7 @@ export const renderToolPreview = (
|
||||
stage: Konva.Stage,
|
||||
tool: Tool,
|
||||
color: RgbColor | null,
|
||||
globalMaskLayerOpacity: number,
|
||||
cursorPos: Vector2d | null,
|
||||
lastMouseDownPos: Vector2d | null,
|
||||
brushSize: number
|
||||
@ -161,7 +160,7 @@ export const renderToolPreview = (
|
||||
x: cursorPos.x,
|
||||
y: cursorPos.y,
|
||||
radius: brushSize / 2,
|
||||
fill: rgbColorToString(color),
|
||||
fill: rgbaColorToString({ ...color, a: globalMaskLayerOpacity }),
|
||||
globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out',
|
||||
});
|
||||
|
||||
@ -200,6 +199,7 @@ const renderVectorMaskLayer = (
|
||||
stage: Konva.Stage,
|
||||
vmLayer: VectorMaskLayer,
|
||||
vmLayerIndex: number,
|
||||
globalMaskLayerOpacity: number,
|
||||
tool: Tool,
|
||||
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
|
||||
) => {
|
||||
@ -354,8 +354,8 @@ const renderVectorMaskLayer = (
|
||||
}
|
||||
|
||||
// Updating group opacity does not require re-caching
|
||||
if (konvaObjectGroup.opacity() !== vmLayer.previewColor.a) {
|
||||
konvaObjectGroup.opacity(vmLayer.previewColor.a);
|
||||
if (konvaObjectGroup.opacity() !== globalMaskLayerOpacity) {
|
||||
konvaObjectGroup.opacity(globalMaskLayerOpacity);
|
||||
}
|
||||
};
|
||||
|
||||
@ -370,6 +370,7 @@ const renderVectorMaskLayer = (
|
||||
export const renderLayers = (
|
||||
stage: Konva.Stage,
|
||||
reduxLayers: Layer[],
|
||||
globalMaskLayerOpacity: number,
|
||||
tool: Tool,
|
||||
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
|
||||
) => {
|
||||
@ -386,7 +387,7 @@ export const renderLayers = (
|
||||
const reduxLayer = reduxLayers[layerIndex];
|
||||
assert(reduxLayer, `Layer at index ${layerIndex} is undefined`);
|
||||
if (isVectorMaskLayer(reduxLayer)) {
|
||||
renderVectorMaskLayer(stage, reduxLayer, layerIndex, tool, onLayerPosChanged);
|
||||
renderVectorMaskLayer(stage, reduxLayer, layerIndex, globalMaskLayerOpacity, tool, onLayerPosChanged);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -426,9 +427,7 @@ export const renderBbox = (
|
||||
// We only need to recalculate the bbox if the layer has changed and it has objects
|
||||
if (reduxLayer.bboxNeedsUpdate && reduxLayer.objects.length) {
|
||||
// We only need to use the pixel-perfect bounding box if the layer has eraser strokes
|
||||
bbox = reduxLayer.needsPixelBbox
|
||||
? getKonvaLayerBbox(konvaLayer)
|
||||
: konvaLayer.getClientRect(GET_CLIENT_RECT_CONFIG);
|
||||
bbox = reduxLayer.needsPixelBbox ? getLayerBboxPixels(konvaLayer) : getLayerBboxFast(konvaLayer);
|
||||
|
||||
// Update the layer's bbox in the redux store
|
||||
onBboxChanged(reduxLayer.id, bbox);
|
||||
|
@ -13,51 +13,60 @@ import {
|
||||
selectValidIPAdapters,
|
||||
selectValidT2IAdapters,
|
||||
} from 'features/controlAdapters/store/controlAdaptersSlice';
|
||||
import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice';
|
||||
import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { Fragment, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiPlusBold } from 'react-icons/pi';
|
||||
|
||||
const selector = createMemoizedSelector(selectControlAdaptersSlice, (controlAdapters) => {
|
||||
const badges: string[] = [];
|
||||
let isError = false;
|
||||
const selector = createMemoizedSelector(
|
||||
[selectControlAdaptersSlice, selectRegionalPromptsSlice],
|
||||
(controlAdapters, regionalPrompts) => {
|
||||
const badges: string[] = [];
|
||||
let isError = false;
|
||||
|
||||
const enabledIPAdapterCount = selectAllIPAdapters(controlAdapters).filter((ca) => ca.isEnabled).length;
|
||||
const validIPAdapterCount = selectValidIPAdapters(controlAdapters).length;
|
||||
if (enabledIPAdapterCount > 0) {
|
||||
badges.push(`${enabledIPAdapterCount} IP`);
|
||||
}
|
||||
if (enabledIPAdapterCount > validIPAdapterCount) {
|
||||
isError = true;
|
||||
}
|
||||
const enabledIPAdapterCount = selectAllIPAdapters(controlAdapters)
|
||||
.filter((ca) => !regionalPrompts.present.layers.some((l) => l.ipAdapterIds.includes(ca.id)))
|
||||
.filter((ca) => ca.isEnabled).length;
|
||||
|
||||
const enabledControlNetCount = selectAllControlNets(controlAdapters).filter((ca) => ca.isEnabled).length;
|
||||
const validControlNetCount = selectValidControlNets(controlAdapters).length;
|
||||
if (enabledControlNetCount > 0) {
|
||||
badges.push(`${enabledControlNetCount} ControlNet`);
|
||||
}
|
||||
if (enabledControlNetCount > validControlNetCount) {
|
||||
isError = true;
|
||||
}
|
||||
const validIPAdapterCount = selectValidIPAdapters(controlAdapters).length;
|
||||
if (enabledIPAdapterCount > 0) {
|
||||
badges.push(`${enabledIPAdapterCount} IP`);
|
||||
}
|
||||
if (enabledIPAdapterCount > validIPAdapterCount) {
|
||||
isError = true;
|
||||
}
|
||||
|
||||
const enabledT2IAdapterCount = selectAllT2IAdapters(controlAdapters).filter((ca) => ca.isEnabled).length;
|
||||
const validT2IAdapterCount = selectValidT2IAdapters(controlAdapters).length;
|
||||
if (enabledT2IAdapterCount > 0) {
|
||||
badges.push(`${enabledT2IAdapterCount} T2I`);
|
||||
}
|
||||
if (enabledT2IAdapterCount > validT2IAdapterCount) {
|
||||
isError = true;
|
||||
}
|
||||
const enabledControlNetCount = selectAllControlNets(controlAdapters).filter((ca) => ca.isEnabled).length;
|
||||
const validControlNetCount = selectValidControlNets(controlAdapters).length;
|
||||
if (enabledControlNetCount > 0) {
|
||||
badges.push(`${enabledControlNetCount} ControlNet`);
|
||||
}
|
||||
if (enabledControlNetCount > validControlNetCount) {
|
||||
isError = true;
|
||||
}
|
||||
|
||||
const controlAdapterIds = selectControlAdapterIds(controlAdapters);
|
||||
const enabledT2IAdapterCount = selectAllT2IAdapters(controlAdapters).filter((ca) => ca.isEnabled).length;
|
||||
const validT2IAdapterCount = selectValidT2IAdapters(controlAdapters).length;
|
||||
if (enabledT2IAdapterCount > 0) {
|
||||
badges.push(`${enabledT2IAdapterCount} T2I`);
|
||||
}
|
||||
if (enabledT2IAdapterCount > validT2IAdapterCount) {
|
||||
isError = true;
|
||||
}
|
||||
|
||||
return {
|
||||
controlAdapterIds,
|
||||
badges,
|
||||
isError, // TODO: Add some visual indicator that the control adapters are in an error state
|
||||
};
|
||||
});
|
||||
const controlAdapterIds = selectControlAdapterIds(controlAdapters).filter(
|
||||
(id) => !regionalPrompts.present.layers.some((l) => l.ipAdapterIds.includes(id))
|
||||
);
|
||||
|
||||
return {
|
||||
controlAdapterIds,
|
||||
badges,
|
||||
isError, // TODO: Add some visual indicator that the control adapters are in an error state
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const ControlSettingsAccordion: React.FC = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
|
@ -14,8 +14,8 @@ const selectValidLayerCount = createSelector(selectRegionalPromptsSlice, (region
|
||||
const validLayers = regionalPrompts.present.layers
|
||||
.filter((l) => l.isVisible)
|
||||
.filter((l) => {
|
||||
const hasTextPrompt = l.textPrompt && (l.textPrompt.positive || l.textPrompt.negative);
|
||||
const hasAtLeastOneImagePrompt = l.imagePrompts.length > 0;
|
||||
const hasTextPrompt = Boolean(l.positivePrompt || l.negativePrompt);
|
||||
const hasAtLeastOneImagePrompt = l.ipAdapterIds.length > 0;
|
||||
return hasTextPrompt || hasAtLeastOneImagePrompt;
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user