Compare commits

...

56 Commits

Author SHA1 Message Date
psychedelicious
abb3bb9f7e Update invokeai_version.py 2024-05-01 06:30:28 +10:00
psychedelicious
631878b212 feat(ui): border radius on canvas 2024-04-30 08:10:59 -04:00
psychedelicious
7a5399e83c feat(ui): display message when no layers are added 2024-04-30 08:10:59 -04:00
psychedelicious
e90775731d fix(ui): layer layout orientation 2024-04-30 08:10:59 -04:00
psychedelicious
3f26880493 fix(ui): "Global Settings" -> "Settings" 2024-04-30 08:10:59 -04:00
psychedelicious
21cf1004db fix(ui): layers default to expanded 2024-04-30 08:10:59 -04:00
psychedelicious
d74cd12aa6 feat(ui): collapsible layers 2024-04-30 08:10:59 -04:00
psychedelicious
cf1883585d chore(ui): lint 2024-04-30 08:10:59 -04:00
psychedelicious
8a791d4f16 feat(ui): make control image opacity filter toggleable 2024-04-30 08:10:59 -04:00
psychedelicious
1212698059 tidy(ui): more renaming of components 2024-04-30 08:10:59 -04:00
psychedelicious
ba6db33b39 tidy(ui): more renaming of components 2024-04-30 08:10:59 -04:00
psychedelicious
b3dbfdaa02 tidy(ui): more renaming of components 2024-04-30 08:10:59 -04:00
psychedelicious
3441187c23 tidy(ui): "regional prompts" -> "control layers" 2024-04-30 08:10:59 -04:00
psychedelicious
8de56fd77c tidy(ui): move regionalPrompts files to controlLayers 2024-04-30 08:10:59 -04:00
psychedelicious
22bd33b7c6 chore(ui): lint 2024-04-30 08:10:59 -04:00
psychedelicious
2af5c4be9f fix(ui): ip adapter layers are not selectable 2024-04-30 08:10:59 -04:00
psychedelicious
415a41e21a perf(ui): reset maskobjects when layer has no bbox (all objects erased) 2024-04-30 08:10:59 -04:00
psychedelicious
aa2ca03056 fix(ui): filter layers based on tab when disabling invoke button 2024-04-30 08:10:59 -04:00
psychedelicious
a20faca20f feat(ui): layer layout tweaks 2024-04-30 08:10:59 -04:00
psychedelicious
9d042baf48 fix(ui): ip adapter layers always at bottom of list 2024-04-30 08:10:59 -04:00
psychedelicious
6195741814 feat(ui): move global mask opacity to settings popover 2024-04-30 08:10:59 -04:00
psychedelicious
c2f8adf93e fix(ui): deselect other layers when new layer added 2024-04-30 08:10:59 -04:00
psychedelicious
ace3955760 fix(ui): tool preview/cursor when non-interactable layer selected 2024-04-30 08:10:59 -04:00
psychedelicious
720e16cea6 feat(ui): tweak layer list styling to better indicate selectablility 2024-04-30 08:10:59 -04:00
psychedelicious
a357a1ac9d feat(ui): remove select layer on click in canvas
It's very easy to end up in a spot where you cannot select a layer at all to move it around. Too tricky to handle otherwise.
2024-04-30 08:10:59 -04:00
psychedelicious
22f160bfcc fix(ui): unlink control adapter opaicty from global mask opacity 2024-04-30 08:10:59 -04:00
psychedelicious
fa637b5c59 fix(ui): add missed ca layer opacity logic
didn't stage the right changes a few commits back
2024-04-30 08:10:59 -04:00
psychedelicious
1f68a60752 feat(ui): hold shift to use control image size w/o model constraints 2024-04-30 08:10:59 -04:00
psychedelicious
048bd18e10 feat(ui): separate ca layer opacity 2024-04-30 08:10:59 -04:00
psychedelicious
e5ec529f0f feat(ui): fix layer arranging 2024-04-30 08:10:59 -04:00
psychedelicious
d884c15d0c feat(ui): update layer menus 2024-04-30 08:10:59 -04:00
psychedelicious
9ee7cad613 feat(ui): make control layer ui exclusive to txt2img tab 2024-04-30 08:10:59 -04:00
psychedelicious
629110784d fix(ui): delete control layers correctly 2024-04-30 08:10:59 -04:00
psychedelicious
c1666a8b5a fix(ui): select default control/ip adapter models in control layers 2024-04-30 08:10:59 -04:00
psychedelicious
d14b315bc6 fix(ui): use optimal size when using control image dims 2024-04-30 08:10:59 -04:00
psychedelicious
fe459295ea fix(ui): exclude disabled control adapters on control layers 2024-04-30 08:10:59 -04:00
psychedelicious
9d67ec9efe fix(ui): toggle control adapter layer vis 2024-04-30 08:10:59 -04:00
psychedelicious
5bf4d37949 perf(ui): reduce control image processing to when it is needed
Only should reprocess if the processor settings or the image has changed.
2024-04-30 08:10:59 -04:00
psychedelicious
387ab9cee7 feat(ui): reset controlnet model to null instead of disabling when base model changes 2024-04-30 08:10:59 -04:00
psychedelicious
56050f7887 fix(ui): fix canvas scaling when window is zoomed
Konva doesn't react to changes to window zoom/scale. If you open the tab at, say, 90%, then bump to 100%, the pixel ratio of the canvas doesn't change. This results in lower-quality renders on the canvas (generation is unaffected).
2024-04-30 08:10:59 -04:00
psychedelicious
c354470cd1 perf(ui): do not cache controlnet images unless required 2024-04-30 08:10:59 -04:00
psychedelicious
ded8267505 WIP control adapters in regional 2024-04-30 08:10:59 -04:00
psychedelicious
e822897b1c feat(nodes): add prototype heuristic image resize node
Uses the fancy cnet resize that retains edges.
2024-04-30 08:10:59 -04:00
psychedelicious
2d7b8c2a1b fix(backend): do not round image dims to 64 in controlnet processor resize
Rounding the dims results in control images that are subtly different than the input. We round to the nearest 8px later, there's no need to round now.
2024-04-30 08:10:59 -04:00
psychedelicious
ebeae41cb2 tidy(ui): minor ca component tidy 2024-04-30 08:10:59 -04:00
psychedelicious
6f5f3381f9 feat(ui): revise internal state for RCC 2024-04-30 08:10:59 -04:00
psychedelicious
2f6fec8c6c chore(ui): lint 2024-04-30 08:10:59 -04:00
psychedelicious
cc4bef4859 refactor(ui): move size state to regional 2024-04-30 08:10:59 -04:00
psychedelicious
b6a45e53f1 refactor(ui): move positive2 and negative2 prompt to regional 2024-04-30 08:10:59 -04:00
psychedelicious
1cf1e53a6c refactor(ui): move positive and negative prompt to regional 2024-04-30 08:10:59 -04:00
psychedelicious
c686625076 feat(ui): add 'control_layer' type 2024-04-30 08:10:59 -04:00
psychedelicious
d861bc690e feat(mm): handle PC_PATH_MAX on external drives on macOS
`PC_PATH_MAX` doesn't exist for (some?) external drives on macOS. We need error handling when retrieving this value.

Also added error handling for `PC_NAME_MAX` just in case. This does work for me for external drives on macOS, though.

Closes #6277
2024-04-30 07:57:03 -04:00
dunkeroni
f262b9032d fix: changed validation to not error on connection 2024-04-28 12:48:56 -04:00
dunkeroni
71c3197eab fix: denoise latents accepts CFG lists as input 2024-04-28 12:48:56 -04:00
psychedelicious
241a1fdb57 feat(mm): support sdxl ckpt inpainting models
There are only a couple SDXL inpainting models, and my tests indicate they are not as good as SD1.5 inpainting, but at least we support them now.

- Add the config file. This matches what is used in A1111. The only difference from the non-inpainting SDXL config is the number of in-channels.
- Update the legacy config maps to use this config file.
2024-04-28 12:57:27 +10:00
psychedelicious
3595beac1e docs: remove references to config script in CONFIGURATION.md 2024-04-25 17:49:32 -04:00
130 changed files with 3660 additions and 1604 deletions

View File

@@ -51,13 +51,11 @@ The settings in this file will override the defaults. You only need
to change this file if the default for a particular setting doesn't
work for you.
You'll find an example file next to `invokeai.yaml` that shows the default values.
Some settings, like [Model Marketplace API Keys], require the YAML
to be formatted correctly. Here is a [basic guide to YAML files].
You can fix a broken `invokeai.yaml` by deleting it and running the
configuration script again -- option [6] in the launcher, "Re-run the
configure script".
#### Custom Config File Location
You can use any config file with the `--config` CLI arg. Pass in the path to the `invokeai.yaml` file you want to use.

View File

@@ -35,15 +35,16 @@ from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.invocations.primitives import ImageOutput
from invokeai.app.invocations.util import validate_begin_end_step, validate_weights
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.controlnet_utils import CONTROLNET_MODE_VALUES, CONTROLNET_RESIZE_VALUES
from invokeai.app.util.controlnet_utils import CONTROLNET_MODE_VALUES, CONTROLNET_RESIZE_VALUES, heuristic_resize
from invokeai.backend.image_util.canny import get_canny_edges
from invokeai.backend.image_util.depth_anything import DepthAnythingDetector
from invokeai.backend.image_util.dw_openpose import DWOpenposeDetector
from invokeai.backend.image_util.hed import HEDProcessor
from invokeai.backend.image_util.lineart import LineartProcessor
from invokeai.backend.image_util.lineart_anime import LineartAnimeProcessor
from invokeai.backend.image_util.util import np_to_pil, pil_to_np
from .baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
from .baseinvocation import BaseInvocation, BaseInvocationOutput, Classification, invocation, invocation_output
class ControlField(BaseModel):
@@ -634,3 +635,27 @@ class DWOpenposeImageProcessorInvocation(ImageProcessorInvocation):
resolution=self.image_resolution,
)
return processed_image
@invocation(
"heuristic_resize",
title="Heuristic Resize",
tags=["image, controlnet"],
category="image",
version="1.0.0",
classification=Classification.Prototype,
)
class HeuristicResizeInvocation(BaseInvocation):
"""Resize an image using a heuristic method. Preserves edge maps."""
image: ImageField = InputField(description="The image to resize")
width: int = InputField(default=512, gt=0, description="The width to resize to (px)")
height: int = InputField(default=512, gt=0, description="The height to resize to (px)")
def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.images.get_pil(self.image.image_name, "RGB")
np_img = pil_to_np(image)
np_resized = heuristic_resize(np_img, (self.width, self.height))
resized = np_to_pil(np_resized)
image_dto = context.images.save(image=resized)
return ImageOutput.build(image_dto)

View File

@@ -341,7 +341,7 @@ class DenoiseLatentsInvocation(BaseInvocation):
)
steps: int = InputField(default=10, gt=0, description=FieldDescriptions.steps)
cfg_scale: Union[float, List[float]] = InputField(
default=7.5, ge=1, description=FieldDescriptions.cfg_scale, title="CFG Scale"
default=7.5, description=FieldDescriptions.cfg_scale, title="CFG Scale"
)
denoising_start: float = InputField(
default=0.0,
@@ -563,6 +563,11 @@ class DenoiseLatentsInvocation(BaseInvocation):
dtype=unet.dtype,
)
if isinstance(self.cfg_scale, list):
assert (
len(self.cfg_scale) == self.steps
), "cfg_scale (list) must have the same length as the number of steps"
conditioning_data = TextConditioningData(
uncond_text=uncond_text_embedding,
cond_text=cond_text_embedding,

View File

@@ -318,10 +318,8 @@ class DownloadQueueService(DownloadQueueServiceBase):
in_progress_path.rename(job.download_path)
def _validate_filename(self, directory: str, filename: str) -> bool:
pc_name_max = os.pathconf(directory, "PC_NAME_MAX") if hasattr(os, "pathconf") else 260 # hardcoded for windows
pc_path_max = (
os.pathconf(directory, "PC_PATH_MAX") if hasattr(os, "pathconf") else 32767
) # hardcoded for windows with long names enabled
pc_name_max = get_pc_name_max(directory)
pc_path_max = get_pc_path_max(directory)
if "/" in filename:
return False
if filename.startswith(".."):
@@ -419,6 +417,26 @@ class DownloadQueueService(DownloadQueueServiceBase):
self._logger.warning(excp)
def get_pc_name_max(directory: str) -> int:
if hasattr(os, "pathconf"):
try:
return os.pathconf(directory, "PC_NAME_MAX")
except OSError:
# macOS w/ external drives raise OSError
pass
return 260 # hardcoded for windows
def get_pc_path_max(directory: str) -> int:
if hasattr(os, "pathconf"):
try:
return os.pathconf(directory, "PC_PATH_MAX")
except OSError:
# some platforms may not have this value
pass
return 32767 # hardcoded for windows with long names enabled
# Example on_progress event handler to display a TQDM status bar
# Activate with:
# download_service.download(DownloadJob('http://foo.bar/baz', '/tmp', on_progress=TqdmProgress().update))

View File

@@ -144,10 +144,8 @@ def resize_image_to_resolution(input_image: np.ndarray, resolution: int) -> np.n
h = float(input_image.shape[0])
w = float(input_image.shape[1])
scaling_factor = float(resolution) / min(h, w)
h *= scaling_factor
w *= scaling_factor
h = int(np.round(h / 64.0)) * 64
w = int(np.round(w / 64.0)) * 64
h = int(h * scaling_factor)
w = int(w * scaling_factor)
if scaling_factor > 1:
return cv2.resize(input_image, (w, h), interpolation=cv2.INTER_LANCZOS4)
else:

View File

@@ -51,6 +51,7 @@ LEGACY_CONFIGS: Dict[BaseModelType, Dict[ModelVariantType, Union[str, Dict[Sched
},
BaseModelType.StableDiffusionXL: {
ModelVariantType.Normal: "sd_xl_base.yaml",
ModelVariantType.Inpaint: "sd_xl_inpaint.yaml",
},
BaseModelType.StableDiffusionXLRefiner: {
ModelVariantType.Normal: "sd_xl_refiner.yaml",

View File

@@ -0,0 +1,98 @@
model:
target: sgm.models.diffusion.DiffusionEngine
params:
scale_factor: 0.13025
disable_first_stage_autocast: True
denoiser_config:
target: sgm.modules.diffusionmodules.denoiser.DiscreteDenoiser
params:
num_idx: 1000
weighting_config:
target: sgm.modules.diffusionmodules.denoiser_weighting.EpsWeighting
scaling_config:
target: sgm.modules.diffusionmodules.denoiser_scaling.EpsScaling
discretization_config:
target: sgm.modules.diffusionmodules.discretizer.LegacyDDPMDiscretization
network_config:
target: sgm.modules.diffusionmodules.openaimodel.UNetModel
params:
adm_in_channels: 2816
num_classes: sequential
use_checkpoint: True
in_channels: 9
out_channels: 4
model_channels: 320
attention_resolutions: [4, 2]
num_res_blocks: 2
channel_mult: [1, 2, 4]
num_head_channels: 64
use_spatial_transformer: True
use_linear_in_transformer: True
transformer_depth: [1, 2, 10] # note: the first is unused (due to attn_res starting at 2) 32, 16, 8 --> 64, 32, 16
context_dim: 2048
spatial_transformer_attn_type: softmax-xformers
legacy: False
conditioner_config:
target: sgm.modules.GeneralConditioner
params:
emb_models:
# crossattn cond
- is_trainable: False
input_key: txt
target: sgm.modules.encoders.modules.FrozenCLIPEmbedder
params:
layer: hidden
layer_idx: 11
# crossattn and vector cond
- is_trainable: False
input_key: txt
target: sgm.modules.encoders.modules.FrozenOpenCLIPEmbedder2
params:
arch: ViT-bigG-14
version: laion2b_s39b_b160k
freeze: True
layer: penultimate
always_return_pooled: True
legacy: False
# vector cond
- is_trainable: False
input_key: original_size_as_tuple
target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND
params:
outdim: 256 # multiplied by two
# vector cond
- is_trainable: False
input_key: crop_coords_top_left
target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND
params:
outdim: 256 # multiplied by two
# vector cond
- is_trainable: False
input_key: target_size_as_tuple
target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND
params:
outdim: 256 # multiplied by two
first_stage_config:
target: sgm.models.autoencoder.AutoencoderKLInferenceWrapper
params:
embed_dim: 4
monitor: val/rec_loss
ddconfig:
attn_type: vanilla-xformers
double_z: true
z_channels: 4
resolution: 256
in_channels: 3
out_ch: 3
ch: 128
ch_mult: [1, 2, 4, 4]
num_res_blocks: 2
attn_resolutions: []
dropout: 0.0
lossconfig:
target: torch.nn.Identity

View File

@@ -101,6 +101,7 @@
"serialize-error": "^11.0.3",
"socket.io-client": "^4.7.5",
"use-debounce": "^10.0.0",
"use-device-pixel-ratio": "^1.1.2",
"use-image": "^1.1.1",
"uuid": "^9.0.1",
"zod": "^3.22.4",

View File

@@ -158,6 +158,9 @@ dependencies:
use-debounce:
specifier: ^10.0.0
version: 10.0.0(react@18.2.0)
use-device-pixel-ratio:
specifier: ^1.1.2
version: 1.1.2(react@18.2.0)
use-image:
specifier: ^1.1.1
version: 1.1.1(react-dom@18.2.0)(react@18.2.0)
@@ -13324,6 +13327,14 @@ packages:
react: 18.2.0
dev: false
/use-device-pixel-ratio@1.1.2(react@18.2.0):
resolution: {integrity: sha512-nFxV0HwLdRUt20kvIgqHYZe6PK/v4mU1X8/eLsT1ti5ck0l2ob0HDRziaJPx+YWzBo6dMm4cTac3mcyk68Gh+A==}
peerDependencies:
react: '>=16.8.0'
dependencies:
react: 18.2.0
dev: false
/use-image@1.1.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-n4YO2k8AJG/BcDtxmBx8Aa+47kxY5m335dJiCQA5tTeVU4XdhrhqR6wT0WISRXwdMEOv5CSjqekDZkEMiiWaYQ==}
peerDependencies:

View File

@@ -156,6 +156,7 @@
"balanced": "Balanced",
"base": "Base",
"beginEndStepPercent": "Begin / End Step Percentage",
"beginEndStepPercentShort": "Begin/End %",
"bgth": "bg_th",
"canny": "Canny",
"cannyDescription": "Canny edge detection",
@@ -227,7 +228,8 @@
"scribble": "scribble",
"selectModel": "Select a model",
"selectCLIPVisionModel": "Select a CLIP Vision model",
"setControlImageDimensions": "Set Control Image Dimensions To W/H",
"setControlImageDimensions": "Copy size to W/H (optimize for model)",
"setControlImageDimensionsForce": "Copy size to W/H (ignore model)",
"showAdvanced": "Show Advanced",
"small": "Small",
"toggleControlNet": "Toggle this ControlNet",
@@ -1511,7 +1513,7 @@
"app": {
"storeNotInitialized": "Store is not initialized"
},
"regionalPrompts": {
"controlLayers": {
"deleteAll": "Delete All",
"addLayer": "Add Layer",
"moveToFront": "Move to Front",
@@ -1519,8 +1521,7 @@
"moveForward": "Move Forward",
"moveBackward": "Move Backward",
"brushSize": "Brush Size",
"regionalControl": "Regional Control (ALPHA)",
"enableRegionalPrompts": "Enable $t(regionalPrompts.regionalPrompts)",
"controlLayers": "Control Layers (BETA)",
"globalMaskOpacity": "Global Mask Opacity",
"autoNegative": "Auto Negative",
"toggleVisibility": "Toggle Layer Visibility",
@@ -1531,6 +1532,16 @@
"maskPreviewColor": "Mask Preview Color",
"addPositivePrompt": "Add $t(common.positivePrompt)",
"addNegativePrompt": "Add $t(common.negativePrompt)",
"addIPAdapter": "Add $t(common.ipAdapter)"
"addIPAdapter": "Add $t(common.ipAdapter)",
"regionalGuidance": "Regional Guidance",
"regionalGuidanceLayer": "$t(controlLayers.regionalGuidance) $t(unifiedCanvas.layer)",
"controlNetLayer": "$t(common.controlNet) $t(unifiedCanvas.layer)",
"ipAdapterLayer": "$t(common.ipAdapter) $t(unifiedCanvas.layer)",
"opacity": "Opacity",
"globalControlAdapter": "Global $t(controlnet.controlAdapter_one)",
"globalControlAdapterLayer": "Global $t(controlnet.controlAdapter_one) $t(unifiedCanvas.layer)",
"globalIPAdapter": "Global $t(common.ipAdapter)",
"globalIPAdapterLayer": "Global $t(common.ipAdapter) $t(unifiedCanvas.layer)",
"opacityFilter": "Opacity Filter"
}
}

View File

@@ -28,7 +28,7 @@ export type LoggerNamespace =
| 'session'
| 'queue'
| 'dnd'
| 'regionalPrompts';
| 'controlLayers';
export const logger = (namespace: LoggerNamespace) => $logger.get().child({ namespace });

View File

@@ -16,6 +16,7 @@ import { addCanvasMaskSavedToGalleryListener } from 'app/store/middleware/listen
import { addCanvasMaskToControlNetListener } from 'app/store/middleware/listenerMiddleware/listeners/canvasMaskToControlNet';
import { addCanvasMergedListener } from 'app/store/middleware/listenerMiddleware/listeners/canvasMerged';
import { addCanvasSavedToGalleryListener } from 'app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery';
import { addControlLayersToControlAdapterBridge } from 'app/store/middleware/listenerMiddleware/listeners/controlLayersToControlAdapterBridge';
import { addControlNetAutoProcessListener } from 'app/store/middleware/listenerMiddleware/listeners/controlNetAutoProcess';
import { addControlNetImageProcessedListener } from 'app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed';
import { addEnqueueRequestedCanvasListener } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedCanvas';
@@ -157,3 +158,5 @@ addUpscaleRequestedListener(startAppListening);
addDynamicPromptsListener(startAppListening);
addSetDefaultSettingsListener(startAppListening);
addControlLayersToControlAdapterBridge(startAppListening);

View File

@@ -48,12 +48,10 @@ export const addCanvasImageToControlNetListener = (startAppListening: AppStartLi
})
).unwrap();
const { image_name } = imageDTO;
dispatch(
controlAdapterImageChanged({
id,
controlImage: image_name,
controlImage: imageDTO,
})
);
},

View File

@@ -58,12 +58,10 @@ export const addCanvasMaskToControlNetListener = (startAppListening: AppStartLis
})
).unwrap();
const { image_name } = imageDTO;
dispatch(
controlAdapterImageChanged({
id,
controlImage: image_name,
controlImage: imageDTO,
})
);
},

View File

