diff --git a/invokeai/app/invocations/controlnet_image_processors.py b/invokeai/app/invocations/controlnet_image_processors.py index 6c647c7ed1..79a46cb7a1 100644 --- a/invokeai/app/invocations/controlnet_image_processors.py +++ b/invokeai/app/invocations/controlnet_image_processors.py @@ -36,7 +36,7 @@ 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 DEPTH_ANYTHING_MODELS, DepthAnythingDetector from invokeai.backend.image_util.dw_openpose import DWOpenposeDetector @@ -44,8 +44,9 @@ 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.util.devices import TorchDevice +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): @@ -641,3 +642,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) diff --git a/invokeai/app/services/download/download_default.py b/invokeai/app/services/download/download_default.py index f393a18dcb..7d8229fba1 100644 --- a/invokeai/app/services/download/download_default.py +++ b/invokeai/app/services/download/download_default.py @@ -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)) diff --git a/invokeai/backend/image_util/util.py b/invokeai/backend/image_util/util.py index f704f068e3..5b2116975f 100644 --- a/invokeai/backend/image_util/util.py +++ b/invokeai/backend/image_util/util.py @@ -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: diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index a591e654a7..9e661e0737 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -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", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index c0cbc59ad2..9910e32391 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -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: diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index c130b11ba6..885a937de3 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -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" } } diff --git a/invokeai/frontend/web/src/app/logging/logger.ts b/invokeai/frontend/web/src/app/logging/logger.ts index 00389d8c4f..ca7a24201a 100644 --- a/invokeai/frontend/web/src/app/logging/logger.ts +++ b/invokeai/frontend/web/src/app/logging/logger.ts @@ -28,7 +28,7 @@ export type LoggerNamespace = | 'session' | 'queue' | 'dnd' - | 'regionalPrompts'; + | 'controlLayers'; export const logger = (namespace: LoggerNamespace) => $logger.get().child({ namespace }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index cd0c1290e9..ac039c2df6 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -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); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasImageToControlNet.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasImageToControlNet.ts index 55392ebff4..b1b19b35dc 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasImageToControlNet.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasImageToControlNet.ts @@ -48,12 +48,10 @@ export const addCanvasImageToControlNetListener = (startAppListening: AppStartLi }) ).unwrap(); - const { image_name } = imageDTO; - dispatch( controlAdapterImageChanged({ id, - controlImage: image_name, + controlImage: imageDTO, }) ); }, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMaskToControlNet.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMaskToControlNet.ts index 569b4badc7..b3014277f1 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMaskToControlNet.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMaskToControlNet.ts @@ -58,12 +58,10 @@ export const addCanvasMaskToControlNetListener = (startAppListening: AppStartLis }) ).unwrap(); - const { image_name } = imageDTO; - dispatch( controlAdapterImageChanged({ id, - controlImage: image_name, + controlImage: imageDTO, }) ); }, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlLayersToControlAdapterBridge.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlLayersToControlAdapterBridge.ts new file mode 100644 index 0000000000..bc14277f88 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlLayersToControlAdapterBridge.ts @@ -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('controlLayers/guidanceLayerAdded'); +export const guidanceLayerDeleted = createAction('controlLayers/guidanceLayerDeleted'); +export const allLayersDeleted = createAction('controlLayers/allLayersDeleted'); +export const guidanceLayerIPAdapterAdded = createAction('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 = { + 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 = { + 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 = { + 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 })); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetAutoProcess.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetAutoProcess.ts index e52df30681..14af0246a2 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetAutoProcess.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetAutoProcess.ts @@ -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 @@ -52,6 +53,11 @@ const predicate: AnyListenerPredicate = (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); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts index 0055866aa7..08afc98836 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts @@ -91,7 +91,7 @@ export const addControlNetImageProcessedListener = (startAppListening: AppStartL dispatch( controlAdapterProcessedImageChanged({ id, - processedControlImage: processedControlImage.image_name, + processedControlImage, }) ); } diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts index 5c1f321b64..307e3487dd 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts @@ -71,7 +71,7 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) => dispatch( controlAdapterImageChanged({ id, - controlImage: activeData.payload.imageDTO.image_name, + controlImage: activeData.payload.imageDTO, }) ); dispatch( diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts index 2cebf0aef8..a2ca4baeb1 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts @@ -96,7 +96,7 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis dispatch( controlAdapterImageChanged({ id, - controlImage: imageDTO.image_name, + controlImage: imageDTO, }) ); dispatch( diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts index bc049cf498..b69e56e84a 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts @@ -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; } }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts index 2ba9aa3cbf..eb86f54c84 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts @@ -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; } } diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/promptChanged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/promptChanged.ts index b78ddc3f69..4633eb45a5 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/promptChanged.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/promptChanged.ts @@ -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; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/setDefaultSettings.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/setDefaultSettings.ts index 7fbb55845f..6f3aa9756a 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/setDefaultSettings.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/setDefaultSettings.ts @@ -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 })); } } diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index a21879cfcb..9661f57f99 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -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) => { diff --git a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts index b31efed970..d765e987eb 100644 --- a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts +++ b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts @@ -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; } diff --git a/invokeai/frontend/web/src/common/util/stopPropagation.ts b/invokeai/frontend/web/src/common/util/stopPropagation.ts new file mode 100644 index 0000000000..b3481b7c0e --- /dev/null +++ b/invokeai/frontend/web/src/common/util/stopPropagation.ts @@ -0,0 +1,3 @@ +export const stopPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); +}; diff --git a/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterConfig.tsx b/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterConfig.tsx index fcc816d75f..032e46f477 100644 --- a/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterConfig.tsx +++ b/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterConfig.tsx @@ -113,7 +113,7 @@ const ControlAdapterConfig = (props: { id: string; number: number }) => { - + {controlAdapterType === 'ip_adapter' && } diff --git a/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx index c136fbe064..56589fe613 100644 --- a/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx @@ -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]); diff --git a/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterIPMethod.tsx b/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterIPMethod.tsx index 7d531e2106..d7d91ab780 100644 --- a/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterIPMethod.tsx +++ b/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterIPMethod.tsx @@ -46,10 +46,6 @@ const ParamControlAdapterIPMethod = ({ id }: Props) => { const value = useMemo(() => options.find((o) => o.value === method), [options, method]); - if (!method) { - return null; - } - return ( diff --git a/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterModel.tsx b/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterModel.tsx index 00c7d5859d..73a7d695b3 100644 --- a/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterModel.tsx +++ b/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterModel.tsx @@ -102,13 +102,9 @@ const ParamControlAdapterModel = ({ id }: ParamControlAdapterModelProps) => { ); return ( - + - + { { 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] ); diff --git a/invokeai/frontend/web/src/features/controlAdapters/store/controlAdaptersSlice.ts b/invokeai/frontend/web/src/features/controlAdapters/store/controlAdaptersSlice.ts index 3e335f4cc3..0c1ac20200 100644 --- a/invokeai/frontend/web/src/features/controlAdapters/store/controlAdaptersSlice.ts +++ b/invokeai/frontend/web/src/features/controlAdapters/store/controlAdaptersSlice.ts @@ -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 = { - 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 = { 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')); - }); }, }); diff --git a/invokeai/frontend/web/src/features/controlAdapters/store/types.ts b/invokeai/frontend/web/src/features/controlAdapters/store/types.ts index 7e2f18af5c..80af59cd01 100644 --- a/invokeai/frontend/web/src/features/controlAdapters/store/types.ts +++ b/invokeai/frontend/web/src/features/controlAdapters/store/types.ts @@ -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; diff --git a/invokeai/frontend/web/src/features/controlAdapters/util/buildControlAdapter.ts b/invokeai/frontend/web/src/features/controlAdapters/util/buildControlAdapter.ts index ad7bdba363..7c9c28e2b3 100644 --- a/invokeai/frontend/web/src/features/controlAdapters/util/buildControlAdapter.ts +++ b/invokeai/frontend/web/src/features/controlAdapters/util/buildControlAdapter.ts @@ -20,7 +20,9 @@ export const initialControlNet: Omit = { 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 = { 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, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx new file mode 100644 index 0000000000..b521153239 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx @@ -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 ( + + } variant="ghost"> + {t('controlLayers.addLayer')} + + + } onClick={addRegionalGuidanceLayer}> + {t('controlLayers.regionalGuidanceLayer')} + + } onClick={addControlAdapterLayer}> + {t('controlLayers.globalControlAdapterLayer')} + + } onClick={addIPAdapterLayer}> + {t('controlLayers.globalIPAdapterLayer')} + + + + ); +}); + +AddLayerButton.displayName = 'AddLayerButton'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/AddPromptButtons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AddPromptButtons.tsx similarity index 78% rename from invokeai/frontend/web/src/features/regionalPrompts/components/AddPromptButtons.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/AddPromptButtons.tsx index 2f1d4e8f4d..88eac207b2 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/AddPromptButtons.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/AddPromptButtons.tsx @@ -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 ( diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/BrushSize.tsx b/invokeai/frontend/web/src/features/controlLayers/components/BrushSize.tsx similarity index 75% rename from invokeai/frontend/web/src/features/regionalPrompts/components/BrushSize.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/BrushSize.tsx index e06e259f6e..a34250c29f 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/BrushSize.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/BrushSize.tsx @@ -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 ( - {t('regionalPrompts.brushSize')} + {t('controlLayers.brushSize')} { { + 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 ( + + + + + + + + + + + {isOpen && ( + + + + )} + + + ); +}); + +CALayerListItem.displayName = 'CALayerListItem'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CALayerOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CALayerOpacity.tsx new file mode 100644 index 0000000000..a6107da1ec --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CALayerOpacity.tsx @@ -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) => { + dispatch(isFilterEnabledChanged({ layerId, isFilterEnabled: e.target.checked })); + }, + [dispatch, layerId] + ); + return ( + + + } + variant="ghost" + onDoubleClick={stopPropagation} + /> + + + + + + + + {t('controlLayers.opacityFilter')} + + + + + {t('controlLayers.opacity')} + + + + + + + + ); +}; + +export default memo(CALayerOpacity); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.stories.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.stories.tsx new file mode 100644 index 0000000000..c0fa306c6b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.stories.tsx @@ -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 = { + title: 'Feature/ControlLayers', + tags: ['autodocs'], + component: ControlLayersEditor, +}; + +export default meta; +type Story = StoryObj; + +const Component = () => { + return ( + + + + ); +}; + +export const Default: Story = { + render: Component, +}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.tsx similarity index 52% rename from invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.tsx index dd2e797235..e9275426fe 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.tsx @@ -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 ( { alignItems="center" justifyContent="center" > - + ); }); -RegionalPromptsEditor.displayName = 'RegionalPromptsEditor'; +ControlLayersEditor.displayName = 'ControlLayersEditor'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx new file mode 100644 index 0000000000..e2865be356 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx @@ -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 ( + + + + + + + + {layerIdTypePairs.map(({ id, type }) => ( + + ))} + + + + ); +}); + +ControlLayersPanelContent.displayName = 'ControlLayersPanelContent'; + +type LayerWrapperProps = { + id: string; + type: Layer['type']; +}; + +const LayerWrapper = memo(({ id, type }: LayerWrapperProps) => { + if (type === 'regional_guidance_layer') { + return ; + } + if (type === 'control_adapter_layer') { + return ; + } + if (type === 'ip_adapter_layer') { + return ; + } +}); + +LayerWrapper.displayName = 'LayerWrapper'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersSettingsPopover.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersSettingsPopover.tsx new file mode 100644 index 0000000000..89032b7c76 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersSettingsPopover.tsx @@ -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 ( + + + } /> + + + + + + + + + + ); +}; + +export default memo(ControlLayersSettingsPopover); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx new file mode 100644 index 0000000000..15a74a332a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx @@ -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 ( + + + + + + + ); +}); + +ControlLayersToolbar.displayName = 'ControlLayersToolbar'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/DeleteAllLayersButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/DeleteAllLayersButton.tsx similarity index 80% rename from invokeai/frontend/web/src/features/regionalPrompts/components/DeleteAllLayersButton.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/DeleteAllLayersButton.tsx index 4306e3f3f3..c55864afa5 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/DeleteAllLayersButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/DeleteAllLayersButton.tsx @@ -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 ( ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/GlobalMaskLayerOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/GlobalMaskLayerOpacity.tsx new file mode 100644 index 0000000000..40985499db --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/GlobalMaskLayerOpacity.tsx @@ -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 ( + + {t('controlLayers.globalMaskOpacity')} + + + + + + ); +}); + +GlobalMaskLayerOpacity.displayName = 'GlobalMaskLayerOpacity'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPLayerListItem.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPLayerListItem.tsx new file mode 100644 index 0000000000..bdc54373a0 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPLayerListItem.tsx @@ -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 ( + + + + + + + + + {isOpen && ( + + + + )} + + + ); +}); + +IPLayerListItem.displayName = 'IPLayerListItem'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerDeleteButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerDeleteButton.tsx similarity index 60% rename from invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerDeleteButton.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/LayerDeleteButton.tsx index 237b710062..0c74b2a9ea 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerDeleteButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerDeleteButton.tsx @@ -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 ( { tooltip={t('common.delete')} icon={} onClick={deleteLayer} + onDoubleClick={stopPropagation} // double click expands the layer /> ); }); -RPLayerDeleteButton.displayName = 'RPLayerDeleteButton'; +LayerDeleteButton.displayName = 'LayerDeleteButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerMenu.tsx new file mode 100644 index 0000000000..e5c8cc0aac --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerMenu.tsx @@ -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 ( + + } + onDoubleClick={stopPropagation} // double click expands the layer + /> + + {layerType === 'regional_guidance_layer' && ( + <> + + + + )} + {(layerType === 'regional_guidance_layer' || layerType === 'control_adapter_layer') && ( + <> + + + + )} + {layerType === 'regional_guidance_layer' && ( + }> + {t('accessibility.reset')} + + )} + } color="error.300"> + {t('common.delete')} + + + + ); +}); + +LayerMenu.displayName = 'LayerMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerMenuArrangeActions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerMenuArrangeActions.tsx new file mode 100644 index 0000000000..9c51671a39 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerMenuArrangeActions.tsx @@ -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 ( + <> + }> + {t('controlLayers.moveToFront')} + + }> + {t('controlLayers.moveForward')} + + }> + {t('controlLayers.moveBackward')} + + }> + {t('controlLayers.moveToBack')} + + + ); +}); + +LayerMenuArrangeActions.displayName = 'LayerMenuArrangeActions'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerMenuRGActions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerMenuRGActions.tsx new file mode 100644 index 0000000000..6c2bb4c26b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerMenuRGActions.tsx @@ -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 ( + <> + }> + {t('controlLayers.addPositivePrompt')} + + }> + {t('controlLayers.addNegativePrompt')} + + }> + {t('controlLayers.addIPAdapter')} + + + ); +}); + +LayerMenuRGActions.displayName = 'LayerMenuRGActions'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerTitle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerTitle.tsx new file mode 100644 index 0000000000..ec13ff7bcc --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerTitle.tsx @@ -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 ( + + {title} + + ); +}); + +LayerTitle.displayName = 'LayerTitle'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerVisibilityToggle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerVisibilityToggle.tsx similarity index 56% rename from invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerVisibilityToggle.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/LayerVisibilityToggle.tsx index 4f9e5e84b4..d2dab39e36 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerVisibilityToggle.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerVisibilityToggle.tsx @@ -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 ( : undefined} onClick={onClick} colorScheme="base" + onDoubleClick={stopPropagation} // double click expands the layer /> ); }); -RPLayerVisibilityToggle.displayName = 'RPLayerVisibilityToggle'; +LayerVisibilityToggle.displayName = 'LayerVisibilityToggle'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCheckbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerAutoNegativeCheckbox.tsx similarity index 68% rename from invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCheckbox.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RGLayerAutoNegativeCheckbox.tsx index 454c30a6d2..6f03d4b28d 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerAutoNegativeCheckbox.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerAutoNegativeCheckbox.tsx @@ -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 ( - {t('regionalPrompts.autoNegative')} + {t('controlLayers.autoNegative')} ); }); -RPLayerAutoNegativeCheckbox.displayName = 'RPLayerAutoNegativeCheckbox'; +RGLayerAutoNegativeCheckbox.displayName = 'RGLayerAutoNegativeCheckbox'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerColorPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerColorPicker.tsx similarity index 68% rename from invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerColorPicker.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RGLayerColorPicker.tsx index 851cd8b13f..e76ab57a51 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerColorPicker.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerColorPicker.tsx @@ -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) => { - + { h={8} cursor="pointer" tabIndex={-1} + onDoubleClick={stopPropagation} // double click expands the layer /> @@ -64,4 +66,4 @@ export const RPLayerColorPicker = memo(({ layerId }: Props) => { ); }); -RPLayerColorPicker.displayName = 'RPLayerColorPicker'; +RGLayerColorPicker.displayName = 'RGLayerColorPicker'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RGLayerIPAdapterList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerIPAdapterList.tsx new file mode 100644 index 0000000000..464bd41897 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerIPAdapterList.tsx @@ -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) => ( + + {index > 0 && ( + + + + )} + + + ))} + + ); +}); + +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 ( + + + {`IP Adapter ${ipAdapterNumber}`} + + } + aria-label="Delete IP Adapter" + onClick={onDeleteIPAdapter} + variant="ghost" + colorScheme="error" + /> + + + + ); +}); + +RGLayerIPAdapterListItem.displayName = 'RGLayerIPAdapterListItem'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RGLayerListItem.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerListItem.tsx new file mode 100644 index 0000000000..3c126cabaa --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerListItem.tsx @@ -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 ( + + + + + + + {autoNegative === 'invert' && ( + + {t('controlLayers.autoNegative')} + + )} + + + + + + {isOpen && ( + + {!hasPositivePrompt && !hasNegativePrompt && !hasIPAdapters && } + {hasPositivePrompt && } + {hasNegativePrompt && } + {hasIPAdapters && } + + )} + + + ); +}); + +RGLayerListItem.displayName = 'RGLayerListItem'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerNegativePrompt.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerNegativePrompt.tsx similarity index 78% rename from invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerNegativePrompt.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RGLayerNegativePrompt.tsx index 382b698b8f..e869c8809a 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerNegativePrompt.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerNegativePrompt.tsx @@ -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(null); @@ -47,7 +47,7 @@ export const RPLayerNegativePrompt = memo(({ layerId }: Props) => { fontSize="sm" /> - + @@ -55,4 +55,4 @@ export const RPLayerNegativePrompt = memo(({ layerId }: Props) => { ); }); -RPLayerNegativePrompt.displayName = 'RPLayerNegativePrompt'; +RGLayerNegativePrompt.displayName = 'RGLayerNegativePrompt'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPositivePrompt.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerPositivePrompt.tsx similarity index 78% rename from invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPositivePrompt.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RGLayerPositivePrompt.tsx index 595a44e83e..6d508338c1 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPositivePrompt.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerPositivePrompt.tsx @@ -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(null); @@ -47,7 +47,7 @@ export const RPLayerPositivePrompt = memo(({ layerId }: Props) => { minH={28} /> - + @@ -55,4 +55,4 @@ export const RPLayerPositivePrompt = memo(({ layerId }: Props) => { ); }); -RPLayerPositivePrompt.displayName = 'RPLayerPositivePrompt'; +RGLayerPositivePrompt.displayName = 'RGLayerPositivePrompt'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPromptDeleteButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerPromptDeleteButton.tsx similarity index 75% rename from invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPromptDeleteButton.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RGLayerPromptDeleteButton.tsx index 7448e3a035..9a32bb68ad 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerPromptDeleteButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerPromptDeleteButton.tsx @@ -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 ( - + } onClick={onClick} /> @@ -35,4 +35,4 @@ export const RPLayerPromptDeleteButton = memo(({ layerId, polarity }: Props) => ); }); -RPLayerPromptDeleteButton.displayName = 'RPLayerPromptDeleteButton'; +RGLayerPromptDeleteButton.displayName = 'RGLayerPromptDeleteButton'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerSettingsPopover.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerSettingsPopover.tsx similarity index 71% rename from invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerSettingsPopover.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RGLayerSettingsPopover.tsx index a9a450c3aa..e270748b9b 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerSettingsPopover.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayerSettingsPopover.tsx @@ -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={} + onDoubleClick={stopPropagation} // double click expands the layer /> @@ -41,7 +43,7 @@ const RPLayerSettingsPopover = ({ layerId }: Props) => { - + @@ -50,4 +52,4 @@ const RPLayerSettingsPopover = ({ layerId }: Props) => { ); }; -export default memo(RPLayerSettingsPopover); +export default memo(RGLayerSettingsPopover); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx similarity index 56% rename from invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx index f286b75711..ecf1121b41 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx @@ -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, + 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( - 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(null); const [wrapper, setWrapper] = useState(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 ( - + ); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx similarity index 82% rename from invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx index a79c443a2f..53535b4248 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx @@ -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} /> } variant={tool === 'rect' ? 'solid' : 'outline'} onClick={setToolToRect} diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx b/invokeai/frontend/web/src/features/controlLayers/components/UndoRedoButtonGroup.tsx similarity index 87% rename from invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/UndoRedoButtonGroup.tsx index bb8f9cfd6e..8babae7fcc 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/UndoRedoButtonGroup.tsx @@ -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]); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ControlAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ControlAdapterImagePreview.tsx new file mode 100644 index 0000000000..b3094e5599 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ControlAdapterImagePreview.tsx @@ -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(() => { + if (controlImage) { + return { + id, + payloadType: 'IMAGE_DTO', + payload: { imageDTO: controlImage }, + }; + } + }, [controlImage, id]); + + const droppableData = useMemo( + () => ({ + id, + actionType: 'SET_CONTROL_ADAPTER_IMAGE', + context: { id }, + }), + [id] + ); + + const postUploadAction = useMemo(() => ({ 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 ( + + + + + + + + <> + : undefined} + tooltip={t('controlnet.resetControlImage')} + /> + : undefined} + tooltip={t('controlnet.saveControlImage')} + styleOverrides={saveControlImageStyleOverrides} + /> + : undefined} + tooltip={shift ? t('controlnet.setControlImageDimensionsForce') : t('controlnet.setControlImageDimensions')} + styleOverrides={setControlImageDimensionsStyleOverrides} + /> + + + {pendingControlImages.includes(id) && ( + + + + )} + + ); +}; + +export default memo(ControlAdapterImagePreview); + +const saveControlImageStyleOverrides: SystemStyleObject = { mt: 6 }; +const setControlImageDimensionsStyleOverrides: SystemStyleObject = { mt: 12 }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ControlAdapterLayerConfig.tsx b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ControlAdapterLayerConfig.tsx new file mode 100644 index 0000000000..29a3502d37 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ControlAdapterLayerConfig.tsx @@ -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 ( + + + + {' '} + + + {controlAdapterType !== 'ip_adapter' && ( + + } + /> + )} + + + + {controlAdapterType === 'ip_adapter' && } + {controlAdapterType === 'controlnet' && } + + + + + + + + {isExpanded && ( + <> + + + + + )} + + ); +}; + +export default memo(ControlAdapterLayerConfig); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterBeginEnd.tsx b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterBeginEnd.tsx new file mode 100644 index 0000000000..e4bc07e0b4 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterBeginEnd.tsx @@ -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 ( + + + {t('controlnet.beginEndStepPercentShort')} + + + + ); +}); + +ParamControlAdapterBeginEnd.displayName = 'ParamControlAdapterBeginEnd'; + +const ariaLabel = ['Begin Step %', 'End Step %']; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterControlMode.tsx b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterControlMode.tsx new file mode 100644 index 0000000000..6b5d34c106 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterControlMode.tsx @@ -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( + (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 ( + + + {t('controlnet.control')} + + + + ); +}; + +export default memo(ParamControlAdapterControlMode); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterModel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterModel.tsx new file mode 100644 index 0000000000..73a7d695b3 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterModel.tsx @@ -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( + (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( + () => [ + { label: 'ViT-H', value: 'ViT-H' }, + { label: 'ViT-G', value: 'ViT-G' }, + ], + [] + ); + + const clipVisionModel = useMemo( + () => clipVisionOptions.find((o) => o.value === currentCLIPVisionModel), + [clipVisionOptions, currentCLIPVisionModel] + ); + + return ( + + + + + + + {modelConfig?.type === 'ip_adapter' && modelConfig.format === 'checkpoint' && ( + + + + )} + + ); +}; + +export default memo(ParamControlAdapterModel); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterWeight.tsx b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterWeight.tsx new file mode 100644 index 0000000000..5e456fc792 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/controlAdapterOverrides/ParamControlAdapterWeight.tsx @@ -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 ( + + + {t('controlnet.weight')} + + + + + ); +}; + +export default memo(ParamControlAdapterWeight); + +const marks = [0, 1, 2]; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts new file mode 100644 index 0000000000..b4880d1dc6 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts @@ -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; +}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts similarity index 96% rename from invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts rename to invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts index fc58de60ed..bab7ef263f 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts @@ -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) => { diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/useRegionalControlTitle.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useControlLayersTitle.ts similarity index 60% rename from invokeai/frontend/web/src/features/regionalPrompts/hooks/useRegionalControlTitle.ts rename to invokeai/frontend/web/src/features/controlLayers/hooks/useControlLayersTitle.ts index 4f23804c2a..93c8bec8a6 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/useRegionalControlTitle.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useControlLayersTitle.ts @@ -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; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts new file mode 100644 index 0000000000..6d351d4d0d --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts @@ -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) => { + 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) => { + 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) => { + const layer = state.layers.find((l) => l.id === action.payload); + if (layer) { + resetLayer(layer); + } + }, + layerDeleted: (state, action: PayloadAction) => { + state.layers = state.layers.filter((l) => l.id !== action.payload); + state.selectedLayerId = state.layers[0]?.id ?? null; + }, + layerMovedForward: (state, action: PayloadAction) => { + 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) => { + 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) => { + 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) => { + 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) => { + state.positivePrompt = action.payload; + }, + negativePromptChanged: (state, action: PayloadAction) => { + state.negativePrompt = action.payload; + }, + positivePrompt2Changed: (state, action: PayloadAction) => { + state.positivePrompt2 = action.payload; + }, + negativePrompt2Changed: (state, action: PayloadAction) => { + state.negativePrompt2 = action.payload; + }, + shouldConcatPromptsChanged: (state, action: PayloadAction) => { + 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) => { + state.size.aspectRatio = action.payload; + }, + //#endregion + + //#region General + brushSizeChanged: (state, action: PayloadAction) => { + state.brushSize = Math.round(action.payload); + }, + globalMaskLayerOpacityChanged: (state, action: PayloadAction) => { + state.globalMaskLayerOpacity = action.payload; + }, + isEnabledChanged: (state, action: PayloadAction) => { + 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(null); +export const $tool = atom('brush'); +export const $cursorPosition = atom(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 = { + 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 = { + 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; + }, +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts new file mode 100644 index 0000000000..58b25f967b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -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; + }; +}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/bbox.ts b/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts similarity index 96% rename from invokeai/frontend/web/src/features/regionalPrompts/util/bbox.ts rename to invokeai/frontend/web/src/features/controlLayers/util/bbox.ts index 3182cbca7e..3c2915e0ab 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/bbox.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts @@ -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(); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts b/invokeai/frontend/web/src/features/controlLayers/util/getLayerBlobs.ts similarity index 78% rename from invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts rename to invokeai/frontend/web/src/features/controlLayers/util/getLayerBlobs.ts index 28a11b649d..1b0808c5f1 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/getLayerBlobs.ts @@ -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> => { 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(`.${VECTOR_MASK_LAYER_NAME}`); + const konvaLayers = stage.find(`.${regional_guidance_layer_NAME}`); const blobs: Record = {}; // First remove all layers diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts similarity index 68% rename from invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts rename to invokeai/frontend/web/src/features/controlLayers/util/renderers.ts index 20e5f75ab7..b2f04a88c1 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts @@ -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(`#${reduxLayer.id}`) ?? createVectorMaskLayer(stage, reduxLayer, onLayerPosChanged); + stage.findOne(`#${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(`.${VECTOR_MASK_LAYER_OBJECT_GROUP_NAME}`); + const konvaObjectGroup = konvaLayer.findOne(`.${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(`#${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(`.${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(`#${reduxLayer.id}`) ?? createControlNetLayer(stage, reduxLayer); + const konvaImage = konvaLayer.findOne(`.${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(`.${VECTOR_MASK_LAYER_NAME}`)) { + for (const konvaLayer of stage.find(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(`.${LAYER_BBOX_NAME}`)) { @@ -494,35 +596,36 @@ const renderBbox = ( } for (const reduxLayer of reduxLayers) { - const konvaLayer = stage.findOne(`#${reduxLayer.id}`); - assert(konvaLayer, `Layer ${reduxLayer.id} not found in stage`); + if (reduxLayer.type === 'regional_guidance_layer') { + const konvaLayer = stage.findOne(`#${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(`.${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(`.${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(`#${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(`#${NO_LAYERS_MESSAGE_LAYER_ID}`) ?? createNoLayersMessageLayer(stage); + if (layerCount === 0) { + noLayersMessageLayer.findOne('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; + } +}; diff --git a/invokeai/frontend/web/src/features/metadata/util/parsers.ts b/invokeai/frontend/web/src/features/metadata/util/parsers.ts index 3decea6737..5d2bd78784 100644 --- a/invokeai/frontend/web/src/features/metadata/util/parsers.ts +++ b/invokeai/frontend/web/src/features/metadata/util/parsers.ts @@ -286,7 +286,9 @@ const parseControlNet: MetadataParseFunc = 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 = 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(), }; diff --git a/invokeai/frontend/web/src/features/metadata/util/recallers.ts b/invokeai/frontend/web/src/features/metadata/util/recallers.ts index 4f332e23a9..f07b2ab8b6 100644 --- a/invokeai/frontend/web/src/features/metadata/util/recallers.ts +++ b/invokeai/frontend/web/src/features/metadata/util/recallers.ts @@ -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 = (positivePrompt) => { - getStore().dispatch(setPositivePrompt(positivePrompt)); + getStore().dispatch(positivePromptChanged(positivePrompt)); }; const recallNegativePrompt: MetadataRecallFunc = (negativePrompt) => { - getStore().dispatch(setNegativePrompt(negativePrompt)); + getStore().dispatch(negativePromptChanged(negativePrompt)); }; const recallSDXLPositiveStylePrompt: MetadataRecallFunc = (positiveStylePrompt) => { - getStore().dispatch(setPositiveStylePromptSDXL(positiveStylePrompt)); + getStore().dispatch(positivePrompt2Changed(positiveStylePrompt)); }; const recallSDXLNegativeStylePrompt: MetadataRecallFunc = (negativeStylePrompt) => { - getStore().dispatch(setNegativeStylePromptSDXL(negativeStylePrompt)); + getStore().dispatch(negativePrompt2Changed(negativeStylePrompt)); }; const recallSeed: MetadataRecallFunc = (seed) => { @@ -101,11 +103,11 @@ const recallInitialImage: MetadataRecallFunc = async (imageDTO) => { }; const recallWidth: MetadataRecallFunc = (width) => { - getStore().dispatch(widthRecalled(width)); + getStore().dispatch(widthChanged({ width, updateAspectRatio: true })); }; const recallHeight: MetadataRecallFunc = (height) => { - getStore().dispatch(heightRecalled(height)); + getStore().dispatch(heightChanged({ height, updateAspectRatio: true })); }; const recallSteps: MetadataRecallFunc = (steps) => { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts similarity index 95% rename from invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts rename to invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts index 8d7a3a6c9a..a7236af3cc 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts @@ -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; } ); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addControlNetToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addControlNetToLinearGraph.ts index e32f00fb86..fb912d0be2 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/addControlNetToLinearGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/addControlNetToLinearGraph.ts @@ -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 => { +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 => { + 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; } diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addHrfToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addHrfToGraph.ts index 5632cfd112..5abf07740a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/addHrfToGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/addHrfToGraph.ts @@ -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); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addIPAdapterToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addIPAdapterToLinearGraph.ts index 0a90622e04..2c53fb3827 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/addIPAdapterToLinearGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/addIPAdapterToLinearGraph.ts @@ -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 => { - 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; } diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addT2IAdapterToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addT2IAdapterToLinearGraph.ts index 42bd277201..1632449724 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/addT2IAdapterToLinearGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/addT2IAdapterToLinearGraph.ts @@ -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 => { +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 => { + 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; } diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasImageToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasImageToImageGraph.ts index 16c42cd111..f2c9957edc 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasImageToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasImageToImageGraph.ts @@ -42,8 +42,6 @@ export const buildCanvasImageToImageGraph = async ( ): Promise => { 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; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasInpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasInpaintGraph.ts index f8390b8b9a..ab73953008 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasInpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasInpaintGraph.ts @@ -47,8 +47,6 @@ export const buildCanvasInpaintGraph = async ( ): Promise => { 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'); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasOutpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasOutpaintGraph.ts index 39eab4aa50..6b564f464e 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasOutpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasOutpaintGraph.ts @@ -51,8 +51,6 @@ export const buildCanvasOutpaintGraph = async ( ): Promise => { 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'); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLImageToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLImageToImageGraph.ts index 059003c34b..ee918d1470 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLImageToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLImageToImageGraph.ts @@ -43,8 +43,6 @@ export const buildCanvasSDXLImageToImageGraph = async ( ): Promise => { 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; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLInpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLInpaintGraph.ts index 6a33a6ef99..68b948a44a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLInpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLInpaintGraph.ts @@ -48,8 +48,6 @@ export const buildCanvasSDXLInpaintGraph = async ( ): Promise => { 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; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLOutpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLOutpaintGraph.ts index 7cb215d5ec..c5c40b695a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLOutpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLOutpaintGraph.ts @@ -52,8 +52,6 @@ export const buildCanvasSDXLOutpaintGraph = async ( ): Promise => { 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; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLTextToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLTextToImageGraph.ts index b7e1ae80b0..f6ac645580 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLTextToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLTextToImageGraph.ts @@ -33,8 +33,6 @@ import { addCoreMetadataNode, getModelMetadataField } from './metadata'; export const buildCanvasSDXLTextToImageGraph = async (state: RootState): Promise => { 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; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasTextToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasTextToImageGraph.ts index c14da86e3e..0749308fb8 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasTextToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasTextToImageGraph.ts @@ -32,8 +32,6 @@ import { addCoreMetadataNode, getModelMetadataField } from './metadata'; export const buildCanvasTextToImageGraph = async (state: RootState): Promise => { 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 { 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, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearImageToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearImageToImageGraph.ts index 120afb98ee..0ca121b667 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearImageToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearImageToImageGraph.ts @@ -38,8 +38,6 @@ import { addCoreMetadataNode, getModelMetadataField } from './metadata'; export const buildLinearImageToImageGraph = async (state: RootState): Promise => { 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 => { 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; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLTextToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLTextToImageGraph.ts index b0b1140d5b..010fb9c5e4 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLTextToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLTextToImageGraph.ts @@ -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 => { 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) { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearTextToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearTextToImageGraph.ts index 90101add6d..ea59d7e41d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearTextToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearTextToImageGraph.ts @@ -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 => { 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 { * 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, }; }; diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx index 55d97757e6..5702cf8bd7 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx @@ -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(null); const { t } = useTranslation(); const _onChange = useCallback( (v: string) => { - dispatch(setNegativePrompt(v)); + dispatch(negativePromptChanged(v)); }, [dispatch] ); diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx index a1c63def8d..0b2890875e 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx @@ -1,8 +1,8 @@ import { Box, Textarea } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { positivePromptChanged } from 'features/controlLayers/store/controlLayersSlice'; import { ShowDynamicPromptsPreviewButton } from 'features/dynamicPrompts/components/ShowDynamicPromptsPreviewButton'; import { PromptOverlayButtonWrapper } from 'features/parameters/components/Prompts/PromptOverlayButtonWrapper'; -import { setPositivePrompt } from 'features/parameters/store/generationSlice'; import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton'; import { PromptPopover } from 'features/prompt/PromptPopover'; import { usePrompt } from 'features/prompt/usePrompt'; @@ -14,14 +14,14 @@ import { useTranslation } from 'react-i18next'; export const ParamPositivePrompt = memo(() => { const dispatch = useAppDispatch(); - const prompt = useAppSelector((s) => s.generation.positivePrompt); + const prompt = useAppSelector((s) => s.controlLayers.present.positivePrompt); const baseModel = useAppSelector((s) => s.generation.model)?.base; const textareaRef = useRef(null); const { t } = useTranslation(); const handleChange = useCallback( (v: string) => { - dispatch(setPositivePrompt(v)); + dispatch(positivePromptChanged(v)); }, [dispatch] ); diff --git a/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioCanvasPreview.tsx b/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioCanvasPreview.tsx index 6584cb14c9..00fa10c0c5 100644 --- a/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioCanvasPreview.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioCanvasPreview.tsx @@ -1,5 +1,5 @@ import { Flex } from '@invoke-ai/ui-library'; -import { StageComponent } from 'features/regionalPrompts/components/StageComponent'; +import { StageComponent } from 'features/controlLayers/components/StageComponent'; import { memo } from 'react'; export const AspectRatioCanvasPreview = memo(() => { diff --git a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts index 0da6e21d9f..18180455ce 100644 --- a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts @@ -1,11 +1,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; -import { roundToMultiple } from 'common/util/roundDownToMultiple'; -import { 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 { CLIP_SKIP_MAP } from 'features/parameters/types/constants'; import type { ParameterCanvasCoherenceMode, @@ -16,7 +12,7 @@ import type { ParameterScheduler, ParameterVAEModel, } from 'features/parameters/types/parameterSchemas'; -import { getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension'; +import { getOptimalDimension } from 'features/parameters/util/optimalDimension'; import { configChanged } from 'features/system/store/configSlice'; import { clamp } from 'lodash-es'; import type { RgbaColor } from 'react-colorful'; @@ -28,12 +24,9 @@ const initialGenerationState: GenerationState = { _version: 2, cfgScale: 7.5, cfgRescaleMultiplier: 0, - height: 512, img2imgStrength: 0.75, infillMethod: 'patchmatch', iterations: 1, - positivePrompt: '', - negativePrompt: '', scheduler: 'euler', maskBlur: 16, maskBlurMethod: 'box', @@ -44,7 +37,6 @@ const initialGenerationState: GenerationState = { shouldFitToWidthHeight: true, shouldRandomizeSeed: true, steps: 50, - width: 512, model: null, vae: null, vaePrecision: 'fp32', @@ -53,7 +45,6 @@ const initialGenerationState: GenerationState = { clipSkip: 0, shouldUseCpuNoise: true, shouldShowAdvancedOptions: false, - aspectRatio: { ...initialAspectRatioState }, infillTileSize: 32, infillPatchmatchDownscaleSize: 1, infillMosaicTileWidth: 64, @@ -67,12 +58,6 @@ export const generationSlice = createSlice({ name: 'generation', initialState: initialGenerationState, reducers: { - setPositivePrompt: (state, action: PayloadAction) => { - state.positivePrompt = action.payload; - }, - setNegativePrompt: (state, action: PayloadAction) => { - state.negativePrompt = action.payload; - }, setIterations: (state, action: PayloadAction) => { state.iterations = action.payload; }, @@ -148,19 +133,6 @@ export const generationSlice = createSlice({ const { maxClip } = CLIP_SKIP_MAP[newModel.base]; state.clipSkip = clamp(state.clipSkip, 0, maxClip); } - - if (action.meta.previousModel?.base === newModel.base) { - // The base model hasn't changed, we don't need to optimize the size - return; - } - - const optimalDimension = getOptimalDimension(newModel); - if (getIsSizeOptimal(state.width, state.height, optimalDimension)) { - return; - } - const { width, height } = calculateNewSize(state.aspectRatio.value, optimalDimension * optimalDimension); - state.width = width; - state.height = height; }, prepare: (payload: ParameterModel | null, previousModel?: ParameterModel | null) => ({ payload, @@ -182,27 +154,6 @@ export const generationSlice = createSlice({ shouldUseCpuNoiseChanged: (state, action: PayloadAction) => { state.shouldUseCpuNoise = action.payload; }, - widthChanged: (state, action: PayloadAction) => { - state.width = action.payload; - }, - heightChanged: (state, action: PayloadAction) => { - state.height = action.payload; - }, - widthRecalled: (state, action: PayloadAction) => { - state.width = action.payload; - state.aspectRatio.value = action.payload / state.height; - state.aspectRatio.id = 'Free'; - state.aspectRatio.isLocked = false; - }, - heightRecalled: (state, action: PayloadAction) => { - state.height = action.payload; - state.aspectRatio.value = state.width / action.payload; - state.aspectRatio.id = 'Free'; - state.aspectRatio.isLocked = false; - }, - aspectRatioChanged: (state, action: PayloadAction) => { - state.aspectRatio = action.payload; - }, setInfillMethod: (state, action: PayloadAction) => { state.infillMethod = action.payload; }, @@ -237,15 +188,6 @@ export const generationSlice = createSlice({ state.vaePrecision = action.payload.sd.vaePrecision; } }); - - // 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.width = roundToMultiple(state.width, 64); - state.height = roundToMultiple(state.height, 64); - } - }); }, selectors: { selectOptimalDimension: (slice) => getOptimalDimension(slice.model), @@ -259,8 +201,6 @@ export const { setImg2imgStrength, setInfillMethod, setIterations, - setPositivePrompt, - setNegativePrompt, setScheduler, setMaskBlur, setCanvasCoherenceMode, @@ -278,11 +218,6 @@ export const { setClipSkip, shouldUseCpuNoiseChanged, vaePrecisionChanged, - aspectRatioChanged, - widthChanged, - heightChanged, - widthRecalled, - heightRecalled, setInfillTileSize, setInfillPatchmatchDownscaleSize, setInfillMosaicTileWidth, diff --git a/invokeai/frontend/web/src/features/parameters/store/types.ts b/invokeai/frontend/web/src/features/parameters/store/types.ts index 773cfbf925..9314f8d076 100644 --- a/invokeai/frontend/web/src/features/parameters/store/types.ts +++ b/invokeai/frontend/web/src/features/parameters/store/types.ts @@ -1,21 +1,16 @@ import type { PayloadAction } from '@reduxjs/toolkit'; -import type { AspectRatioState } from 'features/parameters/components/ImageSize/types'; import type { ParameterCanvasCoherenceMode, ParameterCFGRescaleMultiplier, ParameterCFGScale, - ParameterHeight, ParameterMaskBlurMethod, ParameterModel, - ParameterNegativePrompt, - ParameterPositivePrompt, ParameterPrecision, ParameterScheduler, ParameterSeed, ParameterSteps, ParameterStrength, ParameterVAEModel, - ParameterWidth, } from 'features/parameters/types/parameterSchemas'; import type { RgbaColor } from 'react-colorful'; @@ -23,13 +18,10 @@ export interface GenerationState { _version: 2; cfgScale: ParameterCFGScale; cfgRescaleMultiplier: ParameterCFGRescaleMultiplier; - height: ParameterHeight; img2imgStrength: ParameterStrength; infillMethod: string; initialImage?: { imageName: string; width: number; height: number }; iterations: number; - positivePrompt: ParameterPositivePrompt; - negativePrompt: ParameterNegativePrompt; scheduler: ParameterScheduler; maskBlur: number; maskBlurMethod: ParameterMaskBlurMethod; @@ -40,7 +32,6 @@ export interface GenerationState { shouldFitToWidthHeight: boolean; shouldRandomizeSeed: boolean; steps: ParameterSteps; - width: ParameterWidth; model: ParameterModel | null; vae: ParameterVAEModel | null; vaePrecision: ParameterPrecision; @@ -49,7 +40,6 @@ export interface GenerationState { clipSkip: number; shouldUseCpuNoise: boolean; shouldShowAdvancedOptions: boolean; - aspectRatio: AspectRatioState; infillTileSize: number; infillPatchmatchDownscaleSize: number; infillMosaicTileWidth: number; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueButtonTooltip.tsx b/invokeai/frontend/web/src/features/queue/components/QueueButtonTooltip.tsx index 5d1b7264ea..f63e96c45f 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueButtonTooltip.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueButtonTooltip.tsx @@ -2,19 +2,19 @@ import { Divider, Flex, ListItem, Text, UnorderedList } from '@invoke-ai/ui-libr import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; import { useIsReadyToEnqueue } from 'common/hooks/useIsReadyToEnqueue'; +import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice'; import { selectDynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt'; -import { selectGenerationSlice } from 'features/parameters/store/generationSlice'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useEnqueueBatchMutation } from 'services/api/endpoints/queue'; import { useBoardName } from 'services/api/hooks/useBoardName'; const selectPromptsCount = createSelector( - selectGenerationSlice, + selectControlLayersSlice, selectDynamicPromptsSlice, - (generation, dynamicPrompts) => - getShouldProcessPrompt(generation.positivePrompt) ? dynamicPrompts.prompts.length : 1 + (controlLayers, dynamicPrompts) => + getShouldProcessPrompt(controlLayers.present.positivePrompt) ? dynamicPrompts.prompts.length : 1 ); type Props = { diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx deleted file mode 100644 index 8acfc18c56..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Button } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { layerAdded } from 'features/regionalPrompts/store/regionalPromptsSlice'; -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 onClick = useCallback(() => { - dispatch(layerAdded('vector_mask_layer')); - }, [dispatch]); - - return ( - - ); -}); - -AddLayerButton.displayName = 'AddLayerButton'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/GlobalMaskLayerOpacity.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/GlobalMaskLayerOpacity.tsx deleted file mode 100644 index 8386f522a2..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/GlobalMaskLayerOpacity.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { - CompositeNumberInput, - CompositeSlider, - FormControl, - FormLabel, - Popover, - PopoverArrow, - PopoverBody, - PopoverContent, - PopoverTrigger, -} from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { - globalMaskLayerOpacityChanged, - initialRegionalPromptsState, -} from 'features/regionalPrompts/store/regionalPromptsSlice'; -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.regionalPrompts.present.globalMaskLayerOpacity * 100) - ); - const onChange = useCallback( - (v: number) => { - dispatch(globalMaskLayerOpacityChanged(v / 100)); - }, - [dispatch] - ); - return ( - - {t('regionalPrompts.globalMaskOpacity')} - - - - - - - - - - - - - ); -}); - -GlobalMaskLayerOpacity.displayName = 'GlobalMaskLayerOpacity'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerIPAdapterList.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerIPAdapterList.tsx deleted file mode 100644 index c5d1ca62e9..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerIPAdapterList.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Flex } from '@invoke-ai/ui-library'; -import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; -import { useAppSelector } from 'app/store/storeHooks'; -import ControlAdapterConfig from 'features/controlAdapters/components/ControlAdapterConfig'; -import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { memo, useMemo } from 'react'; -import { assert } from 'tsafe'; - -type Props = { - layerId: string; -}; - -export const RPLayerIPAdapterList = memo(({ layerId }: Props) => { - const selectIPAdapterIds = useMemo( - () => - createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { - const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(layer, `Layer ${layerId} not found`); - return layer.ipAdapterIds; - }), - [layerId] - ); - const ipAdapterIds = useAppSelector(selectIPAdapterIds); - - return ( - - {ipAdapterIds.map((id, index) => ( - - ))} - - ); -}); - -RPLayerIPAdapterList.displayName = 'RPLayerIPAdapterList'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx deleted file mode 100644 index ef98a659ac..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Badge, Flex, Spacer } from '@invoke-ai/ui-library'; -import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { rgbColorToString } from 'features/canvas/util/colorToString'; -import { RPLayerColorPicker } from 'features/regionalPrompts/components/RPLayerColorPicker'; -import { RPLayerDeleteButton } from 'features/regionalPrompts/components/RPLayerDeleteButton'; -import { RPLayerIPAdapterList } from 'features/regionalPrompts/components/RPLayerIPAdapterList'; -import { RPLayerMenu } from 'features/regionalPrompts/components/RPLayerMenu'; -import { RPLayerNegativePrompt } from 'features/regionalPrompts/components/RPLayerNegativePrompt'; -import { RPLayerPositivePrompt } from 'features/regionalPrompts/components/RPLayerPositivePrompt'; -import RPLayerSettingsPopover from 'features/regionalPrompts/components/RPLayerSettingsPopover'; -import { RPLayerVisibilityToggle } from 'features/regionalPrompts/components/RPLayerVisibilityToggle'; -import { - isVectorMaskLayer, - layerSelected, - selectRegionalPromptsSlice, -} from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { memo, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { assert } from 'tsafe'; - -import { AddPromptButtons } from './AddPromptButtons'; - -type Props = { - layerId: string; -}; - -export const RPLayerListItem = memo(({ layerId }: Props) => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const selector = useMemo( - () => - createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { - const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); - return { - color: rgbColorToString(layer.previewColor), - hasPositivePrompt: layer.positivePrompt !== null, - hasNegativePrompt: layer.negativePrompt !== null, - hasIPAdapters: layer.ipAdapterIds.length > 0, - isSelected: layerId === regionalPrompts.present.selectedLayerId, - autoNegative: layer.autoNegative, - }; - }), - [layerId] - ); - const { autoNegative, color, hasPositivePrompt, hasNegativePrompt, hasIPAdapters, isSelected } = - useAppSelector(selector); - const onClickCapture = useCallback(() => { - // Must be capture so that the layer is selected before deleting/resetting/etc - dispatch(layerSelected(layerId)); - }, [dispatch, layerId]); - return ( - - - - - - - {autoNegative === 'invert' && ( - - {t('regionalPrompts.autoNegative')} - - )} - - - - - {!hasPositivePrompt && !hasNegativePrompt && !hasIPAdapters && } - {hasPositivePrompt && } - {hasNegativePrompt && } - {hasIPAdapters && } - - - ); -}); - -RPLayerListItem.displayName = 'RPLayerListItem'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerMenu.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerMenu.tsx deleted file mode 100644 index f3c5317e10..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerMenu.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@invoke-ai/ui-library'; -import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { - isVectorMaskLayer, - layerDeleted, - layerMovedBackward, - layerMovedForward, - layerMovedToBack, - layerMovedToFront, - layerReset, - maskLayerIPAdapterAdded, - maskLayerNegativePromptChanged, - maskLayerPositivePromptChanged, - selectRegionalPromptsSlice, -} from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { memo, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - PiArrowCounterClockwiseBold, - PiArrowDownBold, - PiArrowLineDownBold, - PiArrowLineUpBold, - PiArrowUpBold, - PiDotsThreeVerticalBold, - PiPlusBold, - PiTrashSimpleBold, -} from 'react-icons/pi'; -import { assert } from 'tsafe'; - -type Props = { layerId: string }; - -export const RPLayerMenu = memo(({ layerId }: Props) => { - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - 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`); - const layerIndex = regionalPrompts.present.layers.findIndex((l) => l.id === layerId); - const layerCount = regionalPrompts.present.layers.length; - return { - canAddPositivePrompt: layer.positivePrompt === null, - canAddNegativePrompt: layer.negativePrompt === null, - canMoveForward: layerIndex < layerCount - 1, - canMoveBackward: layerIndex > 0, - canMoveToFront: layerIndex < layerCount - 1, - canMoveToBack: layerIndex > 0, - }; - }), - [layerId] - ); - const validActions = useAppSelector(selectValidActions); - const addPositivePrompt = useCallback(() => { - dispatch(maskLayerPositivePromptChanged({ layerId, prompt: '' })); - }, [dispatch, layerId]); - const addNegativePrompt = useCallback(() => { - dispatch(maskLayerNegativePromptChanged({ layerId, prompt: '' })); - }, [dispatch, layerId]); - const addIPAdapter = useCallback(() => { - dispatch(maskLayerIPAdapterAdded(layerId)); - }, [dispatch, layerId]); - const moveForward = useCallback(() => { - dispatch(layerMovedForward(layerId)); - }, [dispatch, layerId]); - const moveToFront = useCallback(() => { - dispatch(layerMovedToFront(layerId)); - }, [dispatch, layerId]); - const moveBackward = useCallback(() => { - dispatch(layerMovedBackward(layerId)); - }, [dispatch, layerId]); - const moveToBack = useCallback(() => { - dispatch(layerMovedToBack(layerId)); - }, [dispatch, layerId]); - const resetLayer = useCallback(() => { - dispatch(layerReset(layerId)); - }, [dispatch, layerId]); - const deleteLayer = useCallback(() => { - dispatch(layerDeleted(layerId)); - }, [dispatch, layerId]); - return ( - - } /> - - }> - {t('regionalPrompts.addPositivePrompt')} - - }> - {t('regionalPrompts.addNegativePrompt')} - - }> - {t('regionalPrompts.addIPAdapter')} - - - }> - {t('regionalPrompts.moveToFront')} - - }> - {t('regionalPrompts.moveForward')} - - }> - {t('regionalPrompts.moveBackward')} - - }> - {t('regionalPrompts.moveToBack')} - - - }> - {t('accessibility.reset')} - - } color="error.300"> - {t('common.delete')} - - - - ); -}); - -RPLayerMenu.displayName = 'RPLayerMenu'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptingEditor.stories.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptingEditor.stories.tsx deleted file mode 100644 index 943be227c8..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptingEditor.stories.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Flex } from '@invoke-ai/ui-library'; -import type { Meta, StoryObj } from '@storybook/react'; -import { RegionalPromptsEditor } from 'features/regionalPrompts/components/RegionalPromptsEditor'; - -const meta: Meta = { - title: 'Feature/RegionalPrompts', - tags: ['autodocs'], - component: RegionalPromptsEditor, -}; - -export default meta; -type Story = StoryObj; - -const Component = () => { - return ( - - - - ); -}; - -export const Default: Story = { - render: Component, -}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsPanelContent.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsPanelContent.tsx deleted file mode 100644 index 1fe4d53623..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsPanelContent.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* 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/regionalPrompts/components/AddLayerButton'; -import { DeleteAllLayersButton } from 'features/regionalPrompts/components/DeleteAllLayersButton'; -import { RPLayerListItem } from 'features/regionalPrompts/components/RPLayerListItem'; -import { isVectorMaskLayer, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { memo } from 'react'; - -const selectRPLayerIdsReversed = createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => - regionalPrompts.present.layers - .filter(isVectorMaskLayer) - .map((l) => l.id) - .reverse() -); - -export const RegionalPromptsPanelContent = memo(() => { - const rpLayerIdsReversed = useAppSelector(selectRPLayerIdsReversed); - return ( - - - - - - - - {rpLayerIdsReversed.map((id) => ( - - ))} - - - - ); -}); - -RegionalPromptsPanelContent.displayName = 'RegionalPromptsPanelContent'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsToolbar.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsToolbar.tsx deleted file mode 100644 index 4a3b611efd..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsToolbar.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable i18next/no-literal-string */ -import { Flex } from '@invoke-ai/ui-library'; -import { BrushSize } from 'features/regionalPrompts/components/BrushSize'; -import { GlobalMaskLayerOpacity } from 'features/regionalPrompts/components/GlobalMaskLayerOpacity'; -import { ToolChooser } from 'features/regionalPrompts/components/ToolChooser'; -import { UndoRedoButtonGroup } from 'features/regionalPrompts/components/UndoRedoButtonGroup'; -import { memo } from 'react'; - -export const RegionalPromptsToolbar = memo(() => { - return ( - - - - - - - ); -}); - -RegionalPromptsToolbar.displayName = 'RegionalPromptsToolbar'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts deleted file mode 100644 index 8ca830e228..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { useAppSelector } from 'app/store/storeHooks'; -import { isVectorMaskLayer, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { useMemo } from 'react'; -import { assert } from 'tsafe'; - -export const useLayerPositivePrompt = (layerId: string) => { - const selectLayer = useMemo( - () => - createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { - const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); - assert(layer.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(selectRegionalPromptsSlice, (regionalPrompts) => { - const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); - assert(layer.negativePrompt !== null, `Layer ${layerId} does not have a negative prompt`); - return layer.negativePrompt; - }), - [layerId] - ); - const prompt = useAppSelector(selectLayer); - return prompt; -}; - -export const useLayerIsVisible = (layerId: string) => { - const selectLayer = useMemo( - () => - createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { - const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(isVectorMaskLayer(layer), `Layer ${layerId} not found or not an RP layer`); - return layer.isVisible; - }), - [layerId] - ); - const isVisible = useAppSelector(selectLayer); - return isVisible; -}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts deleted file mode 100644 index 1d32938868..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts +++ /dev/null @@ -1,496 +0,0 @@ -import type { PayloadAction, UnknownAction } from '@reduxjs/toolkit'; -import { createSlice, isAnyOf } from '@reduxjs/toolkit'; -import type { PersistConfig, RootState } from 'app/store/store'; -import { moveBackward, moveForward, moveToBack, moveToFront } from 'common/util/arrayUtils'; -import { controlAdapterRemoved } from 'features/controlAdapters/store/controlAdaptersSlice'; -import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas'; -import type { IRect, Vector2d } from 'konva/lib/types'; -import { isEqual } from 'lodash-es'; -import { atom } from 'nanostores'; -import type { RgbColor } from 'react-colorful'; -import type { UndoableOptions } from 'redux-undo'; -import { assert } from 'tsafe'; -import { v4 as uuidv4 } from 'uuid'; - -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; - x: number; - y: number; - bbox: IRect | null; - bboxNeedsUpdate: boolean; - isVisible: boolean; -}; - -type MaskLayerBase = LayerBase & { - positivePrompt: string | null; - negativePrompt: string | null; // Up to one text prompt per mask - ipAdapterIds: string[]; // Any number of image prompts - previewColor: RgbColor; - autoNegative: ParameterAutoNegative; - needsPixelBbox: boolean; // Needs the slower pixel-based bbox calculation - set to true when an there is an eraser object -}; - -export type VectorMaskLayer = MaskLayerBase & { - type: 'vector_mask_layer'; - objects: (VectorMaskLine | VectorMaskRect)[]; -}; - -export type Layer = VectorMaskLayer; - -type RegionalPromptsState = { - _version: 1; - selectedLayerId: string | null; - layers: Layer[]; - brushSize: number; - globalMaskLayerOpacity: number; - isEnabled: boolean; -}; - -export const initialRegionalPromptsState: RegionalPromptsState = { - _version: 1, - selectedLayerId: null, - brushSize: 100, - layers: [], - globalMaskLayerOpacity: 0.5, // this globally changes all mask layers' opacity - isEnabled: true, -}; - -const isLine = (obj: VectorMaskLine | VectorMaskRect): obj is VectorMaskLine => obj.type === 'vector_mask_line'; -export const isVectorMaskLayer = (layer?: Layer): layer is VectorMaskLayer => layer?.type === 'vector_mask_layer'; -const resetLayer = (layer: VectorMaskLayer) => { - layer.objects = []; - layer.bbox = null; - layer.isVisible = true; - layer.needsPixelBbox = false; - layer.bboxNeedsUpdate = false; -}; - -export const regionalPromptsSlice = createSlice({ - name: 'regionalPrompts', - initialState: initialRegionalPromptsState, - reducers: { - //#region All Layers - layerAdded: { - reducer: (state, action: PayloadAction) => { - const kind = action.payload; - if (action.payload === 'vector_mask_layer') { - const lastColor = state.layers[state.layers.length - 1]?.previewColor; - const previewColor = LayerColors.next(lastColor); - const layer: VectorMaskLayer = { - id: getVectorMaskLayerId(action.meta.uuid), - type: kind, - isVisible: true, - bbox: null, - bboxNeedsUpdate: false, - objects: [], - previewColor, - x: 0, - y: 0, - autoNegative: 'invert', - needsPixelBbox: false, - positivePrompt: '', - negativePrompt: null, - ipAdapterIds: [], - }; - state.layers.push(layer); - state.selectedLayerId = layer.id; - return; - } - }, - prepare: (payload: Layer['type']) => ({ payload, meta: { uuid: uuidv4() } }), - }, - layerSelected: (state, action: PayloadAction) => { - const layer = state.layers.find((l) => l.id === action.payload); - if (layer) { - state.selectedLayerId = layer.id; - } - }, - layerVisibilityToggled: (state, action: PayloadAction) => { - const layer = state.layers.find((l) => l.id === action.payload); - if (layer) { - layer.isVisible = !layer.isVisible; - } - }, - layerTranslated: (state, action: PayloadAction<{ layerId: string; x: number; y: number }>) => { - const { layerId, x, y } = action.payload; - const layer = state.layers.find((l) => l.id === layerId); - if (layer) { - layer.x = x; - layer.y = y; - } - }, - layerBboxChanged: (state, action: PayloadAction<{ layerId: string; bbox: IRect | null }>) => { - const { layerId, bbox } = action.payload; - const layer = state.layers.find((l) => l.id === layerId); - if (layer) { - layer.bbox = bbox; - layer.bboxNeedsUpdate = false; - } - }, - layerReset: (state, action: PayloadAction) => { - const layer = state.layers.find((l) => l.id === action.payload); - if (layer) { - resetLayer(layer); - } - }, - layerDeleted: (state, action: PayloadAction) => { - state.layers = state.layers.filter((l) => l.id !== action.payload); - state.selectedLayerId = state.layers[0]?.id ?? null; - }, - layerMovedForward: (state, action: PayloadAction) => { - const cb = (l: Layer) => l.id === action.payload; - moveForward(state.layers, cb); - }, - layerMovedToFront: (state, action: PayloadAction) => { - const cb = (l: Layer) => l.id === action.payload; - // Because the layers are in reverse order, moving to the front is equivalent to moving to the back - moveToBack(state.layers, cb); - }, - layerMovedBackward: (state, action: PayloadAction) => { - const cb = (l: Layer) => l.id === action.payload; - moveBackward(state.layers, cb); - }, - layerMovedToBack: (state, action: PayloadAction) => { - const cb = (l: Layer) => l.id === action.payload; - // Because the layers are in reverse order, moving to the back is equivalent to moving to the front - moveToFront(state.layers, cb); - }, - allLayersDeleted: (state) => { - state.layers = []; - state.selectedLayerId = null; - }, - 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; - }, - //#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) { - 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) { - layer.negativePrompt = prompt; - } - }, - maskLayerIPAdapterAdded: { - reducer: (state, action: PayloadAction) => { - const layer = state.layers.find((l) => l.id === action.payload); - if (layer) { - layer.ipAdapterIds.push(action.meta.uuid); - } - }, - prepare: (payload: string) => ({ payload, meta: { uuid: uuidv4() } }), - }, - maskLayerPreviewColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbColor }>) => { - const { layerId, color } = action.payload; - const layer = state.layers.find((l) => l.id === layerId); - if (layer) { - 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) { - const lineId = getVectorMaskLayerLineId(layer.id, action.meta.uuid); - layer.objects.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) { - const lastLine = layer.objects.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) { - const id = getVectorMaskLayerRectId(layer.id, action.meta.uuid); - layer.objects.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) { - layer.autoNegative = autoNegative; - } - }, - //#endregion - - //#region General - brushSizeChanged: (state, action: PayloadAction) => { - state.brushSize = action.payload; - }, - globalMaskLayerOpacityChanged: (state, action: PayloadAction) => { - state.globalMaskLayerOpacity = action.payload; - }, - isEnabledChanged: (state, action: PayloadAction) => { - state.isEnabled = action.payload; - }, - undo: (state) => { - // Invalidate the bbox for all layers to prevent stale bboxes - for (const layer of state.layers) { - layer.bboxNeedsUpdate = true; - } - }, - redo: (state) => { - // Invalidate the bbox for all layers to prevent stale bboxes - for (const layer of state.layers) { - layer.bboxNeedsUpdate = true; - } - }, - //#endregion - }, - extraReducers(builder) { - builder.addCase(controlAdapterRemoved, (state, action) => { - for (const layer of state.layers) { - layer.ipAdapterIds = layer.ipAdapterIds.filter((id) => id !== action.payload.id); - } - }); - }, -}); - -/** - * This class is used to cycle through a set of colors for the prompt region layers. - */ -class LayerColors { - static COLORS: 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 - layerAdded, - layerDeleted, - layerMovedBackward, - layerMovedForward, - layerMovedToBack, - layerMovedToFront, - layerReset, - layerSelected, - layerTranslated, - layerBboxChanged, - layerVisibilityToggled, - allLayersDeleted, - selectedLayerReset, - selectedLayerDeleted, - // Mask layer actions - maskLayerLineAdded, - maskLayerPointsAdded, - maskLayerRectAdded, - maskLayerNegativePromptChanged, - maskLayerPositivePromptChanged, - maskLayerIPAdapterAdded, - maskLayerAutoNegativeChanged, - maskLayerPreviewColorChanged, - // General actions - brushSizeChanged, - globalMaskLayerOpacityChanged, - undo, - redo, -} = regionalPromptsSlice.actions; - -export const selectRegionalPromptsSlice = (state: RootState) => state.regionalPrompts; - -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -const migrateRegionalPromptsState = (state: any): any => { - return state; -}; - -export const $isMouseDown = atom(false); -export const $isMouseOver = atom(false); -export const $lastMouseDownPos = atom(null); -export const $tool = atom('brush'); -export const $cursorPosition = atom(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'; - -// Names (aka classes) for Konva layers and objects -export const VECTOR_MASK_LAYER_NAME = 'vector_mask_layer'; -export const VECTOR_MASK_LAYER_LINE_NAME = 'vector_mask_layer.line'; -export const VECTOR_MASK_LAYER_OBJECT_GROUP_NAME = 'vector_mask_layer.object_group'; -export const VECTOR_MASK_LAYER_RECT_NAME = 'vector_mask_layer.rect'; -export const LAYER_BBOX_NAME = 'layer.bbox'; - -// Getters for non-singleton layer and object IDs -const getVectorMaskLayerId = (layerId: string) => `${VECTOR_MASK_LAYER_NAME}_${layerId}`; -const getVectorMaskLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`; -const getVectorMaskLayerRectId = (layerId: string, lineId: string) => `${layerId}.rect_${lineId}`; -export const getVectorMaskLayerObjectGroupId = (layerId: string, groupId: string) => - `${layerId}.objectGroup_${groupId}`; -export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`; - -export const regionalPromptsPersistConfig: PersistConfig = { - name: regionalPromptsSlice.name, - initialState: initialRegionalPromptsState, - migrate: migrateRegionalPromptsState, - 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 regionalPromptsUndoableConfig: UndoableOptions = { - limit: 64, - undoType: regionalPromptsSlice.actions.undo.type, - redoType: regionalPromptsSlice.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(regionalPromptsSlice.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; - }, -}; diff --git a/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/ParamSDXLNegativeStylePrompt.tsx b/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/ParamSDXLNegativeStylePrompt.tsx index 067319817a..bba9e0b32d 100644 --- a/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/ParamSDXLNegativeStylePrompt.tsx +++ b/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/ParamSDXLNegativeStylePrompt.tsx @@ -1,22 +1,22 @@ import { Box, Textarea } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { negativePrompt2Changed } 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 { setNegativeStylePromptSDXL } from 'features/sdxl/store/sdxlSlice'; import { memo, useCallback, useRef } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; export const ParamSDXLNegativeStylePrompt = memo(() => { const dispatch = useAppDispatch(); - const prompt = useAppSelector((s) => s.sdxl.negativeStylePrompt); + const prompt = useAppSelector((s) => s.controlLayers.present.negativePrompt2); const textareaRef = useRef(null); const { t } = useTranslation(); const handleChange = useCallback( (v: string) => { - dispatch(setNegativeStylePromptSDXL(v)); + dispatch(negativePrompt2Changed(v)); }, [dispatch] ); diff --git a/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/ParamSDXLPositiveStylePrompt.tsx b/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/ParamSDXLPositiveStylePrompt.tsx index 6fc302cd9c..3828136c74 100644 --- a/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/ParamSDXLPositiveStylePrompt.tsx +++ b/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/ParamSDXLPositiveStylePrompt.tsx @@ -1,21 +1,21 @@ import { Box, Textarea } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { positivePrompt2Changed } 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 { setPositiveStylePromptSDXL } from 'features/sdxl/store/sdxlSlice'; import { memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; export const ParamSDXLPositiveStylePrompt = memo(() => { const dispatch = useAppDispatch(); - const prompt = useAppSelector((s) => s.sdxl.positiveStylePrompt); + const prompt = useAppSelector((s) => s.controlLayers.present.positivePrompt2); const textareaRef = useRef(null); const { t } = useTranslation(); const handleChange = useCallback( (v: string) => { - dispatch(setPositiveStylePromptSDXL(v)); + dispatch(positivePrompt2Changed(v)); }, [dispatch] ); diff --git a/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/SDXLConcatButton.tsx b/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/SDXLConcatButton.tsx index 31df7d62d0..0af3dfcee4 100644 --- a/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/SDXLConcatButton.tsx +++ b/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/SDXLConcatButton.tsx @@ -1,23 +1,23 @@ import { IconButton, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { setShouldConcatSDXLStylePrompt } from 'features/sdxl/store/sdxlSlice'; +import { shouldConcatPromptsChanged } from 'features/controlLayers/store/controlLayersSlice'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiLinkSimpleBold, PiLinkSimpleBreakBold } from 'react-icons/pi'; export const SDXLConcatButton = memo(() => { - const shouldConcatSDXLStylePrompt = useAppSelector((s) => s.sdxl.shouldConcatSDXLStylePrompt); + const shouldConcatPrompts = useAppSelector((s) => s.controlLayers.present.shouldConcatPrompts); const dispatch = useAppDispatch(); const { t } = useTranslation(); const handleShouldConcatPromptChange = useCallback(() => { - dispatch(setShouldConcatSDXLStylePrompt(!shouldConcatSDXLStylePrompt)); - }, [dispatch, shouldConcatSDXLStylePrompt]); + dispatch(shouldConcatPromptsChanged(!shouldConcatPrompts)); + }, [dispatch, shouldConcatPrompts]); const label = useMemo( - () => (shouldConcatSDXLStylePrompt ? t('sdxl.concatPromptStyle') : t('sdxl.freePromptStyle')), - [shouldConcatSDXLStylePrompt, t] + () => (shouldConcatPrompts ? t('sdxl.concatPromptStyle') : t('sdxl.freePromptStyle')), + [shouldConcatPrompts, t] ); return ( @@ -25,7 +25,7 @@ export const SDXLConcatButton = memo(() => { : } + icon={shouldConcatPrompts ? : } variant="promptOverlay" fontSize={12} px={0.5} diff --git a/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/SDXLPrompts.tsx b/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/SDXLPrompts.tsx index 4aca9a85a6..b585e92a5f 100644 --- a/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/SDXLPrompts.tsx +++ b/invokeai/frontend/web/src/features/sdxl/components/SDXLPrompts/SDXLPrompts.tsx @@ -8,13 +8,13 @@ import { ParamSDXLNegativeStylePrompt } from './ParamSDXLNegativeStylePrompt'; import { ParamSDXLPositiveStylePrompt } from './ParamSDXLPositiveStylePrompt'; export const SDXLPrompts = memo(() => { - const shouldConcatSDXLStylePrompt = useAppSelector((s) => s.sdxl.shouldConcatSDXLStylePrompt); + const shouldConcatPrompts = useAppSelector((s) => s.controlLayers.present.shouldConcatPrompts); return ( - {!shouldConcatSDXLStylePrompt && } + {!shouldConcatPrompts && } - {!shouldConcatSDXLStylePrompt && } + {!shouldConcatPrompts && } ); }); diff --git a/invokeai/frontend/web/src/features/sdxl/store/sdxlSlice.ts b/invokeai/frontend/web/src/features/sdxl/store/sdxlSlice.ts index 91e1418e1d..10a8f861f1 100644 --- a/invokeai/frontend/web/src/features/sdxl/store/sdxlSlice.ts +++ b/invokeai/frontend/web/src/features/sdxl/store/sdxlSlice.ts @@ -1,18 +1,10 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; -import type { - ParameterNegativeStylePromptSDXL, - ParameterPositiveStylePromptSDXL, - ParameterScheduler, - ParameterSDXLRefinerModel, -} from 'features/parameters/types/parameterSchemas'; +import type { ParameterScheduler, ParameterSDXLRefinerModel } from 'features/parameters/types/parameterSchemas'; type SDXLState = { _version: 2; - positiveStylePrompt: ParameterPositiveStylePromptSDXL; - negativeStylePrompt: ParameterNegativeStylePromptSDXL; - shouldConcatSDXLStylePrompt: boolean; refinerModel: ParameterSDXLRefinerModel | null; refinerSteps: number; refinerCFGScale: number; @@ -24,9 +16,6 @@ type SDXLState = { const initialSDXLState: SDXLState = { _version: 2, - positiveStylePrompt: '', - negativeStylePrompt: '', - shouldConcatSDXLStylePrompt: true, refinerModel: null, refinerSteps: 20, refinerCFGScale: 7.5, @@ -40,15 +29,6 @@ export const sdxlSlice = createSlice({ name: 'sdxl', initialState: initialSDXLState, reducers: { - setPositiveStylePromptSDXL: (state, action: PayloadAction) => { - state.positiveStylePrompt = action.payload; - }, - setNegativeStylePromptSDXL: (state, action: PayloadAction) => { - state.negativeStylePrompt = action.payload; - }, - setShouldConcatSDXLStylePrompt: (state, action: PayloadAction) => { - state.shouldConcatSDXLStylePrompt = action.payload; - }, refinerModelChanged: (state, action: PayloadAction) => { state.refinerModel = action.payload; }, @@ -74,9 +54,6 @@ export const sdxlSlice = createSlice({ }); export const { - setPositiveStylePromptSDXL, - setNegativeStylePromptSDXL, - setShouldConcatSDXLStylePrompt, refinerModelChanged, setRefinerSteps, setRefinerCFGScale, diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/ControlSettingsAccordion/ControlSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/ControlSettingsAccordion/ControlSettingsAccordion.tsx index 36448c8909..d072cfde0f 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/ControlSettingsAccordion/ControlSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ControlSettingsAccordion/ControlSettingsAccordion.tsx @@ -13,7 +13,7 @@ import { selectValidIPAdapters, selectValidT2IAdapters, } from 'features/controlAdapters/store/controlAdaptersSlice'; -import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { selectAllControlAdapterIds, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice'; import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { Fragment, memo } from 'react'; @@ -21,24 +21,28 @@ import { useTranslation } from 'react-i18next'; import { PiPlusBold } from 'react-icons/pi'; const selector = createMemoizedSelector( - [selectControlAdaptersSlice, selectRegionalPromptsSlice], - (controlAdapters, regionalPrompts) => { + [selectControlAdaptersSlice, selectControlLayersSlice], + (controlAdapters, controlLayers) => { const badges: string[] = []; let isError = false; - const enabledIPAdapterCount = selectAllIPAdapters(controlAdapters) - .filter((ca) => !regionalPrompts.present.layers.some((l) => l.ipAdapterIds.includes(ca.id))) + const controlLayersAdapterIds = selectAllControlAdapterIds(controlLayers.present); + + const enabledNonRegionalIPAdapterCount = selectAllIPAdapters(controlAdapters) + .filter((ca) => !controlLayersAdapterIds.includes(ca.id)) .filter((ca) => ca.isEnabled).length; const validIPAdapterCount = selectValidIPAdapters(controlAdapters).length; - if (enabledIPAdapterCount > 0) { - badges.push(`${enabledIPAdapterCount} IP`); + if (enabledNonRegionalIPAdapterCount > 0) { + badges.push(`${enabledNonRegionalIPAdapterCount} IP`); } - if (enabledIPAdapterCount > validIPAdapterCount) { + if (enabledNonRegionalIPAdapterCount > validIPAdapterCount) { isError = true; } - const enabledControlNetCount = selectAllControlNets(controlAdapters).filter((ca) => ca.isEnabled).length; + const enabledControlNetCount = selectAllControlNets(controlAdapters) + .filter((ca) => !controlLayersAdapterIds.includes(ca.id)) + .filter((ca) => ca.isEnabled).length; const validControlNetCount = selectValidControlNets(controlAdapters).length; if (enabledControlNetCount > 0) { badges.push(`${enabledControlNetCount} ControlNet`); @@ -47,7 +51,9 @@ const selector = createMemoizedSelector( isError = true; } - const enabledT2IAdapterCount = selectAllT2IAdapters(controlAdapters).filter((ca) => ca.isEnabled).length; + const enabledT2IAdapterCount = selectAllT2IAdapters(controlAdapters) + .filter((ca) => !controlLayersAdapterIds.includes(ca.id)) + .filter((ca) => ca.isEnabled).length; const validT2IAdapterCount = selectValidT2IAdapters(controlAdapters).length; if (enabledT2IAdapterCount > 0) { badges.push(`${enabledT2IAdapterCount} T2I`); @@ -57,7 +63,7 @@ const selector = createMemoizedSelector( } const controlAdapterIds = selectControlAdapterIds(controlAdapters).filter( - (id) => !regionalPrompts.present.layers.some((l) => l.ipAdapterIds.includes(id)) + (id) => !controlLayersAdapterIds.includes(id) ); return { diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion.tsx index 125a611876..bb9cfd36ce 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion.tsx @@ -3,6 +3,7 @@ import { Expander, Flex, FormControlGroup, StandaloneAccordion } from '@invoke-a import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { selectCanvasSlice } from 'features/canvas/store/canvasSlice'; +import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice'; import { HrfSettings } from 'features/hrf/components/HrfSettings'; import { selectHrfSlice } from 'features/hrf/store/hrfSlice'; import ParamScaleBeforeProcessing from 'features/parameters/components/Canvas/InfillAndScaling/ParamScaleBeforeProcessing'; @@ -24,8 +25,8 @@ import { ImageSizeCanvas } from './ImageSizeCanvas'; import { ImageSizeLinear } from './ImageSizeLinear'; const selector = createMemoizedSelector( - [selectGenerationSlice, selectCanvasSlice, selectHrfSlice, activeTabNameSelector], - (generation, canvas, hrf, activeTabName) => { + [selectGenerationSlice, selectCanvasSlice, selectHrfSlice, selectControlLayersSlice, activeTabNameSelector], + (generation, canvas, hrf, controlLayers, activeTabName) => { const { shouldRandomizeSeed, model } = generation; const { hrfEnabled } = hrf; const badges: string[] = []; @@ -42,7 +43,7 @@ const selector = createMemoizedSelector( badges.push('locked'); } } else { - const { aspectRatio, width, height } = generation; + const { aspectRatio, width, height } = controlLayers.present.size; badges.push(`${width}×${height}`); badges.push(aspectRatio.id); if (aspectRatio.isLocked) { diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx index 498faf452b..7e436556da 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx @@ -1,31 +1,31 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { aspectRatioChanged, heightChanged, widthChanged } from 'features/controlLayers/store/controlLayersSlice'; import { ParamHeight } from 'features/parameters/components/Core/ParamHeight'; import { ParamWidth } from 'features/parameters/components/Core/ParamWidth'; import { AspectRatioCanvasPreview } from 'features/parameters/components/ImageSize/AspectRatioCanvasPreview'; import { AspectRatioIconPreview } from 'features/parameters/components/ImageSize/AspectRatioIconPreview'; import { ImageSize } from 'features/parameters/components/ImageSize/ImageSize'; import type { AspectRatioState } from 'features/parameters/components/ImageSize/types'; -import { aspectRatioChanged, heightChanged, widthChanged } from 'features/parameters/store/generationSlice'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { memo, useCallback } from 'react'; export const ImageSizeLinear = memo(() => { const dispatch = useAppDispatch(); const tab = useAppSelector(activeTabNameSelector); - const width = useAppSelector((s) => s.generation.width); - const height = useAppSelector((s) => s.generation.height); - const aspectRatioState = useAppSelector((s) => s.generation.aspectRatio); + const width = useAppSelector((s) => s.controlLayers.present.size.width); + const height = useAppSelector((s) => s.controlLayers.present.size.height); + const aspectRatioState = useAppSelector((s) => s.controlLayers.present.size.aspectRatio); const onChangeWidth = useCallback( (width: number) => { - dispatch(widthChanged(width)); + dispatch(widthChanged({ width })); }, [dispatch] ); const onChangeHeight = useCallback( (height: number) => { - dispatch(heightChanged(height)); + dispatch(heightChanged({ height })); }, [dispatch] ); diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx index cb49696dbf..9ac5324d41 100644 --- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx +++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx @@ -11,6 +11,7 @@ import StatusIndicator from 'features/system/components/StatusIndicator'; import { selectConfigSlice } from 'features/system/store/configSlice'; import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton'; import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons'; +import ParametersPanelTextToImage from 'features/ui/components/ParametersPanelTextToImage'; import type { UsePanelOptions } from 'features/ui/hooks/usePanel'; import { usePanel } from 'features/ui/hooks/usePanel'; import { usePanelStorage } from 'features/ui/hooks/usePanelStorage'; @@ -249,7 +250,7 @@ const InvokeTabs = () => { onExpand={optionsPanel.onExpand} collapsible > - {activeTabName === 'nodes' ? : } + { }; export default memo(InvokeTabs); + +const ParametersPanelComponent = memo(() => { + const activeTabName = useAppSelector(activeTabNameSelector); + + if (activeTabName === 'nodes') { + return ; + } + if (activeTabName === 'txt2img') { + return ; + } + return ; +}); +ParametersPanelComponent.displayName = 'ParametersPanelComponent'; diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanel.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanel.tsx index a026a95196..b8d35976e3 100644 --- a/invokeai/frontend/web/src/features/ui/components/ParametersPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanel.tsx @@ -1,10 +1,8 @@ -import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; +import { Box, Flex } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; import { Prompts } from 'features/parameters/components/Prompts/Prompts'; import QueueControls from 'features/queue/components/QueueControls'; -import { RegionalPromptsPanelContent } from 'features/regionalPrompts/components/RegionalPromptsPanelContent'; -import { useRegionalControlTitle } from 'features/regionalPrompts/hooks/useRegionalControlTitle'; import { SDXLPrompts } from 'features/sdxl/components/SDXLPrompts/SDXLPrompts'; import { AdvancedSettingsAccordion } from 'features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion'; import { CompositingSettingsAccordion } from 'features/settingsAccordions/components/CompositingSettingsAccordion/CompositingSettingsAccordion'; @@ -16,7 +14,6 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import type { CSSProperties } from 'react'; import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; const overlayScrollbarsStyles: CSSProperties = { height: '100%', @@ -24,9 +21,7 @@ const overlayScrollbarsStyles: CSSProperties = { }; const ParametersPanel = () => { - const { t } = useTranslation(); const activeTabName = useAppSelector(activeTabNameSelector); - const regionalControlTitle = useRegionalControlTitle(); const isSDXL = useAppSelector((s) => s.generation.model?.base === 'sdxl'); return ( @@ -37,28 +32,12 @@ const ParametersPanel = () => { {isSDXL ? : } - - - {t('parameters.globalSettings')} - {regionalControlTitle} - - - - - - - - - {activeTabName === 'unifiedCanvas' && } - {isSDXL && } - - - - - - - - + + + {activeTabName !== 'txt2img' && } + {activeTabName === 'unifiedCanvas' && } + {isSDXL && } + diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx new file mode 100644 index 0000000000..2d14a50856 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx @@ -0,0 +1,70 @@ +import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; +import { ControlLayersPanelContent } from 'features/controlLayers/components/ControlLayersPanelContent'; +import { useControlLayersTitle } from 'features/controlLayers/hooks/useControlLayersTitle'; +import { Prompts } from 'features/parameters/components/Prompts/Prompts'; +import QueueControls from 'features/queue/components/QueueControls'; +import { SDXLPrompts } from 'features/sdxl/components/SDXLPrompts/SDXLPrompts'; +import { AdvancedSettingsAccordion } from 'features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion'; +import { CompositingSettingsAccordion } from 'features/settingsAccordions/components/CompositingSettingsAccordion/CompositingSettingsAccordion'; +import { ControlSettingsAccordion } from 'features/settingsAccordions/components/ControlSettingsAccordion/ControlSettingsAccordion'; +import { GenerationSettingsAccordion } from 'features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion'; +import { ImageSettingsAccordion } from 'features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion'; +import { RefinerSettingsAccordion } from 'features/settingsAccordions/components/RefinerSettingsAccordion/RefinerSettingsAccordion'; +import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import type { CSSProperties } from 'react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const overlayScrollbarsStyles: CSSProperties = { + height: '100%', + width: '100%', +}; + +const ParametersPanelTextToImage = () => { + const { t } = useTranslation(); + const activeTabName = useAppSelector(activeTabNameSelector); + const controlLayersTitle = useControlLayersTitle(); + const isSDXL = useAppSelector((s) => s.generation.model?.base === 'sdxl'); + + return ( + + + + + + + {isSDXL ? : } + + + {t('common.settingsLabel')} + {controlLayersTitle} + + + + + + + + {activeTabName !== 'txt2img' && } + {activeTabName === 'unifiedCanvas' && } + {isSDXL && } + + + + + + + + + + + + + + ); +}; + +export default memo(ParametersPanelTextToImage); diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ImageToImageTab.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ImageToImageTab.tsx index dcacdbdff4..07e87d202c 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ImageToImageTab.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ImageToImageTab.tsx @@ -1,7 +1,7 @@ -import { Box } from '@invoke-ai/ui-library'; +import { Box, Flex } from '@invoke-ai/ui-library'; +import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay'; import InitialImageDisplay from 'features/parameters/components/ImageToImage/InitialImageDisplay'; import ResizeHandle from 'features/ui/components/tabs/ResizeHandle'; -import TextToImageTabMain from 'features/ui/components/tabs/TextToImageTab'; import { usePanelStorage } from 'features/ui/hooks/usePanelStorage'; import type { CSSProperties } from 'react'; import { memo, useCallback, useRef } from 'react'; @@ -42,7 +42,11 @@ const ImageToImageTab = () => { - + + + + + diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx index 2a79c9b9d1..f9b760bcd5 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx @@ -1,20 +1,20 @@ import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; +import { ControlLayersEditor } from 'features/controlLayers/components/ControlLayersEditor'; +import { useControlLayersTitle } from 'features/controlLayers/hooks/useControlLayersTitle'; import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay'; -import { RegionalPromptsEditor } from 'features/regionalPrompts/components/RegionalPromptsEditor'; -import { useRegionalControlTitle } from 'features/regionalPrompts/hooks/useRegionalControlTitle'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; const TextToImageTab = () => { const { t } = useTranslation(); - const regionalControlTitle = useRegionalControlTitle(); + const controlLayersTitle = useControlLayersTitle(); return ( - + {t('common.viewer')} - {regionalControlTitle} + {controlLayersTitle} @@ -22,7 +22,7 @@ const TextToImageTab = () => { - + diff --git a/invokeai/frontend/web/src/services/api/hooks/useGetModelConfigWithTypeGuard.ts b/invokeai/frontend/web/src/services/api/hooks/useGetModelConfigWithTypeGuard.ts index 6de2941403..8ff4db1acc 100644 --- a/invokeai/frontend/web/src/services/api/hooks/useGetModelConfigWithTypeGuard.ts +++ b/invokeai/frontend/web/src/services/api/hooks/useGetModelConfigWithTypeGuard.ts @@ -8,7 +8,7 @@ export const useGetModelConfigWithTypeGuard = ( ) => { const result = useGetModelConfigQuery(key ?? skipToken, { selectFromResult: (result) => { - const modelConfig = result.data; + const modelConfig = result.currentData; return { ...result, modelConfig: modelConfig && typeGuard(modelConfig) ? modelConfig : undefined, diff --git a/invokeai/version/invokeai_version.py b/invokeai/version/invokeai_version.py index 0c1d77bc3d..7c223b74a7 100644 --- a/invokeai/version/invokeai_version.py +++ b/invokeai/version/invokeai_version.py @@ -1 +1 @@ -__version__ = "4.2.0a3" +__version__ = "4.2.0a4"