@@ -0,0 +1,144 @@
import { createAction } from '@reduxjs/toolkit';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { CONTROLNET_PROCESSORS } from 'features/controlAdapters/store/constants';
import { controlAdapterAdded, controlAdapterRemoved } from 'features/controlAdapters/store/controlAdaptersSlice';
import type { ControlNetConfig, IPAdapterConfig } from 'features/controlAdapters/store/types';
import { isControlAdapterProcessorType } from 'features/controlAdapters/store/types';
import {
controlAdapterLayerAdded,
ipAdapterLayerAdded,
layerDeleted,
maskLayerIPAdapterAdded,
maskLayerIPAdapterDeleted,
regionalGuidanceLayerAdded,
} from 'features/controlLayers/store/controlLayersSlice';
import type { Layer } from 'features/controlLayers/store/types';
import { modelConfigsAdapterSelectors, modelsApi } from 'services/api/endpoints/models';
import { isControlNetModelConfig, isIPAdapterModelConfig } from 'services/api/types';
import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid';
export const guidanceLayerAdded = createAction<Layer['type']>('controlLayers/guidanceLayerAdded');
export const guidanceLayerDeleted = createAction<string>('controlLayers/guidanceLayerDeleted');
export const allLayersDeleted = createAction('controlLayers/allLayersDeleted');
export const guidanceLayerIPAdapterAdded = createAction<string>('controlLayers/guidanceLayerIPAdapterAdded');
export const guidanceLayerIPAdapterDeleted = createAction<{ layerId: string; ipAdapterId: string }>(
'controlLayers/guidanceLayerIPAdapterDeleted'
);
export const addControlLayersToControlAdapterBridge = (startAppListening: AppStartListening) => {
startAppListening({
actionCreator: guidanceLayerAdded,
effect: (action, { dispatch, getState }) => {
const type = action.payload;
const layerId = uuidv4();
if (type === 'regional_guidance_layer') {
dispatch(regionalGuidanceLayerAdded({ layerId }));
return;
}
const state = getState();
const baseModel = state.generation.model?.base;
const modelConfigs = modelsApi.endpoints.getModelConfigs.select(undefined)(state).data;
if (type === 'ip_adapter_layer') {
const ipAdapterId = uuidv4();
const overrides: Partial<IPAdapterConfig> = {
id: ipAdapterId,
};
// Find and select the first matching model
if (modelConfigs) {
const models = modelConfigsAdapterSelectors.selectAll(modelConfigs).filter(isIPAdapterModelConfig);
overrides.model = models.find((m) => m.base === baseModel) ?? null;
}
dispatch(controlAdapterAdded({ type: 'ip_adapter', overrides }));
dispatch(ipAdapterLayerAdded({ layerId, ipAdapterId }));
return;
}
if (type === 'control_adapter_layer') {
const controlNetId = uuidv4();
const overrides: Partial<ControlNetConfig> = {
id: controlNetId,
};
// Find and select the first matching model
if (modelConfigs) {
const models = modelConfigsAdapterSelectors.selectAll(modelConfigs).filter(isControlNetModelConfig);
const model = models.find((m) => m.base === baseModel) ?? null;
overrides.model = model;
const defaultPreprocessor = model?.default_settings?.preprocessor;
overrides.processorType = isControlAdapterProcessorType(defaultPreprocessor) ? defaultPreprocessor : 'none';
overrides.processorNode = CONTROLNET_PROCESSORS[overrides.processorType].buildDefaults(baseModel);
}
dispatch(controlAdapterAdded({ type: 'controlnet', overrides }));
dispatch(controlAdapterLayerAdded({ layerId, controlNetId }));
return;
}
},
});
startAppListening({
actionCreator: guidanceLayerDeleted,
effect: (action, { getState, dispatch }) => {
const layerId = action.payload;
const state = getState();
const layer = state.controlLayers.present.layers.find((l) => l.id === layerId);
assert(layer, `Layer ${layerId} not found`);
if (layer.type === 'ip_adapter_layer') {
dispatch(controlAdapterRemoved({ id: layer.ipAdapterId }));
} else if (layer.type === 'control_adapter_layer') {
dispatch(controlAdapterRemoved({ id: layer.controlNetId }));
} else if (layer.type === 'regional_guidance_layer') {
for (const ipAdapterId of layer.ipAdapterIds) {
dispatch(controlAdapterRemoved({ id: ipAdapterId }));
}
}
dispatch(layerDeleted(layerId));
},
});
startAppListening({
actionCreator: allLayersDeleted,
effect: (action, { dispatch, getOriginalState }) => {
const state = getOriginalState();
for (const layer of state.controlLayers.present.layers) {
dispatch(guidanceLayerDeleted(layer.id));
}
},
});
startAppListening({
actionCreator: guidanceLayerIPAdapterAdded,
effect: (action, { dispatch, getState }) => {
const layerId = action.payload;
const ipAdapterId = uuidv4();
const overrides: Partial<IPAdapterConfig> = {
id: ipAdapterId,
};
// Find and select the first matching model
const state = getState();
const baseModel = state.generation.model?.base;
const modelConfigs = modelsApi.endpoints.getModelConfigs.select(undefined)(state).data;
if (modelConfigs) {
const models = modelConfigsAdapterSelectors.selectAll(modelConfigs).filter(isIPAdapterModelConfig);
overrides.model = models.find((m) => m.base === baseModel) ?? null;
}
dispatch(controlAdapterAdded({ type: 'ip_adapter', overrides }));
dispatch(maskLayerIPAdapterAdded({ layerId, ipAdapterId }));
},
});
startAppListening({
actionCreator: guidanceLayerIPAdapterDeleted,
effect: (action, { dispatch }) => {
const { layerId, ipAdapterId } = action.payload;
dispatch(controlAdapterRemoved({ id: ipAdapterId }));
dispatch(maskLayerIPAdapterDeleted({ layerId, ipAdapterId }));
},
});
};

View File

@@ -12,6 +12,7 @@ import {
selectControlAdapterById,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
import { isEqual } from 'lodash-es';
type AnyControlAdapterParamChangeAction =
| ReturnType<typeof controlAdapterProcessorParamsChanged>
@@ -52,6 +53,11 @@ const predicate: AnyListenerPredicate<RootState> = (action, state, prevState) =>
return false;
}
if (prevCA.controlImage === ca.controlImage && isEqual(prevCA.processorNode, ca.processorNode)) {
// Don't re-process if the processor hasn't changed
return false;
}
const isProcessorSelected = processorType !== 'none';
const hasControlImage = Boolean(controlImage);

View File

@@ -91,7 +91,7 @@ export const addControlNetImageProcessedListener = (startAppListening: AppStartL
dispatch(
controlAdapterProcessedImageChanged({
id,
processedControlImage: processedControlImage.image_name,
processedControlImage,
})
);
}

View File

@@ -71,7 +71,7 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
dispatch(
controlAdapterImageChanged({
id,
controlImage: activeData.payload.imageDTO.image_name,
controlImage: activeData.payload.imageDTO,
})
);
dispatch(

View File

@@ -96,7 +96,7 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis
dispatch(
controlAdapterImageChanged({
id,
controlImage: imageDTO.image_name,
controlImage: imageDTO,
})
);
dispatch(

View File

@@ -1,7 +1,7 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import {
controlAdapterIsEnabledChanged,
controlAdapterModelChanged,
selectControlAdapterAll,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import { loraRemoved } from 'features/lora/store/loraSlice';
@@ -54,7 +54,7 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
// handle incompatible controlnets
selectControlAdapterAll(state.controlAdapters).forEach((ca) => {
if (ca.model?.base !== newBaseModel) {
dispatch(controlAdapterIsEnabledChanged({ id: ca.id, isEnabled: false }));
dispatch(controlAdapterModelChanged({ id: ca.id, modelConfig: null }));
modelsCleared += 1;
}
});

View File

@@ -6,9 +6,10 @@ import {
controlAdapterModelCleared,
selectControlAdapterAll,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import { heightChanged, widthChanged } from 'features/controlLayers/store/controlLayersSlice';
import { loraRemoved } from 'features/lora/store/loraSlice';
import { calculateNewSize } from 'features/parameters/components/ImageSize/calculateNewSize';
import { heightChanged, modelChanged, vaeSelected, widthChanged } from 'features/parameters/store/generationSlice';
import { modelChanged, vaeSelected } from 'features/parameters/store/generationSlice';
import { zParameterModel, zParameterVAEModel } from 'features/parameters/types/parameterSchemas';
import { getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension';
import { refinerModelChanged } from 'features/sdxl/store/sdxlSlice';
@@ -69,16 +70,22 @@ const handleMainModels: ModelHandler = (models, state, dispatch, log) => {
dispatch(modelChanged(defaultModelInList, currentModel));
const optimalDimension = getOptimalDimension(defaultModelInList);
if (getIsSizeOptimal(state.generation.width, state.generation.height, optimalDimension)) {
if (
getIsSizeOptimal(
state.controlLayers.present.size.width,
state.controlLayers.present.size.height,
optimalDimension
)
) {
return;
}
const { width, height } = calculateNewSize(
state.generation.aspectRatio.value,
state.controlLayers.present.size.aspectRatio.value,
optimalDimension * optimalDimension
);
dispatch(widthChanged(width));
dispatch(heightChanged(height));
dispatch(widthChanged({ width }));
dispatch(heightChanged({ height }));
return;
}
}

View File

@@ -1,5 +1,6 @@
import { isAnyOf } from '@reduxjs/toolkit';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { positivePromptChanged } from 'features/controlLayers/store/controlLayersSlice';
import {
combinatorialToggled,
isErrorChanged,
@@ -10,11 +11,16 @@ import {
promptsChanged,
} from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
import { setPositivePrompt } from 'features/parameters/store/generationSlice';
import { utilitiesApi } from 'services/api/endpoints/utilities';
import { socketConnected } from 'services/events/actions';
const matcher = isAnyOf(setPositivePrompt, combinatorialToggled, maxPromptsChanged, maxPromptsReset, socketConnected);
const matcher = isAnyOf(
positivePromptChanged,
combinatorialToggled,
maxPromptsChanged,
maxPromptsReset,
socketConnected
);
export const addDynamicPromptsListener = (startAppListening: AppStartListening) => {
startAppListening({
@@ -22,7 +28,7 @@ export const addDynamicPromptsListener = (startAppListening: AppStartListening)
effect: async (action, { dispatch, getState, cancelActiveListeners, delay }) => {
cancelActiveListeners();
const state = getState();
const { positivePrompt } = state.generation;
const { positivePrompt } = state.controlLayers.present;
const { maxPrompts } = state.dynamicPrompts;
if (state.config.disabledFeatures.includes('dynamicPrompting')) {
@@ -32,7 +38,7 @@ export const addDynamicPromptsListener = (startAppListening: AppStartListening)
const cachedPrompts = utilitiesApi.endpoints.dynamicPrompts.select({
prompt: positivePrompt,
max_prompts: maxPrompts,
})(getState()).data;
})(state).data;
if (cachedPrompts) {
dispatch(promptsChanged(cachedPrompts.prompts));
@@ -40,8 +46,8 @@ export const addDynamicPromptsListener = (startAppListening: AppStartListening)
return;
}
if (!getShouldProcessPrompt(state.generation.positivePrompt)) {
dispatch(promptsChanged([state.generation.positivePrompt]));
if (!getShouldProcessPrompt(positivePrompt)) {
dispatch(promptsChanged([positivePrompt]));
dispatch(parsingErrorChanged(undefined));
dispatch(isErrorChanged(false));
return;

View File

@@ -1,14 +1,13 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { heightChanged, widthChanged } from 'features/controlLayers/store/controlLayersSlice';
import { setDefaultSettings } from 'features/parameters/store/actions';
import {
heightRecalled,
setCfgRescaleMultiplier,
setCfgScale,
setScheduler,
setSteps,
vaePrecisionChanged,
vaeSelected,
widthRecalled,
} from 'features/parameters/store/generationSlice';
import {
isParameterCFGRescaleMultiplier,
@@ -100,13 +99,13 @@ export const addSetDefaultSettingsListener = (startAppListening: AppStartListeni
if (width) {
if (isParameterWidth(width)) {
dispatch(widthRecalled(width));
dispatch(widthChanged({ width, updateAspectRatio: true }));
}
}
if (height) {
if (isParameterHeight(height)) {
dispatch(heightRecalled(height));
dispatch(heightChanged({ height, updateAspectRatio: true }));
}
}

View File

@@ -10,6 +10,11 @@ import {
controlAdaptersPersistConfig,
controlAdaptersSlice,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import {
controlLayersPersistConfig,
controlLayersSlice,
controlLayersUndoableConfig,
} from 'features/controlLayers/store/controlLayersSlice';
import { deleteImageModalSlice } from 'features/deleteImageModal/store/slice';
import { dynamicPromptsPersistConfig, dynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { galleryPersistConfig, gallerySlice } from 'features/gallery/store/gallerySlice';
@@ -21,11 +26,6 @@ import { workflowPersistConfig, workflowSlice } from 'features/nodes/store/workf
import { generationPersistConfig, generationSlice } from 'features/parameters/store/generationSlice';
import { postprocessingPersistConfig, postprocessingSlice } from 'features/parameters/store/postprocessingSlice';
import { queueSlice } from 'features/queue/store/queueSlice';
import {
regionalPromptsPersistConfig,
regionalPromptsSlice,
regionalPromptsUndoableConfig,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
import { sdxlPersistConfig, sdxlSlice } from 'features/sdxl/store/sdxlSlice';
import { configSlice } from 'features/system/store/configSlice';
import { systemPersistConfig, systemSlice } from 'features/system/store/systemSlice';
@@ -65,7 +65,7 @@ const allReducers = {
[queueSlice.name]: queueSlice.reducer,
[workflowSlice.name]: workflowSlice.reducer,
[hrfSlice.name]: hrfSlice.reducer,
[regionalPromptsSlice.name]: undoable(regionalPromptsSlice.reducer, regionalPromptsUndoableConfig),
[controlLayersSlice.name]: undoable(controlLayersSlice.reducer, controlLayersUndoableConfig),
[api.reducerPath]: api.reducer,
};
@@ -110,7 +110,7 @@ const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = {
[loraPersistConfig.name]: loraPersistConfig,
[modelManagerV2PersistConfig.name]: modelManagerV2PersistConfig,
[hrfPersistConfig.name]: hrfPersistConfig,
[regionalPromptsPersistConfig.name]: regionalPromptsPersistConfig,
[controlLayersPersistConfig.name]: controlLayersPersistConfig,
};
const unserialize: UnserializeFunction = (data, key) => {

View File

@@ -5,6 +5,7 @@ import {
selectControlAdaptersSlice,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
import { selectDynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
import { selectNodesSlice } from 'features/nodes/store/nodesSlice';
@@ -23,10 +24,12 @@ const selector = createMemoizedSelector(
selectSystemSlice,
selectNodesSlice,
selectDynamicPromptsSlice,
selectControlLayersSlice,
activeTabNameSelector,
],
(controlAdapters, generation, system, nodes, dynamicPrompts, activeTabName) => {
const { initialImage, model, positivePrompt } = generation;
(controlAdapters, generation, system, nodes, dynamicPrompts, controlLayers, activeTabName) => {
const { initialImage, model } = generation;
const { positivePrompt } = controlLayers.present;
const { isConnected } = system;
@@ -94,7 +97,41 @@ const selector = createMemoizedSelector(
reasons.push(i18n.t('parameters.invoke.noModelSelected'));
}
selectControlAdapterAll(controlAdapters).forEach((ca, i) => {
let enabledControlAdapters = selectControlAdapterAll(controlAdapters).filter((ca) => ca.isEnabled);
if (activeTabName === 'txt2img') {
// Special handling for control layers on txt2img
const enabledControlLayersAdapterIds = controlLayers.present.layers
.filter((l) => l.isEnabled)
.flatMap((layer) => {
if (layer.type === 'regional_guidance_layer') {
return layer.ipAdapterIds;
}
if (layer.type === 'control_adapter_layer') {
return [layer.controlNetId];
}
if (layer.type === 'ip_adapter_layer') {
return [layer.ipAdapterId];
}
});
enabledControlAdapters = enabledControlAdapters.filter((ca) => enabledControlLayersAdapterIds.includes(ca.id));
} else {
const allControlLayerAdapterIds = controlLayers.present.layers.flatMap((layer) => {
if (layer.type === 'regional_guidance_layer') {
return layer.ipAdapterIds;
}
if (layer.type === 'control_adapter_layer') {
return [layer.controlNetId];
}
if (layer.type === 'ip_adapter_layer') {
return [layer.ipAdapterId];
}
});
enabledControlAdapters = enabledControlAdapters.filter((ca) => !allControlLayerAdapterIds.includes(ca.id));
}
enabledControlAdapters.forEach((ca, i) => {
if (!ca.isEnabled) {
return;
}

View File

@@ -0,0 +1,3 @@
export const stopPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
};

View File

@@ -113,7 +113,7 @@ const ControlAdapterConfig = (props: { id: string; number: number }) => {
<Flex w="full" flexDir="column" gap={4}>
<Flex gap={8} w="full" alignItems="center">
<Flex flexDir="column" gap={4} h={controlAdapterType === 'ip_adapter' ? 40 : 32} w="full">
<ParamControlAdapterIPMethod id={id} />
{controlAdapterType === 'ip_adapter' && <ParamControlAdapterIPMethod id={id} />}
<ParamControlAdapterWeight id={id} />
<ParamControlAdapterBeginEnd id={id} />
</Flex>

View File

@@ -13,9 +13,10 @@ import {
controlAdapterImageChanged,
selectControlAdaptersSlice,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import { heightChanged, widthChanged } from 'features/controlLayers/store/controlLayersSlice';
import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
import { calculateNewSize } from 'features/parameters/components/ImageSize/calculateNewSize';
import { heightChanged, selectOptimalDimension, widthChanged } from 'features/parameters/store/generationSlice';
import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -99,8 +100,8 @@ const ControlAdapterImagePreview = ({ isSmall, id }: Props) => {
controlImage.width / controlImage.height,
optimalDimension * optimalDimension
);
dispatch(widthChanged(width));
dispatch(heightChanged(height));
dispatch(widthChanged({ width, updateAspectRatio: true }));
dispatch(heightChanged({ height, updateAspectRatio: true }));
}
}, [controlImage, activeTabName, dispatch, optimalDimension]);

View File

@@ -46,10 +46,6 @@ const ParamControlAdapterIPMethod = ({ id }: Props) => {
const value = useMemo(() => options.find((o) => o.value === method), [options, method]);
if (!method) {
return null;
}
return (
<FormControl>
<InformationalPopover feature="ipAdapterMethod">

View File

@@ -102,13 +102,9 @@ const ParamControlAdapterModel = ({ id }: ParamControlAdapterModelProps) => {
);
return (
<Flex sx={{ gap: 2 }}>
<Flex gap={4}>
<Tooltip label={selectedModel?.description}>
<FormControl
isDisabled={!isEnabled}
isInvalid={!value || mainModel?.base !== modelConfig?.base}
sx={{ width: '100%' }}
>
<FormControl isDisabled={!isEnabled} isInvalid={!value || mainModel?.base !== modelConfig?.base} w="full">
<Combobox
options={options}
placeholder={t('controlnet.selectModel')}
@@ -122,7 +118,8 @@ const ParamControlAdapterModel = ({ id }: ParamControlAdapterModelProps) => {
<FormControl
isDisabled={!isEnabled}
isInvalid={!value || mainModel?.base !== modelConfig?.base}
sx={{ width: 'max-content', minWidth: 28 }}
width="max-content"
minWidth={28}
>
<Combobox
options={clipVisionOptions}

View File

@@ -5,15 +5,15 @@ import {
selectControlAdaptersSlice,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useControlAdapterIPMethod = (id: string) => {
const selector = useMemo(
() =>
createMemoizedSelector(selectControlAdaptersSlice, (controlAdapters) => {
const cn = selectControlAdapterById(controlAdapters, id);
if (cn && cn?.type === 'ip_adapter') {
return cn.method;
}
const ca = selectControlAdapterById(controlAdapters, id);
assert(ca?.type === 'ip_adapter');
return ca.method;
}),
[id]
);

View File

@@ -6,9 +6,8 @@ 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 type { ControlNetModelConfig, ImageDTO, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types';
import { socketInvocationError } from 'services/events/actions';
import { v4 as uuidv4 } from 'uuid';
@@ -135,23 +134,46 @@ export const controlAdaptersSlice = createSlice({
const { id, isEnabled } = action.payload;
caAdapter.updateOne(state, { id, changes: { isEnabled } });
},
controlAdapterImageChanged: (
state,
action: PayloadAction<{
id: string;
controlImage: string | null;
}>
) => {
controlAdapterImageChanged: (state, action: PayloadAction<{ id: string; controlImage: ImageDTO | null }>) => {
const { id, controlImage } = action.payload;
const ca = selectControlAdapterById(state, id);
if (!ca) {
return;
}
caAdapter.updateOne(state, {
id,
changes: { controlImage, processedControlImage: null },
});
if (isControlNetOrT2IAdapter(ca)) {
if (controlImage) {
const { image_name, width, height } = controlImage;
const processorNode = deepClone(ca.processorNode);
const minDim = Math.min(controlImage.width, controlImage.height);
if ('detect_resolution' in processorNode) {
processorNode.detect_resolution = minDim;
}
if ('image_resolution' in processorNode) {
processorNode.image_resolution = minDim;
}
if ('resolution' in processorNode) {
processorNode.resolution = minDim;
}
caAdapter.updateOne(state, {
id,
changes: {
processorNode,
controlImage: image_name,
controlImageDimensions: { width, height },
processedControlImage: null,
},
});
} else {
caAdapter.updateOne(state, {
id,
changes: { controlImage: null, controlImageDimensions: null, processedControlImage: null },
});
}
} else {
// ip adapter
caAdapter.updateOne(state, { id, changes: { controlImage: controlImage?.image_name ?? null } });
}
if (controlImage !== null && isControlNetOrT2IAdapter(ca) && ca.processorType !== 'none') {
state.pendingControlImages.push(id);
@@ -161,7 +183,7 @@ export const controlAdaptersSlice = createSlice({
state,
action: PayloadAction<{
id: string;
processedControlImage: string | null;
processedControlImage: ImageDTO | null;
}>
) => {
const { id, processedControlImage } = action.payload;
@@ -174,12 +196,24 @@ export const controlAdaptersSlice = createSlice({
return;
}
caAdapter.updateOne(state, {
id,
changes: {
processedControlImage,
},
});
if (processedControlImage) {
const { image_name, width, height } = processedControlImage;
caAdapter.updateOne(state, {
id,
changes: {
processedControlImage: image_name,
processedControlImageDimensions: { width, height },
},
});
} else {
caAdapter.updateOne(state, {
id,
changes: {
processedControlImage: null,
processedControlImageDimensions: null,
},
});
}
state.pendingControlImages = state.pendingControlImages.filter((pendingId) => pendingId !== id);
},
@@ -193,7 +227,7 @@ export const controlAdaptersSlice = createSlice({
state,
action: PayloadAction<{
id: string;
modelConfig: ControlNetModelConfig | T2IAdapterModelConfig | IPAdapterModelConfig;
modelConfig: ControlNetModelConfig | T2IAdapterModelConfig | IPAdapterModelConfig | null;
}>
) => {
const { id, modelConfig } = action.payload;
@@ -202,6 +236,11 @@ export const controlAdaptersSlice = createSlice({
return;
}
if (modelConfig === null) {
caAdapter.updateOne(state, { id, changes: { model: null } });
return;
}
const model = zModelIdentifierField.parse(modelConfig);
if (!isControlNetOrT2IAdapter(cn)) {
@@ -209,22 +248,36 @@ export const controlAdaptersSlice = createSlice({
return;
}
const update: Update<ControlNetConfig | T2IAdapterConfig, string> = {
id,
changes: { model, shouldAutoConfig: true },
};
update.changes.processedControlImage = null;
if (modelConfig.type === 'ip_adapter') {
// should never happen...
return;
}
const processor = buildControlAdapterProcessor(modelConfig);
update.changes.processorType = processor.processorType;
update.changes.processorNode = processor.processorNode;
// We always update the model
const update: Update<ControlNetConfig | T2IAdapterConfig, string> = { id, changes: { model } };
// Build the default processor for this model
const processor = buildControlAdapterProcessor(modelConfig);
if (processor.processorType !== cn.processorNode.type) {
// If the processor type has changed, update the processor node
update.changes.shouldAutoConfig = true;
update.changes.processedControlImage = null;
update.changes.processorType = processor.processorType;
update.changes.processorNode = processor.processorNode;
if (cn.controlImageDimensions) {
const minDim = Math.min(cn.controlImageDimensions.width, cn.controlImageDimensions.height);
if ('detect_resolution' in update.changes.processorNode) {
update.changes.processorNode.detect_resolution = minDim;
}
if ('image_resolution' in update.changes.processorNode) {
update.changes.processorNode.image_resolution = minDim;
}
if ('resolution' in update.changes.processorNode) {
update.changes.processorNode.resolution = minDim;
}
}
}
caAdapter.updateOne(state, update);
},
controlAdapterWeightChanged: (state, action: PayloadAction<{ id: string; weight: number }>) => {
@@ -341,8 +394,23 @@ export const controlAdaptersSlice = createSlice({
if (update.changes.shouldAutoConfig && modelConfig) {
const processor = buildControlAdapterProcessor(modelConfig);
update.changes.processorType = processor.processorType;
update.changes.processorNode = processor.processorNode;
if (processor.processorType !== cn.processorNode.type) {
update.changes.processorType = processor.processorType;
update.changes.processorNode = processor.processorNode;
// Copy image resolution settings, urgh
if (cn.controlImageDimensions) {
const minDim = Math.min(cn.controlImageDimensions.width, cn.controlImageDimensions.height);
if ('detect_resolution' in update.changes.processorNode) {
update.changes.processorNode.detect_resolution = minDim;
}
if ('image_resolution' in update.changes.processorNode) {
update.changes.processorNode.image_resolution = minDim;
}
if ('resolution' in update.changes.processorNode) {
update.changes.processorNode.resolution = minDim;
}
}
}
}
caAdapter.updateOne(state, update);
@@ -383,10 +451,6 @@ export const controlAdaptersSlice = createSlice({
builder.addCase(socketInvocationError, (state) => {
state.pendingControlImages = [];
});
builder.addCase(maskLayerIPAdapterAdded, (state, action) => {
caAdapter.addOne(state, buildControlAdapter(action.meta.uuid, 'ip_adapter'));
});
},
});

View File

@@ -225,7 +225,9 @@ export type ControlNetConfig = {
controlMode: ControlMode;
resizeMode: ResizeMode;
controlImage: string | null;
controlImageDimensions: { width: number; height: number } | null;
processedControlImage: string | null;
processedControlImageDimensions: { width: number; height: number } | null;
processorType: ControlAdapterProcessorType;
processorNode: RequiredControlAdapterProcessorNode;
shouldAutoConfig: boolean;
@@ -241,7 +243,9 @@ export type T2IAdapterConfig = {
endStepPct: number;
resizeMode: ResizeMode;
controlImage: string | null;
controlImageDimensions: { width: number; height: number } | null;
processedControlImage: string | null;
processedControlImageDimensions: { width: number; height: number } | null;
processorType: ControlAdapterProcessorType;
processorNode: RequiredControlAdapterProcessorNode;
shouldAutoConfig: boolean;

View File

@@ -20,7 +20,9 @@ export const initialControlNet: Omit<ControlNetConfig, 'id'> = {
controlMode: 'balanced',
resizeMode: 'just_resize',
controlImage: null,
controlImageDimensions: null,
processedControlImage: null,
processedControlImageDimensions: null,
processorType: 'canny_image_processor',
processorNode: CONTROLNET_PROCESSORS.canny_image_processor.buildDefaults() as RequiredCannyImageProcessorInvocation,
shouldAutoConfig: true,
@@ -35,7 +37,9 @@ export const initialT2IAdapter: Omit<T2IAdapterConfig, 'id'> = {
endStepPct: 1,
resizeMode: 'just_resize',
controlImage: null,
controlImageDimensions: null,
processedControlImage: null,
processedControlImageDimensions: null,
processorType: 'canny_image_processor',
processorNode: CONTROLNET_PROCESSORS.canny_image_processor.buildDefaults() as RequiredCannyImageProcessorInvocation,
shouldAutoConfig: true,

View File

@@ -0,0 +1,41 @@
import { Button, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { guidanceLayerAdded } from 'app/store/middleware/listenerMiddleware/listeners/controlLayersToControlAdapterBridge';
import { useAppDispatch } from 'app/store/storeHooks';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
export const AddLayerButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const addRegionalGuidanceLayer = useCallback(() => {
dispatch(guidanceLayerAdded('regional_guidance_layer'));
}, [dispatch]);
const addControlAdapterLayer = useCallback(() => {
dispatch(guidanceLayerAdded('control_adapter_layer'));
}, [dispatch]);
const addIPAdapterLayer = useCallback(() => {
dispatch(guidanceLayerAdded('ip_adapter_layer'));
}, [dispatch]);
return (
<Menu>
<MenuButton as={Button} leftIcon={<PiPlusBold />} variant="ghost">
{t('controlLayers.addLayer')}
</MenuButton>
<MenuList>
<MenuItem icon={<PiPlusBold />} onClick={addRegionalGuidanceLayer}>
{t('controlLayers.regionalGuidanceLayer')}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addControlAdapterLayer}>
{t('controlLayers.globalControlAdapterLayer')}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addIPAdapterLayer}>
{t('controlLayers.globalIPAdapterLayer')}
</MenuItem>
</MenuList>
</Menu>
);
});
AddLayerButton.displayName = 'AddLayerButton';

View File

@@ -1,13 +1,13 @@
import { Button, Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { guidanceLayerIPAdapterAdded } from 'app/store/middleware/listenerMiddleware/listeners/controlLayersToControlAdapterBridge';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
isVectorMaskLayer,
maskLayerIPAdapterAdded,
isRegionalGuidanceLayer,
maskLayerNegativePromptChanged,
maskLayerPositivePromptChanged,
selectRegionalPromptsSlice,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
@@ -21,9 +21,9 @@ export const AddPromptButtons = ({ layerId }: AddPromptButtonProps) => {
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`);
createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(isRegionalGuidanceLayer(layer), `Layer ${layerId} not found or not an RP layer`);
return {
canAddPositivePrompt: layer.positivePrompt === null,
canAddNegativePrompt: layer.negativePrompt === null,
@@ -39,7 +39,7 @@ export const AddPromptButtons = ({ layerId }: AddPromptButtonProps) => {
dispatch(maskLayerNegativePromptChanged({ layerId, prompt: '' }));
}, [dispatch, layerId]);
const addIPAdapter = useCallback(() => {
dispatch(maskLayerIPAdapterAdded(layerId));
dispatch(guidanceLayerIPAdapterAdded(layerId));
}, [dispatch, layerId]);
return (

View File

@@ -10,7 +10,7 @@ import {
PopoverTrigger,
} from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { brushSizeChanged, initialRegionalPromptsState } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { brushSizeChanged, initialControlLayersState } from 'features/controlLayers/store/controlLayersSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -20,22 +20,22 @@ const formatPx = (v: number | string) => `${v} px`;
export const BrushSize = memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const brushSize = useAppSelector((s) => s.regionalPrompts.present.brushSize);
const brushSize = useAppSelector((s) => s.controlLayers.present.brushSize);
const onChange = useCallback(
(v: number) => {
dispatch(brushSizeChanged(v));
dispatch(brushSizeChanged(Math.round(v)));
},
[dispatch]
);
return (
<FormControl w="min-content">
<FormLabel m={0}>{t('regionalPrompts.brushSize')}</FormLabel>
<FormLabel m={0}>{t('controlLayers.brushSize')}</FormLabel>
<Popover isLazy>
<PopoverTrigger>
<CompositeNumberInput
min={1}
max={600}
defaultValue={initialRegionalPromptsState.brushSize}
defaultValue={initialControlLayersState.brushSize}
value={brushSize}
onChange={onChange}
w={24}
@@ -48,7 +48,7 @@ export const BrushSize = memo(() => {
<CompositeSlider
min={1}
max={300}
defaultValue={initialRegionalPromptsState.brushSize}
defaultValue={initialControlLayersState.brushSize}
value={brushSize}
onChange={onChange}
marks={marks}

View File

@@ -0,0 +1,71 @@
import { Flex, Spacer, useDisclosure } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import CALayerOpacity from 'features/controlLayers/components/CALayerOpacity';
import ControlAdapterLayerConfig from 'features/controlLayers/components/controlAdapterOverrides/ControlAdapterLayerConfig';
import { LayerDeleteButton } from 'features/controlLayers/components/LayerDeleteButton';
import { LayerMenu } from 'features/controlLayers/components/LayerMenu';
import { LayerTitle } from 'features/controlLayers/components/LayerTitle';
import { LayerVisibilityToggle } from 'features/controlLayers/components/LayerVisibilityToggle';
import {
isControlAdapterLayer,
layerSelected,
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
import { memo, useCallback, useMemo } from 'react';
import { assert } from 'tsafe';
type Props = {
layerId: string;
};
export const CALayerListItem = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch();
const selector = useMemo(
() =>
createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(isControlAdapterLayer(layer), `Layer ${layerId} not found or not a ControlNet layer`);
return {
controlNetId: layer.controlNetId,
isSelected: layerId === controlLayers.present.selectedLayerId,
};
}),
[layerId]
);
const { controlNetId, isSelected } = useAppSelector(selector);
const onClickCapture = useCallback(() => {
// Must be capture so that the layer is selected before deleting/resetting/etc
dispatch(layerSelected(layerId));
}, [dispatch, layerId]);
const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true });
return (
<Flex
gap={2}
onClickCapture={onClickCapture}
bg={isSelected ? 'base.400' : 'base.800'}
px={2}
borderRadius="base"
py="1px"
>
<Flex flexDir="column" w="full" bg="base.850" borderRadius="base">
<Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}>
<LayerVisibilityToggle layerId={layerId} />
<LayerTitle type="control_adapter_layer" />
<Spacer />
<CALayerOpacity layerId={layerId} />
<LayerMenu layerId={layerId} />
<LayerDeleteButton layerId={layerId} />
</Flex>
{isOpen && (
<Flex flexDir="column" gap={3} px={3} pb={3}>
<ControlAdapterLayerConfig id={controlNetId} />
</Flex>
)}
</Flex>
</Flex>
);
});
CALayerListItem.displayName = 'CALayerListItem';

View File

@@ -0,0 +1,98 @@
import {
CompositeNumberInput,
CompositeSlider,
Flex,
FormControl,
FormLabel,
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Switch,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { stopPropagation } from 'common/util/stopPropagation';
import { useLayerOpacity } from 'features/controlLayers/hooks/layerStateHooks';
import { isFilterEnabledChanged, layerOpacityChanged } from 'features/controlLayers/store/controlLayersSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDropHalfFill } from 'react-icons/pi';
type Props = {
layerId: string;
};
const marks = [0, 25, 50, 75, 100];
const formatPct = (v: number | string) => `${v} %`;
const CALayerOpacity = ({ layerId }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { opacity, isFilterEnabled } = useLayerOpacity(layerId);
const onChangeOpacity = useCallback(
(v: number) => {
dispatch(layerOpacityChanged({ layerId, opacity: v / 100 }));
},
[dispatch, layerId]
);
const onChangeFilter = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(isFilterEnabledChanged({ layerId, isFilterEnabled: e.target.checked }));
},
[dispatch, layerId]
);
return (
<Popover isLazy>
<PopoverTrigger>
<IconButton
aria-label={t('controlLayers.opacity')}
size="sm"
icon={<PiDropHalfFill size={16} />}
variant="ghost"
onDoubleClick={stopPropagation}
/>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverBody>
<Flex direction="column" gap={2}>
<FormControl orientation="horizontal" w="full">
<FormLabel m={0} flexGrow={1} cursor="pointer">
{t('controlLayers.opacityFilter')}
</FormLabel>
<Switch isChecked={isFilterEnabled} onChange={onChangeFilter} />
</FormControl>
<FormControl orientation="horizontal">
<FormLabel m={0}>{t('controlLayers.opacity')}</FormLabel>
<CompositeSlider
min={0}
max={100}
step={1}
value={opacity}
defaultValue={100}
onChange={onChangeOpacity}
marks={marks}
w={48}
/>
<CompositeNumberInput
min={0}
max={100}
step={1}
value={opacity}
defaultValue={100}
onChange={onChangeOpacity}
w={24}
format={formatPct}
/>
</FormControl>
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default memo(CALayerOpacity);

View File

@@ -0,0 +1,24 @@
import { Flex } from '@invoke-ai/ui-library';
import type { Meta, StoryObj } from '@storybook/react';
import { ControlLayersEditor } from 'features/controlLayers/components/ControlLayersEditor';
const meta: Meta<typeof ControlLayersEditor> = {
title: 'Feature/ControlLayers',
tags: ['autodocs'],
component: ControlLayersEditor,
};
export default meta;
type Story = StoryObj<typeof ControlLayersEditor>;
const Component = () => {
return (
<Flex w={1500} h={1500}>
<ControlLayersEditor />
</Flex>
);
};
export const Default: Story = {
render: Component,
};

View File

@@ -1,10 +1,10 @@
/* eslint-disable i18next/no-literal-string */
import { Flex } from '@invoke-ai/ui-library';
import { RegionalPromptsToolbar } from 'features/regionalPrompts/components/RegionalPromptsToolbar';
import { StageComponent } from 'features/regionalPrompts/components/StageComponent';
import { ControlLayersToolbar } from 'features/controlLayers/components/ControlLayersToolbar';
import { StageComponent } from 'features/controlLayers/components/StageComponent';
import { memo } from 'react';
export const RegionalPromptsEditor = memo(() => {
export const ControlLayersEditor = memo(() => {
return (
<Flex
position="relative"
@@ -15,10 +15,10 @@ export const RegionalPromptsEditor = memo(() => {
alignItems="center"
justifyContent="center"
>
<RegionalPromptsToolbar />
<ControlLayersToolbar />
<StageComponent />
</Flex>
);
});
RegionalPromptsEditor.displayName = 'RegionalPromptsEditor';
ControlLayersEditor.displayName = 'ControlLayersEditor';

View File

@@ -0,0 +1,59 @@
/* eslint-disable i18next/no-literal-string */
import { Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { AddLayerButton } from 'features/controlLayers/components/AddLayerButton';
import { CALayerListItem } from 'features/controlLayers/components/CALayerListItem';
import { DeleteAllLayersButton } from 'features/controlLayers/components/DeleteAllLayersButton';
import { IPLayerListItem } from 'features/controlLayers/components/IPLayerListItem';
import { RGLayerListItem } from 'features/controlLayers/components/RGLayerListItem';
import { isRenderableLayer, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
import type { Layer } from 'features/controlLayers/store/types';
import { partition } from 'lodash-es';
import { memo } from 'react';
const selectLayerIdTypePairs = createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
const [renderableLayers, ipAdapterLayers] = partition(controlLayers.present.layers, isRenderableLayer);
return [...ipAdapterLayers, ...renderableLayers].map((l) => ({ id: l.id, type: l.type })).reverse();
});
export const ControlLayersPanelContent = memo(() => {
const layerIdTypePairs = useAppSelector(selectLayerIdTypePairs);
return (
<Flex flexDir="column" gap={4} w="full" h="full">
<Flex justifyContent="space-around">
<AddLayerButton />
<DeleteAllLayersButton />
</Flex>
<ScrollableContent>
<Flex flexDir="column" gap={4}>
{layerIdTypePairs.map(({ id, type }) => (
<LayerWrapper key={id} id={id} type={type} />
))}
</Flex>
</ScrollableContent>
</Flex>
);
});
ControlLayersPanelContent.displayName = 'ControlLayersPanelContent';
type LayerWrapperProps = {
id: string;
type: Layer['type'];
};
const LayerWrapper = memo(({ id, type }: LayerWrapperProps) => {
if (type === 'regional_guidance_layer') {
return <RGLayerListItem key={id} layerId={id} />;
}
if (type === 'control_adapter_layer') {
return <CALayerListItem key={id} layerId={id} />;
}
if (type === 'ip_adapter_layer') {
return <IPLayerListItem key={id} layerId={id} />;
}
});
LayerWrapper.displayName = 'LayerWrapper';

View File

@@ -0,0 +1,26 @@
import { Flex, IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library';
import { GlobalMaskLayerOpacity } from 'features/controlLayers/components/GlobalMaskLayerOpacity';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { RiSettings4Fill } from 'react-icons/ri';
const ControlLayersSettingsPopover = () => {
const { t } = useTranslation();
return (
<Popover isLazy>
<PopoverTrigger>
<IconButton aria-label={t('common.settingsLabel')} icon={<RiSettings4Fill />} />
</PopoverTrigger>
<PopoverContent>
<PopoverBody>
<Flex direction="column" gap={2}>
<GlobalMaskLayerOpacity />
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default memo(ControlLayersSettingsPopover);

View File

@@ -0,0 +1,20 @@
/* eslint-disable i18next/no-literal-string */
import { Flex } from '@invoke-ai/ui-library';
import { BrushSize } from 'features/controlLayers/components/BrushSize';
import ControlLayersSettingsPopover from 'features/controlLayers/components/ControlLayersSettingsPopover';
import { ToolChooser } from 'features/controlLayers/components/ToolChooser';
import { UndoRedoButtonGroup } from 'features/controlLayers/components/UndoRedoButtonGroup';
import { memo } from 'react';
export const ControlLayersToolbar = memo(() => {
return (
<Flex gap={4}>
<BrushSize />
<ToolChooser />
<UndoRedoButtonGroup />
<ControlLayersSettingsPopover />
</Flex>
);
});
ControlLayersToolbar.displayName = 'ControlLayersToolbar';

View File

@@ -1,6 +1,6 @@
import { Button } from '@invoke-ai/ui-library';
import { allLayersDeleted } from 'app/store/middleware/listenerMiddleware/listeners/controlLayersToControlAdapterBridge';
import { useAppDispatch } from 'app/store/storeHooks';
import { allLayersDeleted } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
@@ -14,7 +14,7 @@ export const DeleteAllLayersButton = memo(() => {
return (
<Button onClick={onClick} leftIcon={<PiTrashSimpleBold />} variant="ghost" colorScheme="error">
{t('regionalPrompts.deleteAll')}
{t('controlLayers.deleteAll')}
</Button>
);
});

View File

@@ -0,0 +1,54 @@
import { CompositeNumberInput, CompositeSlider, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
globalMaskLayerOpacityChanged,
initialControlLayersState,
} from 'features/controlLayers/store/controlLayersSlice';
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) =>
Math.round(s.controlLayers.present.globalMaskLayerOpacity * 100)
);
const onChange = useCallback(
(v: number) => {
dispatch(globalMaskLayerOpacityChanged(v / 100));
},
[dispatch]
);
return (
<FormControl orientation="vertical">
<FormLabel m={0}>{t('controlLayers.globalMaskOpacity')}</FormLabel>
<Flex gap={4}>
<CompositeSlider
min={0}
max={100}
step={1}
value={globalMaskLayerOpacity}
defaultValue={initialControlLayersState.globalMaskLayerOpacity * 100}
onChange={onChange}
marks={marks}
minW={48}
/>
<CompositeNumberInput
min={0}
max={100}
step={1}
value={globalMaskLayerOpacity}
defaultValue={initialControlLayersState.globalMaskLayerOpacity * 100}
onChange={onChange}
w={28}
format={formatPct}
/>
</Flex>
</FormControl>
);
});
GlobalMaskLayerOpacity.displayName = 'GlobalMaskLayerOpacity';

View File

@@ -0,0 +1,47 @@
import { Flex, Spacer, useDisclosure } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import ControlAdapterLayerConfig from 'features/controlLayers/components/controlAdapterOverrides/ControlAdapterLayerConfig';
import { LayerDeleteButton } from 'features/controlLayers/components/LayerDeleteButton';
import { LayerTitle } from 'features/controlLayers/components/LayerTitle';
import { LayerVisibilityToggle } from 'features/controlLayers/components/LayerVisibilityToggle';
import { isIPAdapterLayer, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
import { memo, useMemo } from 'react';
import { assert } from 'tsafe';
type Props = {
layerId: string;
};
export const IPLayerListItem = memo(({ layerId }: Props) => {
const selector = useMemo(
() =>
createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(isIPAdapterLayer(layer), `Layer ${layerId} not found or not an IP Adapter layer`);
return layer.ipAdapterId;
}),
[layerId]
);
const ipAdapterId = useAppSelector(selector);
const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true });
return (
<Flex gap={2} bg="base.800" borderRadius="base" p="1px" px={2}>
<Flex flexDir="column" w="full" bg="base.850" borderRadius="base">
<Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}>
<LayerVisibilityToggle layerId={layerId} />
<LayerTitle type="ip_adapter_layer" />
<Spacer />
<LayerDeleteButton layerId={layerId} />
</Flex>
{isOpen && (
<Flex flexDir="column" gap={3} px={3} pb={3}>
<ControlAdapterLayerConfig id={ipAdapterId} />
</Flex>
)}
</Flex>
</Flex>
);
});
IPLayerListItem.displayName = 'IPLayerListItem';

View File

@@ -1,17 +1,18 @@
import { IconButton } from '@invoke-ai/ui-library';
import { guidanceLayerDeleted } from 'app/store/middleware/listenerMiddleware/listeners/controlLayersToControlAdapterBridge';
import { useAppDispatch } from 'app/store/storeHooks';
import { layerDeleted } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { stopPropagation } from 'common/util/stopPropagation';
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) => {
export const LayerDeleteButton = memo(({ layerId }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const deleteLayer = useCallback(() => {
dispatch(layerDeleted(layerId));
dispatch(guidanceLayerDeleted(layerId));
}, [dispatch, layerId]);
return (
<IconButton
@@ -21,8 +22,9 @@ export const RPLayerDeleteButton = memo(({ layerId }: Props) => {
tooltip={t('common.delete')}
icon={<PiTrashSimpleBold />}
onClick={deleteLayer}
onDoubleClick={stopPropagation} // double click expands the layer
/>
);
});
RPLayerDeleteButton.displayName = 'RPLayerDeleteButton';
LayerDeleteButton.displayName = 'LayerDeleteButton';

View File

@@ -0,0 +1,59 @@
import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { stopPropagation } from 'common/util/stopPropagation';
import { LayerMenuArrangeActions } from 'features/controlLayers/components/LayerMenuArrangeActions';
import { LayerMenuRGActions } from 'features/controlLayers/components/LayerMenuRGActions';
import { useLayerType } from 'features/controlLayers/hooks/layerStateHooks';
import { layerDeleted, layerReset } from 'features/controlLayers/store/controlLayersSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiDotsThreeVerticalBold, PiTrashSimpleBold } from 'react-icons/pi';
type Props = { layerId: string };
export const LayerMenu = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const layerType = useLayerType(layerId);
const resetLayer = useCallback(() => {
dispatch(layerReset(layerId));
}, [dispatch, layerId]);
const deleteLayer = useCallback(() => {
dispatch(layerDeleted(layerId));
}, [dispatch, layerId]);
return (
<Menu>
<MenuButton
as={IconButton}
aria-label="Layer menu"
size="sm"
icon={<PiDotsThreeVerticalBold />}
onDoubleClick={stopPropagation} // double click expands the layer
/>
<MenuList>
{layerType === 'regional_guidance_layer' && (
<>
<LayerMenuRGActions layerId={layerId} />
<MenuDivider />
</>
)}
{(layerType === 'regional_guidance_layer' || layerType === 'control_adapter_layer') && (
<>
<LayerMenuArrangeActions layerId={layerId} />
<MenuDivider />
</>
)}
{layerType === 'regional_guidance_layer' && (
<MenuItem onClick={resetLayer} icon={<PiArrowCounterClockwiseBold />}>
{t('accessibility.reset')}
</MenuItem>
)}
<MenuItem onClick={deleteLayer} icon={<PiTrashSimpleBold />} color="error.300">
{t('common.delete')}
</MenuItem>
</MenuList>
</Menu>
);
});
LayerMenu.displayName = 'LayerMenu';

View File

@@ -0,0 +1,69 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
isRenderableLayer,
layerMovedBackward,
layerMovedForward,
layerMovedToBack,
layerMovedToFront,
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowDownBold, PiArrowLineDownBold, PiArrowLineUpBold, PiArrowUpBold } from 'react-icons/pi';
import { assert } from 'tsafe';
type Props = { layerId: string };
export const LayerMenuArrangeActions = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const selectValidActions = useMemo(
() =>
createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(isRenderableLayer(layer), `Layer ${layerId} not found or not an RP layer`);
const layerIndex = controlLayers.present.layers.findIndex((l) => l.id === layerId);
const layerCount = controlLayers.present.layers.length;
return {
canMoveForward: layerIndex < layerCount - 1,
canMoveBackward: layerIndex > 0,
canMoveToFront: layerIndex < layerCount - 1,
canMoveToBack: layerIndex > 0,
};
}),
[layerId]
);
const validActions = useAppSelector(selectValidActions);
const moveForward = useCallback(() => {
dispatch(layerMovedForward(layerId));
}, [dispatch, layerId]);
const moveToFront = useCallback(() => {
dispatch(layerMovedToFront(layerId));
}, [dispatch, layerId]);
const moveBackward = useCallback(() => {
dispatch(layerMovedBackward(layerId));
}, [dispatch, layerId]);
const moveToBack = useCallback(() => {
dispatch(layerMovedToBack(layerId));
}, [dispatch, layerId]);
return (
<>
<MenuItem onClick={moveToFront} isDisabled={!validActions.canMoveToFront} icon={<PiArrowLineUpBold />}>
{t('controlLayers.moveToFront')}
</MenuItem>
<MenuItem onClick={moveForward} isDisabled={!validActions.canMoveForward} icon={<PiArrowUpBold />}>
{t('controlLayers.moveForward')}
</MenuItem>
<MenuItem onClick={moveBackward} isDisabled={!validActions.canMoveBackward} icon={<PiArrowDownBold />}>
{t('controlLayers.moveBackward')}
</MenuItem>
<MenuItem onClick={moveToBack} isDisabled={!validActions.canMoveToBack} icon={<PiArrowLineDownBold />}>
{t('controlLayers.moveToBack')}
</MenuItem>
</>
);
});
LayerMenuArrangeActions.displayName = 'LayerMenuArrangeActions';

View File

@@ -0,0 +1,58 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { guidanceLayerIPAdapterAdded } from 'app/store/middleware/listenerMiddleware/listeners/controlLayersToControlAdapterBridge';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
isRegionalGuidanceLayer,
maskLayerNegativePromptChanged,
maskLayerPositivePromptChanged,
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
import { assert } from 'tsafe';
type Props = { layerId: string };
export const LayerMenuRGActions = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const selectValidActions = useMemo(
() =>
createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(isRegionalGuidanceLayer(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(guidanceLayerIPAdapterAdded(layerId));
}, [dispatch, layerId]);
return (
<>
<MenuItem onClick={addPositivePrompt} isDisabled={!validActions.canAddPositivePrompt} icon={<PiPlusBold />}>
{t('controlLayers.addPositivePrompt')}
</MenuItem>
<MenuItem onClick={addNegativePrompt} isDisabled={!validActions.canAddNegativePrompt} icon={<PiPlusBold />}>
{t('controlLayers.addNegativePrompt')}
</MenuItem>
<MenuItem onClick={addIPAdapter} icon={<PiPlusBold />}>
{t('controlLayers.addIPAdapter')}
</MenuItem>
</>
);
});
LayerMenuRGActions.displayName = 'LayerMenuRGActions';

View File

@@ -0,0 +1,29 @@
import { Text } from '@invoke-ai/ui-library';
import type { Layer } from 'features/controlLayers/store/types';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
type: Layer['type'];
};
export const LayerTitle = memo(({ type }: Props) => {
const { t } = useTranslation();
const title = useMemo(() => {
if (type === 'regional_guidance_layer') {
return t('controlLayers.regionalGuidance');
} else if (type === 'control_adapter_layer') {
return t('controlLayers.globalControlAdapter');
} else if (type === 'ip_adapter_layer') {
return t('controlLayers.globalIPAdapter');
}
}, [t, type]);
return (
<Text size="sm" fontWeight="semibold" userSelect="none" color="base.300">
{title}
</Text>
);
});
LayerTitle.displayName = 'LayerTitle';

View File

@@ -1,7 +1,8 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useLayerIsVisible } from 'features/regionalPrompts/hooks/layerStateHooks';
import { layerVisibilityToggled } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { stopPropagation } from 'common/util/stopPropagation';
import { useLayerIsVisible } from 'features/controlLayers/hooks/layerStateHooks';
import { layerVisibilityToggled } from 'features/controlLayers/store/controlLayersSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCheckBold } from 'react-icons/pi';
@@ -10,7 +11,7 @@ type Props = {
layerId: string;
};
export const RPLayerVisibilityToggle = memo(({ layerId }: Props) => {
export const LayerVisibilityToggle = memo(({ layerId }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isVisible = useLayerIsVisible(layerId);
@@ -21,14 +22,15 @@ export const RPLayerVisibilityToggle = memo(({ layerId }: Props) => {
return (
<IconButton
size="sm"
aria-label={t('regionalPrompts.toggleVisibility')}
tooltip={t('regionalPrompts.toggleVisibility')}
aria-label={t('controlLayers.toggleVisibility')}
tooltip={t('controlLayers.toggleVisibility')}
variant="outline"
icon={isVisible ? <PiCheckBold /> : undefined}
onClick={onClick}
colorScheme="base"
onDoubleClick={stopPropagation} // double click expands the layer
/>
);
});
RPLayerVisibilityToggle.displayName = 'RPLayerVisibilityToggle';
LayerVisibilityToggle.displayName = 'LayerVisibilityToggle';

View File

@@ -2,10 +2,10 @@ import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
isVectorMaskLayer,
isRegionalGuidanceLayer,
maskLayerAutoNegativeChanged,
selectRegionalPromptsSlice,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -18,9 +18,9 @@ type Props = {
const useAutoNegative = (layerId: string) => {
const selectAutoNegative = 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`);
createSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(isRegionalGuidanceLayer(layer), `Layer ${layerId} not found or not an RP layer`);
return layer.autoNegative;
}),
[layerId]
@@ -29,7 +29,7 @@ const useAutoNegative = (layerId: string) => {
return autoNegative;
};
export const RPLayerAutoNegativeCheckbox = memo(({ layerId }: Props) => {
export const RGLayerAutoNegativeCheckbox = memo(({ layerId }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const autoNegative = useAutoNegative(layerId);
@@ -42,10 +42,10 @@ export const RPLayerAutoNegativeCheckbox = memo(({ layerId }: Props) => {
return (
<FormControl gap={2}>
<FormLabel m={0}>{t('regionalPrompts.autoNegative')}</FormLabel>
<FormLabel m={0}>{t('controlLayers.autoNegative')}</FormLabel>
<Checkbox size="md" isChecked={autoNegative === 'invert'} onChange={onChange} />
</FormControl>
);
});
RPLayerAutoNegativeCheckbox.displayName = 'RPLayerAutoNegativeCheckbox';
RGLayerAutoNegativeCheckbox.displayName = 'RGLayerAutoNegativeCheckbox';

View File

@@ -2,12 +2,13 @@ import { Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, Tooltip } f
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import RgbColorPicker from 'common/components/RgbColorPicker';
import { stopPropagation } from 'common/util/stopPropagation';
import { rgbColorToString } from 'features/canvas/util/colorToString';
import {
isVectorMaskLayer,
isRegionalGuidanceLayer,
maskLayerPreviewColorChanged,
selectRegionalPromptsSlice,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
import { memo, useCallback, useMemo } from 'react';
import type { RgbColor } from 'react-colorful';
import { useTranslation } from 'react-i18next';
@@ -17,13 +18,13 @@ type Props = {
layerId: string;
};
export const RPLayerColorPicker = memo(({ layerId }: Props) => {
export const RGLayerColorPicker = memo(({ layerId }: Props) => {
const { t } = useTranslation();
const selectColor = useMemo(
() =>
createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
const layer = regionalPrompts.present.layers.find((l) => l.id === layerId);
assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an vector mask layer`);
createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(isRegionalGuidanceLayer(layer), `Layer ${layerId} not found or not an vector mask layer`);
return layer.previewColor;
}),
[layerId]
@@ -40,10 +41,10 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => {
<Popover isLazy>
<PopoverTrigger>
<span>
<Tooltip label={t('regionalPrompts.maskPreviewColor')}>
<Tooltip label={t('controlLayers.maskPreviewColor')}>
<Flex
as="button"
aria-label={t('regionalPrompts.maskPreviewColor')}
aria-label={t('controlLayers.maskPreviewColor')}
borderRadius="base"
borderWidth={1}
bg={rgbColorToString(color)}
@@ -51,6 +52,7 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => {
h={8}
cursor="pointer"
tabIndex={-1}
onDoubleClick={stopPropagation} // double click expands the layer
/>
</Tooltip>
</span>
@@ -64,4 +66,4 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => {
);
});
RPLayerColorPicker.displayName = 'RPLayerColorPicker';
RGLayerColorPicker.displayName = 'RGLayerColorPicker';

View File

@@ -0,0 +1,80 @@
import { Divider, Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { guidanceLayerIPAdapterDeleted } from 'app/store/middleware/listenerMiddleware/listeners/controlLayersToControlAdapterBridge';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ControlAdapterLayerConfig from 'features/controlLayers/components/controlAdapterOverrides/ControlAdapterLayerConfig';
import { isRegionalGuidanceLayer, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
import { memo, useCallback, useMemo } from 'react';
import { PiTrashSimpleBold } from 'react-icons/pi';
import { assert } from 'tsafe';
type Props = {
layerId: string;
};
export const RGLayerIPAdapterList = memo(({ layerId }: Props) => {
const selectIPAdapterIds = useMemo(
() =>
createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.filter(isRegionalGuidanceLayer).find((l) => l.id === layerId);
assert(layer, `Layer ${layerId} not found`);
return layer.ipAdapterIds;
}),
[layerId]
);
const ipAdapterIds = useAppSelector(selectIPAdapterIds);
if (ipAdapterIds.length === 0) {
return null;
}
return (
<>
{ipAdapterIds.map((id, index) => (
<Flex flexDir="column" key={id}>
{index > 0 && (
<Flex pb={3}>
<Divider />
</Flex>
)}
<RGLayerIPAdapterListItem layerId={layerId} ipAdapterId={id} ipAdapterNumber={index + 1} />
</Flex>
))}
</>
);
});
RGLayerIPAdapterList.displayName = 'RGLayerIPAdapterList';
type IPAdapterListItemProps = {
layerId: string;
ipAdapterId: string;
ipAdapterNumber: number;
};
const RGLayerIPAdapterListItem = memo(({ layerId, ipAdapterId, ipAdapterNumber }: IPAdapterListItemProps) => {
const dispatch = useAppDispatch();
const onDeleteIPAdapter = useCallback(() => {
dispatch(guidanceLayerIPAdapterDeleted({ layerId, ipAdapterId }));
}, [dispatch, ipAdapterId, layerId]);
return (
<Flex flexDir="column" gap={3}>
<Flex alignItems="center" gap={3}>
<Text fontWeight="semibold" color="base.400">{`IP Adapter ${ipAdapterNumber}`}</Text>
<Spacer />
<IconButton
size="sm"
icon={<PiTrashSimpleBold />}
aria-label="Delete IP Adapter"
onClick={onDeleteIPAdapter}
variant="ghost"
colorScheme="error"
/>
</Flex>
<ControlAdapterLayerConfig id={ipAdapterId} />
</Flex>
);
});
RGLayerIPAdapterListItem.displayName = 'RGLayerIPAdapterListItem';

View File

@@ -0,0 +1,84 @@
import { Badge, Flex, Spacer, useDisclosure } 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 { LayerDeleteButton } from 'features/controlLayers/components/LayerDeleteButton';
import { LayerMenu } from 'features/controlLayers/components/LayerMenu';
import { LayerTitle } from 'features/controlLayers/components/LayerTitle';
import { LayerVisibilityToggle } from 'features/controlLayers/components/LayerVisibilityToggle';
import { RGLayerColorPicker } from 'features/controlLayers/components/RGLayerColorPicker';
import { RGLayerIPAdapterList } from 'features/controlLayers/components/RGLayerIPAdapterList';
import { RGLayerNegativePrompt } from 'features/controlLayers/components/RGLayerNegativePrompt';
import { RGLayerPositivePrompt } from 'features/controlLayers/components/RGLayerPositivePrompt';
import RGLayerSettingsPopover from 'features/controlLayers/components/RGLayerSettingsPopover';
import {
isRegionalGuidanceLayer,
layerSelected,
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
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 RGLayerListItem = memo(({ layerId }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const selector = useMemo(
() =>
createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(isRegionalGuidanceLayer(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 === controlLayers.present.selectedLayerId,
autoNegative: layer.autoNegative,
};
}),
[layerId]
);
const { autoNegative, color, hasPositivePrompt, hasNegativePrompt, hasIPAdapters, isSelected } =
useAppSelector(selector);
const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true });
const onClick = useCallback(() => {
dispatch(layerSelected(layerId));
}, [dispatch, layerId]);
return (
<Flex gap={2} onClick={onClick} bg={isSelected ? color : 'base.800'} px={2} borderRadius="base" py="1px">
<Flex flexDir="column" w="full" bg="base.850" borderRadius="base">
<Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}>
<LayerVisibilityToggle layerId={layerId} />
<LayerTitle type="regional_guidance_layer" />
<Spacer />
{autoNegative === 'invert' && (
<Badge color="base.300" bg="transparent" borderWidth={1} userSelect="none">
{t('controlLayers.autoNegative')}
</Badge>
)}
<RGLayerColorPicker layerId={layerId} />
<RGLayerSettingsPopover layerId={layerId} />
<LayerMenu layerId={layerId} />
<LayerDeleteButton layerId={layerId} />
</Flex>
{isOpen && (
<Flex flexDir="column" gap={3} px={3} pb={3}>
{!hasPositivePrompt && !hasNegativePrompt && !hasIPAdapters && <AddPromptButtons layerId={layerId} />}
{hasPositivePrompt && <RGLayerPositivePrompt layerId={layerId} />}
{hasNegativePrompt && <RGLayerNegativePrompt layerId={layerId} />}
{hasIPAdapters && <RGLayerIPAdapterList layerId={layerId} />}
</Flex>
)}
</Flex>
</Flex>
);
});
RGLayerListItem.displayName = 'RGLayerListItem';

View File

@@ -1,12 +1,12 @@
import { Box, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { RGLayerPromptDeleteButton } from 'features/controlLayers/components/RGLayerPromptDeleteButton';
import { useLayerNegativePrompt } from 'features/controlLayers/hooks/layerStateHooks';
import { maskLayerNegativePromptChanged } from 'features/controlLayers/store/controlLayersSlice';
import { PromptOverlayButtonWrapper } from 'features/parameters/components/Prompts/PromptOverlayButtonWrapper';
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover';
import { usePrompt } from 'features/prompt/usePrompt';
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 { useTranslation } from 'react-i18next';
@@ -14,7 +14,7 @@ type Props = {
layerId: string;
};
export const RPLayerNegativePrompt = memo(({ layerId }: Props) => {
export const RGLayerNegativePrompt = memo(({ layerId }: Props) => {
const prompt = useLayerNegativePrompt(layerId);
const dispatch = useAppDispatch();
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -47,7 +47,7 @@ export const RPLayerNegativePrompt = memo(({ layerId }: Props) => {
fontSize="sm"
/>
<PromptOverlayButtonWrapper>
<RPLayerPromptDeleteButton layerId={layerId} polarity="negative" />
<RGLayerPromptDeleteButton layerId={layerId} polarity="negative" />
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
</PromptOverlayButtonWrapper>
</Box>
@@ -55,4 +55,4 @@ export const RPLayerNegativePrompt = memo(({ layerId }: Props) => {
);
});
RPLayerNegativePrompt.displayName = 'RPLayerNegativePrompt';
RGLayerNegativePrompt.displayName = 'RGLayerNegativePrompt';

View File

@@ -1,12 +1,12 @@
import { Box, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { RGLayerPromptDeleteButton } from 'features/controlLayers/components/RGLayerPromptDeleteButton';
import { useLayerPositivePrompt } from 'features/controlLayers/hooks/layerStateHooks';
import { maskLayerPositivePromptChanged } from 'features/controlLayers/store/controlLayersSlice';
import { PromptOverlayButtonWrapper } from 'features/parameters/components/Prompts/PromptOverlayButtonWrapper';
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover';
import { usePrompt } from 'features/prompt/usePrompt';
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 { useTranslation } from 'react-i18next';
@@ -14,7 +14,7 @@ type Props = {
layerId: string;
};
export const RPLayerPositivePrompt = memo(({ layerId }: Props) => {
export const RGLayerPositivePrompt = memo(({ layerId }: Props) => {
const prompt = useLayerPositivePrompt(layerId);
const dispatch = useAppDispatch();
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -47,7 +47,7 @@ export const RPLayerPositivePrompt = memo(({ layerId }: Props) => {
minH={28}
/>
<PromptOverlayButtonWrapper>
<RPLayerPromptDeleteButton layerId={layerId} polarity="positive" />
<RGLayerPromptDeleteButton layerId={layerId} polarity="positive" />
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
</PromptOverlayButtonWrapper>
</Box>
@@ -55,4 +55,4 @@ export const RPLayerPositivePrompt = memo(({ layerId }: Props) => {
);
});
RPLayerPositivePrompt.displayName = 'RPLayerPositivePrompt';
RGLayerPositivePrompt.displayName = 'RGLayerPositivePrompt';

View File

@@ -3,7 +3,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import {
maskLayerNegativePromptChanged,
maskLayerPositivePromptChanged,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
} from 'features/controlLayers/store/controlLayersSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
@@ -13,7 +13,7 @@ type Props = {
polarity: 'positive' | 'negative';
};
export const RPLayerPromptDeleteButton = memo(({ layerId, polarity }: Props) => {
export const RGLayerPromptDeleteButton = memo(({ layerId, polarity }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
@@ -24,10 +24,10 @@ export const RPLayerPromptDeleteButton = memo(({ layerId, polarity }: Props) =>
}
}, [dispatch, layerId, polarity]);
return (
<Tooltip label={t('regionalPrompts.deletePrompt')}>
<Tooltip label={t('controlLayers.deletePrompt')}>
<IconButton
variant="promptOverlay"
aria-label={t('regionalPrompts.deletePrompt')}
aria-label={t('controlLayers.deletePrompt')}
icon={<PiTrashSimpleBold />}
onClick={onClick}
/>
@@ -35,4 +35,4 @@ export const RPLayerPromptDeleteButton = memo(({ layerId, polarity }: Props) =>
);
});
RPLayerPromptDeleteButton.displayName = 'RPLayerPromptDeleteButton';
RGLayerPromptDeleteButton.displayName = 'RGLayerPromptDeleteButton';

View File

@@ -9,7 +9,8 @@ import {
PopoverContent,
PopoverTrigger,
} from '@invoke-ai/ui-library';
import { RPLayerAutoNegativeCheckbox } from 'features/regionalPrompts/components/RPLayerAutoNegativeCheckbox';
import { stopPropagation } from 'common/util/stopPropagation';
import { RGLayerAutoNegativeCheckbox } from 'features/controlLayers/components/RGLayerAutoNegativeCheckbox';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiGearSixBold } from 'react-icons/pi';
@@ -23,7 +24,7 @@ const formLabelProps: FormLabelProps = {
minW: 32,
};
const RPLayerSettingsPopover = ({ layerId }: Props) => {
const RGLayerSettingsPopover = ({ layerId }: Props) => {
const { t } = useTranslation();
return (
@@ -34,6 +35,7 @@ const RPLayerSettingsPopover = ({ layerId }: Props) => {
aria-label={t('common.settingsLabel')}
size="sm"
icon={<PiGearSixBold />}
onDoubleClick={stopPropagation} // double click expands the layer
/>
</PopoverTrigger>
<PopoverContent>
@@ -41,7 +43,7 @@ const RPLayerSettingsPopover = ({ layerId }: Props) => {
<PopoverBody>
<Flex direction="column" gap={2}>
<FormControlGroup formLabelProps={formLabelProps}>
<RPLayerAutoNegativeCheckbox layerId={layerId} />
<RGLayerAutoNegativeCheckbox layerId={layerId} />
</FormControlGroup>
</Flex>
</PopoverBody>
@@ -50,4 +52,4 @@ const RPLayerSettingsPopover = ({ layerId }: Props) => {
);
};
export default memo(RPLayerSettingsPopover);
export default memo(RGLayerSettingsPopover);

View File

@@ -1,59 +1,63 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useMouseEvents } from 'features/regionalPrompts/hooks/mouseEventHooks';
import { useMouseEvents } from 'features/controlLayers/hooks/mouseEventHooks';
import {
$cursorPosition,
$isMouseOver,
$lastMouseDownPos,
$tool,
isVectorMaskLayer,
isRegionalGuidanceLayer,
layerBboxChanged,
layerSelected,
layerTranslated,
selectRegionalPromptsSlice,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
import { debouncedRenderers, renderers as normalRenderers } from 'features/regionalPrompts/util/renderers';
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
import { debouncedRenderers, renderers as normalRenderers } from 'features/controlLayers/util/renderers';
import Konva from 'konva';
import type { IRect } from 'konva/lib/types';
import type { MutableRefObject } from 'react';
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { assert } from 'tsafe';
import { memo, useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { useDevicePixelRatio } from 'use-device-pixel-ratio';
import { v4 as uuidv4 } from 'uuid';
// This will log warnings when layers > 5 - maybe use `import.meta.env.MODE === 'development'` instead?
Konva.showWarnings = false;
const log = logger('regionalPrompts');
const log = logger('controlLayers');
const selectSelectedLayerColor = createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
const layer = regionalPrompts.present.layers.find((l) => l.id === regionalPrompts.present.selectedLayerId);
if (!layer) {
return null;
}
assert(isVectorMaskLayer(layer), `Layer ${regionalPrompts.present.selectedLayerId} is not an RP layer`);
return layer.previewColor;
const selectSelectedLayerColor = createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers
.filter(isRegionalGuidanceLayer)
.find((l) => l.id === controlLayers.present.selectedLayerId);
return layer?.previewColor ?? null;
});
const selectSelectedLayerType = createSelector(selectControlLayersSlice, (controlLayers) => {
const selectedLayer = controlLayers.present.layers.find((l) => l.id === controlLayers.present.selectedLayerId);
return selectedLayer?.type ?? null;
});
const useStageRenderer = (
stageRef: MutableRefObject<Konva.Stage>,
stage: Konva.Stage,
container: HTMLDivElement | null,
wrapper: HTMLDivElement | null,
asPreview: boolean
) => {
const dispatch = useAppDispatch();
const width = useAppSelector((s) => s.generation.width);
const height = useAppSelector((s) => s.generation.height);
const state = useAppSelector((s) => s.regionalPrompts.present);
const state = useAppSelector((s) => s.controlLayers.present);
const tool = useStore($tool);
const { onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave, onMouseWheel } = useMouseEvents();
const cursorPosition = useStore($cursorPosition);
const lastMouseDownPos = useStore($lastMouseDownPos);
const isMouseOver = useStore($isMouseOver);
const selectedLayerIdColor = useAppSelector(selectSelectedLayerColor);
const selectedLayerType = useAppSelector(selectSelectedLayerType);
const layerIds = useMemo(() => state.layers.map((l) => l.id), [state.layers]);
const layerCount = useMemo(() => state.layers.length, [state.layers]);
const renderers = useMemo(() => (asPreview ? debouncedRenderers : normalRenderers), [asPreview]);
const dpr = useDevicePixelRatio({ round: false });
const onLayerPosChanged = useCallback(
(layerId: string, x: number, y: number) => {
@@ -69,37 +73,29 @@ const useStageRenderer = (
[dispatch]
);
const onBboxMouseDown = useCallback(
(layerId: string) => {
dispatch(layerSelected(layerId));
},
[dispatch]
);
useLayoutEffect(() => {
log.trace('Initializing stage');
if (!container) {
return;
}
const stage = stageRef.current.container(container);
stage.container(container);
return () => {
log.trace('Cleaning up stage');
stage.destroy();
};
}, [container, stageRef]);
}, [container, stage]);
useLayoutEffect(() => {
log.trace('Adding stage listeners');
if (asPreview) {
return;
}
stageRef.current.on('mousedown', onMouseDown);
stageRef.current.on('mouseup', onMouseUp);
stageRef.current.on('mousemove', onMouseMove);
stageRef.current.on('mouseenter', onMouseEnter);
stageRef.current.on('mouseleave', onMouseLeave);
stageRef.current.on('wheel', onMouseWheel);
const stage = stageRef.current;
stage.on('mousedown', onMouseDown);
stage.on('mouseup', onMouseUp);
stage.on('mousemove', onMouseMove);
stage.on('mouseenter', onMouseEnter);
stage.on('mouseleave', onMouseLeave);
stage.on('wheel', onMouseWheel);
return () => {
log.trace('Cleaning up stage listeners');
@@ -110,7 +106,7 @@ const useStageRenderer = (
stage.off('mouseleave', onMouseLeave);
stage.off('wheel', onMouseWheel);
};
}, [stageRef, asPreview, onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave, onMouseWheel]);
}, [stage, asPreview, onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave, onMouseWheel]);
useLayoutEffect(() => {
log.trace('Updating stage dimensions');
@@ -118,14 +114,12 @@ const useStageRenderer = (
return;
}
const stage = stageRef.current;
const fitStageToContainer = () => {
const newXScale = wrapper.offsetWidth / width;
const newYScale = wrapper.offsetHeight / height;
const newXScale = wrapper.offsetWidth / state.size.width;
const newYScale = wrapper.offsetHeight / state.size.height;
const newScale = Math.min(newXScale, newYScale, 1);
stage.width(width * newScale);
stage.height(height * newScale);
stage.width(state.size.width * newScale);
stage.height(state.size.height * newScale);
stage.scaleX(newScale);
stage.scaleY(newScale);
};
@@ -137,7 +131,7 @@ const useStageRenderer = (
return () => {
resizeObserver.disconnect();
};
}, [stageRef, width, height, wrapper]);
}, [stage, state.size.width, state.size.height, wrapper]);
useLayoutEffect(() => {
log.trace('Rendering tool preview');
@@ -146,9 +140,10 @@ const useStageRenderer = (
return;
}
renderers.renderToolPreview(
stageRef.current,
stage,
tool,
selectedLayerIdColor,
selectedLayerType,
state.globalMaskLayerOpacity,
cursorPosition,
lastMouseDownPos,
@@ -157,9 +152,10 @@ const useStageRenderer = (
);
}, [
asPreview,
stageRef,
stage,
tool,
selectedLayerIdColor,
selectedLayerType,
state.globalMaskLayerOpacity,
cursorPosition,
lastMouseDownPos,
@@ -170,8 +166,17 @@ const useStageRenderer = (
useLayoutEffect(() => {
log.trace('Rendering layers');
renderers.renderLayers(stageRef.current, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged);
}, [stageRef, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged, renderers]);
renderers.renderLayers(stage, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged);
}, [
stage,
state.layers,
state.globalMaskLayerOpacity,
tool,
onLayerPosChanged,
renderers,
state.size.width,
state.size.height,
]);
useLayoutEffect(() => {
log.trace('Rendering bbox');
@@ -179,8 +184,8 @@ const useStageRenderer = (
// Preview should not display bboxes
return;
}
renderers.renderBbox(stageRef.current, state.layers, state.selectedLayerId, tool, onBboxChanged, onBboxMouseDown);
}, [stageRef, asPreview, state.layers, state.selectedLayerId, tool, onBboxChanged, onBboxMouseDown, renderers]);
renderers.renderBbox(stage, state.layers, tool, onBboxChanged);
}, [stage, asPreview, state.layers, tool, onBboxChanged, renderers]);
useLayoutEffect(() => {
log.trace('Rendering background');
@@ -188,13 +193,26 @@ const useStageRenderer = (
// The preview should not have a background
return;
}
renderers.renderBackground(stageRef.current, width, height);
}, [stageRef, asPreview, width, height, renderers]);
renderers.renderBackground(stage, state.size.width, state.size.height);
}, [stage, asPreview, state.size.width, state.size.height, renderers]);
useLayoutEffect(() => {
log.trace('Arranging layers');
renderers.arrangeLayers(stageRef.current, layerIds);
}, [stageRef, layerIds, renderers]);
renderers.arrangeLayers(stage, layerIds);
}, [stage, layerIds, renderers]);
useLayoutEffect(() => {
log.trace('Rendering no layers message');
if (asPreview) {
// The preview should not display the no layers message
return;
}
renderers.renderNoLayersMessage(stage, layerCount, state.size.width, state.size.height);
}, [stage, layerCount, renderers, asPreview, state.size.width, state.size.height]);
useLayoutEffect(() => {
Konva.pixelRatio = dpr;
}, [dpr]);
};
type Props = {
@@ -202,10 +220,8 @@ type Props = {
};
export const StageComponent = memo(({ asPreview = false }: Props) => {
const stageRef = useRef<Konva.Stage>(
new Konva.Stage({
container: document.createElement('div'), // We will overwrite this shortly...
})
const [stage] = useState(
() => new Konva.Stage({ id: uuidv4(), container: document.createElement('div'), listening: !asPreview })
);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [wrapper, setWrapper] = useState<HTMLDivElement | null>(null);
@@ -218,12 +234,12 @@ export const StageComponent = memo(({ asPreview = false }: Props) => {
setWrapper(el);
}, []);
useStageRenderer(stageRef, container, wrapper, asPreview);
useStageRenderer(stage, container, wrapper, asPreview);
return (
<Flex overflow="hidden" w="full" h="full">
<Flex ref={wrapperRef} w="full" h="full" alignItems="center" justifyContent="center">
<Flex ref={containerRef} tabIndex={-1} bg="base.850" />
<Flex ref={containerRef} tabIndex={-1} bg="base.850" borderRadius="base" overflow="hidden" />
</Flex>
</Flex>
);

View File

@@ -1,21 +1,27 @@
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
$tool,
layerAdded,
selectControlLayersSlice,
selectedLayerDeleted,
selectedLayerReset,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
} from 'features/controlLayers/store/controlLayersSlice';
import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowsOutCardinalBold, PiEraserBold, PiPaintBrushBold, PiRectangleBold } from 'react-icons/pi';
const selectIsDisabled = createSelector(selectControlLayersSlice, (controlLayers) => {
const selectedLayer = controlLayers.present.layers.find((l) => l.id === controlLayers.present.selectedLayerId);
return selectedLayer?.type !== 'regional_guidance_layer';
});
export const ToolChooser: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isDisabled = useAppSelector((s) => s.regionalPrompts.present.layers.length === 0);
const isDisabled = useAppSelector(selectIsDisabled);
const tool = useStore($tool);
const setToolToBrush = useCallback(() => {
@@ -40,11 +46,6 @@ export const ToolChooser: React.FC = () => {
}, [dispatch]);
useHotkeys('shift+c', resetSelectedLayer);
const addLayer = useCallback(() => {
dispatch(layerAdded('vector_mask_layer'));
}, [dispatch]);
useHotkeys('shift+a', addLayer);
const deleteSelectedLayer = useCallback(() => {
dispatch(selectedLayerDeleted());
}, [dispatch]);
@@ -69,8 +70,8 @@ export const ToolChooser: React.FC = () => {
isDisabled={isDisabled}
/>
<IconButton
aria-label={`${t('regionalPrompts.rectangle')} (U)`}
tooltip={`${t('regionalPrompts.rectangle')} (U)`}
aria-label={`${t('controlLayers.rectangle')} (U)`}
tooltip={`${t('controlLayers.rectangle')} (U)`}
icon={<PiRectangleBold />}
variant={tool === 'rect' ? 'solid' : 'outline'}
onClick={setToolToRect}

View File

@@ -1,7 +1,7 @@
/* eslint-disable i18next/no-literal-string */
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { redo, undo } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { redo, undo } from 'features/controlLayers/store/controlLayersSlice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
@@ -11,13 +11,13 @@ export const UndoRedoButtonGroup = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const mayUndo = useAppSelector((s) => s.regionalPrompts.past.length > 0);
const mayUndo = useAppSelector((s) => s.controlLayers.past.length > 0);
const handleUndo = useCallback(() => {
dispatch(undo());
}, [dispatch]);
useHotkeys(['meta+z', 'ctrl+z'], handleUndo, { enabled: mayUndo, preventDefault: true }, [mayUndo, handleUndo]);
const mayRedo = useAppSelector((s) => s.regionalPrompts.future.length > 0);
const mayRedo = useAppSelector((s) => s.controlLayers.future.length > 0);
const handleRedo = useCallback(() => {
dispatch(redo());
}, [dispatch]);

View File

@@ -0,0 +1,237 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Spinner, useShiftModifier } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIDndImage from 'common/components/IAIDndImage';
import IAIDndImageIcon from 'common/components/IAIDndImageIcon';
import { setBoundingBoxDimensions } from 'features/canvas/store/canvasSlice';
import { useControlAdapterControlImage } from 'features/controlAdapters/hooks/useControlAdapterControlImage';
import { useControlAdapterProcessedControlImage } from 'features/controlAdapters/hooks/useControlAdapterProcessedControlImage';
import { useControlAdapterProcessorType } from 'features/controlAdapters/hooks/useControlAdapterProcessorType';
import {
controlAdapterImageChanged,
selectControlAdaptersSlice,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import { heightChanged, widthChanged } from 'features/controlLayers/store/controlLayersSlice';
import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
import { calculateNewSize } from 'features/parameters/components/ImageSize/calculateNewSize';
import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiFloppyDiskBold, PiRulerBold } from 'react-icons/pi';
import {
useAddImageToBoardMutation,
useChangeImageIsIntermediateMutation,
useGetImageDTOQuery,
useRemoveImageFromBoardMutation,
} from 'services/api/endpoints/images';
import type { PostUploadAction } from 'services/api/types';
type Props = {
id: string;
isSmall?: boolean;
};
const selectPendingControlImages = createMemoizedSelector(
selectControlAdaptersSlice,
(controlAdapters) => controlAdapters.pendingControlImages
);
const ControlAdapterImagePreview = ({ isSmall, id }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const controlImageName = useControlAdapterControlImage(id);
const processedControlImageName = useControlAdapterProcessedControlImage(id);
const processorType = useControlAdapterProcessorType(id);
const autoAddBoardId = useAppSelector((s) => s.gallery.autoAddBoardId);
const isConnected = useAppSelector((s) => s.system.isConnected);
const activeTabName = useAppSelector(activeTabNameSelector);
const optimalDimension = useAppSelector(selectOptimalDimension);
const pendingControlImages = useAppSelector(selectPendingControlImages);
const shift = useShiftModifier();
const [isMouseOverImage, setIsMouseOverImage] = useState(false);
const { currentData: controlImage, isError: isErrorControlImage } = useGetImageDTOQuery(
controlImageName ?? skipToken
);
const { currentData: processedControlImage, isError: isErrorProcessedControlImage } = useGetImageDTOQuery(
processedControlImageName ?? skipToken
);
const [changeIsIntermediate] = useChangeImageIsIntermediateMutation();
const [addToBoard] = useAddImageToBoardMutation();
const [removeFromBoard] = useRemoveImageFromBoardMutation();
const handleResetControlImage = useCallback(() => {
dispatch(controlAdapterImageChanged({ id, controlImage: null }));
}, [id, dispatch]);
const handleSaveControlImage = useCallback(async () => {
if (!processedControlImage) {
return;
}
await changeIsIntermediate({
imageDTO: processedControlImage,
is_intermediate: false,
}).unwrap();
if (autoAddBoardId !== 'none') {
addToBoard({
imageDTO: processedControlImage,
board_id: autoAddBoardId,
});
} else {
removeFromBoard({ imageDTO: processedControlImage });
}
}, [processedControlImage, changeIsIntermediate, autoAddBoardId, addToBoard, removeFromBoard]);
const handleSetControlImageToDimensions = useCallback(() => {
if (!controlImage) {
return;
}
if (activeTabName === 'unifiedCanvas') {
dispatch(setBoundingBoxDimensions({ width: controlImage.width, height: controlImage.height }, optimalDimension));
} else {
if (shift) {
const { width, height } = controlImage;
dispatch(widthChanged({ width, updateAspectRatio: true }));
dispatch(heightChanged({ height, updateAspectRatio: true }));
} else {
const { width, height } = calculateNewSize(
controlImage.width / controlImage.height,
optimalDimension * optimalDimension
);
dispatch(widthChanged({ width, updateAspectRatio: true }));
dispatch(heightChanged({ height, updateAspectRatio: true }));
}
}
}, [controlImage, activeTabName, dispatch, optimalDimension, shift]);
const handleMouseEnter = useCallback(() => {
setIsMouseOverImage(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsMouseOverImage(false);
}, []);
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
if (controlImage) {
return {
id,
payloadType: 'IMAGE_DTO',
payload: { imageDTO: controlImage },
};
}
}, [controlImage, id]);
const droppableData = useMemo<TypesafeDroppableData | undefined>(
() => ({
id,
actionType: 'SET_CONTROL_ADAPTER_IMAGE',
context: { id },
}),
[id]
);
const postUploadAction = useMemo<PostUploadAction>(() => ({ type: 'SET_CONTROL_ADAPTER_IMAGE', id }), [id]);
const shouldShowProcessedImage =
controlImage &&
processedControlImage &&
!isMouseOverImage &&
!pendingControlImages.includes(id) &&
processorType !== 'none';
useEffect(() => {
if (isConnected && (isErrorControlImage || isErrorProcessedControlImage)) {
handleResetControlImage();
}
}, [handleResetControlImage, isConnected, isErrorControlImage, isErrorProcessedControlImage]);
return (
<Flex
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
position="relative"
w="full"
h={isSmall ? 36 : 366} // magic no touch
alignItems="center"
justifyContent="center"
>
<IAIDndImage
draggableData={draggableData}
droppableData={droppableData}
imageDTO={controlImage}
isDropDisabled={shouldShowProcessedImage}
postUploadAction={postUploadAction}
/>
<Box
position="absolute"
top={0}
insetInlineStart={0}
w="full"
h="full"
opacity={shouldShowProcessedImage ? 1 : 0}
transitionProperty="common"
transitionDuration="normal"
pointerEvents="none"
>
<IAIDndImage
draggableData={draggableData}
droppableData={droppableData}
imageDTO={processedControlImage}
isUploadDisabled={true}
/>
</Box>
<>
<IAIDndImageIcon
onClick={handleResetControlImage}
icon={controlImage ? <PiArrowCounterClockwiseBold size={16} /> : undefined}
tooltip={t('controlnet.resetControlImage')}
/>
<IAIDndImageIcon
onClick={handleSaveControlImage}
icon={controlImage ? <PiFloppyDiskBold size={16} /> : undefined}
tooltip={t('controlnet.saveControlImage')}
styleOverrides={saveControlImageStyleOverrides}
/>
<IAIDndImageIcon
onClick={handleSetControlImageToDimensions}
icon={controlImage ? <PiRulerBold size={16} /> : undefined}
tooltip={shift ? t('controlnet.setControlImageDimensionsForce') : t('controlnet.setControlImageDimensions')}
styleOverrides={setControlImageDimensionsStyleOverrides}
/>
</>
{pendingControlImages.includes(id) && (
<Flex
position="absolute"
top={0}
insetInlineStart={0}
w="full"
h="full"
alignItems="center"
justifyContent="center"
opacity={0.8}
borderRadius="base"
bg="base.900"
>
<Spinner size="xl" color="base.400" />
</Flex>
)}
</Flex>
);
};
export default memo(ControlAdapterImagePreview);
const saveControlImageStyleOverrides: SystemStyleObject = { mt: 6 };
const setControlImageDimensionsStyleOverrides: SystemStyleObject = { mt: 12 };

View File

@@ -0,0 +1,72 @@
import { Box, Flex, Icon, IconButton } from '@invoke-ai/ui-library';
import ControlAdapterProcessorComponent from 'features/controlAdapters/components/ControlAdapterProcessorComponent';
import ControlAdapterShouldAutoConfig from 'features/controlAdapters/components/ControlAdapterShouldAutoConfig';
import ParamControlAdapterIPMethod from 'features/controlAdapters/components/parameters/ParamControlAdapterIPMethod';
import ParamControlAdapterProcessorSelect from 'features/controlAdapters/components/parameters/ParamControlAdapterProcessorSelect';
import { useControlAdapterType } from 'features/controlAdapters/hooks/useControlAdapterType';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretUpBold } from 'react-icons/pi';
import { useToggle } from 'react-use';
import ControlAdapterImagePreview from './ControlAdapterImagePreview';
import { ParamControlAdapterBeginEnd } from './ParamControlAdapterBeginEnd';
import ParamControlAdapterControlMode from './ParamControlAdapterControlMode';
import ParamControlAdapterModel from './ParamControlAdapterModel';
import ParamControlAdapterWeight from './ParamControlAdapterWeight';
const ControlAdapterLayerConfig = (props: { id: string }) => {
const { id } = props;
const controlAdapterType = useControlAdapterType(id);
const { t } = useTranslation();
const [isExpanded, toggleIsExpanded] = useToggle(false);
return (
<Flex flexDir="column" gap={4} position="relative" w="full">
<Flex gap={3} alignItems="center" w="full">
<Box minW={0} w="full" transitionProperty="common" transitionDuration="0.1s">
<ParamControlAdapterModel id={id} />{' '}
</Box>
{controlAdapterType !== 'ip_adapter' && (
<IconButton
size="sm"
tooltip={isExpanded ? t('controlnet.hideAdvanced') : t('controlnet.showAdvanced')}
aria-label={isExpanded ? t('controlnet.hideAdvanced') : t('controlnet.showAdvanced')}
onClick={toggleIsExpanded}
variant="ghost"
icon={
<Icon
boxSize={4}
as={PiCaretUpBold}
transform={isExpanded ? 'rotate(0deg)' : 'rotate(180deg)'}
transitionProperty="common"
transitionDuration="normal"
/>
}
/>
)}
</Flex>
<Flex gap={4} w="full" alignItems="center">
<Flex flexDir="column" gap={3} w="full">
{controlAdapterType === 'ip_adapter' && <ParamControlAdapterIPMethod id={id} />}
{controlAdapterType === 'controlnet' && <ParamControlAdapterControlMode id={id} />}
<ParamControlAdapterWeight id={id} />
<ParamControlAdapterBeginEnd id={id} />
</Flex>
<Flex alignItems="center" justifyContent="center" h={36} w={36} aspectRatio="1/1">
<ControlAdapterImagePreview id={id} isSmall />
</Flex>
</Flex>
{isExpanded && (
<>
<ControlAdapterShouldAutoConfig id={id} />
<ParamControlAdapterProcessorSelect id={id} />
<ControlAdapterProcessorComponent id={id} />
</>
)}
</Flex>
);
};
export default memo(ControlAdapterLayerConfig);

View File

@@ -0,0 +1,89 @@
import { CompositeRangeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import { useControlAdapterBeginEndStepPct } from 'features/controlAdapters/hooks/useControlAdapterBeginEndStepPct';
import { useControlAdapterIsEnabled } from 'features/controlAdapters/hooks/useControlAdapterIsEnabled';
import {
controlAdapterBeginStepPctChanged,
controlAdapterEndStepPctChanged,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
id: string;
};
const formatPct = (v: number) => `${Math.round(v * 100)}%`;
export const ParamControlAdapterBeginEnd = memo(({ id }: Props) => {
const isEnabled = useControlAdapterIsEnabled(id);
const stepPcts = useControlAdapterBeginEndStepPct(id);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const onChange = useCallback(
(v: [number, number]) => {
dispatch(
controlAdapterBeginStepPctChanged({
id,
beginStepPct: v[0],
})
);
dispatch(
controlAdapterEndStepPctChanged({
id,
endStepPct: v[1],
})
);
},
[dispatch, id]
);
const onReset = useCallback(() => {
dispatch(
controlAdapterBeginStepPctChanged({
id,
beginStepPct: 0,
})
);
dispatch(
controlAdapterEndStepPctChanged({
id,
endStepPct: 1,
})
);
}, [dispatch, id]);
const value = useMemo<[number, number]>(() => [stepPcts?.beginStepPct ?? 0, stepPcts?.endStepPct ?? 1], [stepPcts]);
if (!stepPcts) {
return null;
}
return (
<FormControl isDisabled={!isEnabled} orientation="horizontal">
<InformationalPopover feature="controlNetBeginEnd">
<FormLabel m={0}>{t('controlnet.beginEndStepPercentShort')}</FormLabel>
</InformationalPopover>
<CompositeRangeSlider
aria-label={ariaLabel}
value={value}
onChange={onChange}
onReset={onReset}
min={0}
max={1}
step={0.05}
fineStep={0.01}
minStepsBetweenThumbs={1}
formatValue={formatPct}
marks
withThumbTooltip
/>
</FormControl>
);
});
ParamControlAdapterBeginEnd.displayName = 'ParamControlAdapterBeginEnd';
const ariaLabel = ['Begin Step %', 'End Step %'];

View File

@@ -0,0 +1,66 @@
import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import { useControlAdapterControlMode } from 'features/controlAdapters/hooks/useControlAdapterControlMode';
import { useControlAdapterIsEnabled } from 'features/controlAdapters/hooks/useControlAdapterIsEnabled';
import { controlAdapterControlModeChanged } from 'features/controlAdapters/store/controlAdaptersSlice';
import type { ControlMode } from 'features/controlAdapters/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
id: string;
};
const ParamControlAdapterControlMode = ({ id }: Props) => {
const isEnabled = useControlAdapterIsEnabled(id);
const controlMode = useControlAdapterControlMode(id);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const CONTROL_MODE_DATA = useMemo(
() => [
{ label: t('controlnet.balanced'), value: 'balanced' },
{ label: t('controlnet.prompt'), value: 'more_prompt' },
{ label: t('controlnet.control'), value: 'more_control' },
{ label: t('controlnet.megaControl'), value: 'unbalanced' },
],
[t]
);
const handleControlModeChange = useCallback<ComboboxOnChange>(
(v) => {
if (!v) {
return;
}
dispatch(
controlAdapterControlModeChanged({
id,
controlMode: v.value as ControlMode,
})
);
},
[id, dispatch]
);
const value = useMemo(
() => CONTROL_MODE_DATA.filter((o) => o.value === controlMode)[0],
[CONTROL_MODE_DATA, controlMode]
);
if (!controlMode) {
return null;
}
return (
<FormControl isDisabled={!isEnabled}>
<InformationalPopover feature="controlNetControlMode">
<FormLabel m={0}>{t('controlnet.control')}</FormLabel>
</InformationalPopover>
<Combobox value={value} options={CONTROL_MODE_DATA} onChange={handleControlModeChange} />
</FormControl>
);
};
export default memo(ParamControlAdapterControlMode);

View File

@@ -0,0 +1,136 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox, Flex, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { useControlAdapterCLIPVisionModel } from 'features/controlAdapters/hooks/useControlAdapterCLIPVisionModel';
import { useControlAdapterIsEnabled } from 'features/controlAdapters/hooks/useControlAdapterIsEnabled';
import { useControlAdapterModel } from 'features/controlAdapters/hooks/useControlAdapterModel';
import { useControlAdapterModels } from 'features/controlAdapters/hooks/useControlAdapterModels';
import { useControlAdapterType } from 'features/controlAdapters/hooks/useControlAdapterType';
import {
controlAdapterCLIPVisionModelChanged,
controlAdapterModelChanged,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import type { CLIPVisionModel } from 'features/controlAdapters/store/types';
import { selectGenerationSlice } from 'features/parameters/store/generationSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type {
AnyModelConfig,
ControlNetModelConfig,
IPAdapterModelConfig,
T2IAdapterModelConfig,
} from 'services/api/types';
type ParamControlAdapterModelProps = {
id: string;
};
const selectMainModel = createMemoizedSelector(selectGenerationSlice, (generation) => generation.model);
const ParamControlAdapterModel = ({ id }: ParamControlAdapterModelProps) => {
const isEnabled = useControlAdapterIsEnabled(id);
const controlAdapterType = useControlAdapterType(id);
const { modelConfig } = useControlAdapterModel(id);
const dispatch = useAppDispatch();
const currentBaseModel = useAppSelector((s) => s.generation.model?.base);
const currentCLIPVisionModel = useControlAdapterCLIPVisionModel(id);
const mainModel = useAppSelector(selectMainModel);
const { t } = useTranslation();
const [modelConfigs, { isLoading }] = useControlAdapterModels(controlAdapterType);
const _onChange = useCallback(
(modelConfig: ControlNetModelConfig | IPAdapterModelConfig | T2IAdapterModelConfig | null) => {
if (!modelConfig) {
return;
}
dispatch(
controlAdapterModelChanged({
id,
modelConfig,
})
);
},
[dispatch, id]
);
const onCLIPVisionModelChange = useCallback<ComboboxOnChange>(
(v) => {
if (!v?.value) {
return;
}
dispatch(controlAdapterCLIPVisionModelChanged({ id, clipVisionModel: v.value as CLIPVisionModel }));
},
[dispatch, id]
);
const selectedModel = useMemo(
() => (modelConfig && controlAdapterType ? { ...modelConfig, model_type: controlAdapterType } : null),
[controlAdapterType, modelConfig]
);
const getIsDisabled = useCallback(
(model: AnyModelConfig): boolean => {
const isCompatible = currentBaseModel === model.base;
const hasMainModel = Boolean(currentBaseModel);
return !hasMainModel || !isCompatible;
},
[currentBaseModel]
);
const { options, value, onChange, noOptionsMessage } = useGroupedModelCombobox({
modelConfigs,
onChange: _onChange,
selectedModel,
getIsDisabled,
isLoading,
});
const clipVisionOptions = useMemo<ComboboxOption[]>(
() => [
{ label: 'ViT-H', value: 'ViT-H' },
{ label: 'ViT-G', value: 'ViT-G' },
],
[]
);
const clipVisionModel = useMemo(
() => clipVisionOptions.find((o) => o.value === currentCLIPVisionModel),
[clipVisionOptions, currentCLIPVisionModel]
);
return (
<Flex gap={4}>
<Tooltip label={selectedModel?.description}>
<FormControl isDisabled={!isEnabled} isInvalid={!value || mainModel?.base !== modelConfig?.base} w="full">
<Combobox
options={options}
placeholder={t('controlnet.selectModel')}
value={value}
onChange={onChange}
noOptionsMessage={noOptionsMessage}
/>
</FormControl>
</Tooltip>
{modelConfig?.type === 'ip_adapter' && modelConfig.format === 'checkpoint' && (
<FormControl
isDisabled={!isEnabled}
isInvalid={!value || mainModel?.base !== modelConfig?.base}
width="max-content"
minWidth={28}
>
<Combobox
options={clipVisionOptions}
placeholder={t('controlnet.selectCLIPVisionModel')}
value={clipVisionModel}
onChange={onCLIPVisionModelChange}
/>
</FormControl>
)}
</Flex>
);
};
export default memo(ParamControlAdapterModel);

View File

@@ -0,0 +1,74 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import { useControlAdapterIsEnabled } from 'features/controlAdapters/hooks/useControlAdapterIsEnabled';
import { useControlAdapterWeight } from 'features/controlAdapters/hooks/useControlAdapterWeight';
import { controlAdapterWeightChanged } from 'features/controlAdapters/store/controlAdaptersSlice';
import { isNil } from 'lodash-es';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
type ParamControlAdapterWeightProps = {
id: string;
};
const formatValue = (v: number) => v.toFixed(2);
const ParamControlAdapterWeight = ({ id }: ParamControlAdapterWeightProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isEnabled = useControlAdapterIsEnabled(id);
const weight = useControlAdapterWeight(id);
const initial = useAppSelector((s) => s.config.sd.ca.weight.initial);
const sliderMin = useAppSelector((s) => s.config.sd.ca.weight.sliderMin);
const sliderMax = useAppSelector((s) => s.config.sd.ca.weight.sliderMax);
const numberInputMin = useAppSelector((s) => s.config.sd.ca.weight.numberInputMin);
const numberInputMax = useAppSelector((s) => s.config.sd.ca.weight.numberInputMax);
const coarseStep = useAppSelector((s) => s.config.sd.ca.weight.coarseStep);
const fineStep = useAppSelector((s) => s.config.sd.ca.weight.fineStep);
const onChange = useCallback(
(weight: number) => {
dispatch(controlAdapterWeightChanged({ id, weight }));
},
[dispatch, id]
);
if (isNil(weight)) {
// should never happen
return null;
}
return (
<FormControl isDisabled={!isEnabled} orientation="horizontal">
<InformationalPopover feature="controlNetWeight">
<FormLabel m={0}>{t('controlnet.weight')}</FormLabel>
</InformationalPopover>
<CompositeSlider
value={weight}
onChange={onChange}
defaultValue={initial}
min={sliderMin}
max={sliderMax}
step={coarseStep}
fineStep={fineStep}
marks={marks}
formatValue={formatValue}
/>
<CompositeNumberInput
value={weight}
onChange={onChange}
min={numberInputMin}
max={numberInputMax}
step={coarseStep}
fineStep={fineStep}
maxW={20}
defaultValue={initial}
/>
</FormControl>
);
};
export default memo(ParamControlAdapterWeight);
const marks = [0, 1, 2];

View File

@@ -0,0 +1,81 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import {
isControlAdapterLayer,
isRegionalGuidanceLayer,
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useLayerPositivePrompt = (layerId: string) => {
const selectLayer = useMemo(
() =>
createSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(isRegionalGuidanceLayer(layer), `Layer ${layerId} not found or not an RP layer`);
assert(layer.positivePrompt !== null, `Layer ${layerId} does not have a positive prompt`);
return layer.positivePrompt;
}),
[layerId]
);
const prompt = useAppSelector(selectLayer);
return prompt;
};
export const useLayerNegativePrompt = (layerId: string) => {
const selectLayer = useMemo(
() =>
createSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(isRegionalGuidanceLayer(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) => {
const selectLayer = useMemo(
() =>
createSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(layer, `Layer ${layerId} not found`);
return layer.isEnabled;
}),
[layerId]
);
const isVisible = useAppSelector(selectLayer);
return isVisible;
};
export const useLayerType = (layerId: string) => {
const selectLayer = useMemo(
() =>
createSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.find((l) => l.id === layerId);
assert(layer, `Layer ${layerId} not found`);
return layer.type;
}),
[layerId]
);
const type = useAppSelector(selectLayer);
return type;
};
export const useLayerOpacity = (layerId: string) => {
const selectLayer = useMemo(
() =>
createSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers.filter(isControlAdapterLayer).find((l) => l.id === layerId);
assert(layer, `Layer ${layerId} not found`);
return { opacity: Math.round(layer.opacity * 100), isFilterEnabled: layer.isFilterEnabled };
}),
[layerId]
);
const opacity = useAppSelector(selectLayer);
return opacity;
};

View File

@@ -12,7 +12,7 @@ import {
maskLayerLineAdded,
maskLayerPointsAdded,
maskLayerRectAdded,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
} from 'features/controlLayers/store/controlLayersSlice';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Vector2d } from 'konva/lib/types';
@@ -48,11 +48,11 @@ const BRUSH_SPACING = 20;
export const useMouseEvents = () => {
const dispatch = useAppDispatch();
const selectedLayerId = useAppSelector((s) => s.regionalPrompts.present.selectedLayerId);
const selectedLayerId = useAppSelector((s) => s.controlLayers.present.selectedLayerId);
const tool = useStore($tool);
const lastCursorPosRef = useRef<[number, number] | null>(null);
const shouldInvertBrushSizeScrollDirection = useAppSelector((s) => s.canvas.shouldInvertBrushSizeScrollDirection);
const brushSize = useAppSelector((s) => s.regionalPrompts.present.brushSize);
const brushSize = useAppSelector((s) => s.controlLayers.present.brushSize);
const onMouseDown = useCallback(
(e: KonvaEventObject<MouseEvent | TouchEvent>) => {

View File

@@ -1,15 +1,16 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { isRegionalGuidanceLayer, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
const selectValidLayerCount = createSelector(selectRegionalPromptsSlice, (regionalPrompts) => {
if (!regionalPrompts.present.isEnabled) {
const selectValidLayerCount = createSelector(selectControlLayersSlice, (controlLayers) => {
if (!controlLayers.present.isEnabled) {
return 0;
}
const validLayers = regionalPrompts.present.layers
.filter((l) => l.isVisible)
const validLayers = controlLayers.present.layers
.filter(isRegionalGuidanceLayer)
.filter((l) => l.isEnabled)
.filter((l) => {
const hasTextPrompt = Boolean(l.positivePrompt || l.negativePrompt);
const hasAtLeastOneImagePrompt = l.ipAdapterIds.length > 0;
@@ -19,12 +20,12 @@ const selectValidLayerCount = createSelector(selectRegionalPromptsSlice, (region
return validLayers.length;
});
export const useRegionalControlTitle = () => {
export const useControlLayersTitle = () => {
const { t } = useTranslation();
const validLayerCount = useAppSelector(selectValidLayerCount);
const title = useMemo(() => {
const suffix = validLayerCount > 0 ? ` (${validLayerCount})` : '';
return `${t('regionalPrompts.regionalControl')}${suffix}`;
return `${t('controlLayers.controlLayers')}${suffix}`;
}, [t, validLayerCount]);
return title;
};

View File

@@ -0,0 +1,676 @@
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 { deepClone } from 'common/util/deepClone';
import { roundToMultiple } from 'common/util/roundDownToMultiple';
import {
controlAdapterImageChanged,
controlAdapterProcessedImageChanged,
isAnyControlAdapterAdded,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import { calculateNewSize } from 'features/parameters/components/ImageSize/calculateNewSize';
import { initialAspectRatioState } from 'features/parameters/components/ImageSize/constants';
import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
import { modelChanged } from 'features/parameters/store/generationSlice';
import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas';
import { getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension';
import type { IRect, Vector2d } from 'konva/lib/types';
import { isEqual, partition } from 'lodash-es';
import { atom } from 'nanostores';
import type { RgbColor } from 'react-colorful';
import type { UndoableOptions } from 'redux-undo';
import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid';
import type {
ControlAdapterLayer,
ControlLayersState,
DrawingTool,
IPAdapterLayer,
Layer,
RegionalGuidanceLayer,
Tool,
VectorMaskLine,
VectorMaskRect,
} from './types';
export const initialControlLayersState: ControlLayersState = {
_version: 1,
selectedLayerId: null,
brushSize: 100,
layers: [],
globalMaskLayerOpacity: 0.3, // this globally changes all mask layers' opacity
isEnabled: true,
positivePrompt: '',
negativePrompt: '',
positivePrompt2: '',
negativePrompt2: '',
shouldConcatPrompts: true,
initialImage: null,
size: {
width: 512,
height: 512,
aspectRatio: deepClone(initialAspectRatioState),
},
};
const isLine = (obj: VectorMaskLine | VectorMaskRect): obj is VectorMaskLine => obj.type === 'vector_mask_line';
export const isRegionalGuidanceLayer = (layer?: Layer): layer is RegionalGuidanceLayer =>
layer?.type === 'regional_guidance_layer';
export const isControlAdapterLayer = (layer?: Layer): layer is ControlAdapterLayer =>
layer?.type === 'control_adapter_layer';
export const isIPAdapterLayer = (layer?: Layer): layer is IPAdapterLayer => layer?.type === 'ip_adapter_layer';
export const isRenderableLayer = (layer?: Layer): layer is RegionalGuidanceLayer | ControlAdapterLayer =>
layer?.type === 'regional_guidance_layer' || layer?.type === 'control_adapter_layer';
const resetLayer = (layer: Layer) => {
if (layer.type === 'regional_guidance_layer') {
layer.maskObjects = [];
layer.bbox = null;
layer.isEnabled = true;
layer.needsPixelBbox = false;
layer.bboxNeedsUpdate = false;
return;
}
if (layer.type === 'control_adapter_layer') {
// TODO
}
};
const getVectorMaskPreviewColor = (state: ControlLayersState): RgbColor => {
const vmLayers = state.layers.filter(isRegionalGuidanceLayer);
const lastColor = vmLayers[vmLayers.length - 1]?.previewColor;
return LayerColors.next(lastColor);
};
export const controlLayersSlice = createSlice({
name: 'controlLayers',
initialState: initialControlLayersState,
reducers: {
//#region All Layers
regionalGuidanceLayerAdded: (state, action: PayloadAction<{ layerId: string }>) => {
const { layerId } = action.payload;
const layer: RegionalGuidanceLayer = {
id: getRegionalGuidanceLayerId(layerId),
type: 'regional_guidance_layer',
isEnabled: true,
bbox: null,
bboxNeedsUpdate: false,
maskObjects: [],
previewColor: getVectorMaskPreviewColor(state),
x: 0,
y: 0,
autoNegative: 'invert',
needsPixelBbox: false,
positivePrompt: '',
negativePrompt: null,
ipAdapterIds: [],
isSelected: true,
};
state.layers.push(layer);
state.selectedLayerId = layer.id;
for (const layer of state.layers.filter(isRenderableLayer)) {
if (layer.id !== layerId) {
layer.isSelected = false;
}
}
return;
},
ipAdapterLayerAdded: (state, action: PayloadAction<{ layerId: string; ipAdapterId: string }>) => {
const { layerId, ipAdapterId } = action.payload;
const layer: IPAdapterLayer = {
id: getIPAdapterLayerId(layerId),
type: 'ip_adapter_layer',
isEnabled: true,
ipAdapterId,
};
state.layers.push(layer);
return;
},
controlAdapterLayerAdded: (state, action: PayloadAction<{ layerId: string; controlNetId: string }>) => {
const { layerId, controlNetId } = action.payload;
const layer: ControlAdapterLayer = {
id: getControlNetLayerId(layerId),
type: 'control_adapter_layer',
controlNetId,
x: 0,
y: 0,
bbox: null,
bboxNeedsUpdate: false,
isEnabled: true,
imageName: null,
opacity: 1,
isSelected: true,
isFilterEnabled: true,
};
state.layers.push(layer);
state.selectedLayerId = layer.id;
for (const layer of state.layers.filter(isRenderableLayer)) {
if (layer.id !== layerId) {
layer.isSelected = false;
}
}
return;
},
layerSelected: (state, action: PayloadAction<string>) => {
for (const layer of state.layers.filter(isRenderableLayer)) {
if (layer.id === action.payload) {
layer.isSelected = true;
state.selectedLayerId = action.payload;
} else {
layer.isSelected = false;
}
}
},
layerVisibilityToggled: (state, action: PayloadAction<string>) => {
const layer = state.layers.find((l) => l.id === action.payload);
if (layer) {
layer.isEnabled = !layer.isEnabled;
}
},
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 (isRenderableLayer(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 (isRenderableLayer(layer)) {
layer.bbox = bbox;
layer.bboxNeedsUpdate = false;
if (bbox === null && layer.type === 'regional_guidance_layer') {
// The layer was fully erased, empty its objects to prevent accumulation of invisible objects
layer.maskObjects = [];
layer.needsPixelBbox = false;
}
}
},
layerReset: (state, action: PayloadAction<string>) => {
const layer = state.layers.find((l) => l.id === action.payload);
if (layer) {
resetLayer(layer);
}
},
layerDeleted: (state, action: PayloadAction<string>) => {
state.layers = state.layers.filter((l) => l.id !== action.payload);
state.selectedLayerId = state.layers[0]?.id ?? null;
},
layerMovedForward: (state, action: PayloadAction<string>) => {
const cb = (l: Layer) => l.id === action.payload;
const [renderableLayers, ipAdapterLayers] = partition(state.layers, isRenderableLayer);
moveForward(renderableLayers, cb);
state.layers = [...ipAdapterLayers, ...renderableLayers];
},
layerMovedToFront: (state, action: PayloadAction<string>) => {
const cb = (l: Layer) => l.id === action.payload;
const [renderableLayers, ipAdapterLayers] = partition(state.layers, isRenderableLayer);
// Because the layers are in reverse order, moving to the front is equivalent to moving to the back
moveToBack(renderableLayers, cb);
state.layers = [...ipAdapterLayers, ...renderableLayers];
},
layerMovedBackward: (state, action: PayloadAction<string>) => {
const cb = (l: Layer) => l.id === action.payload;
const [renderableLayers, ipAdapterLayers] = partition(state.layers, isRenderableLayer);
moveBackward(renderableLayers, cb);
state.layers = [...ipAdapterLayers, ...renderableLayers];
},
layerMovedToBack: (state, action: PayloadAction<string>) => {
const cb = (l: Layer) => l.id === action.payload;
const [renderableLayers, ipAdapterLayers] = partition(state.layers, isRenderableLayer);
// Because the layers are in reverse order, moving to the back is equivalent to moving to the front
moveToFront(renderableLayers, cb);
state.layers = [...ipAdapterLayers, ...renderableLayers];
},
selectedLayerReset: (state) => {
const layer = state.layers.find((l) => l.id === state.selectedLayerId);
if (layer) {
resetLayer(layer);
}
},
selectedLayerDeleted: (state) => {
state.layers = state.layers.filter((l) => l.id !== state.selectedLayerId);
state.selectedLayerId = state.layers[0]?.id ?? null;
},
layerOpacityChanged: (state, action: PayloadAction<{ layerId: string; opacity: number }>) => {
const { layerId, opacity } = action.payload;
const layer = state.layers.filter(isControlAdapterLayer).find((l) => l.id === layerId);
if (layer) {
layer.opacity = opacity;
}
},
//#endregion
//#region CA Layers
isFilterEnabledChanged: (state, action: PayloadAction<{ layerId: string; isFilterEnabled: boolean }>) => {
const { layerId, isFilterEnabled } = action.payload;
const layer = state.layers.filter(isControlAdapterLayer).find((l) => l.id === layerId);
if (layer) {
layer.isFilterEnabled = isFilterEnabled;
}
},
//#endregion
//#region Mask Layers
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?.type === 'regional_guidance_layer') {
layer.positivePrompt = prompt;
}
},
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?.type === 'regional_guidance_layer') {
layer.negativePrompt = prompt;
}
},
maskLayerIPAdapterAdded: (state, action: PayloadAction<{ layerId: string; ipAdapterId: string }>) => {
const { layerId, ipAdapterId } = action.payload;
const layer = state.layers.find((l) => l.id === layerId);
if (layer?.type === 'regional_guidance_layer') {
layer.ipAdapterIds.push(ipAdapterId);
}
},
maskLayerIPAdapterDeleted: (state, action: PayloadAction<{ layerId: string; ipAdapterId: string }>) => {
const { layerId, ipAdapterId } = action.payload;
const layer = state.layers.find((l) => l.id === layerId);
if (layer?.type === 'regional_guidance_layer') {
layer.ipAdapterIds = layer.ipAdapterIds.filter((id) => id !== ipAdapterId);
}
},
maskLayerPreviewColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbColor }>) => {
const { layerId, color } = action.payload;
const layer = state.layers.find((l) => l.id === layerId);
if (layer?.type === 'regional_guidance_layer') {
layer.previewColor = color;
}
},
maskLayerLineAdded: {
reducer: (
state,
action: PayloadAction<
{ layerId: string; points: [number, number, number, number]; tool: DrawingTool },
string,
{ uuid: string }
>
) => {
const { layerId, points, tool } = action.payload;
const layer = state.layers.find((l) => l.id === layerId);
if (layer?.type === 'regional_guidance_layer') {
const lineId = getRegionalGuidanceLayerLineId(layer.id, action.meta.uuid);
layer.maskObjects.push({
type: '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?
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.needsPixelBbox && tool === 'eraser') {
layer.needsPixelBbox = true;
}
}
},
prepare: (payload: { layerId: string; points: [number, number, number, number]; tool: DrawingTool }) => ({
payload,
meta: { uuid: uuidv4() },
}),
},
maskLayerPointsAdded: (state, action: PayloadAction<{ layerId: string; point: [number, number] }>) => {
const { layerId, point } = action.payload;
const layer = state.layers.find((l) => l.id === layerId);
if (layer?.type === 'regional_guidance_layer') {
const lastLine = layer.maskObjects.findLast(isLine);
if (!lastLine) {
return;
}
// Points must be offset by the layer's x and y coordinates
// TODO: Handle this in the event listener
lastLine.points.push(point[0] - layer.x, point[1] - layer.y);
layer.bboxNeedsUpdate = true;
}
},
maskLayerRectAdded: {
reducer: (state, action: PayloadAction<{ layerId: string; rect: IRect }, string, { uuid: string }>) => {
const { layerId, rect } = action.payload;
if (rect.height === 0 || rect.width === 0) {
// Ignore zero-area rectangles
return;
}
const layer = state.layers.find((l) => l.id === layerId);
if (layer?.type === 'regional_guidance_layer') {
const id = getMaskedGuidnaceLayerRectId(layer.id, action.meta.uuid);
layer.maskObjects.push({
type: 'vector_mask_rect',
id,
x: rect.x - layer.x,
y: rect.y - layer.y,
width: rect.width,
height: rect.height,
});
layer.bboxNeedsUpdate = true;
}
},
prepare: (payload: { layerId: string; rect: IRect }) => ({ payload, meta: { uuid: uuidv4() } }),
},
maskLayerAutoNegativeChanged: (
state,
action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }>
) => {
const { layerId, autoNegative } = action.payload;
const layer = state.layers.find((l) => l.id === layerId);
if (layer?.type === 'regional_guidance_layer') {
layer.autoNegative = autoNegative;
}
},
//#endregion
//#region Base Layer
positivePromptChanged: (state, action: PayloadAction<string>) => {
state.positivePrompt = action.payload;
},
negativePromptChanged: (state, action: PayloadAction<string>) => {
state.negativePrompt = action.payload;
},
positivePrompt2Changed: (state, action: PayloadAction<string>) => {
state.positivePrompt2 = action.payload;
},
negativePrompt2Changed: (state, action: PayloadAction<string>) => {
state.negativePrompt2 = action.payload;
},
shouldConcatPromptsChanged: (state, action: PayloadAction<boolean>) => {
state.shouldConcatPrompts = action.payload;
},
widthChanged: (state, action: PayloadAction<{ width: number; updateAspectRatio?: boolean }>) => {
const { width, updateAspectRatio } = action.payload;
state.size.width = width;
if (updateAspectRatio) {
state.size.aspectRatio.value = width / state.size.height;
state.size.aspectRatio.id = 'Free';
state.size.aspectRatio.isLocked = false;
}
},
heightChanged: (state, action: PayloadAction<{ height: number; updateAspectRatio?: boolean }>) => {
const { height, updateAspectRatio } = action.payload;
state.size.height = height;
if (updateAspectRatio) {
state.size.aspectRatio.value = state.size.width / height;
state.size.aspectRatio.id = 'Free';
state.size.aspectRatio.isLocked = false;
}
},
aspectRatioChanged: (state, action: PayloadAction<AspectRatioState>) => {
state.size.aspectRatio = action.payload;
},
//#endregion
//#region General
brushSizeChanged: (state, action: PayloadAction<number>) => {
state.brushSize = Math.round(action.payload);
},
globalMaskLayerOpacityChanged: (state, action: PayloadAction<number>) => {
state.globalMaskLayerOpacity = action.payload;
},
isEnabledChanged: (state, action: PayloadAction<boolean>) => {
state.isEnabled = action.payload;
},
undo: (state) => {
// Invalidate the bbox for all layers to prevent stale bboxes
for (const layer of state.layers.filter(isRenderableLayer)) {
layer.bboxNeedsUpdate = true;
}
},
redo: (state) => {
// Invalidate the bbox for all layers to prevent stale bboxes
for (const layer of state.layers.filter(isRenderableLayer)) {
layer.bboxNeedsUpdate = true;
}
},
//#endregion
},
extraReducers(builder) {
builder.addCase(modelChanged, (state, action) => {
const newModel = action.payload;
if (!newModel || action.meta.previousModel?.base === newModel.base) {
// Model was cleared or the base didn't change
return;
}
const optimalDimension = getOptimalDimension(newModel);
if (getIsSizeOptimal(state.size.width, state.size.height, optimalDimension)) {
return;
}
const { width, height } = calculateNewSize(state.size.aspectRatio.value, optimalDimension * optimalDimension);
state.size.width = width;
state.size.height = height;
});
builder.addCase(controlAdapterImageChanged, (state, action) => {
const { id, controlImage } = action.payload;
const layer = state.layers.filter(isControlAdapterLayer).find((l) => l.controlNetId === id);
if (layer) {
layer.bbox = null;
layer.bboxNeedsUpdate = true;
layer.isEnabled = true;
layer.imageName = controlImage?.image_name ?? null;
}
});
builder.addCase(controlAdapterProcessedImageChanged, (state, action) => {
const { id, processedControlImage } = action.payload;
const layer = state.layers.filter(isControlAdapterLayer).find((l) => l.controlNetId === id);
if (layer) {
layer.bbox = null;
layer.bboxNeedsUpdate = true;
layer.isEnabled = true;
layer.imageName = processedControlImage?.image_name ?? null;
}
});
// TODO: This is a temp fix to reduce issues with T2I adapter having a different downscaling
// factor than the UNet. Hopefully we get an upstream fix in diffusers.
builder.addMatcher(isAnyControlAdapterAdded, (state, action) => {
if (action.payload.type === 't2i_adapter') {
state.size.width = roundToMultiple(state.size.width, 64);
state.size.height = roundToMultiple(state.size.height, 64);
}
});
},
});
/**
* This class is used to cycle through a set of colors for the prompt region layers.
*/
class LayerColors {
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?: RgbColor): RgbColor {
if (currentColor) {
const i = this.COLORS.findIndex((c) => isEqual(c, currentColor));
if (i !== -1) {
this.i = i;
}
}
this.i = (this.i + 1) % this.COLORS.length;
const color = this.COLORS[this.i];
assert(color);
return color;
}
}
export const {
// All layer actions
layerDeleted,
layerMovedBackward,
layerMovedForward,
layerMovedToBack,
layerMovedToFront,
layerReset,
layerSelected,
layerTranslated,
layerBboxChanged,
layerVisibilityToggled,
selectedLayerReset,
selectedLayerDeleted,
regionalGuidanceLayerAdded,
ipAdapterLayerAdded,
controlAdapterLayerAdded,
layerOpacityChanged,
// CA layer actions
isFilterEnabledChanged,
// Mask layer actions
maskLayerLineAdded,
maskLayerPointsAdded,
maskLayerRectAdded,
maskLayerNegativePromptChanged,
maskLayerPositivePromptChanged,
maskLayerIPAdapterAdded,
maskLayerIPAdapterDeleted,
maskLayerAutoNegativeChanged,
maskLayerPreviewColorChanged,
// Base layer actions
positivePromptChanged,
negativePromptChanged,
positivePrompt2Changed,
negativePrompt2Changed,
shouldConcatPromptsChanged,
widthChanged,
heightChanged,
aspectRatioChanged,
// General actions
brushSizeChanged,
globalMaskLayerOpacityChanged,
undo,
redo,
} = controlLayersSlice.actions;
export const selectAllControlAdapterIds = (controlLayers: ControlLayersState) =>
controlLayers.layers.flatMap((l) => {
if (l.type === 'control_adapter_layer') {
return [l.controlNetId];
}
if (l.type === 'ip_adapter_layer') {
return [l.ipAdapterId];
}
if (l.type === 'regional_guidance_layer') {
return l.ipAdapterIds;
}
return [];
});
export const selectControlLayersSlice = (state: RootState) => state.controlLayers;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const migrateControlLayersState = (state: any): any => {
return state;
};
export const $isMouseDown = atom(false);
export const $isMouseOver = atom(false);
export const $lastMouseDownPos = atom<Vector2d | null>(null);
export const $tool = atom<Tool>('brush');
export const $cursorPosition = atom<Vector2d | null>(null);
// IDs for singleton Konva layers and objects
export const TOOL_PREVIEW_LAYER_ID = 'tool_preview_layer';
export const TOOL_PREVIEW_BRUSH_GROUP_ID = 'tool_preview_layer.brush_group';
export const TOOL_PREVIEW_BRUSH_FILL_ID = 'tool_preview_layer.brush_fill';
export const TOOL_PREVIEW_BRUSH_BORDER_INNER_ID = 'tool_preview_layer.brush_border_inner';
export const TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID = 'tool_preview_layer.brush_border_outer';
export const TOOL_PREVIEW_RECT_ID = 'tool_preview_layer.rect';
export const BACKGROUND_LAYER_ID = 'background_layer';
export const BACKGROUND_RECT_ID = 'background_layer.rect';
export const NO_LAYERS_MESSAGE_LAYER_ID = 'no_layers_message';
// Names (aka classes) for Konva layers and objects
export const CONTROLNET_LAYER_NAME = 'control_adapter_layer';
export const CONTROLNET_LAYER_IMAGE_NAME = 'control_adapter_layer.image';
export const regional_guidance_layer_NAME = 'regional_guidance_layer';
export const regional_guidance_layer_LINE_NAME = 'regional_guidance_layer.line';
export const regional_guidance_layer_OBJECT_GROUP_NAME = 'regional_guidance_layer.object_group';
export const regional_guidance_layer_RECT_NAME = 'regional_guidance_layer.rect';
export const LAYER_BBOX_NAME = 'layer.bbox';
// Getters for non-singleton layer and object IDs
const getRegionalGuidanceLayerId = (layerId: string) => `${regional_guidance_layer_NAME}_${layerId}`;
const getRegionalGuidanceLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`;
const getMaskedGuidnaceLayerRectId = (layerId: string, lineId: string) => `${layerId}.rect_${lineId}`;
export const getRegionalGuidanceLayerObjectGroupId = (layerId: string, groupId: string) =>
`${layerId}.objectGroup_${groupId}`;
export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`;
const getControlNetLayerId = (layerId: string) => `control_adapter_layer_${layerId}`;
export const getControlNetLayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`;
const getIPAdapterLayerId = (layerId: string) => `ip_adapter_layer_${layerId}`;
export const controlLayersPersistConfig: PersistConfig<ControlLayersState> = {
name: controlLayersSlice.name,
initialState: initialControlLayersState,
migrate: migrateControlLayersState,
persistDenylist: [],
};
// These actions are _individually_ grouped together as single undoable actions
const undoableGroupByMatcher = isAnyOf(
layerTranslated,
brushSizeChanged,
globalMaskLayerOpacityChanged,
maskLayerPositivePromptChanged,
maskLayerNegativePromptChanged,
maskLayerPreviewColorChanged
);
// These are used to group actions into logical lines below (hate typos)
const LINE_1 = 'LINE_1';
const LINE_2 = 'LINE_2';
export const controlLayersUndoableConfig: UndoableOptions<ControlLayersState, UnknownAction> = {
limit: 64,
undoType: controlLayersSlice.actions.undo.type,
redoType: controlLayersSlice.actions.redo.type,
groupBy: (action, state, history) => {
// Lines are started with `maskLayerLineAdded` and may have any number of subsequent `maskLayerPointsAdded` 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 (maskLayerLineAdded.match(action)) {
return history.group === LINE_1 ? LINE_2 : LINE_1;
}
if (maskLayerPointsAdded.match(action)) {
if (history.group === LINE_1 || history.group === LINE_2) {
return history.group;
}
}
if (undoableGroupByMatcher(action)) {
return action.type;
}
return null;
},
filter: (action, _state, _history) => {
// Ignore all actions from other slices
if (!action.type.startsWith(controlLayersSlice.name)) {
return false;
}
// 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 (layerBboxChanged.match(action)) {
return false;
}
return true;
},
};

View File

@@ -0,0 +1,92 @@
import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
import type {
ParameterAutoNegative,
ParameterHeight,
ParameterNegativePrompt,
ParameterNegativeStylePromptSDXL,
ParameterPositivePrompt,
ParameterPositiveStylePromptSDXL,
ParameterWidth,
} from 'features/parameters/types/parameterSchemas';
import type { IRect } from 'konva/lib/types';
import type { RgbColor } from 'react-colorful';
export type DrawingTool = 'brush' | 'eraser';
export type Tool = DrawingTool | 'move' | 'rect';
export type VectorMaskLine = {
id: string;
type: 'vector_mask_line';
tool: DrawingTool;
strokeWidth: number;
points: number[];
};
export type VectorMaskRect = {
id: string;
type: 'vector_mask_rect';
x: number;
y: number;
width: number;
height: number;
};
type LayerBase = {
id: string;
isEnabled: boolean;
};
type RenderableLayerBase = LayerBase & {
x: number;
y: number;
bbox: IRect | null;
bboxNeedsUpdate: boolean;
isSelected: boolean;
};
export type ControlAdapterLayer = RenderableLayerBase & {
type: 'control_adapter_layer'; // technically, also t2i adapter layer
controlNetId: string;
imageName: string | null;
opacity: number;
isFilterEnabled: boolean;
};
export type IPAdapterLayer = LayerBase & {
type: 'ip_adapter_layer'; // technically, also t2i adapter layer
ipAdapterId: string;
};
export type RegionalGuidanceLayer = RenderableLayerBase & {
type: 'regional_guidance_layer';
maskObjects: (VectorMaskLine | VectorMaskRect)[];
positivePrompt: ParameterPositivePrompt | null;
negativePrompt: ParameterNegativePrompt | null; // Up to one text prompt per mask
ipAdapterIds: string[]; // Any number of image prompts
previewColor: RgbColor;
autoNegative: ParameterAutoNegative;
needsPixelBbox: boolean; // Needs the slower pixel-based bbox calculation - set to true when an there is an eraser object
};
export type Layer = RegionalGuidanceLayer | ControlAdapterLayer | IPAdapterLayer;
export type ControlLayersState = {
_version: 1;
selectedLayerId: string | null;
layers: Layer[];
brushSize: number;
globalMaskLayerOpacity: number;
isEnabled: boolean;
positivePrompt: ParameterPositivePrompt;
negativePrompt: ParameterNegativePrompt;
positivePrompt2: ParameterPositiveStylePromptSDXL;
negativePrompt2: ParameterNegativeStylePromptSDXL;
shouldConcatPrompts: boolean;
initialImage: string | null;
size: {
width: ParameterWidth;
height: ParameterHeight;
aspectRatio: AspectRatioState;
};
};

View File

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

View File

@@ -1,8 +1,8 @@
import { getStore } from 'app/store/nanostores/store';
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
import { VECTOR_MASK_LAYER_NAME } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { renderers } from 'features/regionalPrompts/util/renderers';
import { isRegionalGuidanceLayer, regional_guidance_layer_NAME } from 'features/controlLayers/store/controlLayersSlice';
import { renderers } from 'features/controlLayers/util/renderers';
import Konva from 'konva';
import { assert } from 'tsafe';
@@ -17,12 +17,14 @@ export const getRegionalPromptLayerBlobs = async (
preview: boolean = false
): Promise<Record<string, Blob>> => {
const state = getStore().getState();
const reduxLayers = state.regionalPrompts.present.layers;
const { layers } = state.controlLayers.present;
const { width, height } = state.controlLayers.present.size;
const reduxLayers = layers.filter(isRegionalGuidanceLayer);
const container = document.createElement('div');
const stage = new Konva.Stage({ container, width: state.generation.width, height: state.generation.height });
const stage = new Konva.Stage({ container, width, height });
renderers.renderLayers(stage, reduxLayers, 1, 'brush');
const konvaLayers = stage.find<Konva.Layer>(`.${VECTOR_MASK_LAYER_NAME}`);
const konvaLayers = stage.find<Konva.Layer>(`.${regional_guidance_layer_NAME}`);
const blobs: Record<string, Blob> = {};
// First remove all layers

View File

@@ -1,43 +1,49 @@
import { getStore } from 'app/store/nanostores/store';
import { rgbaColorToString, rgbColorToString } from 'features/canvas/util/colorToString';
import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks';
import type {
Layer,
Tool,
VectorMaskLayer,
VectorMaskLine,
VectorMaskRect,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
import { getScaledFlooredCursorPosition } from 'features/controlLayers/hooks/mouseEventHooks';
import {
$tool,
BACKGROUND_LAYER_ID,
BACKGROUND_RECT_ID,
CONTROLNET_LAYER_IMAGE_NAME,
CONTROLNET_LAYER_NAME,
getControlNetLayerImageId,
getLayerBboxId,
getVectorMaskLayerObjectGroupId,
isVectorMaskLayer,
getRegionalGuidanceLayerObjectGroupId,
isControlAdapterLayer,
isRegionalGuidanceLayer,
isRenderableLayer,
LAYER_BBOX_NAME,
NO_LAYERS_MESSAGE_LAYER_ID,
regional_guidance_layer_LINE_NAME,
regional_guidance_layer_NAME,
regional_guidance_layer_OBJECT_GROUP_NAME,
regional_guidance_layer_RECT_NAME,
TOOL_PREVIEW_BRUSH_BORDER_INNER_ID,
TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID,
TOOL_PREVIEW_BRUSH_FILL_ID,
TOOL_PREVIEW_BRUSH_GROUP_ID,
TOOL_PREVIEW_LAYER_ID,
TOOL_PREVIEW_RECT_ID,
VECTOR_MASK_LAYER_LINE_NAME,
VECTOR_MASK_LAYER_NAME,
VECTOR_MASK_LAYER_OBJECT_GROUP_NAME,
VECTOR_MASK_LAYER_RECT_NAME,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
import { getLayerBboxFast, getLayerBboxPixels } from 'features/regionalPrompts/util/bbox';
} from 'features/controlLayers/store/controlLayersSlice';
import type {
ControlAdapterLayer,
Layer,
RegionalGuidanceLayer,
Tool,
VectorMaskLine,
VectorMaskRect,
} from 'features/controlLayers/store/types';
import { getLayerBboxFast, getLayerBboxPixels } from 'features/controlLayers/util/bbox';
import Konva from 'konva';
import type { IRect, Vector2d } from 'konva/lib/types';
import { debounce } from 'lodash-es';
import type { RgbColor } from 'react-colorful';
import { imagesApi } from 'services/api/endpoints/images';
import { assert } from 'tsafe';
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_BORDER_INNER_COLOR = 'rgba(0,0,0,1)';
const BRUSH_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)';
// This is invokeai/frontend/web/public/assets/images/transparent_bg.png as a dataURL
@@ -46,15 +52,11 @@ const STAGE_BG_DATAURL =
const mapId = (object: { id: string }) => object.id;
const getIsSelected = (layerId?: string | null) => {
if (!layerId) {
return false;
}
return layerId === getStore().getState().regionalPrompts.present.selectedLayerId;
};
const selectRenderableLayers = (n: Konva.Node) =>
n.name() === regional_guidance_layer_NAME || n.name() === CONTROLNET_LAYER_NAME;
const selectVectorMaskObjects = (node: Konva.Node) => {
return node.name() === VECTOR_MASK_LAYER_LINE_NAME || node.name() === VECTOR_MASK_LAYER_RECT_NAME;
return node.name() === regional_guidance_layer_LINE_NAME || node.name() === regional_guidance_layer_RECT_NAME;
};
/**
@@ -132,17 +134,21 @@ const renderToolPreview = (
stage: Konva.Stage,
tool: Tool,
color: RgbColor | null,
selectedLayerType: Layer['type'] | null,
globalMaskLayerOpacity: number,
cursorPos: Vector2d | null,
lastMouseDownPos: Vector2d | null,
isMouseOver: boolean,
brushSize: number
) => {
const layerCount = stage.find(`.${VECTOR_MASK_LAYER_NAME}`).length;
const layerCount = stage.find(`.${regional_guidance_layer_NAME}`).length;
// Update the stage's pointer style
if (layerCount === 0) {
// We have no layers, so we should not render any tool
stage.container().style.cursor = 'default';
} else if (selectedLayerType !== 'regional_guidance_layer') {
// Non-mask-guidance layers don't have tools
stage.container().style.cursor = 'not-allowed';
} else if (tool === 'move') {
// Move tool gets a pointer
stage.container().style.cursor = 'default';
@@ -219,15 +225,15 @@ const renderToolPreview = (
* @param reduxLayer The redux layer to create the konva layer from.
* @param onLayerPosChanged Callback for when the layer's position changes.
*/
const createVectorMaskLayer = (
const createRegionalGuidanceLayer = (
stage: Konva.Stage,
reduxLayer: VectorMaskLayer,
reduxLayer: RegionalGuidanceLayer,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
) => {
// This layer hasn't been added to the konva state yet
const konvaLayer = new Konva.Layer({
id: reduxLayer.id,
name: VECTOR_MASK_LAYER_NAME,
name: regional_guidance_layer_NAME,
draggable: true,
dragDistance: 0,
});
@@ -259,8 +265,8 @@ const createVectorMaskLayer = (
// The object group holds all of the layer's objects (e.g. lines and rects)
const konvaObjectGroup = new Konva.Group({
id: getVectorMaskLayerObjectGroupId(reduxLayer.id, uuidv4()),
name: VECTOR_MASK_LAYER_OBJECT_GROUP_NAME,
id: getRegionalGuidanceLayerObjectGroupId(reduxLayer.id, uuidv4()),
name: regional_guidance_layer_OBJECT_GROUP_NAME,
listening: false,
});
konvaLayer.add(konvaObjectGroup);
@@ -279,7 +285,7 @@ const createVectorMaskLine = (reduxObject: VectorMaskLine, konvaGroup: Konva.Gro
const vectorMaskLine = new Konva.Line({
id: reduxObject.id,
key: reduxObject.id,
name: VECTOR_MASK_LAYER_LINE_NAME,
name: regional_guidance_layer_LINE_NAME,
strokeWidth: reduxObject.strokeWidth,
tension: 0,
lineCap: 'round',
@@ -301,7 +307,7 @@ const createVectorMaskRect = (reduxObject: VectorMaskRect, konvaGroup: Konva.Gro
const vectorMaskRect = new Konva.Rect({
id: reduxObject.id,
key: reduxObject.id,
name: VECTOR_MASK_LAYER_RECT_NAME,
name: regional_guidance_layer_RECT_NAME,
x: reduxObject.x,
y: reduxObject.y,
width: reduxObject.width,
@@ -320,15 +326,16 @@ const createVectorMaskRect = (reduxObject: VectorMaskRect, konvaGroup: Konva.Gro
* @param globalMaskLayerOpacity The opacity of the global mask layer.
* @param tool The current tool.
*/
const renderVectorMaskLayer = (
const renderRegionalGuidanceLayer = (
stage: Konva.Stage,
reduxLayer: VectorMaskLayer,
reduxLayer: RegionalGuidanceLayer,
globalMaskLayerOpacity: number,
tool: Tool,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
): void => {
const konvaLayer =
stage.findOne<Konva.Layer>(`#${reduxLayer.id}`) ?? createVectorMaskLayer(stage, reduxLayer, onLayerPosChanged);
stage.findOne<Konva.Layer>(`#${reduxLayer.id}`) ??
createRegionalGuidanceLayer(stage, reduxLayer, onLayerPosChanged);
// Update the layer's position and listening state
konvaLayer.setAttrs({
@@ -340,13 +347,13 @@ const renderVectorMaskLayer = (
// Convert the color to a string, stripping the alpha - the object group will handle opacity.
const rgbColor = rgbColorToString(reduxLayer.previewColor);
const konvaObjectGroup = konvaLayer.findOne<Konva.Group>(`.${VECTOR_MASK_LAYER_OBJECT_GROUP_NAME}`);
const konvaObjectGroup = konvaLayer.findOne<Konva.Group>(`.${regional_guidance_layer_OBJECT_GROUP_NAME}`);
assert(konvaObjectGroup, `Object group not found for layer ${reduxLayer.id}`);
// We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required.
let groupNeedsCache = false;
const objectIds = reduxLayer.objects.map(mapId);
const objectIds = reduxLayer.maskObjects.map(mapId);
for (const objectNode of konvaObjectGroup.find(selectVectorMaskObjects)) {
if (!objectIds.includes(objectNode.id())) {
objectNode.destroy();
@@ -354,7 +361,7 @@ const renderVectorMaskLayer = (
}
}
for (const reduxObject of reduxLayer.objects) {
for (const reduxObject of reduxLayer.maskObjects) {
if (reduxObject.type === 'vector_mask_line') {
const vectorMaskLine =
stage.findOne<Konva.Line>(`#${reduxObject.id}`) ?? createVectorMaskLine(reduxObject, konvaObjectGroup);
@@ -383,8 +390,8 @@ const renderVectorMaskLayer = (
}
// Only update layer visibility if it has changed.
if (konvaLayer.visible() !== reduxLayer.isVisible) {
konvaLayer.visible(reduxLayer.isVisible);
if (konvaLayer.visible() !== reduxLayer.isEnabled) {
konvaLayer.visible(reduxLayer.isEnabled);
groupNeedsCache = true;
}
@@ -401,6 +408,118 @@ const renderVectorMaskLayer = (
}
};
const createControlNetLayer = (stage: Konva.Stage, reduxLayer: ControlAdapterLayer): Konva.Layer => {
const konvaLayer = new Konva.Layer({
id: reduxLayer.id,
name: CONTROLNET_LAYER_NAME,
imageSmoothingEnabled: true,
});
stage.add(konvaLayer);
return konvaLayer;
};
const createControlNetLayerImage = (konvaLayer: Konva.Layer, image: HTMLImageElement): Konva.Image => {
const konvaImage = new Konva.Image({
name: CONTROLNET_LAYER_IMAGE_NAME,
image,
});
konvaLayer.add(konvaImage);
return konvaImage;
};
const updateControlNetLayerImageSource = async (
stage: Konva.Stage,
konvaLayer: Konva.Layer,
reduxLayer: ControlAdapterLayer
) => {
if (reduxLayer.imageName) {
const imageName = reduxLayer.imageName;
const req = getStore().dispatch(imagesApi.endpoints.getImageDTO.initiate(reduxLayer.imageName));
const imageDTO = await req.unwrap();
req.unsubscribe();
const image = new Image();
const imageId = getControlNetLayerImageId(reduxLayer.id, imageName);
image.onload = () => {
// Find the existing image or create a new one - must find using the name, bc the id may have just changed
const konvaImage =
konvaLayer.findOne<Konva.Image>(`.${CONTROLNET_LAYER_IMAGE_NAME}`) ??
createControlNetLayerImage(konvaLayer, image);
// Update the image's attributes
konvaImage.setAttrs({
id: imageId,
image,
});
updateControlNetLayerImageAttrs(stage, konvaImage, reduxLayer);
// Must cache after this to apply the filters
konvaImage.cache();
image.id = imageId;
};
image.src = imageDTO.image_url;
} else {
konvaLayer.findOne(`.${CONTROLNET_LAYER_IMAGE_NAME}`)?.destroy();
}
};
const updateControlNetLayerImageAttrs = (
stage: Konva.Stage,
konvaImage: Konva.Image,
reduxLayer: ControlAdapterLayer
) => {
let needsCache = false;
const newWidth = stage.width() / stage.scaleX();
const newHeight = stage.height() / stage.scaleY();
const hasFilter = konvaImage.filters() !== null && konvaImage.filters().length > 0;
if (
konvaImage.width() !== newWidth ||
konvaImage.height() !== newHeight ||
konvaImage.visible() !== reduxLayer.isEnabled ||
hasFilter !== reduxLayer.isFilterEnabled
) {
konvaImage.setAttrs({
opacity: reduxLayer.opacity,
scaleX: 1,
scaleY: 1,
width: stage.width() / stage.scaleX(),
height: stage.height() / stage.scaleY(),
visible: reduxLayer.isEnabled,
filters: reduxLayer.isFilterEnabled ? [LightnessToAlphaFilter] : [],
});
needsCache = true;
}
if (konvaImage.opacity() !== reduxLayer.opacity) {
konvaImage.opacity(reduxLayer.opacity);
}
if (needsCache) {
konvaImage.cache();
}
};
const renderControlNetLayer = (stage: Konva.Stage, reduxLayer: ControlAdapterLayer) => {
const konvaLayer = stage.findOne<Konva.Layer>(`#${reduxLayer.id}`) ?? createControlNetLayer(stage, reduxLayer);
const konvaImage = konvaLayer.findOne<Konva.Image>(`.${CONTROLNET_LAYER_IMAGE_NAME}`);
const canvasImageSource = konvaImage?.image();
let imageSourceNeedsUpdate = false;
if (canvasImageSource instanceof HTMLImageElement) {
if (
reduxLayer.imageName &&
canvasImageSource.id !== getControlNetLayerImageId(reduxLayer.id, reduxLayer.imageName)
) {
imageSourceNeedsUpdate = true;
} else if (!reduxLayer.imageName) {
imageSourceNeedsUpdate = true;
}
} else if (!canvasImageSource) {
imageSourceNeedsUpdate = true;
}
if (imageSourceNeedsUpdate) {
updateControlNetLayerImageSource(stage, konvaLayer, reduxLayer);
} else if (konvaImage) {
updateControlNetLayerImageAttrs(stage, konvaImage, reduxLayer);
}
};
/**
* Renders the layers on the stage.
* @param stage The konva stage to render on.
@@ -416,18 +535,20 @@ const renderLayers = (
tool: Tool,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
) => {
const reduxLayerIds = reduxLayers.map(mapId);
const reduxLayerIds = reduxLayers.filter(isRenderableLayer).map(mapId);
// Remove un-rendered layers
for (const konvaLayer of stage.find<Konva.Layer>(`.${VECTOR_MASK_LAYER_NAME}`)) {
for (const konvaLayer of stage.find<Konva.Layer>(selectRenderableLayers)) {
if (!reduxLayerIds.includes(konvaLayer.id())) {
konvaLayer.destroy();
}
}
for (const reduxLayer of reduxLayers) {
if (isVectorMaskLayer(reduxLayer)) {
renderVectorMaskLayer(stage, reduxLayer, globalMaskLayerOpacity, tool, onLayerPosChanged);
if (isRegionalGuidanceLayer(reduxLayer)) {
renderRegionalGuidanceLayer(stage, reduxLayer, globalMaskLayerOpacity, tool, onLayerPosChanged);
}
if (isControlAdapterLayer(reduxLayer)) {
renderControlNetLayer(stage, reduxLayer);
}
}
};
@@ -438,29 +559,12 @@ const renderLayers = (
* @param konvaLayer The konva layer to attach the bounding box to.
* @param onBboxMouseDown Callback for when the bounding box is clicked.
*/
const createBboxRect = (reduxLayer: Layer, konvaLayer: Konva.Layer, onBboxMouseDown: (layerId: string) => void) => {
const createBboxRect = (reduxLayer: Layer, konvaLayer: Konva.Layer) => {
const rect = new Konva.Rect({
id: getLayerBboxId(reduxLayer.id),
name: LAYER_BBOX_NAME,
strokeWidth: 1,
});
rect.on('mousedown', function () {
onBboxMouseDown(reduxLayer.id);
});
rect.on('mouseover', function (e) {
if (getIsSelected(e.target.getLayer()?.id())) {
this.stroke(BBOX_SELECTED_STROKE);
} else {
this.stroke(BBOX_NOT_SELECTED_MOUSEOVER_STROKE);
}
});
rect.on('mouseout', function (e) {
if (getIsSelected(e.target.getLayer()?.id())) {
this.stroke(BBOX_SELECTED_STROKE);
} else {
this.stroke(BBOX_NOT_SELECTED_STROKE);
}
});
konvaLayer.add(rect);
return rect;
};
@@ -478,10 +582,8 @@ const createBboxRect = (reduxLayer: Layer, konvaLayer: Konva.Layer, onBboxMouseD
const renderBbox = (
stage: Konva.Stage,
reduxLayers: Layer[],
selectedLayerId: string | null,
tool: Tool,
onBboxChanged: (layerId: string, bbox: IRect | null) => void,
onBboxMouseDown: (layerId: string) => void
onBboxChanged: (layerId: string, bbox: IRect | null) => void
) => {
// Hide all bboxes so they don't interfere with getClientRect
for (const bboxRect of stage.find<Konva.Rect>(`.${LAYER_BBOX_NAME}`)) {
@@ -494,35 +596,36 @@ const renderBbox = (
}
for (const reduxLayer of reduxLayers) {
const konvaLayer = stage.findOne<Konva.Layer>(`#${reduxLayer.id}`);
assert(konvaLayer, `Layer ${reduxLayer.id} not found in stage`);
if (reduxLayer.type === 'regional_guidance_layer') {
const konvaLayer = stage.findOne<Konva.Layer>(`#${reduxLayer.id}`);
assert(konvaLayer, `Layer ${reduxLayer.id} not found in stage`);
let bbox = reduxLayer.bbox;
let bbox = reduxLayer.bbox;
// 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 ? getLayerBboxPixels(konvaLayer) : getLayerBboxFast(konvaLayer);
// Update the layer's bbox in the redux store
onBboxChanged(reduxLayer.id, bbox);
// We only need to recalculate the bbox if the layer has changed and it has objects
if (reduxLayer.bboxNeedsUpdate && reduxLayer.maskObjects.length) {
// We only need to use the pixel-perfect bounding box if the layer has eraser strokes
bbox = reduxLayer.needsPixelBbox ? getLayerBboxPixels(konvaLayer) : getLayerBboxFast(konvaLayer);
// Update the layer's bbox in the redux store
onBboxChanged(reduxLayer.id, bbox);
}
if (!bbox) {
continue;
}
const rect = konvaLayer.findOne<Konva.Rect>(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(reduxLayer, konvaLayer);
rect.setAttrs({
visible: true,
listening: reduxLayer.isSelected,
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
stroke: reduxLayer.isSelected ? BBOX_SELECTED_STROKE : '',
});
}
if (!bbox) {
continue;
}
const rect =
konvaLayer.findOne<Konva.Rect>(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(reduxLayer, konvaLayer, onBboxMouseDown);
rect.setAttrs({
visible: true,
listening: true,
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
stroke: reduxLayer.id === selectedLayerId ? BBOX_SELECTED_STROKE : BBOX_NOT_SELECTED_STROKE,
});
}
};
@@ -600,11 +703,47 @@ const arrangeLayers = (stage: Konva.Stage, layerIds: string[]): void => {
stage.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)?.zIndex(nextZIndex++);
};
const createNoLayersMessageLayer = (stage: Konva.Stage): Konva.Layer => {
const noLayersMessageLayer = new Konva.Layer({
id: NO_LAYERS_MESSAGE_LAYER_ID,
opacity: 0.7,
listening: false,
});
const text = new Konva.Text({
x: 0,
y: 0,
align: 'center',
verticalAlign: 'middle',
text: 'No Layers Added',
fontFamily: '"Inter Variable", sans-serif',
fontStyle: '600',
fill: 'white',
});
noLayersMessageLayer.add(text);
stage.add(noLayersMessageLayer);
return noLayersMessageLayer;
};
const renderNoLayersMessage = (stage: Konva.Stage, layerCount: number, width: number, height: number) => {
const noLayersMessageLayer =
stage.findOne<Konva.Layer>(`#${NO_LAYERS_MESSAGE_LAYER_ID}`) ?? createNoLayersMessageLayer(stage);
if (layerCount === 0) {
noLayersMessageLayer.findOne<Konva.Text>('Text')?.setAttrs({
width,
height,
fontSize: 32 / stage.scaleX(),
});
} else {
noLayersMessageLayer?.destroy();
}
};
export const renderers = {
renderToolPreview,
renderLayers,
renderBbox,
renderBackground,
renderNoLayersMessage,
arrangeLayers,
};
@@ -615,5 +754,23 @@ export const debouncedRenderers = {
renderLayers: debounce(renderLayers, DEBOUNCE_MS),
renderBbox: debounce(renderBbox, DEBOUNCE_MS),
renderBackground: debounce(renderBackground, DEBOUNCE_MS),
renderNoLayersMessage: debounce(renderNoLayersMessage, DEBOUNCE_MS),
arrangeLayers: debounce(arrangeLayers, DEBOUNCE_MS),
};
/**
* Calculates the lightness (HSL) of a given pixel and sets the alpha channel to that value.
* This is useful for edge maps and other masks, to make the black areas transparent.
* @param imageData The image data to apply the filter to
*/
const LightnessToAlphaFilter = (imageData: ImageData) => {
const len = imageData.data.length / 4;
for (let i = 0; i < len; i++) {
const r = imageData.data[i * 4 + 0] as number;
const g = imageData.data[i * 4 + 1] as number;
const b = imageData.data[i * 4 + 2] as number;
const cMin = Math.min(r, g, b);
const cMax = Math.max(r, g, b);
imageData.data[i * 4 + 3] = (cMin + cMax) / 2;
}
};

View File

@@ -286,7 +286,9 @@ const parseControlNet: MetadataParseFunc<ControlNetConfigMetadata> = async (meta
controlMode: control_mode ?? initialControlNet.controlMode,
resizeMode: resize_mode ?? initialControlNet.resizeMode,
controlImage: image?.image_name ?? null,
controlImageDimensions: null,
processedControlImage: processedImage?.image_name ?? null,
processedControlImageDimensions: null,
processorType,
processorNode,
shouldAutoConfig: true,
@@ -350,9 +352,11 @@ const parseT2IAdapter: MetadataParseFunc<T2IAdapterConfigMetadata> = async (meta
endStepPct: end_step_percent ?? initialT2IAdapter.endStepPct,
resizeMode: resize_mode ?? initialT2IAdapter.resizeMode,
controlImage: image?.image_name ?? null,
controlImageDimensions: null,
processedControlImage: processedImage?.image_name ?? null,
processorType,
processedControlImageDimensions: null,
processorNode,
processorType,
shouldAutoConfig: true,
id: uuidv4(),
};

View File

@@ -5,6 +5,14 @@ import {
ipAdaptersReset,
t2iAdaptersReset,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import {
heightChanged,
negativePrompt2Changed,
negativePromptChanged,
positivePrompt2Changed,
positivePromptChanged,
widthChanged,
} from 'features/controlLayers/store/controlLayersSlice';
import { setHrfEnabled, setHrfMethod, setHrfStrength } from 'features/hrf/store/hrfSlice';
import type { LoRA } from 'features/lora/store/loraSlice';
import { loraRecalled, lorasReset } from 'features/lora/store/loraSlice';
@@ -16,18 +24,14 @@ import type {
} from 'features/metadata/types';
import { modelSelected } from 'features/parameters/store/actions';
import {
heightRecalled,
initialImageChanged,
setCfgRescaleMultiplier,
setCfgScale,
setImg2imgStrength,
setNegativePrompt,
setPositivePrompt,
setScheduler,
setSeed,
setSteps,
vaeSelected,
widthRecalled,
} from 'features/parameters/store/generationSlice';
import type {
ParameterCFGRescaleMultiplier,
@@ -53,8 +57,6 @@ import type {
} from 'features/parameters/types/parameterSchemas';
import {
refinerModelChanged,
setNegativeStylePromptSDXL,
setPositiveStylePromptSDXL,
setRefinerCFGScale,
setRefinerNegativeAestheticScore,
setRefinerPositiveAestheticScore,
@@ -65,19 +67,19 @@ import {
import type { ImageDTO } from 'services/api/types';
const recallPositivePrompt: MetadataRecallFunc<ParameterPositivePrompt> = (positivePrompt) => {
getStore().dispatch(setPositivePrompt(positivePrompt));
getStore().dispatch(positivePromptChanged(positivePrompt));
};
const recallNegativePrompt: MetadataRecallFunc<ParameterNegativePrompt> = (negativePrompt) => {
getStore().dispatch(setNegativePrompt(negativePrompt));
getStore().dispatch(negativePromptChanged(negativePrompt));
};
const recallSDXLPositiveStylePrompt: MetadataRecallFunc<ParameterPositiveStylePromptSDXL> = (positiveStylePrompt) => {
getStore().dispatch(setPositiveStylePromptSDXL(positiveStylePrompt));
getStore().dispatch(positivePrompt2Changed(positiveStylePrompt));
};
const recallSDXLNegativeStylePrompt: MetadataRecallFunc<ParameterNegativeStylePromptSDXL> = (negativeStylePrompt) => {
getStore().dispatch(setNegativeStylePromptSDXL(negativeStylePrompt));
getStore().dispatch(negativePrompt2Changed(negativeStylePrompt));
};
const recallSeed: MetadataRecallFunc<ParameterSeed> = (seed) => {
@@ -101,11 +103,11 @@ const recallInitialImage: MetadataRecallFunc<ImageDTO> = async (imageDTO) => {
};
const recallWidth: MetadataRecallFunc<ParameterWidth> = (width) => {
getStore().dispatch(widthRecalled(width));
getStore().dispatch(widthChanged({ width, updateAspectRatio: true }));
};
const recallHeight: MetadataRecallFunc<ParameterHeight> = (height) => {
getStore().dispatch(heightRecalled(height));
getStore().dispatch(heightChanged({ height, updateAspectRatio: true }));
};
const recallSteps: MetadataRecallFunc<ParameterSteps> = (steps) => {

View File

@@ -1,6 +1,8 @@
import { getStore } from 'app/store/nanostores/store';
import type { RootState } from 'app/store/store';
import { selectAllIPAdapters } from 'features/controlAdapters/store/controlAdaptersSlice';
import { isRegionalGuidanceLayer } from 'features/controlLayers/store/controlLayersSlice';
import { getRegionalPromptLayerBlobs } from 'features/controlLayers/util/getLayerBlobs';
import {
IP_ADAPTER_COLLECT,
NEGATIVE_CONDITIONING,
@@ -13,25 +15,23 @@ import {
PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX,
PROMPT_REGION_POSITIVE_COND_PREFIX,
} 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 { imagesApi } from 'services/api/endpoints/images';
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) => {
if (!state.regionalPrompts.present.isEnabled) {
export const addControlLayersToGraph = async (state: RootState, graph: NonNullableGraph, denoiseNodeId: string) => {
if (!state.controlLayers.present.isEnabled) {
return;
}
const { dispatch } = getStore();
const isSDXL = state.generation.model?.base === 'sdxl';
const layers = state.regionalPrompts.present.layers
const layers = state.controlLayers.present.layers
// Only support vector mask layers now
// TODO: Image masks
.filter(isVectorMaskLayer)
.filter(isRegionalGuidanceLayer)
// Only visible layers are rendered on the canvas
.filter((l) => l.isVisible)
.filter((l) => l.isEnabled)
// Only layers with prompts get added to the graph
.filter((l) => {
const hasTextPrompt = Boolean(l.positivePrompt || l.negativePrompt);
@@ -39,12 +39,15 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull
return hasTextPrompt || hasIPAdapter;
});
// Collect all IP Adapter ids for IP adapter layers
const layerIPAdapterIds = layers.flatMap((l) => l.ipAdapterIds);
const regionalIPAdapters = selectAllIPAdapters(state.controlAdapters).filter(
({ id, model, controlImage, isEnabled }) => {
const hasModel = Boolean(model);
const doesBaseMatch = model?.base === state.generation.model?.base;
const hasControlImage = controlImage;
const isRegional = layers.some((l) => l.ipAdapterIds.includes(id));
const isRegional = layerIPAdapterIds.includes(id);
return isEnabled && hasModel && doesBaseMatch && hasControlImage && isRegional;
}
);

View File

@@ -1,7 +1,10 @@
import type { RootState } from 'app/store/store';
import { selectValidControlNets } from 'features/controlAdapters/store/controlAdaptersSlice';
import type { ControlAdapterProcessorType, ControlNetConfig } from 'features/controlAdapters/store/types';
import { isControlAdapterLayer } from 'features/controlLayers/store/controlLayersSlice';
import type { ImageField } from 'features/nodes/types/common';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { differenceWith, intersectionWith } from 'lodash-es';
import type {
CollectInvocation,
ControlNetInvocation,
@@ -14,11 +17,8 @@ import { assert } from 'tsafe';
import { CONTROL_NET_COLLECT } from './constants';
import { upsertMetadata } from './metadata';
export const addControlNetToLinearGraph = async (
state: RootState,
graph: NonNullableGraph,
baseNodeId: string
): Promise<void> => {
const getControlNets = (state: RootState) => {
// Start with the valid controlnets
const validControlNets = selectValidControlNets(state.controlAdapters).filter(
({ model, processedControlImage, processorType, controlImage, isEnabled }) => {
const hasModel = Boolean(model);
@@ -29,9 +29,37 @@ export const addControlNetToLinearGraph = async (
}
);
// txt2img tab has special handling - it uses layers exclusively, while the other tabs use the older control adapters
// accordion. We need to filter the list of valid T2I adapters according to the tab.
const activeTabName = activeTabNameSelector(state);
if (activeTabName === 'txt2img') {
// Add only the cnets that are used in control layers
// Collect all ControlNet ids for enabled ControlNet layers
const layerControlNetIds = state.controlLayers.present.layers
.filter(isControlAdapterLayer)
.filter((l) => l.isEnabled)
.map((l) => l.controlNetId);
return intersectionWith(validControlNets, layerControlNetIds, (a, b) => a.id === b);
} else {
// Else, we want to exclude the cnets that are used in control layers
// Collect all ControlNet ids for all ControlNet layers
const layerControlNetIds = state.controlLayers.present.layers
.filter(isControlAdapterLayer)
.map((l) => l.controlNetId);
return differenceWith(validControlNets, layerControlNetIds, (a, b) => a.id === b);
}
};
export const addControlNetToLinearGraph = async (
state: RootState,
graph: NonNullableGraph,
baseNodeId: string
): Promise<void> => {
const controlNets = getControlNets(state);
const controlNetMetadata: CoreMetadataInvocation['controlnets'] = [];
if (validControlNets.length) {
if (controlNets.length) {
// Even though denoise_latents' control input is collection or scalar, keep it simple and always use a collect
const controlNetIterateNode: CollectInvocation = {
id: CONTROL_NET_COLLECT,
@@ -47,7 +75,7 @@ export const addControlNetToLinearGraph = async (
},
});
for (const controlNet of validControlNets) {
for (const controlNet of controlNets) {
if (!controlNet.model) {
return;
}

View File

@@ -110,10 +110,9 @@ export const addHrfToGraph = (state: RootState, graph: NonNullableGraph): void =
const { vae, seamlessXAxis, seamlessYAxis } = state.generation;
const { hrfStrength, hrfEnabled, hrfMethod } = state.hrf;
const { width, height } = state.controlLayers.present.size;
const isAutoVae = !vae;
const isSeamlessEnabled = seamlessXAxis || seamlessYAxis;
const width = state.generation.width;
const height = state.generation.height;
const optimalDimension = selectOptimalDimension(state);
const { newWidth: hrfWidth, newHeight: hrfHeight } = calculateHrfRes(optimalDimension, width, height);

View File

@@ -1,7 +1,10 @@
import type { RootState } from 'app/store/store';
import { selectValidIPAdapters } from 'features/controlAdapters/store/controlAdaptersSlice';
import type { IPAdapterConfig } from 'features/controlAdapters/store/types';
import { isIPAdapterLayer, isRegionalGuidanceLayer } from 'features/controlLayers/store/controlLayersSlice';
import type { ImageField } from 'features/nodes/types/common';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { differenceWith, intersectionWith } from 'lodash-es';
import type {
CollectInvocation,
CoreMetadataInvocation,
@@ -14,21 +17,50 @@ import { assert } from 'tsafe';
import { IP_ADAPTER_COLLECT } from './constants';
import { upsertMetadata } from './metadata';
const getIPAdapters = (state: RootState) => {
// Start with the valid IP adapters
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;
});
// Masked IP adapters are handled in the graph helper for regional control - skip them here
const maskedIPAdapterIds = state.controlLayers.present.layers
.filter(isRegionalGuidanceLayer)
.map((l) => l.ipAdapterIds)
.flat();
const nonMaskedIPAdapters = differenceWith(validIPAdapters, maskedIPAdapterIds, (a, b) => a.id === b);
// txt2img tab has special handling - it uses layers exclusively, while the other tabs use the older control adapters
// accordion. We need to filter the list of valid IP adapters according to the tab.
const activeTabName = activeTabNameSelector(state);
if (activeTabName === 'txt2img') {
// If we are on the t2i tab, we only want to add the IP adapters that are used in unmasked IP Adapter layers
// Collect all IP Adapter ids for enabled IP adapter layers
const layerIPAdapterIds = state.controlLayers.present.layers
.filter(isIPAdapterLayer)
.filter((l) => l.isEnabled)
.map((l) => l.ipAdapterId);
return intersectionWith(nonMaskedIPAdapters, layerIPAdapterIds, (a, b) => a.id === b);
} else {
// Else, we want to exclude the IP adapters that are used in IP Adapter layers
// Collect all IP Adapter ids for enabled IP adapter layers
const layerIPAdapterIds = state.controlLayers.present.layers.filter(isIPAdapterLayer).map((l) => l.ipAdapterId);
return differenceWith(nonMaskedIPAdapters, layerIPAdapterIds, (a, b) => a.id === b);
}
};
export const addIPAdapterToLinearGraph = async (
state: RootState,
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;
})
.filter((ca) => !state.regionalPrompts.present.layers.some((l) => l.ipAdapterIds.includes(ca.id)));
const ipAdapters = getIPAdapters(state);
if (validIPAdapters.length) {
if (ipAdapters.length) {
// Even though denoise_latents' ip adapter input is collection or scalar, keep it simple and always use a collect
const ipAdapterCollectNode: CollectInvocation = {
id: IP_ADAPTER_COLLECT,
@@ -46,7 +78,7 @@ export const addIPAdapterToLinearGraph = async (
const ipAdapterMetdata: CoreMetadataInvocation['ipAdapters'] = [];
for (const ipAdapter of validIPAdapters) {
for (const ipAdapter of ipAdapters) {
if (!ipAdapter.model) {
return;
}

View File

@@ -1,7 +1,10 @@
import type { RootState } from 'app/store/store';
import { selectValidT2IAdapters } from 'features/controlAdapters/store/controlAdaptersSlice';
import type { ControlAdapterProcessorType, T2IAdapterConfig } from 'features/controlAdapters/store/types';
import { isControlAdapterLayer } from 'features/controlLayers/store/controlLayersSlice';
import type { ImageField } from 'features/nodes/types/common';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { differenceWith, intersectionWith } from 'lodash-es';
import type {
CollectInvocation,
CoreMetadataInvocation,
@@ -14,11 +17,8 @@ import { assert } from 'tsafe';
import { T2I_ADAPTER_COLLECT } from './constants';
import { upsertMetadata } from './metadata';
export const addT2IAdaptersToLinearGraph = async (
state: RootState,
graph: NonNullableGraph,
baseNodeId: string
): Promise<void> => {
const getT2IAdapters = (state: RootState) => {
// Start with the valid controlnets
const validT2IAdapters = selectValidT2IAdapters(state.controlAdapters).filter(
({ model, processedControlImage, processorType, controlImage, isEnabled }) => {
const hasModel = Boolean(model);
@@ -29,7 +29,35 @@ export const addT2IAdaptersToLinearGraph = async (
}
);
if (validT2IAdapters.length) {
// txt2img tab has special handling - it uses layers exclusively, while the other tabs use the older control adapters
// accordion. We need to filter the list of valid T2I adapters according to the tab.
const activeTabName = activeTabNameSelector(state);
if (activeTabName === 'txt2img') {
// Add only the T2Is that are used in control layers
// Collect all ids for enabled control adapter layers
const layerControlAdapterIds = state.controlLayers.present.layers
.filter(isControlAdapterLayer)
.filter((l) => l.isEnabled)
.map((l) => l.controlNetId);
return intersectionWith(validT2IAdapters, layerControlAdapterIds, (a, b) => a.id === b);
} else {
// Else, we want to exclude the T2Is that are used in control layers
const layerControlAdapterIds = state.controlLayers.present.layers
.filter(isControlAdapterLayer)
.map((l) => l.controlNetId);
return differenceWith(validT2IAdapters, layerControlAdapterIds, (a, b) => a.id === b);
}
};
export const addT2IAdaptersToLinearGraph = async (
state: RootState,
graph: NonNullableGraph,
baseNodeId: string
): Promise<void> => {
const t2iAdapters = getT2IAdapters(state);
if (t2iAdapters.length) {
// Even though denoise_latents' t2i adapter input is collection or scalar, keep it simple and always use a collect
const t2iAdapterCollectNode: CollectInvocation = {
id: T2I_ADAPTER_COLLECT,
@@ -47,7 +75,7 @@ export const addT2IAdaptersToLinearGraph = async (
const t2iAdapterMetadata: CoreMetadataInvocation['t2iAdapters'] = [];
for (const t2iAdapter of validT2IAdapters) {
for (const t2iAdapter of t2iAdapters) {
if (!t2iAdapter.model) {
return;
}

View File

@@ -42,8 +42,6 @@ export const buildCanvasImageToImageGraph = async (
): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
@@ -57,6 +55,7 @@ export const buildCanvasImageToImageGraph = async (
seamlessXAxis,
seamlessYAxis,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
// The bounding box determines width and height, not the width and height params
const { width, height } = state.canvas.boundingBoxDimensions;

View File

@@ -47,8 +47,6 @@ export const buildCanvasInpaintGraph = async (
): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
@@ -66,6 +64,7 @@ export const buildCanvasInpaintGraph = async (
canvasCoherenceEdgeSize,
maskBlur,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
if (!model) {
log.error('No model found in state');

View File

@@ -51,8 +51,6 @@ export const buildCanvasOutpaintGraph = async (
): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
@@ -78,6 +76,7 @@ export const buildCanvasOutpaintGraph = async (
canvasCoherenceEdgeSize,
maskBlur,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
if (!model) {
log.error('No model found in state');

View File

@@ -43,8 +43,6 @@ export const buildCanvasSDXLImageToImageGraph = async (
): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
@@ -57,6 +55,7 @@ export const buildCanvasSDXLImageToImageGraph = async (
seamlessYAxis,
img2imgStrength: strength,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { refinerModel, refinerStart } = state.sdxl;

View File

@@ -48,8 +48,6 @@ export const buildCanvasSDXLInpaintGraph = async (
): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
@@ -66,6 +64,7 @@ export const buildCanvasSDXLInpaintGraph = async (
canvasCoherenceEdgeSize,
maskBlur,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { refinerModel, refinerStart } = state.sdxl;

View File

@@ -52,8 +52,6 @@ export const buildCanvasSDXLOutpaintGraph = async (
): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
@@ -78,6 +76,7 @@ export const buildCanvasSDXLOutpaintGraph = async (
canvasCoherenceEdgeSize,
maskBlur,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { refinerModel, refinerStart } = state.sdxl;

View File

@@ -33,8 +33,6 @@ import { addCoreMetadataNode, getModelMetadataField } from './metadata';
export const buildCanvasSDXLTextToImageGraph = async (state: RootState): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
@@ -46,6 +44,7 @@ export const buildCanvasSDXLTextToImageGraph = async (state: RootState): Promise
seamlessXAxis,
seamlessYAxis,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
// The bounding box determines width and height, not the width and height params
const { width, height } = state.canvas.boundingBoxDimensions;

View File

@@ -32,8 +32,6 @@ import { addCoreMetadataNode, getModelMetadataField } from './metadata';
export const buildCanvasTextToImageGraph = async (state: RootState): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
@@ -46,6 +44,7 @@ export const buildCanvasTextToImageGraph = async (state: RootState): Promise<Non
seamlessXAxis,
seamlessYAxis,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
// The bounding box determines width and height, not the width and height params
const { width, height } = state.canvas.boundingBoxDimensions;

View File

@@ -10,7 +10,7 @@ import { getHasMetadata, removeMetadata } from './metadata';
export const prepareLinearUIBatch = (state: RootState, graph: NonNullableGraph, prepend: boolean): BatchConfig => {
const { iterations, model, shouldRandomizeSeed, seed } = state.generation;
const { shouldConcatSDXLStylePrompt } = state.sdxl;
const { shouldConcatPrompts } = state.controlLayers.present;
const { prompts, seedBehaviour } = state.dynamicPrompts;
const data: Batch['data'] = [];
@@ -105,7 +105,7 @@ export const prepareLinearUIBatch = (state: RootState, graph: NonNullableGraph,
});
}
if (shouldConcatSDXLStylePrompt && model?.base === 'sdxl') {
if (shouldConcatPrompts && model?.base === 'sdxl') {
if (graph.nodes[POSITIVE_CONDITIONING]) {
firstBatchDatumList.push({
node_path: POSITIVE_CONDITIONING,

View File

@@ -38,8 +38,6 @@ import { addCoreMetadataNode, getModelMetadataField } from './metadata';
export const buildLinearImageToImageGraph = async (state: RootState): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
@@ -49,14 +47,14 @@ export const buildLinearImageToImageGraph = async (state: RootState): Promise<No
initialImage,
img2imgStrength: strength,
shouldFitToWidthHeight,
width,
height,
clipSkip,
shouldUseCpuNoise,
vaePrecision,
seamlessXAxis,
seamlessYAxis,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { width, height } = state.controlLayers.present.size;
/**
* The easiest way to build linear graphs is to do it in the node editor, then copy and paste the

View File

@@ -39,8 +39,6 @@ import { addCoreMetadataNode, getModelMetadataField } from './metadata';
export const buildLinearSDXLImageToImageGraph = async (state: RootState): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
@@ -49,14 +47,14 @@ export const buildLinearSDXLImageToImageGraph = async (state: RootState): Promis
steps,
initialImage,
shouldFitToWidthHeight,
width,
height,
shouldUseCpuNoise,
vaePrecision,
seamlessXAxis,
seamlessYAxis,
img2imgStrength: strength,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { width, height } = state.controlLayers.present.size;
const { refinerModel, refinerStart } = state.sdxl;

View File

@@ -1,7 +1,7 @@
import { logger } from 'app/logging/logger';
import type { RootState } from 'app/store/store';
import { fetchModelConfigWithTypeGuard } from 'features/metadata/util/modelFetchingHelpers';
import { addRegionalPromptsToGraph } from 'features/nodes/util/graph/addRegionalPromptsToGraph';
import { addControlLayersToGraph } from 'features/nodes/util/graph/addControlLayersToGraph';
import { isNonRefinerMainModelConfig, type NonNullableGraph } from 'services/api/types';
import { addControlNetToLinearGraph } from './addControlNetToLinearGraph';
@@ -30,21 +30,19 @@ import { addCoreMetadataNode, getModelMetadataField } from './metadata';
export const buildLinearSDXLTextToImageGraph = async (state: RootState): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
scheduler,
seed,
steps,
width,
height,
shouldUseCpuNoise,
vaePrecision,
seamlessXAxis,
seamlessYAxis,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { width, height } = state.controlLayers.present.size;
const { refinerModel, refinerStart } = state.sdxl;
@@ -274,7 +272,7 @@ export const buildLinearSDXLTextToImageGraph = async (state: RootState): Promise
await addT2IAdaptersToLinearGraph(state, graph, SDXL_DENOISE_LATENTS);
await addRegionalPromptsToGraph(state, graph, SDXL_DENOISE_LATENTS);
await addControlLayersToGraph(state, graph, SDXL_DENOISE_LATENTS);
// NSFW & watermark - must be last thing added to graph
if (state.system.shouldUseNSFWChecker) {

View File

@@ -1,7 +1,7 @@
import { logger } from 'app/logging/logger';
import type { RootState } from 'app/store/store';
import { fetchModelConfigWithTypeGuard } from 'features/metadata/util/modelFetchingHelpers';
import { addRegionalPromptsToGraph } from 'features/nodes/util/graph/addRegionalPromptsToGraph';
import { addControlLayersToGraph } from 'features/nodes/util/graph/addControlLayersToGraph';
import { getBoardField, getIsIntermediate } from 'features/nodes/util/graph/graphBuilderUtils';
import { isNonRefinerMainModelConfig, type NonNullableGraph } from 'services/api/types';
@@ -30,15 +30,11 @@ import { addCoreMetadataNode, getModelMetadataField } from './metadata';
export const buildLinearTextToImageGraph = async (state: RootState): Promise<NonNullableGraph> => {
const log = logger('nodes');
const {
positivePrompt,
negativePrompt,
model,
cfgScale: cfg_scale,
cfgRescaleMultiplier: cfg_rescale_multiplier,
scheduler,
steps,
width,
height,
clipSkip,
shouldUseCpuNoise,
vaePrecision,
@@ -46,6 +42,8 @@ export const buildLinearTextToImageGraph = async (state: RootState): Promise<Non
seamlessYAxis,
seed,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { width, height } = state.controlLayers.present.size;
const use_cpu = shouldUseCpuNoise;
@@ -256,7 +254,7 @@ export const buildLinearTextToImageGraph = async (state: RootState): Promise<Non
await addT2IAdaptersToLinearGraph(state, graph, DENOISE_LATENTS);
await addRegionalPromptsToGraph(state, graph, DENOISE_LATENTS);
await addControlLayersToGraph(state, graph, DENOISE_LATENTS);
// High resolution fix.
if (state.hrf.hrfEnabled) {

View File

@@ -17,12 +17,12 @@ export const getBoardField = (state: RootState): BoardField | undefined => {
* Gets the SDXL style prompts, based on the concat setting.
*/
export const getSDXLStylePrompts = (state: RootState): { positiveStylePrompt: string; negativeStylePrompt: string } => {
const { positivePrompt, negativePrompt } = state.generation;
const { positiveStylePrompt, negativeStylePrompt, shouldConcatSDXLStylePrompt } = state.sdxl;
const { positivePrompt, negativePrompt, positivePrompt2, negativePrompt2, shouldConcatPrompts } =
state.controlLayers.present;
return {
positiveStylePrompt: shouldConcatSDXLStylePrompt ? positivePrompt : positiveStylePrompt,
negativeStylePrompt: shouldConcatSDXLStylePrompt ? negativePrompt : negativeStylePrompt,
positiveStylePrompt: shouldConcatPrompts ? positivePrompt : positivePrompt2,
negativeStylePrompt: shouldConcatPrompts ? negativePrompt : negativePrompt2,
};
};

View File

@@ -1,7 +1,7 @@
import { Box, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { negativePromptChanged } from 'features/controlLayers/store/controlLayersSlice';
import { PromptOverlayButtonWrapper } from 'features/parameters/components/Prompts/PromptOverlayButtonWrapper';
import { setNegativePrompt } from 'features/parameters/store/generationSlice';
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover';
import { usePrompt } from 'features/prompt/usePrompt';
@@ -10,12 +10,12 @@ import { useTranslation } from 'react-i18next';
export const ParamNegativePrompt = memo(() => {
const dispatch = useAppDispatch();
const prompt = useAppSelector((s) => s.generation.negativePrompt);
const prompt = useAppSelector((s) => s.controlLayers.present.negativePrompt);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation();
const _onChange = useCallback(
(v: string) => {
dispatch(setNegativePrompt(v));
dispatch(negativePromptChanged(v));
},
[dispatch]
);

Some files were not shown because too many files have changed in this diff Show More