From b1d8f3a3f9713eb36970a4e1dfa35fc3d7688dbf Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 15:50:29 +1000
Subject: [PATCH 01/59] tidy(ui): revert changes to old CA implementation

These changes were left over from the previous attempt to handle control adapters in control layers with the same logic. Control Layers are now handled totally separately, so these changes may be reverted.
---
 .../listeners/canvasImageToControlNet.ts      |   4 +-
 .../listeners/canvasMaskToControlNet.ts       |   4 +-
 .../listeners/controlNetAutoProcess.ts        |   6 -
 .../listeners/controlNetImageProcessed.ts     |   2 +-
 .../listeners/imageDropped.ts                 |   2 +-
 .../listeners/imageUploaded.ts                |   2 +-
 .../listeners/modelSelected.ts                |   4 +-
 .../components/ControlAdapterConfig.tsx       |   2 +-
 .../ParamControlAdapterIPMethod.tsx           |   6 +-
 .../parameters/ParamControlAdapterModel.tsx   |  11 +-
 .../hooks/useControlAdapterIPMethod.ts        |   8 +-
 .../store/controlAdaptersSlice.ts             | 131 +++++-------------
 .../features/controlAdapters/store/types.ts   |   4 -
 .../util/buildControlAdapter.ts               |   4 -
 .../web/src/features/metadata/util/parsers.ts |   6 +-
 15 files changed, 60 insertions(+), 136 deletions(-)

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 b1b19b35dc..55392ebff4 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,10 +48,12 @@ export const addCanvasImageToControlNetListener = (startAppListening: AppStartLi
         })
       ).unwrap();
 
+      const { image_name } = imageDTO;
+
       dispatch(
         controlAdapterImageChanged({
           id,
-          controlImage: imageDTO,
+          controlImage: image_name,
         })
       );
     },
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 b3014277f1..569b4badc7 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,10 +58,12 @@ export const addCanvasMaskToControlNetListener = (startAppListening: AppStartLis
         })
       ).unwrap();
 
+      const { image_name } = imageDTO;
+
       dispatch(
         controlAdapterImageChanged({
           id,
-          controlImage: imageDTO,
+          controlImage: image_name,
         })
       );
     },
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 14af0246a2..e52df30681 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,7 +12,6 @@ import {
   selectControlAdapterById,
 } from 'features/controlAdapters/store/controlAdaptersSlice';
 import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
-import { isEqual } from 'lodash-es';
 
 type AnyControlAdapterParamChangeAction =
   | ReturnType<typeof controlAdapterProcessorParamsChanged>
@@ -53,11 +52,6 @@ const predicate: AnyListenerPredicate<RootState> = (action, state, prevState) =>
     return false;
   }
 
-  if (prevCA.controlImage === ca.controlImage && isEqual(prevCA.processorNode, ca.processorNode)) {
-    // Don't re-process if the processor hasn't changed
-    return false;
-  }
-
   const isProcessorSelected = processorType !== 'none';
 
   const hasControlImage = Boolean(controlImage);
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 08afc98836..0055866aa7 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: processedControlImage.image_name,
             })
           );
         }
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 de2ac3a39a..5db78ed75e 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
@@ -76,7 +76,7 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
         dispatch(
           controlAdapterImageChanged({
             id,
-            controlImage: activeData.payload.imageDTO,
+            controlImage: activeData.payload.imageDTO.image_name,
           })
         );
         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 fd568ef1bd..d0edfafd57 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
@@ -101,7 +101,7 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis
         dispatch(
           controlAdapterImageChanged({
             id,
-            controlImage: imageDTO,
+            controlImage: imageDTO.image_name,
           })
         );
         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 b69e56e84a..bc049cf498 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 {
-  controlAdapterModelChanged,
+  controlAdapterIsEnabledChanged,
   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(controlAdapterModelChanged({ id: ca.id, modelConfig: null }));
+            dispatch(controlAdapterIsEnabledChanged({ id: ca.id, isEnabled: false }));
             modelsCleared += 1;
           }
         });
diff --git a/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterConfig.tsx b/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterConfig.tsx
index 032e46f477..fcc816d75f 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 }) => {
       <Flex w="full" flexDir="column" gap={4}>
         <Flex gap={8} w="full" alignItems="center">
           <Flex flexDir="column" gap={4} h={controlAdapterType === 'ip_adapter' ? 40 : 32} w="full">
-            {controlAdapterType === 'ip_adapter' && <ParamControlAdapterIPMethod id={id} />}
+            <ParamControlAdapterIPMethod id={id} />
             <ParamControlAdapterWeight id={id} />
             <ParamControlAdapterBeginEnd id={id} />
           </Flex>
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 d7d91ab780..c7aaa9f26c 100644
--- a/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterIPMethod.tsx
+++ b/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterIPMethod.tsx
@@ -46,9 +46,13 @@ const ParamControlAdapterIPMethod = ({ id }: Props) => {
 
   const value = useMemo(() => options.find((o) => o.value === method), [options, method]);
 
+  if (!method) {
+    return null;
+  }
+
   return (
     <FormControl>
-      <InformationalPopover feature="ipAdapterMethod">
+      <InformationalPopover feature="controlNetResizeMode">
         <FormLabel>{t('controlnet.ipAdapterMethod')}</FormLabel>
       </InformationalPopover>
       <Combobox value={value} options={options} isDisabled={!isEnabled} onChange={handleIPMethodChanged} />
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 73a7d695b3..00c7d5859d 100644
--- a/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterModel.tsx
+++ b/invokeai/frontend/web/src/features/controlAdapters/components/parameters/ParamControlAdapterModel.tsx
@@ -102,9 +102,13 @@ const ParamControlAdapterModel = ({ id }: ParamControlAdapterModelProps) => {
   );
 
   return (
-    <Flex gap={4}>
+    <Flex sx={{ gap: 2 }}>
       <Tooltip label={selectedModel?.description}>
-        <FormControl isDisabled={!isEnabled} isInvalid={!value || mainModel?.base !== modelConfig?.base} w="full">
+        <FormControl
+          isDisabled={!isEnabled}
+          isInvalid={!value || mainModel?.base !== modelConfig?.base}
+          sx={{ width: '100%' }}
+        >
           <Combobox
             options={options}
             placeholder={t('controlnet.selectModel')}
@@ -118,8 +122,7 @@ const ParamControlAdapterModel = ({ id }: ParamControlAdapterModelProps) => {
         <FormControl
           isDisabled={!isEnabled}
           isInvalid={!value || mainModel?.base !== modelConfig?.base}
-          width="max-content"
-          minWidth={28}
+          sx={{ width: 'max-content', minWidth: 28 }}
         >
           <Combobox
             options={clipVisionOptions}
diff --git a/invokeai/frontend/web/src/features/controlAdapters/hooks/useControlAdapterIPMethod.ts b/invokeai/frontend/web/src/features/controlAdapters/hooks/useControlAdapterIPMethod.ts
index 2a19980f1a..a179899396 100644
--- a/invokeai/frontend/web/src/features/controlAdapters/hooks/useControlAdapterIPMethod.ts
+++ b/invokeai/frontend/web/src/features/controlAdapters/hooks/useControlAdapterIPMethod.ts
@@ -5,15 +5,15 @@ import {
   selectControlAdaptersSlice,
 } from 'features/controlAdapters/store/controlAdaptersSlice';
 import { useMemo } from 'react';
-import { assert } from 'tsafe';
 
 export const useControlAdapterIPMethod = (id: string) => {
   const selector = useMemo(
     () =>
       createMemoizedSelector(selectControlAdaptersSlice, (controlAdapters) => {
-        const ca = selectControlAdapterById(controlAdapters, id);
-        assert(ca?.type === 'ip_adapter');
-        return ca.method;
+        const cn = selectControlAdapterById(controlAdapters, id);
+        if (cn && cn?.type === 'ip_adapter') {
+          return cn.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 f8afde677c..8ec397f99c 100644
--- a/invokeai/frontend/web/src/features/controlAdapters/store/controlAdaptersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlAdapters/store/controlAdaptersSlice.ts
@@ -7,7 +7,7 @@ import { buildControlAdapter } from 'features/controlAdapters/util/buildControlA
 import { buildControlAdapterProcessor } from 'features/controlAdapters/util/buildControlAdapterProcessor';
 import { zModelIdentifierField } from 'features/nodes/types/common';
 import { merge, uniq } from 'lodash-es';
-import type { ControlNetModelConfig, ImageDTO, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types';
+import type { ControlNetModelConfig, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types';
 import { socketInvocationError } from 'services/events/actions';
 import { v4 as uuidv4 } from 'uuid';
 
@@ -134,46 +134,23 @@ export const controlAdaptersSlice = createSlice({
       const { id, isEnabled } = action.payload;
       caAdapter.updateOne(state, { id, changes: { isEnabled } });
     },
-    controlAdapterImageChanged: (state, action: PayloadAction<{ id: string; controlImage: ImageDTO | null }>) => {
+    controlAdapterImageChanged: (
+      state,
+      action: PayloadAction<{
+        id: string;
+        controlImage: string | null;
+      }>
+    ) => {
       const { id, controlImage } = action.payload;
       const ca = selectControlAdapterById(state, id);
       if (!ca) {
         return;
       }
 
-      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 } });
-      }
+      caAdapter.updateOne(state, {
+        id,
+        changes: { controlImage, processedControlImage: null },
+      });
 
       if (controlImage !== null && isControlNetOrT2IAdapter(ca) && ca.processorType !== 'none') {
         state.pendingControlImages.push(id);
@@ -183,7 +160,7 @@ export const controlAdaptersSlice = createSlice({
       state,
       action: PayloadAction<{
         id: string;
-        processedControlImage: ImageDTO | null;
+        processedControlImage: string | null;
       }>
     ) => {
       const { id, processedControlImage } = action.payload;
@@ -196,24 +173,12 @@ export const controlAdaptersSlice = createSlice({
         return;
       }
 
-      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,
-          },
-        });
-      }
+      caAdapter.updateOne(state, {
+        id,
+        changes: {
+          processedControlImage,
+        },
+      });
 
       state.pendingControlImages = state.pendingControlImages.filter((pendingId) => pendingId !== id);
     },
@@ -227,7 +192,7 @@ export const controlAdaptersSlice = createSlice({
       state,
       action: PayloadAction<{
         id: string;
-        modelConfig: ControlNetModelConfig | T2IAdapterModelConfig | IPAdapterModelConfig | null;
+        modelConfig: ControlNetModelConfig | T2IAdapterModelConfig | IPAdapterModelConfig;
       }>
     ) => {
       const { id, modelConfig } = action.payload;
@@ -236,11 +201,6 @@ export const controlAdaptersSlice = createSlice({
         return;
       }
 
-      if (modelConfig === null) {
-        caAdapter.updateOne(state, { id, changes: { model: null } });
-        return;
-      }
-
       const model = zModelIdentifierField.parse(modelConfig);
 
       if (!isControlNetOrT2IAdapter(cn)) {
@@ -248,36 +208,22 @@ export const controlAdaptersSlice = createSlice({
         return;
       }
 
+      const update: Update<ControlNetConfig | T2IAdapterConfig, string> = {
+        id,
+        changes: { model, shouldAutoConfig: true },
+      };
+
+      update.changes.processedControlImage = null;
+
       if (modelConfig.type === 'ip_adapter') {
         // should never happen...
         return;
       }
 
-      // We always update the model
-      const update: Update<ControlNetConfig | T2IAdapterConfig, string> = { id, changes: { model } };
-
-      // Build the default processor for this model
       const processor = buildControlAdapterProcessor(modelConfig);
-      if (processor.processorType !== cn.processorNode.type) {
-        // If the processor type has changed, update the processor node
-        update.changes.shouldAutoConfig = true;
-        update.changes.processedControlImage = null;
-        update.changes.processorType = processor.processorType;
-        update.changes.processorNode = processor.processorNode;
+      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 }>) => {
@@ -394,23 +340,8 @@ export const controlAdaptersSlice = createSlice({
 
       if (update.changes.shouldAutoConfig && modelConfig) {
         const processor = buildControlAdapterProcessor(modelConfig);
-        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;
-            }
-          }
-        }
+        update.changes.processorType = processor.processorType;
+        update.changes.processorNode = processor.processorNode;
       }
 
       caAdapter.updateOne(state, update);
diff --git a/invokeai/frontend/web/src/features/controlAdapters/store/types.ts b/invokeai/frontend/web/src/features/controlAdapters/store/types.ts
index 80af59cd01..7e2f18af5c 100644
--- a/invokeai/frontend/web/src/features/controlAdapters/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlAdapters/store/types.ts
@@ -225,9 +225,7 @@ 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;
@@ -243,9 +241,7 @@ 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 7c9c28e2b3..ad7bdba363 100644
--- a/invokeai/frontend/web/src/features/controlAdapters/util/buildControlAdapter.ts
+++ b/invokeai/frontend/web/src/features/controlAdapters/util/buildControlAdapter.ts
@@ -20,9 +20,7 @@ export const initialControlNet: Omit<ControlNetConfig, 'id'> = {
   controlMode: 'balanced',
   resizeMode: 'just_resize',
   controlImage: null,
-  controlImageDimensions: null,
   processedControlImage: null,
-  processedControlImageDimensions: null,
   processorType: 'canny_image_processor',
   processorNode: CONTROLNET_PROCESSORS.canny_image_processor.buildDefaults() as RequiredCannyImageProcessorInvocation,
   shouldAutoConfig: true,
@@ -37,9 +35,7 @@ export const initialT2IAdapter: Omit<T2IAdapterConfig, 'id'> = {
   endStepPct: 1,
   resizeMode: 'just_resize',
   controlImage: null,
-  controlImageDimensions: null,
   processedControlImage: null,
-  processedControlImageDimensions: null,
   processorType: 'canny_image_processor',
   processorNode: CONTROLNET_PROCESSORS.canny_image_processor.buildDefaults() as RequiredCannyImageProcessorInvocation,
   shouldAutoConfig: true,
diff --git a/invokeai/frontend/web/src/features/metadata/util/parsers.ts b/invokeai/frontend/web/src/features/metadata/util/parsers.ts
index 5d2bd78784..3decea6737 100644
--- a/invokeai/frontend/web/src/features/metadata/util/parsers.ts
+++ b/invokeai/frontend/web/src/features/metadata/util/parsers.ts
@@ -286,9 +286,7 @@ const parseControlNet: MetadataParseFunc<ControlNetConfigMetadata> = async (meta
     controlMode: control_mode ?? initialControlNet.controlMode,
     resizeMode: resize_mode ?? initialControlNet.resizeMode,
     controlImage: image?.image_name ?? null,
-    controlImageDimensions: null,
     processedControlImage: processedImage?.image_name ?? null,
-    processedControlImageDimensions: null,
     processorType,
     processorNode,
     shouldAutoConfig: true,
@@ -352,11 +350,9 @@ const parseT2IAdapter: MetadataParseFunc<T2IAdapterConfigMetadata> = async (meta
     endStepPct: end_step_percent ?? initialT2IAdapter.endStepPct,
     resizeMode: resize_mode ?? initialT2IAdapter.resizeMode,
     controlImage: image?.image_name ?? null,
-    controlImageDimensions: null,
     processedControlImage: processedImage?.image_name ?? null,
-    processedControlImageDimensions: null,
-    processorNode,
     processorType,
+    processorNode,
     shouldAutoConfig: true,
     id: uuidv4(),
   };

From f9555f03f5c31f90b0596d20a6be938d42d12d01 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 15:59:59 +1000
Subject: [PATCH 02/59] tidy(ui): "CONTROLNET_PROCESSORS" ->
 "CA_PROCESSOR_DATA"

---
 .../listeners/controlAdapterPreprocessor.ts               | 4 ++--
 .../ControlAdapterProcessorTypeSelect.tsx                 | 6 +++---
 .../ControlAndIPAdapter/processors/CannyProcessor.tsx     | 4 ++--
 .../ControlAndIPAdapter/processors/ColorMapProcessor.tsx  | 4 ++--
 .../processors/ContentShuffleProcessor.tsx                | 4 ++--
 .../processors/DWOpenposeProcessor.tsx                    | 4 ++--
 .../processors/DepthAnythingProcessor.tsx                 | 4 ++--
 .../processors/MediapipeFaceProcessor.tsx                 | 4 ++--
 .../processors/MidasDepthProcessor.tsx                    | 4 ++--
 .../ControlAndIPAdapter/processors/MlsdImageProcessor.tsx | 4 ++--
 .../web/src/features/controlLayers/hooks/addLayerHooks.ts | 4 ++--
 .../src/features/controlLayers/util/controlAdapters.ts    | 8 ++++----
 12 files changed, 27 insertions(+), 27 deletions(-)

diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts
index 50395dc9dc..7d5aa27f20 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts
@@ -10,7 +10,7 @@ import {
   caLayerProcessorConfigChanged,
   isControlAdapterLayer,
 } from 'features/controlLayers/store/controlLayersSlice';
-import { CONTROLNET_PROCESSORS } from 'features/controlLayers/util/controlAdapters';
+import { CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters';
 import { isImageOutput } from 'features/nodes/types/common';
 import { addToast } from 'features/system/store/systemSlice';
 import { t } from 'i18next';
@@ -76,7 +76,7 @@ export const addControlAdapterPreprocessor = (startAppListening: AppStartListeni
       }
 
       // @ts-expect-error: TS isn't able to narrow the typing of buildNode and `config` will error...
-      const processorNode = CONTROLNET_PROCESSORS[config.type].buildNode(image, config);
+      const processorNode = CA_PROCESSOR_DATA[config.type].buildNode(image, config);
       const enqueueBatchArg: BatchConfig = {
         prepend: true,
         batch: {
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterProcessorTypeSelect.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterProcessorTypeSelect.tsx
index 46e6131353..1d14d8606f 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterProcessorTypeSelect.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterProcessorTypeSelect.tsx
@@ -4,7 +4,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
 import { useAppSelector } from 'app/store/storeHooks';
 import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
 import type { ProcessorConfig } from 'features/controlLayers/util/controlAdapters';
-import { CONTROLNET_PROCESSORS, isProcessorType } from 'features/controlLayers/util/controlAdapters';
+import { CA_PROCESSOR_DATA, isProcessorType } from 'features/controlLayers/util/controlAdapters';
 import { configSelector } from 'features/system/store/configSelectors';
 import { includes, map } from 'lodash-es';
 import { memo, useCallback, useMemo } from 'react';
@@ -26,7 +26,7 @@ export const ControlAdapterProcessorTypeSelect = memo(({ config, onChange }: Pro
   const { t } = useTranslation();
   const disabledProcessors = useAppSelector(selectDisabledProcessors);
   const options = useMemo(() => {
-    return map(CONTROLNET_PROCESSORS, ({ labelTKey }, type) => ({ value: type, label: t(labelTKey) })).filter(
+    return map(CA_PROCESSOR_DATA, ({ labelTKey }, type) => ({ value: type, label: t(labelTKey) })).filter(
       (o) => !includes(disabledProcessors, o.value)
     );
   }, [disabledProcessors, t]);
@@ -37,7 +37,7 @@ export const ControlAdapterProcessorTypeSelect = memo(({ config, onChange }: Pro
         onChange(null);
       } else {
         assert(isProcessorType(v.value));
-        onChange(CONTROLNET_PROCESSORS[v.value].buildDefaults());
+        onChange(CA_PROCESSOR_DATA[v.value].buildDefaults());
       }
     },
     [onChange]
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/CannyProcessor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/CannyProcessor.tsx
index 999c8c7764..cc3e9ba996 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/CannyProcessor.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/CannyProcessor.tsx
@@ -1,13 +1,13 @@
 import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
 import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types';
-import { type CannyProcessorConfig, CONTROLNET_PROCESSORS } from 'features/controlLayers/util/controlAdapters';
+import { type CannyProcessorConfig, CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters';
 import { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import ProcessorWrapper from './ProcessorWrapper';
 
 type Props = ProcessorComponentProps<CannyProcessorConfig>;
-const DEFAULTS = CONTROLNET_PROCESSORS['canny_image_processor'].buildDefaults();
+const DEFAULTS = CA_PROCESSOR_DATA['canny_image_processor'].buildDefaults();
 
 export const CannyProcessor = ({ onChange, config }: Props) => {
   const { t } = useTranslation();
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ColorMapProcessor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ColorMapProcessor.tsx
index bf2d0d5d6d..eda9af47a5 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ColorMapProcessor.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ColorMapProcessor.tsx
@@ -1,13 +1,13 @@
 import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
 import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types';
-import { type ColorMapProcessorConfig, CONTROLNET_PROCESSORS } from 'features/controlLayers/util/controlAdapters';
+import { type ColorMapProcessorConfig, CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters';
 import { memo, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import ProcessorWrapper from './ProcessorWrapper';
 
 type Props = ProcessorComponentProps<ColorMapProcessorConfig>;
-const DEFAULTS = CONTROLNET_PROCESSORS['color_map_image_processor'].buildDefaults();
+const DEFAULTS = CA_PROCESSOR_DATA['color_map_image_processor'].buildDefaults();
 
 export const ColorMapProcessor = memo(({ onChange, config }: Props) => {
   const { t } = useTranslation();
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ContentShuffleProcessor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ContentShuffleProcessor.tsx
index 041ab0ac9a..c03efd27c6 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ContentShuffleProcessor.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ContentShuffleProcessor.tsx
@@ -1,14 +1,14 @@
 import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
 import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types';
 import type { ContentShuffleProcessorConfig } from 'features/controlLayers/util/controlAdapters';
-import { CONTROLNET_PROCESSORS } from 'features/controlLayers/util/controlAdapters';
+import { CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters';
 import { memo, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import ProcessorWrapper from './ProcessorWrapper';
 
 type Props = ProcessorComponentProps<ContentShuffleProcessorConfig>;
-const DEFAULTS = CONTROLNET_PROCESSORS['content_shuffle_image_processor'].buildDefaults();
+const DEFAULTS = CA_PROCESSOR_DATA['content_shuffle_image_processor'].buildDefaults();
 
 export const ContentShuffleProcessor = memo(({ onChange, config }: Props) => {
   const { t } = useTranslation();
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/DWOpenposeProcessor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/DWOpenposeProcessor.tsx
index 70d608f7a9..3bbe813dcc 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/DWOpenposeProcessor.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/DWOpenposeProcessor.tsx
@@ -1,7 +1,7 @@
 import { Flex, FormControl, FormLabel, Switch } from '@invoke-ai/ui-library';
 import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types';
 import type { DWOpenposeProcessorConfig } from 'features/controlLayers/util/controlAdapters';
-import { CONTROLNET_PROCESSORS } from 'features/controlLayers/util/controlAdapters';
+import { CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters';
 import type { ChangeEvent } from 'react';
 import { memo, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
 import ProcessorWrapper from './ProcessorWrapper';
 
 type Props = ProcessorComponentProps<DWOpenposeProcessorConfig>;
-const DEFAULTS = CONTROLNET_PROCESSORS['dw_openpose_image_processor'].buildDefaults();
+const DEFAULTS = CA_PROCESSOR_DATA['dw_openpose_image_processor'].buildDefaults();
 
 export const DWOpenposeProcessor = memo(({ onChange, config }: Props) => {
   const { t } = useTranslation();
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/DepthAnythingProcessor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/DepthAnythingProcessor.tsx
index d2e14a17f9..00993789b1 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/DepthAnythingProcessor.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/DepthAnythingProcessor.tsx
@@ -2,14 +2,14 @@ import type { ComboboxOnChange } from '@invoke-ai/ui-library';
 import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
 import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types';
 import type { DepthAnythingModelSize, DepthAnythingProcessorConfig } from 'features/controlLayers/util/controlAdapters';
-import { CONTROLNET_PROCESSORS, isDepthAnythingModelSize } from 'features/controlLayers/util/controlAdapters';
+import { CA_PROCESSOR_DATA, isDepthAnythingModelSize } from 'features/controlLayers/util/controlAdapters';
 import { memo, useCallback, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import ProcessorWrapper from './ProcessorWrapper';
 
 type Props = ProcessorComponentProps<DepthAnythingProcessorConfig>;
-const DEFAULTS = CONTROLNET_PROCESSORS['depth_anything_image_processor'].buildDefaults();
+const DEFAULTS = CA_PROCESSOR_DATA['depth_anything_image_processor'].buildDefaults();
 
 export const DepthAnythingProcessor = memo(({ onChange, config }: Props) => {
   const { t } = useTranslation();
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MediapipeFaceProcessor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MediapipeFaceProcessor.tsx
index 72f0d52dc5..0f45d83ef0 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MediapipeFaceProcessor.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MediapipeFaceProcessor.tsx
@@ -1,13 +1,13 @@
 import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
 import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types';
-import { CONTROLNET_PROCESSORS, type MediapipeFaceProcessorConfig } from 'features/controlLayers/util/controlAdapters';
+import { CA_PROCESSOR_DATA, type MediapipeFaceProcessorConfig } from 'features/controlLayers/util/controlAdapters';
 import { memo, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import ProcessorWrapper from './ProcessorWrapper';
 
 type Props = ProcessorComponentProps<MediapipeFaceProcessorConfig>;
-const DEFAULTS = CONTROLNET_PROCESSORS['mediapipe_face_processor'].buildDefaults();
+const DEFAULTS = CA_PROCESSOR_DATA['mediapipe_face_processor'].buildDefaults();
 
 export const MediapipeFaceProcessor = memo(({ onChange, config }: Props) => {
   const { t } = useTranslation();
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MidasDepthProcessor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MidasDepthProcessor.tsx
index 9078d14a53..1ce728984c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MidasDepthProcessor.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MidasDepthProcessor.tsx
@@ -1,14 +1,14 @@
 import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
 import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types';
 import type { MidasDepthProcessorConfig } from 'features/controlLayers/util/controlAdapters';
-import { CONTROLNET_PROCESSORS } from 'features/controlLayers/util/controlAdapters';
+import { CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters';
 import { memo, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import ProcessorWrapper from './ProcessorWrapper';
 
 type Props = ProcessorComponentProps<MidasDepthProcessorConfig>;
-const DEFAULTS = CONTROLNET_PROCESSORS['midas_depth_image_processor'].buildDefaults();
+const DEFAULTS = CA_PROCESSOR_DATA['midas_depth_image_processor'].buildDefaults();
 
 export const MidasDepthProcessor = memo(({ onChange, config }: Props) => {
   const { t } = useTranslation();
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MlsdImageProcessor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MlsdImageProcessor.tsx
index 5fc0c21ecd..b6eef311ef 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MlsdImageProcessor.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/MlsdImageProcessor.tsx
@@ -1,14 +1,14 @@
 import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
 import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types';
 import type { MlsdProcessorConfig } from 'features/controlLayers/util/controlAdapters';
-import { CONTROLNET_PROCESSORS } from 'features/controlLayers/util/controlAdapters';
+import { CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters';
 import { memo, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import ProcessorWrapper from './ProcessorWrapper';
 
 type Props = ProcessorComponentProps<MlsdProcessorConfig>;
-const DEFAULTS = CONTROLNET_PROCESSORS['mlsd_image_processor'].buildDefaults();
+const DEFAULTS = CA_PROCESSOR_DATA['mlsd_image_processor'].buildDefaults();
 
 export const MlsdImageProcessor = memo(({ onChange, config }: Props) => {
   const { t } = useTranslation();
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
index 17f0d4bf2d..75ffd81202 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
@@ -4,7 +4,7 @@ import {
   buildControlNet,
   buildIPAdapter,
   buildT2IAdapter,
-  CONTROLNET_PROCESSORS,
+  CA_PROCESSOR_DATA,
   isProcessorType,
 } from 'features/controlLayers/util/controlAdapters';
 import { zModelIdentifierField } from 'features/nodes/types/common';
@@ -31,7 +31,7 @@ export const useAddCALayer = () => {
     const id = uuidv4();
     const defaultPreprocessor = model.default_settings?.preprocessor;
     const processorConfig = isProcessorType(defaultPreprocessor)
-      ? CONTROLNET_PROCESSORS[defaultPreprocessor].buildDefaults(baseModel)
+      ? CA_PROCESSOR_DATA[defaultPreprocessor].buildDefaults(baseModel)
       : null;
 
     const builder = model.type === 'controlnet' ? buildControlNet : buildT2IAdapter;
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts b/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts
index 6cedc81a0b..afc168d749 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts
@@ -176,7 +176,7 @@ type CAProcessorsData = {
  *
  * TODO: Generate from the OpenAPI schema
  */
-export const CONTROLNET_PROCESSORS: CAProcessorsData = {
+export const CA_PROCESSOR_DATA: CAProcessorsData = {
   canny_image_processor: {
     type: 'canny_image_processor',
     labelTKey: 'controlnet.canny',
@@ -414,7 +414,7 @@ const initialControlNet: Omit<ControlNetConfig, 'id'> = {
   image: null,
   processedImage: null,
   isProcessingImage: false,
-  processorConfig: CONTROLNET_PROCESSORS.canny_image_processor.buildDefaults(),
+  processorConfig: CA_PROCESSOR_DATA.canny_image_processor.buildDefaults(),
 };
 
 const initialT2IAdapter: Omit<T2IAdapterConfig, 'id'> = {
@@ -425,7 +425,7 @@ const initialT2IAdapter: Omit<T2IAdapterConfig, 'id'> = {
   image: null,
   processedImage: null,
   isProcessingImage: false,
-  processorConfig: CONTROLNET_PROCESSORS.canny_image_processor.buildDefaults(),
+  processorConfig: CA_PROCESSOR_DATA.canny_image_processor.buildDefaults(),
 };
 
 const initialIPAdapter: Omit<IPAdapterConfig, 'id'> = {
@@ -457,7 +457,7 @@ export const buildControlAdapterProcessor = (
   if (!isProcessorType(defaultPreprocessor)) {
     return null;
   }
-  const processorConfig = CONTROLNET_PROCESSORS[defaultPreprocessor].buildDefaults(modelConfig.base);
+  const processorConfig = CA_PROCESSOR_DATA[defaultPreprocessor].buildDefaults(modelConfig.base);
   return processorConfig;
 };
 

From 2cde8a643e4b96455b4f656af12c82073c2866e3 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 16:06:06 +1000
Subject: [PATCH 03/59] tidy(ui): suffix a control adapter types/objects with
 V2

Prevent mixing the old and new implementations up
---
 .../CALayer/CALayerControlAdapterWrapper.tsx  |  4 +-
 .../ControlAndIPAdapter/ControlAdapter.tsx    | 10 +--
 .../ControlAdapterControlModeSelect.tsx       | 10 +--
 .../ControlAdapterImagePreview.tsx            |  4 +-
 .../ControlAdapterProcessorTypeSelect.tsx     |  4 +-
 .../ControlAndIPAdapter/IPAdapter.tsx         |  8 +--
 .../ControlAndIPAdapter/IPAdapterMethod.tsx   | 12 ++--
 .../IPAdapterModelSelect.tsx                  | 10 +--
 .../processors/CannyProcessor.tsx             |  2 +-
 .../processors/ColorMapProcessor.tsx          |  2 +-
 .../IPALayer/IPALayerIPAdapterWrapper.tsx     |  6 +-
 .../RGLayer/RGLayerIPAdapterWrapper.tsx       |  6 +-
 .../controlLayers/hooks/addLayerHooks.ts      |  4 +-
 .../controlLayers/store/controlLayersSlice.ts | 38 +++++-----
 .../src/features/controlLayers/store/types.ts | 12 ++--
 .../util/controlAdapters.test.ts              | 16 ++---
 .../controlLayers/util/controlAdapters.ts     | 72 +++++++++----------
 .../util/graph/addControlLayersToGraph.ts     | 28 ++++----
 18 files changed, 126 insertions(+), 122 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx
index 6793a33f69..8ff1f9711f 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx
@@ -9,7 +9,7 @@ import {
   caOrIPALayerWeightChanged,
   selectCALayerOrThrow,
 } from 'features/controlLayers/store/controlLayersSlice';
-import type { ControlMode, ProcessorConfig } from 'features/controlLayers/util/controlAdapters';
+import type { ControlModeV2, ProcessorConfig } from 'features/controlLayers/util/controlAdapters';
 import type { CALayerImageDropData } from 'features/dnd/types';
 import { memo, useCallback, useMemo } from 'react';
 import type {
@@ -40,7 +40,7 @@ export const CALayerControlAdapterWrapper = memo(({ layerId }: Props) => {
   );
 
   const onChangeControlMode = useCallback(
-    (controlMode: ControlMode) => {
+    (controlMode: ControlModeV2) => {
       dispatch(
         caLayerControlModeChanged({
           layerId,
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapter.tsx
index 087f634d73..c28c40ecc1 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapter.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapter.tsx
@@ -1,10 +1,10 @@
 import { Box, Divider, Flex, Icon, IconButton } from '@invoke-ai/ui-library';
 import { ControlAdapterModelCombobox } from 'features/controlLayers/components/ControlAndIPAdapter/ControlAdapterModelCombobox';
 import type {
-  ControlMode,
-  ControlNetConfig,
+  ControlModeV2,
+  ControlNetConfigV2,
   ProcessorConfig,
-  T2IAdapterConfig,
+  T2IAdapterConfigV2,
 } from 'features/controlLayers/util/controlAdapters';
 import type { TypesafeDroppableData } from 'features/dnd/types';
 import { memo } from 'react';
@@ -21,9 +21,9 @@ import { ControlAdapterProcessorTypeSelect } from './ControlAdapterProcessorType
 import { ControlAdapterWeight } from './ControlAdapterWeight';
 
 type Props = {
-  controlAdapter: ControlNetConfig | T2IAdapterConfig;
+  controlAdapter: ControlNetConfigV2 | T2IAdapterConfigV2;
   onChangeBeginEndStepPct: (beginEndStepPct: [number, number]) => void;
-  onChangeControlMode: (controlMode: ControlMode) => void;
+  onChangeControlMode: (controlMode: ControlModeV2) => void;
   onChangeWeight: (weight: number) => void;
   onChangeProcessorConfig: (processorConfig: ProcessorConfig | null) => void;
   onChangeModel: (modelConfig: ControlNetModelConfig | T2IAdapterModelConfig) => void;
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterControlModeSelect.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterControlModeSelect.tsx
index 34f4c85467..2c35ce51b6 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterControlModeSelect.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterControlModeSelect.tsx
@@ -1,15 +1,15 @@
 import type { ComboboxOnChange } from '@invoke-ai/ui-library';
 import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
 import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
-import type { ControlMode } from 'features/controlLayers/util/controlAdapters';
-import { isControlMode } from 'features/controlLayers/util/controlAdapters';
+import type { ControlModeV2 } from 'features/controlLayers/util/controlAdapters';
+import { isControlModeV2 } from 'features/controlLayers/util/controlAdapters';
 import { memo, useCallback, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { assert } from 'tsafe';
 
 type Props = {
-  controlMode: ControlMode;
-  onChange: (controlMode: ControlMode) => void;
+  controlMode: ControlModeV2;
+  onChange: (controlMode: ControlModeV2) => void;
 };
 
 export const ControlAdapterControlModeSelect = memo(({ controlMode, onChange }: Props) => {
@@ -26,7 +26,7 @@ export const ControlAdapterControlModeSelect = memo(({ controlMode, onChange }:
 
   const handleControlModeChange = useCallback<ComboboxOnChange>(
     (v) => {
-      assert(isControlMode(v?.value));
+      assert(isControlModeV2(v?.value));
       onChange(v.value);
     },
     [onChange]
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
index 7def6b2b56..675118c534 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
@@ -6,7 +6,7 @@ import IAIDndImage from 'common/components/IAIDndImage';
 import IAIDndImageIcon from 'common/components/IAIDndImageIcon';
 import { setBoundingBoxDimensions } from 'features/canvas/store/canvasSlice';
 import { heightChanged, widthChanged } from 'features/controlLayers/store/controlLayersSlice';
-import type { ControlNetConfig, T2IAdapterConfig } from 'features/controlLayers/util/controlAdapters';
+import type { ControlNetConfigV2, T2IAdapterConfigV2 } from 'features/controlLayers/util/controlAdapters';
 import type { ImageDraggableData, TypesafeDroppableData } from 'features/dnd/types';
 import { calculateNewSize } from 'features/parameters/components/ImageSize/calculateNewSize';
 import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
@@ -23,7 +23,7 @@ import {
 import type { ImageDTO, PostUploadAction } from 'services/api/types';
 
 type Props = {
-  controlAdapter: ControlNetConfig | T2IAdapterConfig;
+  controlAdapter: ControlNetConfigV2 | T2IAdapterConfigV2;
   onChangeImage: (imageDTO: ImageDTO | null) => void;
   droppableData: TypesafeDroppableData;
   postUploadAction: PostUploadAction;
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterProcessorTypeSelect.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterProcessorTypeSelect.tsx
index 1d14d8606f..5598b81787 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterProcessorTypeSelect.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterProcessorTypeSelect.tsx
@@ -4,7 +4,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
 import { useAppSelector } from 'app/store/storeHooks';
 import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
 import type { ProcessorConfig } from 'features/controlLayers/util/controlAdapters';
-import { CA_PROCESSOR_DATA, isProcessorType } from 'features/controlLayers/util/controlAdapters';
+import { CA_PROCESSOR_DATA, isProcessorTypeV2 } from 'features/controlLayers/util/controlAdapters';
 import { configSelector } from 'features/system/store/configSelectors';
 import { includes, map } from 'lodash-es';
 import { memo, useCallback, useMemo } from 'react';
@@ -36,7 +36,7 @@ export const ControlAdapterProcessorTypeSelect = memo(({ config, onChange }: Pro
       if (!v) {
         onChange(null);
       } else {
-        assert(isProcessorType(v.value));
+        assert(isProcessorTypeV2(v.value));
         onChange(CA_PROCESSOR_DATA[v.value].buildDefaults());
       }
     },
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapter.tsx
index a0aa7d79a1..86ed77ce36 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapter.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapter.tsx
@@ -4,18 +4,18 @@ import { ControlAdapterWeight } from 'features/controlLayers/components/ControlA
 import { IPAdapterImagePreview } from 'features/controlLayers/components/ControlAndIPAdapter/IPAdapterImagePreview';
 import { IPAdapterMethod } from 'features/controlLayers/components/ControlAndIPAdapter/IPAdapterMethod';
 import { IPAdapterModelSelect } from 'features/controlLayers/components/ControlAndIPAdapter/IPAdapterModelSelect';
-import type { CLIPVisionModel, IPAdapterConfig, IPMethod } from 'features/controlLayers/util/controlAdapters';
+import type { CLIPVisionModelV2, IPAdapterConfigV2, IPMethodV2 } from 'features/controlLayers/util/controlAdapters';
 import type { TypesafeDroppableData } from 'features/dnd/types';
 import { memo } from 'react';
 import type { ImageDTO, IPAdapterModelConfig, PostUploadAction } from 'services/api/types';
 
 type Props = {
-  ipAdapter: IPAdapterConfig;
+  ipAdapter: IPAdapterConfigV2;
   onChangeBeginEndStepPct: (beginEndStepPct: [number, number]) => void;
   onChangeWeight: (weight: number) => void;
-  onChangeIPMethod: (method: IPMethod) => void;
+  onChangeIPMethod: (method: IPMethodV2) => void;
   onChangeModel: (modelConfig: IPAdapterModelConfig) => void;
-  onChangeCLIPVisionModel: (clipVisionModel: CLIPVisionModel) => void;
+  onChangeCLIPVisionModel: (clipVisionModel: CLIPVisionModelV2) => void;
   onChangeImage: (imageDTO: ImageDTO | null) => void;
   droppableData: TypesafeDroppableData;
   postUploadAction: PostUploadAction;
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterMethod.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterMethod.tsx
index 70fd63f9c0..4f6a468fc3 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterMethod.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterMethod.tsx
@@ -1,20 +1,20 @@
 import type { ComboboxOnChange } from '@invoke-ai/ui-library';
 import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
 import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
-import type { IPMethod } from 'features/controlLayers/util/controlAdapters';
-import { isIPMethod } from 'features/controlLayers/util/controlAdapters';
+import type { IPMethodV2 } from 'features/controlLayers/util/controlAdapters';
+import { isIPMethodV2 } from 'features/controlLayers/util/controlAdapters';
 import { memo, useCallback, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { assert } from 'tsafe';
 
 type Props = {
-  method: IPMethod;
-  onChange: (method: IPMethod) => void;
+  method: IPMethodV2;
+  onChange: (method: IPMethodV2) => void;
 };
 
 export const IPAdapterMethod = memo(({ method, onChange }: Props) => {
   const { t } = useTranslation();
-  const options: { label: string; value: IPMethod }[] = useMemo(
+  const options: { label: string; value: IPMethodV2 }[] = useMemo(
     () => [
       { label: t('controlnet.full'), value: 'full' },
       { label: `${t('controlnet.style')} (${t('common.beta')})`, value: 'style' },
@@ -24,7 +24,7 @@ export const IPAdapterMethod = memo(({ method, onChange }: Props) => {
   );
   const _onChange = useCallback<ComboboxOnChange>(
     (v) => {
-      assert(isIPMethod(v?.value));
+      assert(isIPMethodV2(v?.value));
       onChange(v.value);
     },
     [onChange]
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterModelSelect.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterModelSelect.tsx
index e47bcd5182..b0541dca2c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterModelSelect.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterModelSelect.tsx
@@ -2,8 +2,8 @@ import type { ComboboxOnChange } from '@invoke-ai/ui-library';
 import { Combobox, Flex, FormControl, Tooltip } from '@invoke-ai/ui-library';
 import { useAppSelector } from 'app/store/storeHooks';
 import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
-import type { CLIPVisionModel } from 'features/controlLayers/util/controlAdapters';
-import { isCLIPVisionModel } from 'features/controlLayers/util/controlAdapters';
+import type { CLIPVisionModelV2 } from 'features/controlLayers/util/controlAdapters';
+import { isCLIPVisionModelV2 } from 'features/controlLayers/util/controlAdapters';
 import { memo, useCallback, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useIPAdapterModels } from 'services/api/hooks/modelsByType';
@@ -18,8 +18,8 @@ const CLIP_VISION_OPTIONS = [
 type Props = {
   modelKey: string | null;
   onChangeModel: (modelConfig: IPAdapterModelConfig) => void;
-  clipVisionModel: CLIPVisionModel;
-  onChangeCLIPVisionModel: (clipVisionModel: CLIPVisionModel) => void;
+  clipVisionModel: CLIPVisionModelV2;
+  onChangeCLIPVisionModel: (clipVisionModel: CLIPVisionModelV2) => void;
 };
 
 export const IPAdapterModelSelect = memo(
@@ -41,7 +41,7 @@ export const IPAdapterModelSelect = memo(
 
     const _onChangeCLIPVisionModel = useCallback<ComboboxOnChange>(
       (v) => {
-        assert(isCLIPVisionModel(v?.value));
+        assert(isCLIPVisionModelV2(v?.value));
         onChangeCLIPVisionModel(v.value);
       },
       [onChangeCLIPVisionModel]
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/CannyProcessor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/CannyProcessor.tsx
index cc3e9ba996..ef6e4160d6 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/CannyProcessor.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/CannyProcessor.tsx
@@ -1,6 +1,6 @@
 import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
 import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types';
-import { type CannyProcessorConfig, CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters';
+import { CA_PROCESSOR_DATA, type CannyProcessorConfig } from 'features/controlLayers/util/controlAdapters';
 import { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ColorMapProcessor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ColorMapProcessor.tsx
index eda9af47a5..6faa00dd14 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ColorMapProcessor.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/processors/ColorMapProcessor.tsx
@@ -1,6 +1,6 @@
 import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
 import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types';
-import { type ColorMapProcessorConfig, CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters';
+import { CA_PROCESSOR_DATA, type ColorMapProcessorConfig } from 'features/controlLayers/util/controlAdapters';
 import { memo, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayerIPAdapterWrapper.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayerIPAdapterWrapper.tsx
index b8dfae6c03..9f99710dac 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayerIPAdapterWrapper.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayerIPAdapterWrapper.tsx
@@ -9,7 +9,7 @@ import {
   ipaLayerModelChanged,
   selectIPALayerOrThrow,
 } from 'features/controlLayers/store/controlLayersSlice';
-import type { CLIPVisionModel, IPMethod } from 'features/controlLayers/util/controlAdapters';
+import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/util/controlAdapters';
 import type { IPALayerImageDropData } from 'features/dnd/types';
 import { memo, useCallback, useMemo } from 'react';
 import type { ImageDTO, IPAdapterModelConfig, IPALayerImagePostUploadAction } from 'services/api/types';
@@ -42,7 +42,7 @@ export const IPALayerIPAdapterWrapper = memo(({ layerId }: Props) => {
   );
 
   const onChangeIPMethod = useCallback(
-    (method: IPMethod) => {
+    (method: IPMethodV2) => {
       dispatch(ipaLayerMethodChanged({ layerId, method }));
     },
     [dispatch, layerId]
@@ -56,7 +56,7 @@ export const IPALayerIPAdapterWrapper = memo(({ layerId }: Props) => {
   );
 
   const onChangeCLIPVisionModel = useCallback(
-    (clipVisionModel: CLIPVisionModel) => {
+    (clipVisionModel: CLIPVisionModelV2) => {
       dispatch(ipaLayerCLIPVisionModelChanged({ layerId, clipVisionModel }));
     },
     [dispatch, layerId]
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerIPAdapterWrapper.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerIPAdapterWrapper.tsx
index 015cf75e4d..f7be62eb0a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerIPAdapterWrapper.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayerIPAdapterWrapper.tsx
@@ -11,7 +11,7 @@ import {
   rgLayerIPAdapterWeightChanged,
   selectRGLayerIPAdapterOrThrow,
 } from 'features/controlLayers/store/controlLayersSlice';
-import type { CLIPVisionModel, IPMethod } from 'features/controlLayers/util/controlAdapters';
+import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/util/controlAdapters';
 import type { RGLayerIPAdapterImageDropData } from 'features/dnd/types';
 import { memo, useCallback, useMemo } from 'react';
 import { PiTrashSimpleBold } from 'react-icons/pi';
@@ -51,7 +51,7 @@ export const RGLayerIPAdapterWrapper = memo(({ layerId, ipAdapterId, ipAdapterNu
   );
 
   const onChangeIPMethod = useCallback(
-    (method: IPMethod) => {
+    (method: IPMethodV2) => {
       dispatch(rgLayerIPAdapterMethodChanged({ layerId, ipAdapterId, method }));
     },
     [dispatch, ipAdapterId, layerId]
@@ -65,7 +65,7 @@ export const RGLayerIPAdapterWrapper = memo(({ layerId, ipAdapterId, ipAdapterNu
   );
 
   const onChangeCLIPVisionModel = useCallback(
-    (clipVisionModel: CLIPVisionModel) => {
+    (clipVisionModel: CLIPVisionModelV2) => {
       dispatch(rgLayerIPAdapterCLIPVisionModelChanged({ layerId, ipAdapterId, clipVisionModel }));
     },
     [dispatch, ipAdapterId, layerId]
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
index 75ffd81202..7a4e7ebc09 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
@@ -5,7 +5,7 @@ import {
   buildIPAdapter,
   buildT2IAdapter,
   CA_PROCESSOR_DATA,
-  isProcessorType,
+  isProcessorTypeV2,
 } from 'features/controlLayers/util/controlAdapters';
 import { zModelIdentifierField } from 'features/nodes/types/common';
 import { useCallback, useMemo } from 'react';
@@ -30,7 +30,7 @@ export const useAddCALayer = () => {
 
     const id = uuidv4();
     const defaultPreprocessor = model.default_settings?.preprocessor;
-    const processorConfig = isProcessorType(defaultPreprocessor)
+    const processorConfig = isProcessorTypeV2(defaultPreprocessor)
       ? CA_PROCESSOR_DATA[defaultPreprocessor].buildDefaults(baseModel)
       : null;
 
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index c27a98c826..5f70cbf4a9 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -4,16 +4,16 @@ import type { PersistConfig, RootState } from 'app/store/store';
 import { moveBackward, moveForward, moveToBack, moveToFront } from 'common/util/arrayUtils';
 import { deepClone } from 'common/util/deepClone';
 import type {
-  CLIPVisionModel,
-  ControlMode,
-  ControlNetConfig,
-  IPAdapterConfig,
-  IPMethod,
+  CLIPVisionModelV2,
+  ControlModeV2,
+  ControlNetConfigV2,
+  IPAdapterConfigV2,
+  IPMethodV2,
   ProcessorConfig,
-  T2IAdapterConfig,
+  T2IAdapterConfigV2,
 } from 'features/controlLayers/util/controlAdapters';
 import {
-  buildControlAdapterProcessor,
+  buildControlAdapterProcessorV2,
   controlNetToT2IAdapter,
   imageDTOToImageWithDims,
   t2iAdapterToControlNet,
@@ -110,7 +110,7 @@ export const selectRGLayerIPAdapterOrThrow = (
   state: ControlLayersState,
   layerId: string,
   ipAdapterId: string
-): IPAdapterConfig => {
+): IPAdapterConfigV2 => {
   const layer = state.layers.find((l) => l.id === layerId);
   assert(isRegionalGuidanceLayer(layer));
   const ipAdapter = layer.ipAdapters.find((ipAdapter) => ipAdapter.id === ipAdapterId);
@@ -221,7 +221,7 @@ export const controlLayersSlice = createSlice({
     caLayerAdded: {
       reducer: (
         state,
-        action: PayloadAction<{ layerId: string; controlAdapter: ControlNetConfig | T2IAdapterConfig }>
+        action: PayloadAction<{ layerId: string; controlAdapter: ControlNetConfigV2 | T2IAdapterConfigV2 }>
       ) => {
         const { layerId, controlAdapter } = action.payload;
         const layer: ControlAdapterLayer = {
@@ -245,7 +245,7 @@ export const controlLayersSlice = createSlice({
           }
         }
       },
-      prepare: (controlAdapter: ControlNetConfig | T2IAdapterConfig) => ({
+      prepare: (controlAdapter: ControlNetConfigV2 | T2IAdapterConfigV2) => ({
         payload: { layerId: uuidv4(), controlAdapter },
       }),
     },
@@ -297,7 +297,7 @@ export const controlLayersSlice = createSlice({
         layer.controlAdapter = controlNetToT2IAdapter(layer.controlAdapter);
       }
 
-      const candidateProcessorConfig = buildControlAdapterProcessor(modelConfig);
+      const candidateProcessorConfig = buildControlAdapterProcessorV2(modelConfig);
       if (candidateProcessorConfig?.type !== layer.controlAdapter.processorConfig?.type) {
         // The processor has changed. For example, the previous model was a Canny model and the new model is a Depth
         // model. We need to use the new processor.
@@ -305,7 +305,7 @@ export const controlLayersSlice = createSlice({
         layer.controlAdapter.processorConfig = candidateProcessorConfig;
       }
     },
-    caLayerControlModeChanged: (state, action: PayloadAction<{ layerId: string; controlMode: ControlMode }>) => {
+    caLayerControlModeChanged: (state, action: PayloadAction<{ layerId: string; controlMode: ControlModeV2 }>) => {
       const { layerId, controlMode } = action.payload;
       const layer = selectCALayerOrThrow(state, layerId);
       assert(layer.controlAdapter.type === 'controlnet');
@@ -344,7 +344,7 @@ export const controlLayersSlice = createSlice({
 
     //#region IP Adapter Layers
     ipaLayerAdded: {
-      reducer: (state, action: PayloadAction<{ layerId: string; ipAdapter: IPAdapterConfig }>) => {
+      reducer: (state, action: PayloadAction<{ layerId: string; ipAdapter: IPAdapterConfigV2 }>) => {
         const { layerId, ipAdapter } = action.payload;
         const layer: IPAdapterLayer = {
           id: getIPALayerId(layerId),
@@ -354,14 +354,14 @@ export const controlLayersSlice = createSlice({
         };
         state.layers.push(layer);
       },
-      prepare: (ipAdapter: IPAdapterConfig) => ({ payload: { layerId: uuidv4(), ipAdapter } }),
+      prepare: (ipAdapter: IPAdapterConfigV2) => ({ payload: { layerId: uuidv4(), ipAdapter } }),
     },
     ipaLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
       const { layerId, imageDTO } = action.payload;
       const layer = selectIPALayerOrThrow(state, layerId);
       layer.ipAdapter.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null;
     },
-    ipaLayerMethodChanged: (state, action: PayloadAction<{ layerId: string; method: IPMethod }>) => {
+    ipaLayerMethodChanged: (state, action: PayloadAction<{ layerId: string; method: IPMethodV2 }>) => {
       const { layerId, method } = action.payload;
       const layer = selectIPALayerOrThrow(state, layerId);
       layer.ipAdapter.method = method;
@@ -383,7 +383,7 @@ export const controlLayersSlice = createSlice({
     },
     ipaLayerCLIPVisionModelChanged: (
       state,
-      action: PayloadAction<{ layerId: string; clipVisionModel: CLIPVisionModel }>
+      action: PayloadAction<{ layerId: string; clipVisionModel: CLIPVisionModelV2 }>
     ) => {
       const { layerId, clipVisionModel } = action.payload;
       const layer = selectIPALayerOrThrow(state, layerId);
@@ -533,7 +533,7 @@ export const controlLayersSlice = createSlice({
       const layer = selectRGLayerOrThrow(state, layerId);
       layer.autoNegative = autoNegative;
     },
-    rgLayerIPAdapterAdded: (state, action: PayloadAction<{ layerId: string; ipAdapter: IPAdapterConfig }>) => {
+    rgLayerIPAdapterAdded: (state, action: PayloadAction<{ layerId: string; ipAdapter: IPAdapterConfigV2 }>) => {
       const { layerId, ipAdapter } = action.payload;
       const layer = selectRGLayerOrThrow(state, layerId);
       layer.ipAdapters.push(ipAdapter);
@@ -569,7 +569,7 @@ export const controlLayersSlice = createSlice({
     },
     rgLayerIPAdapterMethodChanged: (
       state,
-      action: PayloadAction<{ layerId: string; ipAdapterId: string; method: IPMethod }>
+      action: PayloadAction<{ layerId: string; ipAdapterId: string; method: IPMethodV2 }>
     ) => {
       const { layerId, ipAdapterId, method } = action.payload;
       const ipAdapter = selectRGLayerIPAdapterOrThrow(state, layerId, ipAdapterId);
@@ -593,7 +593,7 @@ export const controlLayersSlice = createSlice({
     },
     rgLayerIPAdapterCLIPVisionModelChanged: (
       state,
-      action: PayloadAction<{ layerId: string; ipAdapterId: string; clipVisionModel: CLIPVisionModel }>
+      action: PayloadAction<{ layerId: string; ipAdapterId: string; clipVisionModel: CLIPVisionModelV2 }>
     ) => {
       const { layerId, ipAdapterId, clipVisionModel } = action.payload;
       const ipAdapter = selectRGLayerIPAdapterOrThrow(state, layerId, ipAdapterId);
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index a4d88f3a0a..cbf47ff3ad 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -1,4 +1,8 @@
-import type { ControlNetConfig, IPAdapterConfig, T2IAdapterConfig } from 'features/controlLayers/util/controlAdapters';
+import type {
+  ControlNetConfigV2,
+  IPAdapterConfigV2,
+  T2IAdapterConfigV2,
+} from 'features/controlLayers/util/controlAdapters';
 import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
 import type {
   ParameterAutoNegative,
@@ -50,12 +54,12 @@ export type ControlAdapterLayer = RenderableLayerBase & {
   type: 'control_adapter_layer'; // technically, also t2i adapter layer
   opacity: number;
   isFilterEnabled: boolean;
-  controlAdapter: ControlNetConfig | T2IAdapterConfig;
+  controlAdapter: ControlNetConfigV2 | T2IAdapterConfigV2;
 };
 
 export type IPAdapterLayer = LayerBase & {
   type: 'ip_adapter_layer';
-  ipAdapter: IPAdapterConfig;
+  ipAdapter: IPAdapterConfigV2;
 };
 
 export type RegionalGuidanceLayer = RenderableLayerBase & {
@@ -63,7 +67,7 @@ export type RegionalGuidanceLayer = RenderableLayerBase & {
   maskObjects: (VectorMaskLine | VectorMaskRect)[];
   positivePrompt: ParameterPositivePrompt | null;
   negativePrompt: ParameterNegativePrompt | null; // Up to one text prompt per mask
-  ipAdapters: IPAdapterConfig[]; // Any number of image prompts
+  ipAdapters: IPAdapterConfigV2[]; // 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
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.test.ts b/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.test.ts
index 656b759faa..880514bf7c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.test.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.test.ts
@@ -4,20 +4,20 @@ import { assert } from 'tsafe';
 import { describe, test } from 'vitest';
 
 import type {
-  CLIPVisionModel,
-  ControlMode,
+  CLIPVisionModelV2,
+  ControlModeV2,
   DepthAnythingModelSize,
-  IPMethod,
+  IPMethodV2,
   ProcessorConfig,
-  ProcessorType,
+  ProcessorTypeV2,
 } from './controlAdapters';
 
 describe('Control Adapter Types', () => {
-  test('ProcessorType', () => assert<Equals<ProcessorConfig['type'], ProcessorType>>());
-  test('IP Adapter Method', () => assert<Equals<NonNullable<S['IPAdapterInvocation']['method']>, IPMethod>>());
+  test('ProcessorType', () => assert<Equals<ProcessorConfig['type'], ProcessorTypeV2>>());
+  test('IP Adapter Method', () => assert<Equals<NonNullable<S['IPAdapterInvocation']['method']>, IPMethodV2>>());
   test('CLIP Vision Model', () =>
-    assert<Equals<NonNullable<S['IPAdapterInvocation']['clip_vision_model']>, CLIPVisionModel>>());
-  test('Control Mode', () => assert<Equals<NonNullable<S['ControlNetInvocation']['control_mode']>, ControlMode>>());
+    assert<Equals<NonNullable<S['IPAdapterInvocation']['clip_vision_model']>, CLIPVisionModelV2>>());
+  test('Control Mode', () => assert<Equals<NonNullable<S['ControlNetInvocation']['control_mode']>, ControlModeV2>>());
   test('DepthAnything Model Size', () =>
     assert<Equals<NonNullable<S['DepthAnythingImageProcessorInvocation']['model_size']>, DepthAnythingModelSize>>());
 });
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts b/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts
index afc168d749..360cfcabc6 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts
@@ -94,45 +94,45 @@ type ControlAdapterBase = {
   beginEndStepPct: [number, number];
 };
 
-const zControlMode = z.enum(['balanced', 'more_prompt', 'more_control', 'unbalanced']);
-export type ControlMode = z.infer<typeof zControlMode>;
-export const isControlMode = (v: unknown): v is ControlMode => zControlMode.safeParse(v).success;
+const zControlModeV2 = z.enum(['balanced', 'more_prompt', 'more_control', 'unbalanced']);
+export type ControlModeV2 = z.infer<typeof zControlModeV2>;
+export const isControlModeV2 = (v: unknown): v is ControlModeV2 => zControlModeV2.safeParse(v).success;
 
-export type ControlNetConfig = ControlAdapterBase & {
+export type ControlNetConfigV2 = ControlAdapterBase & {
   type: 'controlnet';
   model: ParameterControlNetModel | null;
-  controlMode: ControlMode;
+  controlMode: ControlModeV2;
 };
-export const isControlNetConfig = (ca: ControlNetConfig | T2IAdapterConfig): ca is ControlNetConfig =>
+export const isControlNetConfigV2 = (ca: ControlNetConfigV2 | T2IAdapterConfigV2): ca is ControlNetConfigV2 =>
   ca.type === 'controlnet';
 
-export type T2IAdapterConfig = ControlAdapterBase & {
+export type T2IAdapterConfigV2 = ControlAdapterBase & {
   type: 't2i_adapter';
   model: ParameterT2IAdapterModel | null;
 };
-export const isT2IAdapterConfig = (ca: ControlNetConfig | T2IAdapterConfig): ca is T2IAdapterConfig =>
+export const isT2IAdapterConfigV2 = (ca: ControlNetConfigV2 | T2IAdapterConfigV2): ca is T2IAdapterConfigV2 =>
   ca.type === 't2i_adapter';
 
-const zCLIPVisionModel = z.enum(['ViT-H', 'ViT-G']);
-export type CLIPVisionModel = z.infer<typeof zCLIPVisionModel>;
-export const isCLIPVisionModel = (v: unknown): v is CLIPVisionModel => zCLIPVisionModel.safeParse(v).success;
+const zCLIPVisionModelV2 = z.enum(['ViT-H', 'ViT-G']);
+export type CLIPVisionModelV2 = z.infer<typeof zCLIPVisionModelV2>;
+export const isCLIPVisionModelV2 = (v: unknown): v is CLIPVisionModelV2 => zCLIPVisionModelV2.safeParse(v).success;
 
-const zIPMethod = z.enum(['full', 'style', 'composition']);
-export type IPMethod = z.infer<typeof zIPMethod>;
-export const isIPMethod = (v: unknown): v is IPMethod => zIPMethod.safeParse(v).success;
+const zIPMethodV2 = z.enum(['full', 'style', 'composition']);
+export type IPMethodV2 = z.infer<typeof zIPMethodV2>;
+export const isIPMethodV2 = (v: unknown): v is IPMethodV2 => zIPMethodV2.safeParse(v).success;
 
-export type IPAdapterConfig = {
+export type IPAdapterConfigV2 = {
   id: string;
   type: 'ip_adapter';
   weight: number;
-  method: IPMethod;
+  method: IPMethodV2;
   image: ImageWithDims | null;
   model: ParameterIPAdapterModel | null;
-  clipVisionModel: CLIPVisionModel;
+  clipVisionModel: CLIPVisionModelV2;
   beginEndStepPct: [number, number];
 };
 
-const zProcessorType = z.enum([
+const zProcessorTypeV2 = z.enum([
   'canny_image_processor',
   'color_map_image_processor',
   'content_shuffle_image_processor',
@@ -148,10 +148,10 @@ const zProcessorType = z.enum([
   'pidi_image_processor',
   'zoe_depth_image_processor',
 ]);
-export type ProcessorType = z.infer<typeof zProcessorType>;
-export const isProcessorType = (v: unknown): v is ProcessorType => zProcessorType.safeParse(v).success;
+export type ProcessorTypeV2 = z.infer<typeof zProcessorTypeV2>;
+export const isProcessorTypeV2 = (v: unknown): v is ProcessorTypeV2 => zProcessorTypeV2.safeParse(v).success;
 
-type ProcessorData<T extends ProcessorType> = {
+type ProcessorData<T extends ProcessorTypeV2> = {
   type: T;
   labelTKey: string;
   descriptionTKey: string;
@@ -165,7 +165,7 @@ type ProcessorData<T extends ProcessorType> = {
 const minDim = (image: ImageWithDims): number => Math.min(image.width, image.height);
 
 type CAProcessorsData = {
-  [key in ProcessorType]: ProcessorData<key>;
+  [key in ProcessorTypeV2]: ProcessorData<key>;
 };
 /**
  * A dict of ControlNet processors, including:
@@ -405,7 +405,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
   },
 };
 
-const initialControlNet: Omit<ControlNetConfig, 'id'> = {
+const initialControlNetV2: Omit<ControlNetConfigV2, 'id'> = {
   type: 'controlnet',
   model: null,
   weight: 1,
@@ -417,7 +417,7 @@ const initialControlNet: Omit<ControlNetConfig, 'id'> = {
   processorConfig: CA_PROCESSOR_DATA.canny_image_processor.buildDefaults(),
 };
 
-const initialT2IAdapter: Omit<T2IAdapterConfig, 'id'> = {
+const initialT2IAdapterV2: Omit<T2IAdapterConfigV2, 'id'> = {
   type: 't2i_adapter',
   model: null,
   weight: 1,
@@ -428,7 +428,7 @@ const initialT2IAdapter: Omit<T2IAdapterConfig, 'id'> = {
   processorConfig: CA_PROCESSOR_DATA.canny_image_processor.buildDefaults(),
 };
 
-const initialIPAdapter: Omit<IPAdapterConfig, 'id'> = {
+const initialIPAdapterV2: Omit<IPAdapterConfigV2, 'id'> = {
   type: 'ip_adapter',
   image: null,
   model: null,
@@ -438,23 +438,23 @@ const initialIPAdapter: Omit<IPAdapterConfig, 'id'> = {
   weight: 1,
 };
 
-export const buildControlNet = (id: string, overrides?: Partial<ControlNetConfig>): ControlNetConfig => {
-  return merge(deepClone(initialControlNet), { id, ...overrides });
+export const buildControlNet = (id: string, overrides?: Partial<ControlNetConfigV2>): ControlNetConfigV2 => {
+  return merge(deepClone(initialControlNetV2), { id, ...overrides });
 };
 
-export const buildT2IAdapter = (id: string, overrides?: Partial<T2IAdapterConfig>): T2IAdapterConfig => {
-  return merge(deepClone(initialT2IAdapter), { id, ...overrides });
+export const buildT2IAdapter = (id: string, overrides?: Partial<T2IAdapterConfigV2>): T2IAdapterConfigV2 => {
+  return merge(deepClone(initialT2IAdapterV2), { id, ...overrides });
 };
 
-export const buildIPAdapter = (id: string, overrides?: Partial<IPAdapterConfig>): IPAdapterConfig => {
-  return merge(deepClone(initialIPAdapter), { id, ...overrides });
+export const buildIPAdapter = (id: string, overrides?: Partial<IPAdapterConfigV2>): IPAdapterConfigV2 => {
+  return merge(deepClone(initialIPAdapterV2), { id, ...overrides });
 };
 
-export const buildControlAdapterProcessor = (
+export const buildControlAdapterProcessorV2 = (
   modelConfig: ControlNetModelConfig | T2IAdapterModelConfig
 ): ProcessorConfig | null => {
   const defaultPreprocessor = modelConfig.default_settings?.preprocessor;
-  if (!isProcessorType(defaultPreprocessor)) {
+  if (!isProcessorTypeV2(defaultPreprocessor)) {
     return null;
   }
   const processorConfig = CA_PROCESSOR_DATA[defaultPreprocessor].buildDefaults(modelConfig.base);
@@ -467,15 +467,15 @@ export const imageDTOToImageWithDims = ({ image_name, width, height }: ImageDTO)
   height,
 });
 
-export const t2iAdapterToControlNet = (t2iAdapter: T2IAdapterConfig): ControlNetConfig => {
+export const t2iAdapterToControlNet = (t2iAdapter: T2IAdapterConfigV2): ControlNetConfigV2 => {
   return {
     ...deepClone(t2iAdapter),
     type: 'controlnet',
-    controlMode: initialControlNet.controlMode,
+    controlMode: initialControlNetV2.controlMode,
   };
 };
 
-export const controlNetToT2IAdapter = (controlNet: ControlNetConfig): T2IAdapterConfig => {
+export const controlNetToT2IAdapter = (controlNet: ControlNetConfigV2): T2IAdapterConfigV2 => {
   return {
     ...omit(deepClone(controlNet), 'controlMode'),
     type: 't2i_adapter',
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts
index 4581b51ee1..da13fed9f5 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts
@@ -6,13 +6,13 @@ import {
   isRegionalGuidanceLayer,
 } from 'features/controlLayers/store/controlLayersSlice';
 import {
-  type ControlNetConfig,
+  type ControlNetConfigV2,
   type ImageWithDims,
-  type IPAdapterConfig,
-  isControlNetConfig,
-  isT2IAdapterConfig,
+  type IPAdapterConfigV2,
+  isControlNetConfigV2,
+  isT2IAdapterConfigV2,
   type ProcessorConfig,
-  type T2IAdapterConfig,
+  type T2IAdapterConfigV2,
 } from 'features/controlLayers/util/controlAdapters';
 import { getRegionalPromptLayerBlobs } from 'features/controlLayers/util/getLayerBlobs';
 import type { ImageField } from 'features/nodes/types/common';
@@ -64,7 +64,7 @@ const buildControlImage = (
   assert(false, 'Attempted to add unprocessed control image');
 };
 
-const buildControlNetMetadata = (controlNet: ControlNetConfig): S['ControlNetMetadataField'] => {
+const buildControlNetMetadata = (controlNet: ControlNetConfigV2): S['ControlNetMetadataField'] => {
   const { beginEndStepPct, controlMode, image, model, processedImage, processorConfig, weight } = controlNet;
 
   assert(model, 'ControlNet model is required');
@@ -113,7 +113,7 @@ const addControlNetCollectorSafe = (graph: NonNullableGraph, denoiseNodeId: stri
 };
 
 const addGlobalControlNetsToGraph = async (
-  controlNets: ControlNetConfig[],
+  controlNets: ControlNetConfigV2[],
   graph: NonNullableGraph,
   denoiseNodeId: string
 ) => {
@@ -157,7 +157,7 @@ const addGlobalControlNetsToGraph = async (
   upsertMetadata(graph, { controlnets: controlNetMetadata });
 };
 
-const buildT2IAdapterMetadata = (t2iAdapter: T2IAdapterConfig): S['T2IAdapterMetadataField'] => {
+const buildT2IAdapterMetadata = (t2iAdapter: T2IAdapterConfigV2): S['T2IAdapterMetadataField'] => {
   const { beginEndStepPct, image, model, processedImage, processorConfig, weight } = t2iAdapter;
 
   assert(model, 'T2I Adapter model is required');
@@ -205,7 +205,7 @@ const addT2IAdapterCollectorSafe = (graph: NonNullableGraph, denoiseNodeId: stri
 };
 
 const addGlobalT2IAdaptersToGraph = async (
-  t2iAdapters: T2IAdapterConfig[],
+  t2iAdapters: T2IAdapterConfigV2[],
   graph: NonNullableGraph,
   denoiseNodeId: string
 ) => {
@@ -249,7 +249,7 @@ const addGlobalT2IAdaptersToGraph = async (
   upsertMetadata(graph, { t2iAdapters: t2iAdapterMetadata });
 };
 
-const buildIPAdapterMetadata = (ipAdapter: IPAdapterConfig): S['IPAdapterMetadataField'] => {
+const buildIPAdapterMetadata = (ipAdapter: IPAdapterConfigV2): S['IPAdapterMetadataField'] => {
   const { weight, model, clipVisionModel, method, beginEndStepPct, image } = ipAdapter;
 
   assert(model, 'IP Adapter model is required');
@@ -290,7 +290,7 @@ const addIPAdapterCollectorSafe = (graph: NonNullableGraph, denoiseNodeId: strin
 };
 
 const addGlobalIPAdaptersToGraph = async (
-  ipAdapters: IPAdapterConfig[],
+  ipAdapters: IPAdapterConfigV2[],
   graph: NonNullableGraph,
   denoiseNodeId: string
 ) => {
@@ -351,7 +351,7 @@ export const addControlLayersToGraph = async (state: RootState, graph: NonNullab
     // We want the CAs themselves
     .map((l) => l.controlAdapter)
     // Must be a ControlNet
-    .filter(isControlNetConfig)
+    .filter(isControlNetConfigV2)
     .filter((ca) => {
       const hasModel = Boolean(ca.model);
       const modelMatchesBase = ca.model?.base === mainModel.base;
@@ -368,7 +368,7 @@ export const addControlLayersToGraph = async (state: RootState, graph: NonNullab
     // We want the CAs themselves
     .map((l) => l.controlAdapter)
     // Must have a ControlNet CA
-    .filter(isT2IAdapterConfig)
+    .filter(isT2IAdapterConfigV2)
     .filter((ca) => {
       const hasModel = Boolean(ca.model);
       const modelMatchesBase = ca.model?.base === mainModel.base;
@@ -633,7 +633,7 @@ export const addControlLayersToGraph = async (state: RootState, graph: NonNullab
     }
 
     // TODO(psyche): For some reason, I have to explicitly annotate regionalIPAdapters here. Not sure why.
-    const regionalIPAdapters: IPAdapterConfig[] = layer.ipAdapters.filter((ipAdapter) => {
+    const regionalIPAdapters: IPAdapterConfigV2[] = layer.ipAdapters.filter((ipAdapter) => {
       const hasModel = Boolean(ipAdapter.model);
       const modelMatchesBase = ipAdapter.model?.base === mainModel.base;
       const hasControlImage = Boolean(ipAdapter.image);

From 4cd78b9478b877746359716f19659e21930eca6b Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 16:27:58 +1000
Subject: [PATCH 04/59] feat(ui): add getImageDTO imperative RTKQ helper

---
 .../web/src/services/api/endpoints/images.ts  | 20 +++++++++++++++++++
 1 file changed, 20 insertions(+)

diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts
index ecb65f31b1..70358ebc8c 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/images.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts
@@ -1,5 +1,6 @@
 import type { EntityState, Update } from '@reduxjs/toolkit';
 import type { PatchCollection } from '@reduxjs/toolkit/dist/query/core/buildThunks';
+import { getStore } from 'app/store/nanostores/store';
 import type { JSONObject } from 'common/types';
 import type { BoardId } from 'features/gallery/store/types';
 import { ASSETS_CATEGORIES, IMAGE_CATEGORIES, IMAGE_LIMIT } from 'features/gallery/store/types';
@@ -1319,3 +1320,22 @@ export const {
   useUnstarImagesMutation,
   useBulkDownloadImagesMutation,
 } = imagesApi;
+
+/**
+ * Imperative RTKQ helper to fetch an ImageDTO.
+ * @param image_name The name of the image to fetch
+ * @param forceRefetch Whether to force a refetch of the image
+ * @returns
+ */
+export const getImageDTO = async (image_name: string, forceRefetch?: boolean): Promise<ImageDTO | null> => {
+  const options = {
+    subscribe: false,
+    forceRefetch,
+  };
+  const req = getStore().dispatch(imagesApi.endpoints.getImageDTO.initiate(image_name, options));
+  try {
+    return await req.unwrap();
+  } catch {
+    return null;
+  }
+};

From 6363095b293d5296c337f5a13b65420b4bfeff21 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 17:41:02 +1000
Subject: [PATCH 05/59] feat(ui): control adapter recall for control layers

- Add set of metadata handlers for the control layers CAs
- Use these conditionally depending on the active tab - when recalling on txt2img, the CAs go to control layers, else they go to the old CA area.
---
 .../controlLayers/store/controlLayersSlice.ts |  12 +
 .../controlLayers/util/controlAdapters.ts     |   6 +-
 .../ImageMetadataActions.tsx                  |  15 +-
 .../features/gallery/hooks/useImageActions.ts |  11 +-
 .../components/MetadataControlNetsV2.tsx      |  72 ++++++
 .../components/MetadataIPAdaptersV2.tsx       |  72 ++++++
 .../components/MetadataT2IAdaptersV2.tsx      |  72 ++++++
 .../web/src/features/metadata/types.ts        |   9 +
 .../src/features/metadata/util/handlers.ts    |  58 ++++-
 .../web/src/features/metadata/util/parsers.ts | 216 +++++++++++++++++-
 .../src/features/metadata/util/recallers.ts   |  60 +++++
 .../src/features/metadata/util/validators.ts  |  63 +++++
 .../parameters/hooks/usePreselectedImage.ts   |   2 +-
 13 files changed, 654 insertions(+), 14 deletions(-)
 create mode 100644 invokeai/frontend/web/src/features/metadata/components/MetadataControlNetsV2.tsx
 create mode 100644 invokeai/frontend/web/src/features/metadata/components/MetadataIPAdaptersV2.tsx
 create mode 100644 invokeai/frontend/web/src/features/metadata/components/MetadataT2IAdaptersV2.tsx

diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index 5f70cbf4a9..7c8d6a895c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -340,6 +340,12 @@ export const controlLayersSlice = createSlice({
       const layer = selectCALayerOrThrow(state, layerId);
       layer.controlAdapter.isProcessingImage = isProcessingImage;
     },
+    caLayerControlNetsDeleted: (state) => {
+      state.layers = state.layers.filter((l) => !isControlAdapterLayer(l) || l.controlAdapter.type !== 'controlnet');
+    },
+    caLayerT2IAdaptersDeleted: (state) => {
+      state.layers = state.layers.filter((l) => !isControlAdapterLayer(l) || l.controlAdapter.type !== 't2i_adapter');
+    },
     //#endregion
 
     //#region IP Adapter Layers
@@ -389,6 +395,9 @@ export const controlLayersSlice = createSlice({
       const layer = selectIPALayerOrThrow(state, layerId);
       layer.ipAdapter.clipVisionModel = clipVisionModel;
     },
+    ipaLayersDeleted: (state) => {
+      state.layers = state.layers.filter((l) => !isIPAdapterLayer(l));
+    },
     //#endregion
 
     //#region CA or IPA Layers
@@ -741,12 +750,15 @@ export const {
   caLayerIsFilterEnabledChanged,
   caLayerOpacityChanged,
   caLayerIsProcessingImageChanged,
+  caLayerControlNetsDeleted,
+  caLayerT2IAdaptersDeleted,
   // IPA Layers
   ipaLayerAdded,
   ipaLayerImageChanged,
   ipaLayerMethodChanged,
   ipaLayerModelChanged,
   ipaLayerCLIPVisionModelChanged,
+  ipaLayersDeleted,
   // CA or IPA Layers
   caOrIPALayerWeightChanged,
   caOrIPALayerBeginEndStepPctChanged,
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts b/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts
index 360cfcabc6..2964a2eb6c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/controlAdapters.ts
@@ -405,7 +405,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
   },
 };
 
-const initialControlNetV2: Omit<ControlNetConfigV2, 'id'> = {
+export const initialControlNetV2: Omit<ControlNetConfigV2, 'id'> = {
   type: 'controlnet',
   model: null,
   weight: 1,
@@ -417,7 +417,7 @@ const initialControlNetV2: Omit<ControlNetConfigV2, 'id'> = {
   processorConfig: CA_PROCESSOR_DATA.canny_image_processor.buildDefaults(),
 };
 
-const initialT2IAdapterV2: Omit<T2IAdapterConfigV2, 'id'> = {
+export const initialT2IAdapterV2: Omit<T2IAdapterConfigV2, 'id'> = {
   type: 't2i_adapter',
   model: null,
   weight: 1,
@@ -428,7 +428,7 @@ const initialT2IAdapterV2: Omit<T2IAdapterConfigV2, 'id'> = {
   processorConfig: CA_PROCESSOR_DATA.canny_image_processor.buildDefaults(),
 };
 
-const initialIPAdapterV2: Omit<IPAdapterConfigV2, 'id'> = {
+export const initialIPAdapterV2: Omit<IPAdapterConfigV2, 'id'> = {
   type: 'ip_adapter',
   image: null,
   model: null,
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx
index ce75ea62e0..007a89f1a1 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx
@@ -1,9 +1,14 @@
+import { useAppSelector } from 'app/store/storeHooks';
 import { MetadataControlNets } from 'features/metadata/components/MetadataControlNets';
+import { MetadataControlNetsV2 } from 'features/metadata/components/MetadataControlNetsV2';
 import { MetadataIPAdapters } from 'features/metadata/components/MetadataIPAdapters';
+import { MetadataIPAdaptersV2 } from 'features/metadata/components/MetadataIPAdaptersV2';
 import { MetadataItem } from 'features/metadata/components/MetadataItem';
 import { MetadataLoRAs } from 'features/metadata/components/MetadataLoRAs';
 import { MetadataT2IAdapters } from 'features/metadata/components/MetadataT2IAdapters';
+import { MetadataT2IAdaptersV2 } from 'features/metadata/components/MetadataT2IAdaptersV2';
 import { handlers } from 'features/metadata/util/handlers';
+import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
 import { memo } from 'react';
 
 type Props = {
@@ -11,6 +16,7 @@ type Props = {
 };
 
 const ImageMetadataActions = (props: Props) => {
+  const activeTabName = useAppSelector(activeTabNameSelector);
   const { metadata } = props;
 
   if (!metadata || Object.keys(metadata).length === 0) {
@@ -46,9 +52,12 @@ const ImageMetadataActions = (props: Props) => {
       <MetadataItem metadata={metadata} handlers={handlers.refinerStart} />
       <MetadataItem metadata={metadata} handlers={handlers.refinerSteps} />
       <MetadataLoRAs metadata={metadata} />
-      <MetadataControlNets metadata={metadata} />
-      <MetadataT2IAdapters metadata={metadata} />
-      <MetadataIPAdapters metadata={metadata} />
+      {activeTabName !== 'txt2img' && <MetadataControlNets metadata={metadata} />}
+      {activeTabName !== 'txt2img' && <MetadataT2IAdapters metadata={metadata} />}
+      {activeTabName !== 'txt2img' && <MetadataIPAdapters metadata={metadata} />}
+      {activeTabName === 'txt2img' && <MetadataControlNetsV2 metadata={metadata} />}
+      {activeTabName === 'txt2img' && <MetadataT2IAdaptersV2 metadata={metadata} />}
+      {activeTabName === 'txt2img' && <MetadataIPAdaptersV2 metadata={metadata} />}
     </>
   );
 };
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts
index c3ae0cea5f..3f0f0bdf91 100644
--- a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts
+++ b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts
@@ -1,8 +1,11 @@
+import { useAppSelector } from 'app/store/storeHooks';
 import { handlers, parseAndRecallAllMetadata, parseAndRecallPrompts } from 'features/metadata/util/handlers';
+import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
 import { useCallback, useEffect, useState } from 'react';
 import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
 
 export const useImageActions = (image_name?: string) => {
+  const activeTabName = useAppSelector(activeTabNameSelector);
   const { metadata, isLoading: isLoadingMetadata } = useDebouncedMetadata(image_name);
   const [hasMetadata, setHasMetadata] = useState(false);
   const [hasSeed, setHasSeed] = useState(false);
@@ -40,13 +43,13 @@ export const useImageActions = (image_name?: string) => {
   }, [metadata]);
 
   const recallAll = useCallback(() => {
-    parseAndRecallAllMetadata(metadata);
-  }, [metadata]);
+    parseAndRecallAllMetadata(metadata, activeTabName === 'txt2img');
+  }, [activeTabName, metadata]);
 
   const remix = useCallback(() => {
     // Recalls all metadata parameters except seed
-    parseAndRecallAllMetadata(metadata, ['seed']);
-  }, [metadata]);
+    parseAndRecallAllMetadata(metadata, activeTabName === 'txt2img', ['seed']);
+  }, [activeTabName, metadata]);
 
   const recallSeed = useCallback(() => {
     handlers.seed.parse(metadata).then((seed) => {
diff --git a/invokeai/frontend/web/src/features/metadata/components/MetadataControlNetsV2.tsx b/invokeai/frontend/web/src/features/metadata/components/MetadataControlNetsV2.tsx
new file mode 100644
index 0000000000..5f4df78afc
--- /dev/null
+++ b/invokeai/frontend/web/src/features/metadata/components/MetadataControlNetsV2.tsx
@@ -0,0 +1,72 @@
+import { MetadataItemView } from 'features/metadata/components/MetadataItemView';
+import type { ControlNetConfigV2Metadata, MetadataHandlers } from 'features/metadata/types';
+import { handlers } from 'features/metadata/util/handlers';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+type Props = {
+  metadata: unknown;
+};
+
+export const MetadataControlNetsV2 = ({ metadata }: Props) => {
+  const [controlNets, setControlNets] = useState<ControlNetConfigV2Metadata[]>([]);
+
+  useEffect(() => {
+    const parse = async () => {
+      try {
+        const parsed = await handlers.controlNetsV2.parse(metadata);
+        setControlNets(parsed);
+      } catch (e) {
+        setControlNets([]);
+      }
+    };
+    parse();
+  }, [metadata]);
+
+  const label = useMemo(() => handlers.controlNetsV2.getLabel(), []);
+
+  return (
+    <>
+      {controlNets.map((controlNet) => (
+        <MetadataViewControlNet
+          key={controlNet.id}
+          label={label}
+          controlNet={controlNet}
+          handlers={handlers.controlNetsV2}
+        />
+      ))}
+    </>
+  );
+};
+
+const MetadataViewControlNet = ({
+  label,
+  controlNet,
+  handlers,
+}: {
+  label: string;
+  controlNet: ControlNetConfigV2Metadata;
+  handlers: MetadataHandlers<ControlNetConfigV2Metadata[], ControlNetConfigV2Metadata>;
+}) => {
+  const onRecall = useCallback(() => {
+    if (!handlers.recallItem) {
+      return;
+    }
+    handlers.recallItem(controlNet, true);
+  }, [handlers, controlNet]);
+
+  const [renderedValue, setRenderedValue] = useState<React.ReactNode>(null);
+  useEffect(() => {
+    const _renderValue = async () => {
+      if (!handlers.renderItemValue) {
+        setRenderedValue(null);
+        return;
+      }
+      const rendered = await handlers.renderItemValue(controlNet);
+      setRenderedValue(rendered);
+    };
+
+    _renderValue();
+  }, [handlers, controlNet]);
+
+  return <MetadataItemView label={label} isDisabled={false} onRecall={onRecall} renderedValue={renderedValue} />;
+};
diff --git a/invokeai/frontend/web/src/features/metadata/components/MetadataIPAdaptersV2.tsx b/invokeai/frontend/web/src/features/metadata/components/MetadataIPAdaptersV2.tsx
new file mode 100644
index 0000000000..201ebc4cb4
--- /dev/null
+++ b/invokeai/frontend/web/src/features/metadata/components/MetadataIPAdaptersV2.tsx
@@ -0,0 +1,72 @@
+import { MetadataItemView } from 'features/metadata/components/MetadataItemView';
+import type { IPAdapterConfigV2Metadata, MetadataHandlers } from 'features/metadata/types';
+import { handlers } from 'features/metadata/util/handlers';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+type Props = {
+  metadata: unknown;
+};
+
+export const MetadataIPAdaptersV2 = ({ metadata }: Props) => {
+  const [ipAdapters, setIPAdapters] = useState<IPAdapterConfigV2Metadata[]>([]);
+
+  useEffect(() => {
+    const parse = async () => {
+      try {
+        const parsed = await handlers.ipAdaptersV2.parse(metadata);
+        setIPAdapters(parsed);
+      } catch (e) {
+        setIPAdapters([]);
+      }
+    };
+    parse();
+  }, [metadata]);
+
+  const label = useMemo(() => handlers.ipAdaptersV2.getLabel(), []);
+
+  return (
+    <>
+      {ipAdapters.map((ipAdapter) => (
+        <MetadataViewIPAdapter
+          key={ipAdapter.id}
+          label={label}
+          ipAdapter={ipAdapter}
+          handlers={handlers.ipAdaptersV2}
+        />
+      ))}
+    </>
+  );
+};
+
+const MetadataViewIPAdapter = ({
+  label,
+  ipAdapter,
+  handlers,
+}: {
+  label: string;
+  ipAdapter: IPAdapterConfigV2Metadata;
+  handlers: MetadataHandlers<IPAdapterConfigV2Metadata[], IPAdapterConfigV2Metadata>;
+}) => {
+  const onRecall = useCallback(() => {
+    if (!handlers.recallItem) {
+      return;
+    }
+    handlers.recallItem(ipAdapter, true);
+  }, [handlers, ipAdapter]);
+
+  const [renderedValue, setRenderedValue] = useState<React.ReactNode>(null);
+  useEffect(() => {
+    const _renderValue = async () => {
+      if (!handlers.renderItemValue) {
+        setRenderedValue(null);
+        return;
+      }
+      const rendered = await handlers.renderItemValue(ipAdapter);
+      setRenderedValue(rendered);
+    };
+
+    _renderValue();
+  }, [handlers, ipAdapter]);
+
+  return <MetadataItemView label={label} isDisabled={false} onRecall={onRecall} renderedValue={renderedValue} />;
+};
diff --git a/invokeai/frontend/web/src/features/metadata/components/MetadataT2IAdaptersV2.tsx b/invokeai/frontend/web/src/features/metadata/components/MetadataT2IAdaptersV2.tsx
new file mode 100644
index 0000000000..42d3de2ec2
--- /dev/null
+++ b/invokeai/frontend/web/src/features/metadata/components/MetadataT2IAdaptersV2.tsx
@@ -0,0 +1,72 @@
+import { MetadataItemView } from 'features/metadata/components/MetadataItemView';
+import type { MetadataHandlers, T2IAdapterConfigV2Metadata } from 'features/metadata/types';
+import { handlers } from 'features/metadata/util/handlers';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+type Props = {
+  metadata: unknown;
+};
+
+export const MetadataT2IAdaptersV2 = ({ metadata }: Props) => {
+  const [t2iAdapters, setT2IAdapters] = useState<T2IAdapterConfigV2Metadata[]>([]);
+
+  useEffect(() => {
+    const parse = async () => {
+      try {
+        const parsed = await handlers.t2iAdaptersV2.parse(metadata);
+        setT2IAdapters(parsed);
+      } catch (e) {
+        setT2IAdapters([]);
+      }
+    };
+    parse();
+  }, [metadata]);
+
+  const label = useMemo(() => handlers.t2iAdaptersV2.getLabel(), []);
+
+  return (
+    <>
+      {t2iAdapters.map((t2iAdapter) => (
+        <MetadataViewT2IAdapter
+          key={t2iAdapter.id}
+          label={label}
+          t2iAdapter={t2iAdapter}
+          handlers={handlers.t2iAdaptersV2}
+        />
+      ))}
+    </>
+  );
+};
+
+const MetadataViewT2IAdapter = ({
+  label,
+  t2iAdapter,
+  handlers,
+}: {
+  label: string;
+  t2iAdapter: T2IAdapterConfigV2Metadata;
+  handlers: MetadataHandlers<T2IAdapterConfigV2Metadata[], T2IAdapterConfigV2Metadata>;
+}) => {
+  const onRecall = useCallback(() => {
+    if (!handlers.recallItem) {
+      return;
+    }
+    handlers.recallItem(t2iAdapter, true);
+  }, [handlers, t2iAdapter]);
+
+  const [renderedValue, setRenderedValue] = useState<React.ReactNode>(null);
+  useEffect(() => {
+    const _renderValue = async () => {
+      if (!handlers.renderItemValue) {
+        setRenderedValue(null);
+        return;
+      }
+      const rendered = await handlers.renderItemValue(t2iAdapter);
+      setRenderedValue(rendered);
+    };
+
+    _renderValue();
+  }, [handlers, t2iAdapter]);
+
+  return <MetadataItemView label={label} isDisabled={false} onRecall={onRecall} renderedValue={renderedValue} />;
+};
diff --git a/invokeai/frontend/web/src/features/metadata/types.ts b/invokeai/frontend/web/src/features/metadata/types.ts
index 0791cdf449..8621031896 100644
--- a/invokeai/frontend/web/src/features/metadata/types.ts
+++ b/invokeai/frontend/web/src/features/metadata/types.ts
@@ -1,4 +1,5 @@
 import type { ControlNetConfig, IPAdapterConfig, T2IAdapterConfig } from 'features/controlAdapters/store/types';
+import type { ControlNetConfigV2, IPAdapterConfigV2, T2IAdapterConfigV2 } from 'features/controlLayers/util/controlAdapters';
 import type { O } from 'ts-toolbelt';
 
 /**
@@ -135,3 +136,11 @@ export type AnyControlAdapterConfigMetadata =
   | ControlNetConfigMetadata
   | T2IAdapterConfigMetadata
   | IPAdapterConfigMetadata;
+
+export type ControlNetConfigV2Metadata = O.NonNullable<ControlNetConfigV2, 'model'>;
+export type T2IAdapterConfigV2Metadata = O.NonNullable<T2IAdapterConfigV2, 'model'>;
+export type IPAdapterConfigV2Metadata = O.NonNullable<IPAdapterConfigV2, 'model'>;
+export type AnyControlAdapterConfigV2Metadata =
+  | ControlNetConfigV2Metadata
+  | T2IAdapterConfigV2Metadata
+  | IPAdapterConfigV2Metadata;
diff --git a/invokeai/frontend/web/src/features/metadata/util/handlers.ts b/invokeai/frontend/web/src/features/metadata/util/handlers.ts
index 2fb840afcb..467f702cea 100644
--- a/invokeai/frontend/web/src/features/metadata/util/handlers.ts
+++ b/invokeai/frontend/web/src/features/metadata/util/handlers.ts
@@ -3,6 +3,7 @@ import { toast } from 'common/util/toast';
 import type { LoRA } from 'features/lora/store/loraSlice';
 import type {
   AnyControlAdapterConfigMetadata,
+  AnyControlAdapterConfigV2Metadata,
   BuildMetadataHandlers,
   MetadataGetLabelFunc,
   MetadataHandlers,
@@ -43,6 +44,14 @@ const renderControlAdapterValue: MetadataRenderValueFunc<AnyControlAdapterConfig
     return `${value.model.key} (${value.model.base.toUpperCase()}) - ${value.weight}`;
   }
 };
+const renderControlAdapterValueV2: MetadataRenderValueFunc<AnyControlAdapterConfigV2Metadata> = async (value) => {
+  try {
+    const modelConfig = await fetchModelConfig(value.model.key ?? 'none');
+    return `${modelConfig.name} (${modelConfig.base.toUpperCase()}) - ${value.weight}`;
+  } catch {
+    return `${value.model.key} (${value.model.base.toUpperCase()}) - ${value.weight}`;
+  }
+};
 
 const parameterSetToast = (parameter: string, description?: string) => {
   toast({
@@ -341,6 +350,36 @@ export const handlers = {
     itemValidator: validators.t2iAdapter,
     renderItemValue: renderControlAdapterValue,
   }),
+  controlNetsV2: buildHandlers({
+    getLabel: () => t('common.controlNet'),
+    parser: parsers.controlNetsV2,
+    itemParser: parsers.controlNetV2,
+    recaller: recallers.controlNetsV2,
+    itemRecaller: recallers.controlNetV2,
+    validator: validators.controlNetsV2,
+    itemValidator: validators.controlNetV2,
+    renderItemValue: renderControlAdapterValueV2,
+  }),
+  ipAdaptersV2: buildHandlers({
+    getLabel: () => t('common.ipAdapter'),
+    parser: parsers.ipAdaptersV2,
+    itemParser: parsers.ipAdapterV2,
+    recaller: recallers.ipAdaptersV2,
+    itemRecaller: recallers.ipAdapterV2,
+    validator: validators.ipAdaptersV2,
+    itemValidator: validators.ipAdapterV2,
+    renderItemValue: renderControlAdapterValueV2,
+  }),
+  t2iAdaptersV2: buildHandlers({
+    getLabel: () => t('common.t2iAdapter'),
+    parser: parsers.t2iAdaptersV2,
+    itemParser: parsers.t2iAdapterV2,
+    recaller: recallers.t2iAdaptersV2,
+    itemRecaller: recallers.t2iAdapterV2,
+    validator: validators.t2iAdaptersV2,
+    itemValidator: validators.t2iAdapterV2,
+    renderItemValue: renderControlAdapterValueV2,
+  }),
 } as const;
 
 export const parseAndRecallPrompts = async (metadata: unknown) => {
@@ -395,10 +434,25 @@ export const parseAndRecallImageDimensions = async (metadata: unknown) => {
   }
 };
 
-export const parseAndRecallAllMetadata = async (metadata: unknown, skip: (keyof typeof handlers)[] = []) => {
+// These handlers should be omitted when recalling to control layers
+const TO_CONTROL_LAYERS_SKIP_KEYS: (keyof typeof handlers)[] = ['controlNets', 'ipAdapters', 't2iAdapters'];
+// These handlers should be omitted when recalling to the rest of the app
+const NOT_TO_CONTROL_LAYERS_SKIP_KEYS: (keyof typeof handlers)[] = ['controlNetsV2', 'ipAdaptersV2', 't2iAdaptersV2'];
+
+export const parseAndRecallAllMetadata = async (
+  metadata: unknown,
+  toControlLayers: boolean,
+  skip: (keyof typeof handlers)[] = []
+) => {
+  const skipKeys = skip ?? [];
+  if (toControlLayers) {
+    skipKeys.push(...TO_CONTROL_LAYERS_SKIP_KEYS);
+  } else {
+    skipKeys.push(...NOT_TO_CONTROL_LAYERS_SKIP_KEYS);
+  }
   const results = await Promise.allSettled(
     objectKeys(handlers)
-      .filter((key) => !skip.includes(key))
+      .filter((key) => !skipKeys.includes(key))
       .map((key) => {
         const { parse, recall } = handlers[key];
         return parse(metadata).then((value) => {
diff --git a/invokeai/frontend/web/src/features/metadata/util/parsers.ts b/invokeai/frontend/web/src/features/metadata/util/parsers.ts
index 3decea6737..8641977b1f 100644
--- a/invokeai/frontend/web/src/features/metadata/util/parsers.ts
+++ b/invokeai/frontend/web/src/features/metadata/util/parsers.ts
@@ -5,13 +5,24 @@ import {
   initialT2IAdapter,
 } from 'features/controlAdapters/util/buildControlAdapter';
 import { buildControlAdapterProcessor } from 'features/controlAdapters/util/buildControlAdapterProcessor';
+import {
+  CA_PROCESSOR_DATA,
+  imageDTOToImageWithDims,
+  initialControlNetV2,
+  initialIPAdapterV2,
+  initialT2IAdapterV2,
+  isProcessorTypeV2,
+} from 'features/controlLayers/util/controlAdapters';
 import type { LoRA } from 'features/lora/store/loraSlice';
 import { defaultLoRAConfig } from 'features/lora/store/loraSlice';
 import type {
   ControlNetConfigMetadata,
+  ControlNetConfigV2Metadata,
   IPAdapterConfigMetadata,
+  IPAdapterConfigV2Metadata,
   MetadataParseFunc,
   T2IAdapterConfigMetadata,
+  T2IAdapterConfigV2Metadata,
 } from 'features/metadata/types';
 import { fetchModelConfigWithTypeGuard, getModelKey } from 'features/metadata/util/modelFetchingHelpers';
 import { zControlField, zIPAdapterField, zModelIdentifierField, zT2IAdapterField } from 'features/nodes/types/common';
@@ -58,7 +69,7 @@ import {
   isParameterWidth,
 } from 'features/parameters/types/parameterSchemas';
 import { get, isArray, isString } from 'lodash-es';
-import { imagesApi } from 'services/api/endpoints/images';
+import { getImageDTO, imagesApi } from 'services/api/endpoints/images';
 import type { ImageDTO } from 'services/api/types';
 import {
   isControlNetModelConfig,
@@ -428,6 +439,203 @@ const parseAllIPAdapters: MetadataParseFunc<IPAdapterConfigMetadata[]> = async (
   }
 };
 
+//#region V2/Control Layers
+const parseControlNetV2: MetadataParseFunc<ControlNetConfigV2Metadata> = async (metadataItem) => {
+  const control_model = await getProperty(metadataItem, 'control_model');
+  const key = await getModelKey(control_model, 'controlnet');
+  const controlNetModel = await fetchModelConfigWithTypeGuard(key, isControlNetModelConfig);
+  const image = zControlField.shape.image
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'image'));
+  const processedImage = zControlField.shape.image
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'processed_image'));
+  const control_weight = zControlField.shape.control_weight
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'control_weight'));
+  const begin_step_percent = zControlField.shape.begin_step_percent
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'begin_step_percent'));
+  const end_step_percent = zControlField.shape.end_step_percent
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'end_step_percent'));
+  const control_mode = zControlField.shape.control_mode
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'control_mode'));
+
+  const id = uuidv4();
+  const defaultPreprocessor = controlNetModel.default_settings?.preprocessor;
+  const processorConfig = isProcessorTypeV2(defaultPreprocessor)
+    ? CA_PROCESSOR_DATA[defaultPreprocessor].buildDefaults()
+    : null;
+  const beginEndStepPct: [number, number] = [
+    begin_step_percent ?? initialControlNetV2.beginEndStepPct[0],
+    end_step_percent ?? initialControlNetV2.beginEndStepPct[1],
+  ];
+  const imageDTO = image ? await getImageDTO(image.image_name) : null;
+  const processedImageDTO = processedImage ? await getImageDTO(processedImage.image_name) : null;
+
+  const controlNet: ControlNetConfigV2Metadata = {
+    id,
+    type: 'controlnet',
+    model: zModelIdentifierField.parse(controlNetModel),
+    weight: typeof control_weight === 'number' ? control_weight : initialControlNetV2.weight,
+    beginEndStepPct,
+    controlMode: control_mode ?? initialControlNetV2.controlMode,
+    image: imageDTO ? imageDTOToImageWithDims(imageDTO) : null,
+    processedImage: processedImageDTO ? imageDTOToImageWithDims(processedImageDTO) : null,
+    processorConfig,
+    isProcessingImage: false,
+  };
+
+  return controlNet;
+};
+
+const parseAllControlNetsV2: MetadataParseFunc<ControlNetConfigV2Metadata[]> = async (metadata) => {
+  try {
+    const controlNetsRaw = await getProperty(metadata, 'controlnets', isArray || undefined);
+    const parseResults = await Promise.allSettled(controlNetsRaw.map((cn) => parseControlNetV2(cn)));
+    const controlNets = parseResults
+      .filter((result): result is PromiseFulfilledResult<ControlNetConfigV2Metadata> => result.status === 'fulfilled')
+      .map((result) => result.value);
+    return controlNets;
+  } catch {
+    return [];
+  }
+};
+
+const parseT2IAdapterV2: MetadataParseFunc<T2IAdapterConfigV2Metadata> = async (metadataItem) => {
+  const t2i_adapter_model = await getProperty(metadataItem, 't2i_adapter_model');
+  const key = await getModelKey(t2i_adapter_model, 't2i_adapter');
+  const t2iAdapterModel = await fetchModelConfigWithTypeGuard(key, isT2IAdapterModelConfig);
+
+  const image = zT2IAdapterField.shape.image
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'image'));
+  const processedImage = zT2IAdapterField.shape.image
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'processed_image'));
+  const weight = zT2IAdapterField.shape.weight
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'weight'));
+  const begin_step_percent = zT2IAdapterField.shape.begin_step_percent
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'begin_step_percent'));
+  const end_step_percent = zT2IAdapterField.shape.end_step_percent
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'end_step_percent'));
+
+  const id = uuidv4();
+  const defaultPreprocessor = t2iAdapterModel.default_settings?.preprocessor;
+  const processorConfig = isProcessorTypeV2(defaultPreprocessor)
+    ? CA_PROCESSOR_DATA[defaultPreprocessor].buildDefaults()
+    : null;
+  const beginEndStepPct: [number, number] = [
+    begin_step_percent ?? initialT2IAdapterV2.beginEndStepPct[0],
+    end_step_percent ?? initialT2IAdapterV2.beginEndStepPct[1],
+  ];
+  const imageDTO = image ? await getImageDTO(image.image_name) : null;
+  const processedImageDTO = processedImage ? await getImageDTO(processedImage.image_name) : null;
+
+  const t2iAdapter: T2IAdapterConfigV2Metadata = {
+    id,
+    type: 't2i_adapter',
+    model: zModelIdentifierField.parse(t2iAdapterModel),
+    weight: typeof weight === 'number' ? weight : initialT2IAdapterV2.weight,
+    beginEndStepPct,
+    image: imageDTO ? imageDTOToImageWithDims(imageDTO) : null,
+    processedImage: processedImageDTO ? imageDTOToImageWithDims(processedImageDTO) : null,
+    processorConfig,
+    isProcessingImage: false,
+  };
+
+  return t2iAdapter;
+};
+
+const parseAllT2IAdaptersV2: MetadataParseFunc<T2IAdapterConfigV2Metadata[]> = async (metadata) => {
+  try {
+    const t2iAdaptersRaw = await getProperty(metadata, 't2iAdapters', isArray);
+    const parseResults = await Promise.allSettled(t2iAdaptersRaw.map((t2iAdapter) => parseT2IAdapterV2(t2iAdapter)));
+    const t2iAdapters = parseResults
+      .filter((result): result is PromiseFulfilledResult<T2IAdapterConfigV2Metadata> => result.status === 'fulfilled')
+      .map((result) => result.value);
+    return t2iAdapters;
+  } catch {
+    return [];
+  }
+};
+
+const parseIPAdapterV2: MetadataParseFunc<IPAdapterConfigV2Metadata> = async (metadataItem) => {
+  const ip_adapter_model = await getProperty(metadataItem, 'ip_adapter_model');
+  const key = await getModelKey(ip_adapter_model, 'ip_adapter');
+  const ipAdapterModel = await fetchModelConfigWithTypeGuard(key, isIPAdapterModelConfig);
+
+  const image = zIPAdapterField.shape.image
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'image'));
+  const weight = zIPAdapterField.shape.weight
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'weight'));
+  const method = zIPAdapterField.shape.method
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'method'));
+  const begin_step_percent = zIPAdapterField.shape.begin_step_percent
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'begin_step_percent'));
+  const end_step_percent = zIPAdapterField.shape.end_step_percent
+    .nullish()
+    .catch(null)
+    .parse(await getProperty(metadataItem, 'end_step_percent'));
+
+  const id = uuidv4();
+  const beginEndStepPct: [number, number] = [
+    begin_step_percent ?? initialIPAdapterV2.beginEndStepPct[0],
+    end_step_percent ?? initialIPAdapterV2.beginEndStepPct[1],
+  ];
+  const imageDTO = image ? await getImageDTO(image.image_name) : null;
+
+  const ipAdapter: IPAdapterConfigV2Metadata = {
+    id,
+    type: 'ip_adapter',
+    model: zModelIdentifierField.parse(ipAdapterModel),
+    weight: typeof weight === 'number' ? weight : initialIPAdapterV2.weight,
+    beginEndStepPct,
+    image: imageDTO ? imageDTOToImageWithDims(imageDTO) : null,
+    clipVisionModel: initialIPAdapterV2.clipVisionModel, // TODO: This needs to be added to the zIPAdapterField...
+    method: method ?? initialIPAdapterV2.method,
+  };
+
+  return ipAdapter;
+};
+
+const parseAllIPAdaptersV2: MetadataParseFunc<IPAdapterConfigV2Metadata[]> = async (metadata) => {
+  try {
+    const ipAdaptersRaw = await getProperty(metadata, 'ipAdapters', isArray);
+    const parseResults = await Promise.allSettled(ipAdaptersRaw.map((ipAdapter) => parseIPAdapterV2(ipAdapter)));
+    const ipAdapters = parseResults
+      .filter((result): result is PromiseFulfilledResult<IPAdapterConfigV2Metadata> => result.status === 'fulfilled')
+      .map((result) => result.value);
+    return ipAdapters;
+  } catch {
+    return [];
+  }
+};
+
 export const parsers = {
   createdBy: parseCreatedBy,
   generationMode: parseGenerationMode,
@@ -464,4 +672,10 @@ export const parsers = {
   t2iAdapters: parseAllT2IAdapters,
   ipAdapter: parseIPAdapter,
   ipAdapters: parseAllIPAdapters,
+  controlNetV2: parseControlNetV2,
+  controlNetsV2: parseAllControlNetsV2,
+  t2iAdapterV2: parseT2IAdapterV2,
+  t2iAdaptersV2: parseAllT2IAdaptersV2,
+  ipAdapterV2: parseIPAdapterV2,
+  ipAdaptersV2: parseAllIPAdaptersV2,
 } as const;
diff --git a/invokeai/frontend/web/src/features/metadata/util/recallers.ts b/invokeai/frontend/web/src/features/metadata/util/recallers.ts
index f07b2ab8b6..a7b0cd5b2e 100644
--- a/invokeai/frontend/web/src/features/metadata/util/recallers.ts
+++ b/invokeai/frontend/web/src/features/metadata/util/recallers.ts
@@ -6,7 +6,12 @@ import {
   t2iAdaptersReset,
 } from 'features/controlAdapters/store/controlAdaptersSlice';
 import {
+  caLayerAdded,
+  caLayerControlNetsDeleted,
+  caLayerT2IAdaptersDeleted,
   heightChanged,
+  ipaLayerAdded,
+  ipaLayersDeleted,
   negativePrompt2Changed,
   negativePromptChanged,
   positivePrompt2Changed,
@@ -18,9 +23,12 @@ import type { LoRA } from 'features/lora/store/loraSlice';
 import { loraRecalled, lorasReset } from 'features/lora/store/loraSlice';
 import type {
   ControlNetConfigMetadata,
+  ControlNetConfigV2Metadata,
   IPAdapterConfigMetadata,
+  IPAdapterConfigV2Metadata,
   MetadataRecallFunc,
   T2IAdapterConfigMetadata,
+  T2IAdapterConfigV2Metadata,
 } from 'features/metadata/types';
 import { modelSelected } from 'features/parameters/store/actions';
 import {
@@ -234,6 +242,52 @@ const recallIPAdapters: MetadataRecallFunc<IPAdapterConfigMetadata[]> = (ipAdapt
   });
 };
 
+//#region V2/Control Layer
+const recallControlNetV2: MetadataRecallFunc<ControlNetConfigV2Metadata> = (controlNet) => {
+  getStore().dispatch(caLayerAdded(controlNet));
+};
+
+const recallControlNetsV2: MetadataRecallFunc<ControlNetConfigV2Metadata[]> = (controlNets) => {
+  const { dispatch } = getStore();
+  dispatch(caLayerControlNetsDeleted());
+  if (!controlNets.length) {
+    return;
+  }
+  controlNets.forEach((controlNet) => {
+    dispatch(caLayerAdded(controlNet));
+  });
+};
+
+const recallT2IAdapterV2: MetadataRecallFunc<T2IAdapterConfigV2Metadata> = (t2iAdapter) => {
+  getStore().dispatch(caLayerAdded(t2iAdapter));
+};
+
+const recallT2IAdaptersV2: MetadataRecallFunc<T2IAdapterConfigV2Metadata[]> = (t2iAdapters) => {
+  const { dispatch } = getStore();
+  dispatch(caLayerT2IAdaptersDeleted());
+  if (!t2iAdapters.length) {
+    return;
+  }
+  t2iAdapters.forEach((t2iAdapters) => {
+    dispatch(caLayerAdded(t2iAdapters));
+  });
+};
+
+const recallIPAdapterV2: MetadataRecallFunc<IPAdapterConfigV2Metadata> = (ipAdapter) => {
+  getStore().dispatch(ipaLayerAdded(ipAdapter));
+};
+
+const recallIPAdaptersV2: MetadataRecallFunc<IPAdapterConfigV2Metadata[]> = (ipAdapters) => {
+  const { dispatch } = getStore();
+  dispatch(ipaLayersDeleted());
+  if (!ipAdapters.length) {
+    return;
+  }
+  ipAdapters.forEach((ipAdapter) => {
+    dispatch(ipaLayerAdded(ipAdapter));
+  });
+};
+
 export const recallers = {
   positivePrompt: recallPositivePrompt,
   negativePrompt: recallNegativePrompt,
@@ -268,4 +322,10 @@ export const recallers = {
   t2iAdapter: recallT2IAdapter,
   ipAdapters: recallIPAdapters,
   ipAdapter: recallIPAdapter,
+  controlNetV2: recallControlNetV2,
+  controlNetsV2: recallControlNetsV2,
+  t2iAdapterV2: recallT2IAdapterV2,
+  t2iAdaptersV2: recallT2IAdaptersV2,
+  ipAdapterV2: recallIPAdapterV2,
+  ipAdaptersV2: recallIPAdaptersV2,
 } as const;
diff --git a/invokeai/frontend/web/src/features/metadata/util/validators.ts b/invokeai/frontend/web/src/features/metadata/util/validators.ts
index 66454778f2..d09321003f 100644
--- a/invokeai/frontend/web/src/features/metadata/util/validators.ts
+++ b/invokeai/frontend/web/src/features/metadata/util/validators.ts
@@ -2,9 +2,12 @@ import { getStore } from 'app/store/nanostores/store';
 import type { LoRA } from 'features/lora/store/loraSlice';
 import type {
   ControlNetConfigMetadata,
+  ControlNetConfigV2Metadata,
   IPAdapterConfigMetadata,
+  IPAdapterConfigV2Metadata,
   MetadataValidateFunc,
   T2IAdapterConfigMetadata,
+  T2IAdapterConfigV2Metadata,
 } from 'features/metadata/types';
 import { InvalidModelConfigError } from 'features/metadata/util/modelFetchingHelpers';
 import type { ParameterSDXLRefinerModel, ParameterVAEModel } from 'features/parameters/types/parameterSchemas';
@@ -108,6 +111,60 @@ const validateIPAdapters: MetadataValidateFunc<IPAdapterConfigMetadata[]> = (ipA
   return new Promise((resolve) => resolve(validatedIPAdapters));
 };
 
+const validateControlNetV2: MetadataValidateFunc<ControlNetConfigV2Metadata> = (controlNet) => {
+  validateBaseCompatibility(controlNet.model?.base, 'ControlNet incompatible with currently-selected model');
+  return new Promise((resolve) => resolve(controlNet));
+};
+
+const validateControlNetsV2: MetadataValidateFunc<ControlNetConfigV2Metadata[]> = (controlNets) => {
+  const validatedControlNets: ControlNetConfigV2Metadata[] = [];
+  controlNets.forEach((controlNet) => {
+    try {
+      validateBaseCompatibility(controlNet.model?.base, 'ControlNet incompatible with currently-selected model');
+      validatedControlNets.push(controlNet);
+    } catch {
+      // This is a no-op - we want to continue validating the rest of the ControlNets, and an empty list is valid.
+    }
+  });
+  return new Promise((resolve) => resolve(validatedControlNets));
+};
+
+const validateT2IAdapterV2: MetadataValidateFunc<T2IAdapterConfigV2Metadata> = (t2iAdapter) => {
+  validateBaseCompatibility(t2iAdapter.model?.base, 'T2I Adapter incompatible with currently-selected model');
+  return new Promise((resolve) => resolve(t2iAdapter));
+};
+
+const validateT2IAdaptersV2: MetadataValidateFunc<T2IAdapterConfigV2Metadata[]> = (t2iAdapters) => {
+  const validatedT2IAdapters: T2IAdapterConfigV2Metadata[] = [];
+  t2iAdapters.forEach((t2iAdapter) => {
+    try {
+      validateBaseCompatibility(t2iAdapter.model?.base, 'T2I Adapter incompatible with currently-selected model');
+      validatedT2IAdapters.push(t2iAdapter);
+    } catch {
+      // This is a no-op - we want to continue validating the rest of the T2I Adapters, and an empty list is valid.
+    }
+  });
+  return new Promise((resolve) => resolve(validatedT2IAdapters));
+};
+
+const validateIPAdapterV2: MetadataValidateFunc<IPAdapterConfigV2Metadata> = (ipAdapter) => {
+  validateBaseCompatibility(ipAdapter.model?.base, 'IP Adapter incompatible with currently-selected model');
+  return new Promise((resolve) => resolve(ipAdapter));
+};
+
+const validateIPAdaptersV2: MetadataValidateFunc<IPAdapterConfigV2Metadata[]> = (ipAdapters) => {
+  const validatedIPAdapters: IPAdapterConfigV2Metadata[] = [];
+  ipAdapters.forEach((ipAdapter) => {
+    try {
+      validateBaseCompatibility(ipAdapter.model?.base, 'IP Adapter incompatible with currently-selected model');
+      validatedIPAdapters.push(ipAdapter);
+    } catch {
+      // This is a no-op - we want to continue validating the rest of the IP Adapters, and an empty list is valid.
+    }
+  });
+  return new Promise((resolve) => resolve(validatedIPAdapters));
+};
+
 export const validators = {
   refinerModel: validateRefinerModel,
   vaeModel: validateVAEModel,
@@ -119,4 +176,10 @@ export const validators = {
   t2iAdapters: validateT2IAdapters,
   ipAdapter: validateIPAdapter,
   ipAdapters: validateIPAdapters,
+  controlNetV2: validateControlNetV2,
+  controlNetsV2: validateControlNetsV2,
+  t2iAdapterV2: validateT2IAdapterV2,
+  t2iAdaptersV2: validateT2IAdaptersV2,
+  ipAdapterV2: validateIPAdapterV2,
+  ipAdaptersV2: validateIPAdaptersV2,
 } as const;
diff --git a/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts b/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts
index 30f954dedb..0069bea881 100644
--- a/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts
+++ b/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts
@@ -43,7 +43,7 @@ export const usePreselectedImage = (selectedImage?: {
 
   const handleUseAllMetadata = useCallback(() => {
     if (selectedImageMetadata) {
-      parseAndRecallAllMetadata(selectedImageMetadata);
+      parseAndRecallAllMetadata(selectedImageMetadata, true);
     }
   }, [selectedImageMetadata]);
 

From 1b13fee2565a62027639a8027a9a1aa87153ab95 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 18:11:09 +1000
Subject: [PATCH 06/59] fix(ui): firefox drawing lag

Firefox v125.0.3 and below has a bug where `mouseenter` events are fired continually during mouse moves. The issue isn't present on FF v126.0b6 Developer Edition. It's not clear if the issue is present on FF nightly, and we're not sure if it will actually be fixed in the stable v126 release.

The control layers drawing logic relied on on `mouseenter` events to create new lines, and `mousemove` to extend existing lines. On the affected version of FF, all line extensions are turned into new lines, resulting in very poor performance, noncontiguous lines, and way-too-big internal state.

To resolve this, the drawing handling was updated to not use `mouseenter` at all. As a bonus, resolving this issue has resulted in simpler logic for drawing on the canvas.
---
 .../components/StageComponent.tsx             | 30 +++----
 .../controlLayers/hooks/mouseEventHooks.ts    | 80 ++++++-------------
 .../controlLayers/store/controlLayersSlice.ts |  3 +-
 .../features/controlLayers/util/renderers.ts  |  3 +-
 4 files changed, 39 insertions(+), 77 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
index ecf1121b41..c66c15d61b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
@@ -7,7 +7,6 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
 import { useMouseEvents } from 'features/controlLayers/hooks/mouseEventHooks';
 import {
   $cursorPosition,
-  $isMouseOver,
   $lastMouseDownPos,
   $tool,
   isRegionalGuidanceLayer,
@@ -48,10 +47,9 @@ const useStageRenderer = (
   const dispatch = useAppDispatch();
   const state = useAppSelector((s) => s.controlLayers.present);
   const tool = useStore($tool);
-  const { onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave, onMouseWheel } = useMouseEvents();
+  const mouseEventHandlers = 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]);
@@ -90,23 +88,21 @@ const useStageRenderer = (
     if (asPreview) {
       return;
     }
-    stage.on('mousedown', onMouseDown);
-    stage.on('mouseup', onMouseUp);
-    stage.on('mousemove', onMouseMove);
-    stage.on('mouseenter', onMouseEnter);
-    stage.on('mouseleave', onMouseLeave);
-    stage.on('wheel', onMouseWheel);
+    stage.on('mousedown', mouseEventHandlers.onMouseDown);
+    stage.on('mouseup', mouseEventHandlers.onMouseUp);
+    stage.on('mousemove', mouseEventHandlers.onMouseMove);
+    stage.on('mouseleave', mouseEventHandlers.onMouseLeave);
+    stage.on('wheel', mouseEventHandlers.onMouseWheel);
 
     return () => {
       log.trace('Cleaning up stage listeners');
-      stage.off('mousedown', onMouseDown);
-      stage.off('mouseup', onMouseUp);
-      stage.off('mousemove', onMouseMove);
-      stage.off('mouseenter', onMouseEnter);
-      stage.off('mouseleave', onMouseLeave);
-      stage.off('wheel', onMouseWheel);
+      stage.off('mousedown', mouseEventHandlers.onMouseDown);
+      stage.off('mouseup', mouseEventHandlers.onMouseUp);
+      stage.off('mousemove', mouseEventHandlers.onMouseMove);
+      stage.off('mouseleave', mouseEventHandlers.onMouseLeave);
+      stage.off('wheel', mouseEventHandlers.onMouseWheel);
     };
-  }, [stage, asPreview, onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave, onMouseWheel]);
+  }, [stage, asPreview, mouseEventHandlers]);
 
   useLayoutEffect(() => {
     log.trace('Updating stage dimensions');
@@ -147,7 +143,6 @@ const useStageRenderer = (
       state.globalMaskLayerOpacity,
       cursorPosition,
       lastMouseDownPos,
-      isMouseOver,
       state.brushSize
     );
   }, [
@@ -159,7 +154,6 @@ const useStageRenderer = (
     state.globalMaskLayerOpacity,
     cursorPosition,
     lastMouseDownPos,
-    isMouseOver,
     state.brushSize,
     renderers,
   ]);
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
index e3e87d0c42..7fe81ad567 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
@@ -4,8 +4,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
 import { calculateNewBrushSize } from 'features/canvas/hooks/useCanvasZoom';
 import {
   $cursorPosition,
-  $isMouseDown,
-  $isMouseOver,
+  $isDrawing,
   $lastMouseDownPos,
   $tool,
   brushSizeChanged,
@@ -21,6 +20,7 @@ import { useCallback, useRef } from 'react';
 const getIsFocused = (stage: Konva.Stage) => {
   return stage.container().contains(document.activeElement);
 };
+const getIsMouseDown = (e: KonvaEventObject<MouseEvent>) => e.evt.buttons === 1;
 
 export const getScaledFlooredCursorPosition = (stage: Konva.Stage) => {
   const pointerPosition = stage.getPointerPosition();
@@ -55,7 +55,7 @@ export const useMouseEvents = () => {
   const brushSize = useAppSelector((s) => s.controlLayers.present.brushSize);
 
   const onMouseDown = useCallback(
-    (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
+    (e: KonvaEventObject<MouseEvent>) => {
       const stage = e.target.getStage();
       if (!stage) {
         return;
@@ -64,7 +64,6 @@ export const useMouseEvents = () => {
       if (!pos) {
         return;
       }
-      $isMouseDown.set(true);
       $lastMouseDownPos.set(pos);
       if (!selectedLayerId) {
         return;
@@ -77,18 +76,18 @@ export const useMouseEvents = () => {
             tool,
           })
         );
+        $isDrawing.set(true);
       }
     },
     [dispatch, selectedLayerId, tool]
   );
 
   const onMouseUp = useCallback(
-    (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
+    (e: KonvaEventObject<MouseEvent>) => {
       const stage = e.target.getStage();
       if (!stage) {
         return;
       }
-      $isMouseDown.set(false);
       const pos = $cursorPosition.get();
       const lastPos = $lastMouseDownPos.get();
       const tool = $tool.get();
@@ -105,13 +104,14 @@ export const useMouseEvents = () => {
           })
         );
       }
+      $isDrawing.set(false);
       $lastMouseDownPos.set(null);
     },
     [dispatch, selectedLayerId]
   );
 
   const onMouseMove = useCallback(
-    (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
+    (e: KonvaEventObject<MouseEvent>) => {
       const stage = e.target.getStage();
       if (!stage) {
         return;
@@ -120,22 +120,29 @@ export const useMouseEvents = () => {
       if (!pos || !selectedLayerId) {
         return;
       }
-      if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) {
-        if (lastCursorPosRef.current) {
-          // Dispatching redux events impacts perf substantially - using brush spacing keeps dispatches to a reasonable number
-          if (Math.hypot(lastCursorPosRef.current[0] - pos.x, lastCursorPosRef.current[1] - pos.y) < BRUSH_SPACING) {
-            return;
+      if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) {
+        if ($isDrawing.get()) {
+          // Continue the last line
+          if (lastCursorPosRef.current) {
+            // Dispatching redux events impacts perf substantially - using brush spacing keeps dispatches to a reasonable number
+            if (Math.hypot(lastCursorPosRef.current[0] - pos.x, lastCursorPosRef.current[1] - pos.y) < BRUSH_SPACING) {
+              return;
+            }
           }
+          lastCursorPosRef.current = [pos.x, pos.y];
+          dispatch(rgLayerPointsAdded({ layerId: selectedLayerId, point: lastCursorPosRef.current }));
+        } else {
+          // Start a new line
+          dispatch(rgLayerLineAdded({ layerId: selectedLayerId, points: [pos.x, pos.y, pos.x, pos.y], tool }));
         }
-        lastCursorPosRef.current = [pos.x, pos.y];
-        dispatch(rgLayerPointsAdded({ layerId: selectedLayerId, point: lastCursorPosRef.current }));
+        $isDrawing.set(true);
       }
     },
     [dispatch, selectedLayerId, tool]
   );
 
   const onMouseLeave = useCallback(
-    (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
+    (e: KonvaEventObject<MouseEvent>) => {
       const stage = e.target.getStage();
       if (!stage) {
         return;
@@ -145,54 +152,17 @@ export const useMouseEvents = () => {
         pos &&
         selectedLayerId &&
         getIsFocused(stage) &&
-        $isMouseOver.get() &&
-        $isMouseDown.get() &&
+        getIsMouseDown(e) &&
         (tool === 'brush' || tool === 'eraser')
       ) {
         dispatch(rgLayerPointsAdded({ layerId: selectedLayerId, point: [pos.x, pos.y] }));
       }
-      $isMouseOver.set(false);
-      $isMouseDown.set(false);
+      $isDrawing.set(false);
       $cursorPosition.set(null);
     },
     [selectedLayerId, tool, dispatch]
   );
 
-  const onMouseEnter = useCallback(
-    (e: KonvaEventObject<MouseEvent>) => {
-      const stage = e.target.getStage();
-      if (!stage) {
-        return;
-      }
-      $isMouseOver.set(true);
-      const pos = syncCursorPos(stage);
-      if (!pos) {
-        return;
-      }
-      if (!getIsFocused(stage)) {
-        return;
-      }
-      if (e.evt.buttons !== 1) {
-        $isMouseDown.set(false);
-      } else {
-        $isMouseDown.set(true);
-        if (!selectedLayerId) {
-          return;
-        }
-        if (tool === 'brush' || tool === 'eraser') {
-          dispatch(
-            rgLayerLineAdded({
-              layerId: selectedLayerId,
-              points: [pos.x, pos.y, pos.x, pos.y],
-              tool,
-            })
-          );
-        }
-      }
-    },
-    [dispatch, selectedLayerId, tool]
-  );
-
   const onMouseWheel = useCallback(
     (e: KonvaEventObject<WheelEvent>) => {
       e.evt.preventDefault();
@@ -213,5 +183,5 @@ export const useMouseEvents = () => {
     [shouldInvertBrushSizeScrollDirection, brushSize, dispatch]
   );
 
-  return { onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave, onMouseWheel };
+  return { onMouseDown, onMouseUp, onMouseMove, onMouseLeave, onMouseWheel };
 };
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index 7c8d6a895c..0b17b6bc9c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -801,8 +801,7 @@ const migrateControlLayersState = (state: any): any => {
   return state;
 };
 
-export const $isMouseDown = atom(false);
-export const $isMouseOver = atom(false);
+export const $isDrawing = atom(false);
 export const $lastMouseDownPos = atom<Vector2d | null>(null);
 export const $tool = atom<Tool>('brush');
 export const $cursorPosition = atom<Vector2d | null>(null);
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
index 85f48baa6e..12091d3e8b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
@@ -137,7 +137,6 @@ const renderToolPreview = (
   globalMaskLayerOpacity: number,
   cursorPos: Vector2d | null,
   lastMouseDownPos: Vector2d | null,
-  isMouseOver: boolean,
   brushSize: number
 ) => {
   const layerCount = stage.find(`.${RG_LAYER_NAME}`).length;
@@ -161,7 +160,7 @@ const renderToolPreview = (
 
   const toolPreviewLayer = stage.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`) ?? createToolPreviewLayer(stage);
 
-  if (!isMouseOver || layerCount === 0) {
+  if (!cursorPos || layerCount === 0) {
     // We can bail early if the mouse isn't over the stage or there are no layers
     toolPreviewLayer.visible(false);
     return;

From 474eab6f8acb106e986aca90cf73dfff7fbfd15a Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 18:28:46 +1000
Subject: [PATCH 07/59] fix(ui): clamp incoming w/h to ensure always a multiple
 of 8

When recalling metadata and/or using control image dimensions, it was possible to set a width or height that was not a multiple of 8, resulting in generation failures.

Added a `clamp` option to the w/h actions to fix this. The option is used for all untrusted sources - everything except for the w/h number inputs, which clamp the values themselves.
---
 .../listeners/setDefaultSettings.ts             |  6 +++---
 .../components/ControlAdapterImagePreview.tsx   |  5 +++--
 .../ControlAdapterImagePreview.tsx              | 10 ++++++----
 .../IPAdapterImagePreview.tsx                   |  9 +++++----
 .../controlLayers/store/controlLayersSlice.ts   | 17 +++++++++--------
 .../web/src/features/metadata/util/recallers.ts |  6 ++++--
 6 files changed, 30 insertions(+), 23 deletions(-)

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 6f3aa9756a..61a978d576 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
@@ -96,16 +96,16 @@ export const addSetDefaultSettingsListener = (startAppListening: AppStartListeni
             dispatch(setScheduler(scheduler));
           }
         }
-
+        const setSizeOptions = { updateAspectRatio: true, clamp: true };
         if (width) {
           if (isParameterWidth(width)) {
-            dispatch(widthChanged({ width, updateAspectRatio: true }));
+            dispatch(widthChanged({ width, ...setSizeOptions }));
           }
         }
 
         if (height) {
           if (isParameterHeight(height)) {
-            dispatch(heightChanged({ height, updateAspectRatio: true }));
+            dispatch(heightChanged({ height, ...setSizeOptions }));
           }
         }
 
diff --git a/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx
index 56589fe613..1360c76240 100644
--- a/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx
@@ -96,12 +96,13 @@ const ControlAdapterImagePreview = ({ isSmall, id }: Props) => {
     if (activeTabName === 'unifiedCanvas') {
       dispatch(setBoundingBoxDimensions({ width: controlImage.width, height: controlImage.height }, optimalDimension));
     } else {
+      const options = { updateAspectRatio: true, clamp: true };
       const { width, height } = calculateNewSize(
         controlImage.width / controlImage.height,
         optimalDimension * optimalDimension
       );
-      dispatch(widthChanged({ width, updateAspectRatio: true }));
-      dispatch(heightChanged({ height, updateAspectRatio: true }));
+      dispatch(widthChanged({ width, ...options }));
+      dispatch(heightChanged({ height, ...options }));
     }
   }, [controlImage, activeTabName, dispatch, optimalDimension]);
 
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
index 675118c534..81bd7b14f2 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
@@ -85,17 +85,19 @@ export const ControlAdapterImagePreview = memo(
           setBoundingBoxDimensions({ width: controlImage.width, height: controlImage.height }, optimalDimension)
         );
       } else {
+        const options = { updateAspectRatio: true, clamp: true };
+
         if (shift) {
           const { width, height } = controlImage;
-          dispatch(widthChanged({ width, updateAspectRatio: true }));
-          dispatch(heightChanged({ height, updateAspectRatio: true }));
+          dispatch(widthChanged({ width, ...options }));
+          dispatch(heightChanged({ height, ...options }));
         } else {
           const { width, height } = calculateNewSize(
             controlImage.width / controlImage.height,
             optimalDimension * optimalDimension
           );
-          dispatch(widthChanged({ width, updateAspectRatio: true }));
-          dispatch(heightChanged({ height, updateAspectRatio: true }));
+          dispatch(widthChanged({ width, ...options }));
+          dispatch(heightChanged({ height, ...options }));
         }
       }
     }, [controlImage, activeTabName, dispatch, optimalDimension, shift]);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterImagePreview.tsx
index 7de726cda5..f73f61cbbd 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterImagePreview.tsx
@@ -51,17 +51,18 @@ export const IPAdapterImagePreview = memo(
           setBoundingBoxDimensions({ width: controlImage.width, height: controlImage.height }, optimalDimension)
         );
       } else {
+        const options = { updateAspectRatio: true, clamp: true };
         if (shift) {
           const { width, height } = controlImage;
-          dispatch(widthChanged({ width, updateAspectRatio: true }));
-          dispatch(heightChanged({ height, updateAspectRatio: true }));
+          dispatch(widthChanged({ width, ...options }));
+          dispatch(heightChanged({ height, ...options }));
         } else {
           const { width, height } = calculateNewSize(
             controlImage.width / controlImage.height,
             optimalDimension * optimalDimension
           );
-          dispatch(widthChanged({ width, updateAspectRatio: true }));
-          dispatch(heightChanged({ height, updateAspectRatio: true }));
+          dispatch(widthChanged({ width, ...options }));
+          dispatch(heightChanged({ height, ...options }));
         }
       }
     }, [controlImage, activeTabName, dispatch, optimalDimension, shift]);
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index 0b17b6bc9c..9f36401e83 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -3,6 +3,7 @@ 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 { roundDownToMultiple } from 'common/util/roundDownToMultiple';
 import type {
   CLIPVisionModelV2,
   ControlModeV2,
@@ -626,20 +627,20 @@ export const controlLayersSlice = createSlice({
     shouldConcatPromptsChanged: (state, action: PayloadAction<boolean>) => {
       state.shouldConcatPrompts = action.payload;
     },
-    widthChanged: (state, action: PayloadAction<{ width: number; updateAspectRatio?: boolean }>) => {
-      const { width, updateAspectRatio } = action.payload;
-      state.size.width = width;
+    widthChanged: (state, action: PayloadAction<{ width: number; updateAspectRatio?: boolean; clamp?: boolean }>) => {
+      const { width, updateAspectRatio, clamp } = action.payload;
+      state.size.width = clamp ? Math.max(roundDownToMultiple(width, 8), 64) : width;
       if (updateAspectRatio) {
-        state.size.aspectRatio.value = width / state.size.height;
+        state.size.aspectRatio.value = state.size.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;
+    heightChanged: (state, action: PayloadAction<{ height: number; updateAspectRatio?: boolean; clamp?: boolean }>) => {
+      const { height, updateAspectRatio, clamp } = action.payload;
+      state.size.height = clamp ? Math.max(roundDownToMultiple(height, 8), 64) : height;
       if (updateAspectRatio) {
-        state.size.aspectRatio.value = state.size.width / height;
+        state.size.aspectRatio.value = state.size.width / state.size.height;
         state.size.aspectRatio.id = 'Free';
         state.size.aspectRatio.isLocked = false;
       }
diff --git a/invokeai/frontend/web/src/features/metadata/util/recallers.ts b/invokeai/frontend/web/src/features/metadata/util/recallers.ts
index a7b0cd5b2e..c04259ac62 100644
--- a/invokeai/frontend/web/src/features/metadata/util/recallers.ts
+++ b/invokeai/frontend/web/src/features/metadata/util/recallers.ts
@@ -110,12 +110,14 @@ const recallInitialImage: MetadataRecallFunc<ImageDTO> = async (imageDTO) => {
   getStore().dispatch(initialImageChanged(imageDTO));
 };
 
+const setSizeOptions = { updateAspectRatio: true, clamp: true };
+
 const recallWidth: MetadataRecallFunc<ParameterWidth> = (width) => {
-  getStore().dispatch(widthChanged({ width, updateAspectRatio: true }));
+  getStore().dispatch(widthChanged({ width, ...setSizeOptions }));
 };
 
 const recallHeight: MetadataRecallFunc<ParameterHeight> = (height) => {
-  getStore().dispatch(heightChanged({ height, updateAspectRatio: true }));
+  getStore().dispatch(heightChanged({ height, ...setSizeOptions }));
 };
 
 const recallSteps: MetadataRecallFunc<ParameterSteps> = (steps) => {

From d55ea318ec76961b50d2f2b28c2c37af87053104 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 19:53:56 +1000
Subject: [PATCH 08/59] tidy(ui): remove unused gallery hotkeys

---
 .../system/components/HotkeysModal/useHotkeyData.ts    | 10 ----------
 1 file changed, 10 deletions(-)

diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
index f9a0df52e4..84aa632ea9 100644
--- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
@@ -140,16 +140,6 @@ export const useHotkeyData = (): HotkeyGroup[] => {
           desc: t('hotkeys.nextImage.desc'),
           hotkeys: [['Arrow Right']],
         },
-        {
-          title: t('hotkeys.increaseGalleryThumbSize.title'),
-          desc: t('hotkeys.increaseGalleryThumbSize.desc'),
-          hotkeys: [['Shift', 'Up']],
-        },
-        {
-          title: t('hotkeys.decreaseGalleryThumbSize.title'),
-          desc: t('hotkeys.decreaseGalleryThumbSize.desc'),
-          hotkeys: [['Shift', 'Down']],
-        },
       ],
     }),
     [t]

From d67480d92ca56f33a2fdc4e23cea36fa85ba7a42 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 20:52:49 +1000
Subject: [PATCH 09/59] feat(ui): add layerwrapper component

---
 .../components/CALayer/CALayer.tsx            | 40 +++++++---------
 .../components/IPALayer/IPALayer.tsx          | 27 +++++------
 .../components/LayerCommon/LayerWrapper.tsx   | 21 +++++++++
 .../components/RGLayer/RGLayer.tsx            | 47 +++++++++----------
 4 files changed, 73 insertions(+), 62 deletions(-)
 create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx

diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayer.tsx
index 24de817df2..984331a050 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayer.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayer.tsx
@@ -5,6 +5,7 @@ import { LayerDeleteButton } from 'features/controlLayers/components/LayerCommon
 import { LayerMenu } from 'features/controlLayers/components/LayerCommon/LayerMenu';
 import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle';
 import { LayerVisibilityToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle';
+import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper';
 import { layerSelected, selectCALayerOrThrow } from 'features/controlLayers/store/controlLayersSlice';
 import { memo, useCallback } from 'react';
 
@@ -17,37 +18,28 @@ type Props = {
 export const CALayer = memo(({ layerId }: Props) => {
   const dispatch = useAppDispatch();
   const isSelected = useAppSelector((s) => selectCALayerOrThrow(s.controlLayers.present, layerId).isSelected);
-  const onClickCapture = useCallback(() => {
+  const onClick = useCallback(() => {
     // Must be capture so that the layer is selected before deleting/resetting/etc
     dispatch(layerSelected(layerId));
   }, [dispatch, layerId]);
   const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true });
 
   return (
-    <Flex
-      gap={2}
-      onClickCapture={onClickCapture}
-      bg={isSelected ? 'base.400' : 'base.800'}
-      px={2}
-      borderRadius="base"
-      py="1px"
-    >
-      <Flex flexDir="column" w="full" bg="base.850" borderRadius="base">
-        <Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}>
-          <LayerVisibilityToggle layerId={layerId} />
-          <LayerTitle type="control_adapter_layer" />
-          <Spacer />
-          <CALayerOpacity layerId={layerId} />
-          <LayerMenu layerId={layerId} />
-          <LayerDeleteButton layerId={layerId} />
-        </Flex>
-        {isOpen && (
-          <Flex flexDir="column" gap={3} px={3} pb={3}>
-            <CALayerControlAdapterWrapper layerId={layerId} />
-          </Flex>
-        )}
+    <LayerWrapper onClick={onClick} borderColor={isSelected ? 'base.400' : 'base.800'}>
+      <Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}>
+        <LayerVisibilityToggle layerId={layerId} />
+        <LayerTitle type="control_adapter_layer" />
+        <Spacer />
+        <CALayerOpacity layerId={layerId} />
+        <LayerMenu layerId={layerId} />
+        <LayerDeleteButton layerId={layerId} />
       </Flex>
-    </Flex>
+      {isOpen && (
+        <Flex flexDir="column" gap={3} px={3} pb={3}>
+          <CALayerControlAdapterWrapper layerId={layerId} />
+        </Flex>
+      )}
+    </LayerWrapper>
   );
 });
 
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayer.tsx
index 715e538679..02a161608d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayer.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IPALayer/IPALayer.tsx
@@ -3,6 +3,7 @@ import { IPALayerIPAdapterWrapper } from 'features/controlLayers/components/IPAL
 import { LayerDeleteButton } from 'features/controlLayers/components/LayerCommon/LayerDeleteButton';
 import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle';
 import { LayerVisibilityToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle';
+import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper';
 import { memo } from 'react';
 
 type Props = {
@@ -12,21 +13,19 @@ type Props = {
 export const IPALayer = memo(({ layerId }: Props) => {
   const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true });
   return (
-    <Flex gap={2} bg="base.800" borderRadius="base" p="1px" px={2}>
-      <Flex flexDir="column" w="full" bg="base.850" borderRadius="base">
-        <Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}>
-          <LayerVisibilityToggle layerId={layerId} />
-          <LayerTitle type="ip_adapter_layer" />
-          <Spacer />
-          <LayerDeleteButton layerId={layerId} />
-        </Flex>
-        {isOpen && (
-          <Flex flexDir="column" gap={3} px={3} pb={3}>
-            <IPALayerIPAdapterWrapper layerId={layerId} />
-          </Flex>
-        )}
+    <LayerWrapper borderColor="base.800">
+      <Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}>
+        <LayerVisibilityToggle layerId={layerId} />
+        <LayerTitle type="ip_adapter_layer" />
+        <Spacer />
+        <LayerDeleteButton layerId={layerId} />
       </Flex>
-    </Flex>
+      {isOpen && (
+        <Flex flexDir="column" gap={3} px={3} pb={3}>
+          <IPALayerIPAdapterWrapper layerId={layerId} />
+        </Flex>
+      )}
+    </LayerWrapper>
   );
 });
 
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx
new file mode 100644
index 0000000000..9d5fb6ea4b
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx
@@ -0,0 +1,21 @@
+import type { ChakraProps } from '@invoke-ai/ui-library';
+import { Flex } from '@invoke-ai/ui-library';
+import type { PropsWithChildren } from 'react';
+import { memo } from 'react';
+
+type Props = PropsWithChildren<{
+  onClick?: () => void;
+  borderColor: ChakraProps['bg'];
+}>;
+
+export const LayerWrapper = memo(({ onClick, borderColor, children }: Props) => {
+  return (
+    <Flex gap={2} onClick={onClick} bg={borderColor} px={2} borderRadius="base" py="1px">
+      <Flex flexDir="column" w="full" bg="base.850" borderRadius="base">
+        {children}
+      </Flex>
+    </Flex>
+  );
+});
+
+LayerWrapper.displayName = 'LayerWrapper';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayer.tsx
index baed22f6ca..a6bce5316e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayer.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RGLayer/RGLayer.tsx
@@ -7,6 +7,7 @@ import { LayerDeleteButton } from 'features/controlLayers/components/LayerCommon
 import { LayerMenu } from 'features/controlLayers/components/LayerCommon/LayerMenu';
 import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle';
 import { LayerVisibilityToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle';
+import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper';
 import {
   isRegionalGuidanceLayer,
   layerSelected,
@@ -52,32 +53,30 @@ export const RGLayer = memo(({ layerId }: Props) => {
     dispatch(layerSelected(layerId));
   }, [dispatch, layerId]);
   return (
-    <Flex gap={2} onClick={onClick} bg={isSelected ? color : 'base.800'} px={2} borderRadius="base" py="1px">
-      <Flex flexDir="column" w="full" bg="base.850" borderRadius="base">
-        <Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}>
-          <LayerVisibilityToggle layerId={layerId} />
-          <LayerTitle type="regional_guidance_layer" />
-          <Spacer />
-          {autoNegative === 'invert' && (
-            <Badge color="base.300" bg="transparent" borderWidth={1} userSelect="none">
-              {t('controlLayers.autoNegative')}
-            </Badge>
-          )}
-          <RGLayerColorPicker layerId={layerId} />
-          <RGLayerSettingsPopover layerId={layerId} />
-          <LayerMenu layerId={layerId} />
-          <LayerDeleteButton layerId={layerId} />
-        </Flex>
-        {isOpen && (
-          <Flex flexDir="column" gap={3} px={3} pb={3}>
-            {!hasPositivePrompt && !hasNegativePrompt && !hasIPAdapters && <AddPromptButtons layerId={layerId} />}
-            {hasPositivePrompt && <RGLayerPositivePrompt layerId={layerId} />}
-            {hasNegativePrompt && <RGLayerNegativePrompt layerId={layerId} />}
-            {hasIPAdapters && <RGLayerIPAdapterList layerId={layerId} />}
-          </Flex>
+    <LayerWrapper onClick={onClick} borderColor={isSelected ? color : 'base.800'}>
+      <Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}>
+        <LayerVisibilityToggle layerId={layerId} />
+        <LayerTitle type="regional_guidance_layer" />
+        <Spacer />
+        {autoNegative === 'invert' && (
+          <Badge color="base.300" bg="transparent" borderWidth={1} userSelect="none">
+            {t('controlLayers.autoNegative')}
+          </Badge>
         )}
+        <RGLayerColorPicker layerId={layerId} />
+        <RGLayerSettingsPopover layerId={layerId} />
+        <LayerMenu layerId={layerId} />
+        <LayerDeleteButton layerId={layerId} />
       </Flex>
-    </Flex>
+      {isOpen && (
+        <Flex flexDir="column" gap={3} px={3} pb={3}>
+          {!hasPositivePrompt && !hasNegativePrompt && !hasIPAdapters && <AddPromptButtons layerId={layerId} />}
+          {hasPositivePrompt && <RGLayerPositivePrompt layerId={layerId} />}
+          {hasNegativePrompt && <RGLayerNegativePrompt layerId={layerId} />}
+          {hasIPAdapters && <RGLayerIPAdapterList layerId={layerId} />}
+        </Flex>
+      )}
+    </LayerWrapper>
   );
 });
 

From 1d213067e8a61969b7cc440fca185d3df0781c4d Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 21:12:58 +1000
Subject: [PATCH 10/59] feat(ui): add initial image layer to CL

---
 invokeai/frontend/web/public/locales/en.json  |   2 +
 .../listeners/imageDropped.ts                 |  19 +++
 .../listeners/imageUploaded.ts                |  12 ++
 .../src/common/hooks/useIsReadyToEnqueue.ts   |   3 +-
 .../components/AddLayerButton.tsx             |   6 +-
 .../components/ControlLayersPanelContent.tsx  |   4 +
 .../components/IILayer/IILayer.tsx            |  81 +++++++++++++
 .../IILayer/InitialImagePreview.tsx           | 109 ++++++++++++++++++
 .../components/LayerCommon/LayerMenu.tsx      |   4 +-
 .../components/LayerCommon/LayerTitle.tsx     |   2 +
 .../controlLayers/hooks/addLayerHooks.ts      |  18 ++-
 .../controlLayers/store/controlLayersSlice.ts |  57 ++++++++-
 .../src/features/controlLayers/store/types.ts |   8 +-
 .../web/src/features/dnd/types/index.ts       |  10 +-
 .../web/src/features/dnd/util/isValidDrop.ts  |   2 +
 .../frontend/web/src/services/api/types.ts    |   8 +-
 16 files changed, 335 insertions(+), 10 deletions(-)
 create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx
 create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/IILayer/InitialImagePreview.tsx

diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index c80283b664..c211b4a574 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1543,6 +1543,8 @@
         "globalControlAdapterLayer": "Global $t(controlnet.controlAdapter_one) $t(unifiedCanvas.layer)",
         "globalIPAdapter": "Global $t(common.ipAdapter)",
         "globalIPAdapterLayer": "Global $t(common.ipAdapter) $t(unifiedCanvas.layer)",
+        "globalInitialImage": "Global Initial Image",
+        "globalInitialImageLayer": "$t(controlLayers.globalInitialImage) $t(unifiedCanvas.layer)",
         "opacityFilter": "Opacity Filter",
         "clearProcessor": "Clear Processor",
         "resetProcessor": "Reset Processor to Defaults"
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 5db78ed75e..734867d0e1 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
@@ -9,6 +9,7 @@ import {
 } from 'features/controlAdapters/store/controlAdaptersSlice';
 import {
   caLayerImageChanged,
+  iiLayerImageChanged,
   ipaLayerImageChanged,
   rgLayerIPAdapterImageChanged,
 } from 'features/controlLayers/store/controlLayersSlice';
@@ -143,6 +144,24 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
         return;
       }
 
+      /**
+       * Image dropped on II Layer Image
+       */
+      if (
+        overData.actionType === 'SET_II_LAYER_IMAGE' &&
+        activeData.payloadType === 'IMAGE_DTO' &&
+        activeData.payload.imageDTO
+      ) {
+        const { layerId } = overData.context;
+        dispatch(
+          iiLayerImageChanged({
+            layerId,
+            imageDTO: activeData.payload.imageDTO,
+          })
+        );
+        return;
+      }
+
       /**
        * Image dropped on Canvas
        */
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 d0edfafd57..8f93d023ce 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
@@ -8,6 +8,7 @@ import {
 } from 'features/controlAdapters/store/controlAdaptersSlice';
 import {
   caLayerImageChanged,
+  iiLayerImageChanged,
   ipaLayerImageChanged,
   rgLayerIPAdapterImageChanged,
 } from 'features/controlLayers/store/controlLayersSlice';
@@ -146,6 +147,17 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis
         );
       }
 
+      if (postUploadAction?.type === 'SET_II_LAYER_IMAGE') {
+        const { layerId } = postUploadAction;
+        dispatch(iiLayerImageChanged({ layerId, imageDTO }));
+        dispatch(
+          addToast({
+            ...DEFAULT_UPLOADED_TOAST,
+            description: t('toast.setControlImage'),
+          })
+        );
+      }
+
       if (postUploadAction?.type === 'SET_INITIAL_IMAGE') {
         dispatch(initialImageChanged(imageDTO));
         dispatch(
diff --git a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts
index 6073564305..d06fc259df 100644
--- a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts
+++ b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts
@@ -16,7 +16,6 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
 import i18n from 'i18next';
 import { forEach } from 'lodash-es';
 import { getConnectedEdges } from 'reactflow';
-import { assert } from 'tsafe';
 
 const selector = createMemoizedSelector(
   [
@@ -110,7 +109,7 @@ const selector = createMemoizedSelector(
             } else if (l.type === 'regional_guidance_layer') {
               return l.ipAdapters;
             }
-            assert(false);
+            return [];
           })
           .forEach((ca, i) => {
             const hasNoModel = !ca.model;
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx
index 3eb97dddff..3102e4afa8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx
@@ -1,6 +1,6 @@
 import { Button, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
 import { useAppDispatch } from 'app/store/storeHooks';
-import { useAddCALayer, useAddIPALayer } from 'features/controlLayers/hooks/addLayerHooks';
+import { useAddCALayer, useAddIILayer, useAddIPALayer } from 'features/controlLayers/hooks/addLayerHooks';
 import { rgLayerAdded } from 'features/controlLayers/store/controlLayersSlice';
 import { memo, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
@@ -11,6 +11,7 @@ export const AddLayerButton = memo(() => {
   const dispatch = useAppDispatch();
   const [addCALayer, isAddCALayerDisabled] = useAddCALayer();
   const [addIPALayer, isAddIPALayerDisabled] = useAddIPALayer();
+  const [addIILayer, isAddIILayerDisabled] = useAddIILayer();
   const addRGLayer = useCallback(() => {
     dispatch(rgLayerAdded());
   }, [dispatch]);
@@ -30,6 +31,9 @@ export const AddLayerButton = memo(() => {
         <MenuItem icon={<PiPlusBold />} onClick={addIPALayer} isDisabled={isAddIPALayerDisabled}>
           {t('controlLayers.globalIPAdapterLayer')}
         </MenuItem>
+        <MenuItem icon={<PiPlusBold />} onClick={addIILayer} isDisabled={isAddIILayerDisabled}>
+          {t('controlLayers.globalInitialImageLayer')}
+        </MenuItem>
       </MenuList>
     </Menu>
   );
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
index ffa2856116..14bea9bc1e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
@@ -6,6 +6,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon
 import { AddLayerButton } from 'features/controlLayers/components/AddLayerButton';
 import { CALayer } from 'features/controlLayers/components/CALayer/CALayer';
 import { DeleteAllLayersButton } from 'features/controlLayers/components/DeleteAllLayersButton';
+import { IILayer } from 'features/controlLayers/components/IILayer/IILayer';
 import { IPALayer } from 'features/controlLayers/components/IPALayer/IPALayer';
 import { RGLayer } from 'features/controlLayers/components/RGLayer/RGLayer';
 import { isRenderableLayer, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
@@ -54,6 +55,9 @@ const LayerWrapper = memo(({ id, type }: LayerWrapperProps) => {
   if (type === 'ip_adapter_layer') {
     return <IPALayer key={id} layerId={id} />;
   }
+  if (type === 'initial_image_layer') {
+    return <IILayer key={id} layerId={id} />;
+  }
 });
 
 LayerWrapper.displayName = 'LayerWrapper';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx
new file mode 100644
index 0000000000..3c54bffb6c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx
@@ -0,0 +1,81 @@
+import { Flex, Spacer, useDisclosure } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { InitialImagePreview } from 'features/controlLayers/components/IILayer/InitialImagePreview';
+import { LayerDeleteButton } from 'features/controlLayers/components/LayerCommon/LayerDeleteButton';
+import { LayerMenu } from 'features/controlLayers/components/LayerCommon/LayerMenu';
+import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle';
+import { LayerVisibilityToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle';
+import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper';
+import {
+  iiLayerImageChanged,
+  layerSelected,
+  selectIILayerOrThrow,
+} from 'features/controlLayers/store/controlLayersSlice';
+import type { IILayerImageDropData } from 'features/dnd/types';
+import ImageToImageStrength from 'features/parameters/components/ImageToImage/ImageToImageStrength';
+import { memo, useCallback, useMemo } from 'react';
+import type { IILayerImagePostUploadAction, ImageDTO } from 'services/api/types';
+
+type Props = {
+  layerId: string;
+};
+
+export const IILayer = memo(({ layerId }: Props) => {
+  const dispatch = useAppDispatch();
+  const layer = useAppSelector((s) => selectIILayerOrThrow(s.controlLayers.present, layerId));
+  const onClick = useCallback(() => {
+    dispatch(layerSelected(layerId));
+  }, [dispatch, layerId]);
+  const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true });
+
+  const onChangeImage = useCallback(
+    (imageDTO: ImageDTO | null) => {
+      dispatch(iiLayerImageChanged({ layerId, imageDTO }));
+    },
+    [dispatch, layerId]
+  );
+
+  const droppableData = useMemo<IILayerImageDropData>(
+    () => ({
+      actionType: 'SET_II_LAYER_IMAGE',
+      context: {
+        layerId,
+      },
+      id: layerId,
+    }),
+    [layerId]
+  );
+
+  const postUploadAction = useMemo<IILayerImagePostUploadAction>(
+    () => ({
+      layerId,
+      type: 'SET_II_LAYER_IMAGE',
+    }),
+    [layerId]
+  );
+
+  return (
+    <LayerWrapper onClick={onClick} borderColor={layer.isSelected ? 'base.400' : 'base.800'}>
+      <Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}>
+        <LayerVisibilityToggle layerId={layerId} />
+        <LayerTitle type="initial_image_layer" />
+        <Spacer />
+        <LayerMenu layerId={layerId} />
+        <LayerDeleteButton layerId={layerId} />
+      </Flex>
+      {isOpen && (
+        <Flex flexDir="column" gap={3} px={3} pb={3}>
+          <ImageToImageStrength />
+          <InitialImagePreview
+            image={layer.image}
+            onChangeImage={onChangeImage}
+            droppableData={droppableData}
+            postUploadAction={postUploadAction}
+          />
+        </Flex>
+      )}
+    </LayerWrapper>
+  );
+});
+
+IILayer.displayName = 'IILayer';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/InitialImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/InitialImagePreview.tsx
new file mode 100644
index 0000000000..740e81cbde
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/InitialImagePreview.tsx
@@ -0,0 +1,109 @@
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+import { Flex, useShiftModifier } from '@invoke-ai/ui-library';
+import { skipToken } from '@reduxjs/toolkit/query';
+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 { heightChanged, widthChanged } from 'features/controlLayers/store/controlLayersSlice';
+import type { ImageWithDims } from 'features/controlLayers/util/controlAdapters';
+import type { ImageDraggableData, 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 } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiArrowCounterClockwiseBold, PiRulerBold } from 'react-icons/pi';
+import { useGetImageDTOQuery } from 'services/api/endpoints/images';
+import type { ImageDTO, PostUploadAction } from 'services/api/types';
+
+type Props = {
+  image: ImageWithDims | null;
+  onChangeImage: (imageDTO: ImageDTO | null) => void;
+  droppableData: TypesafeDroppableData;
+  postUploadAction: PostUploadAction;
+};
+
+export const InitialImagePreview = memo(({ image, onChangeImage, droppableData, postUploadAction }: Props) => {
+  const { t } = useTranslation();
+  const dispatch = useAppDispatch();
+  const isConnected = useAppSelector((s) => s.system.isConnected);
+  const activeTabName = useAppSelector(activeTabNameSelector);
+  const optimalDimension = useAppSelector(selectOptimalDimension);
+  const shift = useShiftModifier();
+
+  const { currentData: imageDTO, isError: isErrorControlImage } = useGetImageDTOQuery(image?.imageName ?? skipToken);
+
+  const onReset = useCallback(() => {
+    onChangeImage(null);
+  }, [onChangeImage]);
+
+  const onUseSize = useCallback(() => {
+    if (!imageDTO) {
+      return;
+    }
+
+    if (activeTabName === 'unifiedCanvas') {
+      dispatch(setBoundingBoxDimensions({ width: imageDTO.width, height: imageDTO.height }, optimalDimension));
+    } else {
+      const options = { updateAspectRatio: true, clamp: true };
+      if (shift) {
+        const { width, height } = imageDTO;
+        dispatch(widthChanged({ width, ...options }));
+        dispatch(heightChanged({ height, ...options }));
+      } else {
+        const { width, height } = calculateNewSize(
+          imageDTO.width / imageDTO.height,
+          optimalDimension * optimalDimension
+        );
+        dispatch(widthChanged({ width, ...options }));
+        dispatch(heightChanged({ height, ...options }));
+      }
+    }
+  }, [imageDTO, activeTabName, dispatch, optimalDimension, shift]);
+
+  const draggableData = useMemo<ImageDraggableData | undefined>(() => {
+    if (imageDTO) {
+      return {
+        id: 'initial_image_layer',
+        payloadType: 'IMAGE_DTO',
+        payload: { imageDTO: imageDTO },
+      };
+    }
+  }, [imageDTO]);
+
+  useEffect(() => {
+    if (isConnected && isErrorControlImage) {
+      onReset();
+    }
+  }, [onReset, isConnected, isErrorControlImage]);
+
+  return (
+    <Flex position="relative" w="full" h={36} alignItems="center" justifyContent="center">
+      <IAIDndImage
+        draggableData={draggableData}
+        droppableData={droppableData}
+        imageDTO={imageDTO}
+        postUploadAction={postUploadAction}
+      />
+
+      <>
+        <IAIDndImageIcon
+          onClick={onReset}
+          icon={imageDTO ? <PiArrowCounterClockwiseBold size={16} /> : undefined}
+          tooltip={t('controlnet.resetControlImage')}
+        />
+        <IAIDndImageIcon
+          onClick={onUseSize}
+          icon={imageDTO ? <PiRulerBold size={16} /> : undefined}
+          tooltip={shift ? t('controlnet.setControlImageDimensionsForce') : t('controlnet.setControlImageDimensions')}
+          styleOverrides={useSizeStyleOverrides}
+        />
+      </>
+    </Flex>
+  );
+});
+
+InitialImagePreview.displayName = 'InitialImagePreview';
+
+const useSizeStyleOverrides: SystemStyleObject = { mt: 6 };
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenu.tsx
index b83f48188f..12074d12b8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenu.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenu.tsx
@@ -37,7 +37,9 @@ export const LayerMenu = memo(({ layerId }: Props) => {
             <MenuDivider />
           </>
         )}
-        {(layerType === 'regional_guidance_layer' || layerType === 'control_adapter_layer') && (
+        {(layerType === 'regional_guidance_layer' ||
+          layerType === 'control_adapter_layer' ||
+          layerType === 'initial_image_layer') && (
           <>
             <LayerMenuArrangeActions layerId={layerId} />
             <MenuDivider />
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerTitle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerTitle.tsx
index ec13ff7bcc..b29c3753fc 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerTitle.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerTitle.tsx
@@ -16,6 +16,8 @@ export const LayerTitle = memo(({ type }: Props) => {
       return t('controlLayers.globalControlAdapter');
     } else if (type === 'ip_adapter_layer') {
       return t('controlLayers.globalIPAdapter');
+    } else if (type === 'initial_image_layer') {
+      return t('controlLayers.globalInitialImage');
     }
   }, [t, type]);
 
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
index 7a4e7ebc09..dcbbeb8db5 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
@@ -1,5 +1,11 @@
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { caLayerAdded, ipaLayerAdded, rgLayerIPAdapterAdded } from 'features/controlLayers/store/controlLayersSlice';
+import {
+  caLayerAdded,
+  iiLayerAdded,
+  ipaLayerAdded,
+  isInitialImageLayer,
+  rgLayerIPAdapterAdded,
+} from 'features/controlLayers/store/controlLayersSlice';
 import {
   buildControlNet,
   buildIPAdapter,
@@ -93,3 +99,13 @@ export const useAddIPAdapterToIPALayer = (layerId: string) => {
 
   return [addIPAdapter, isDisabled] as const;
 };
+
+export const useAddIILayer = () => {
+  const dispatch = useAppDispatch();
+  const isDisabled = useAppSelector((s) => Boolean(s.controlLayers.present.layers.find(isInitialImageLayer)));
+  const addIILayer = useCallback(() => {
+    dispatch(iiLayerAdded(null));
+  }, [dispatch]);
+
+  return [addIILayer, isDisabled] as const;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index 9f36401e83..bd130a0236 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -39,6 +39,7 @@ import type {
   ControlAdapterLayer,
   ControlLayersState,
   DrawingTool,
+  InitialImageLayer,
   IPAdapterLayer,
   Layer,
   RegionalGuidanceLayer,
@@ -71,8 +72,13 @@ export const isRegionalGuidanceLayer = (layer?: Layer): layer is RegionalGuidanc
 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';
+export const isInitialImageLayer = (layer?: Layer): layer is InitialImageLayer => layer?.type === 'initial_image_layer';
+export const isRenderableLayer = (
+  layer?: Layer
+): layer is RegionalGuidanceLayer | ControlAdapterLayer | InitialImageLayer =>
+  layer?.type === 'regional_guidance_layer' ||
+  layer?.type === 'control_adapter_layer' ||
+  layer?.type === 'initial_image_layer';
 const resetLayer = (layer: Layer) => {
   if (layer.type === 'regional_guidance_layer') {
     layer.maskObjects = [];
@@ -94,6 +100,11 @@ export const selectIPALayerOrThrow = (state: ControlLayersState, layerId: string
   assert(isIPAdapterLayer(layer));
   return layer;
 };
+export const selectIILayerOrThrow = (state: ControlLayersState, layerId: string): InitialImageLayer => {
+  const layer = state.layers.find((l) => l.id === layerId);
+  assert(isInitialImageLayer(layer));
+  return layer;
+};
 const selectCAOrIPALayerOrThrow = (
   state: ControlLayersState,
   layerId: string
@@ -611,6 +622,45 @@ export const controlLayersSlice = createSlice({
     },
     //#endregion
 
+    //#region Initial Image Layer
+    iiLayerAdded: {
+      reducer: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
+        const { layerId, imageDTO } = action.payload;
+        // Highlander! There can be only one!
+        assert(!state.layers.find(isInitialImageLayer));
+        const layer: InitialImageLayer = {
+          id: layerId,
+          type: 'initial_image_layer',
+          x: 0,
+          y: 0,
+          bbox: null,
+          bboxNeedsUpdate: false,
+          isEnabled: true,
+          image: imageDTO ? imageDTOToImageWithDims(imageDTO) : null,
+          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;
+          }
+        }
+      },
+      prepare: (imageDTO: ImageDTO | null) => ({ payload: { layerId: 'initial_image_layer', imageDTO } }),
+    },
+    iiLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
+      const { layerId, imageDTO } = action.payload;
+      const layer = selectIILayerOrThrow(state, layerId);
+      if (layer) {
+        layer.bbox = null;
+        layer.bboxNeedsUpdate = true;
+        layer.isEnabled = true;
+        layer.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null;
+      }
+    },
+    //#endregion
+
     //#region Globals
     positivePromptChanged: (state, action: PayloadAction<string>) => {
       state.positivePrompt = action.payload;
@@ -780,6 +830,9 @@ export const {
   rgLayerIPAdapterMethodChanged,
   rgLayerIPAdapterModelChanged,
   rgLayerIPAdapterCLIPVisionModelChanged,
+  // II Layer
+  iiLayerAdded,
+  iiLayerImageChanged,
   // Globals
   positivePromptChanged,
   negativePromptChanged,
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index cbf47ff3ad..efcfb0f0bc 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -1,5 +1,6 @@
 import type {
   ControlNetConfigV2,
+  ImageWithDims,
   IPAdapterConfigV2,
   T2IAdapterConfigV2,
 } from 'features/controlLayers/util/controlAdapters';
@@ -73,7 +74,12 @@ export type RegionalGuidanceLayer = RenderableLayerBase & {
   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 InitialImageLayer = RenderableLayerBase & {
+  type: 'initial_image_layer';
+  image: ImageWithDims | null;
+};
+
+export type Layer = RegionalGuidanceLayer | ControlAdapterLayer | IPAdapterLayer | InitialImageLayer;
 
 export type ControlLayersState = {
   _version: 1;
diff --git a/invokeai/frontend/web/src/features/dnd/types/index.ts b/invokeai/frontend/web/src/features/dnd/types/index.ts
index 7d109473ed..b8d3cfe31e 100644
--- a/invokeai/frontend/web/src/features/dnd/types/index.ts
+++ b/invokeai/frontend/web/src/features/dnd/types/index.ts
@@ -55,6 +55,13 @@ export type RGLayerIPAdapterImageDropData = BaseDropData & {
   };
 };
 
+export type IILayerImageDropData = BaseDropData & {
+  actionType: 'SET_II_LAYER_IMAGE';
+  context: {
+    layerId: string;
+  };
+};
+
 export type CanvasInitialImageDropData = BaseDropData & {
   actionType: 'SET_CANVAS_INITIAL_IMAGE';
 };
@@ -86,7 +93,8 @@ export type TypesafeDroppableData =
   | RemoveFromBoardDropData
   | CALayerImageDropData
   | IPALayerImageDropData
-  | RGLayerIPAdapterImageDropData;
+  | RGLayerIPAdapterImageDropData
+  | IILayerImageDropData;
 
 type BaseDragData = {
   id: string;
diff --git a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts b/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts
index c1da111087..757a21bd5c 100644
--- a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts
+++ b/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts
@@ -25,6 +25,8 @@ export const isValidDrop = (overData: TypesafeDroppableData | undefined, active:
       return payloadType === 'IMAGE_DTO';
     case 'SET_RG_LAYER_IP_ADAPTER_IMAGE':
       return payloadType === 'IMAGE_DTO';
+    case 'SET_II_LAYER_IMAGE':
+      return payloadType === 'IMAGE_DTO';
     case 'SET_CANVAS_INITIAL_IMAGE':
       return payloadType === 'IMAGE_DTO';
     case 'SET_NODES_IMAGE':
diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts
index 5b88170d41..183b81478d 100644
--- a/invokeai/frontend/web/src/services/api/types.ts
+++ b/invokeai/frontend/web/src/services/api/types.ts
@@ -193,6 +193,11 @@ export type RGLayerIPAdapterImagePostUploadAction = {
   ipAdapterId: string;
 };
 
+export type IILayerImagePostUploadAction = {
+  type: 'SET_II_LAYER_IMAGE';
+  layerId: string;
+};
+
 type InitialImageAction = {
   type: 'SET_INITIAL_IMAGE';
 };
@@ -225,4 +230,5 @@ export type PostUploadAction =
   | AddToBatchAction
   | CALayerImagePostUploadAction
   | IPALayerImagePostUploadAction
-  | RGLayerIPAdapterImagePostUploadAction;
+  | RGLayerIPAdapterImagePostUploadAction
+  | IILayerImagePostUploadAction;

From 75be6814bb27f3dcad95c81b541ef94d6b2c86e5 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 21:22:53 +1000
Subject: [PATCH 11/59] feat(ui): add renderer for initial image

---
 .../controlLayers/store/controlLayersSlice.ts |   3 +
 .../features/controlLayers/util/renderers.ts  | 108 ++++++++++++++++++
 2 files changed, 111 insertions(+)

diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index bd130a0236..d8d159f19a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -878,6 +878,8 @@ export const RG_LAYER_NAME = 'regional_guidance_layer';
 export const RG_LAYER_LINE_NAME = 'regional_guidance_layer.line';
 export const RG_LAYER_OBJECT_GROUP_NAME = 'regional_guidance_layer.object_group';
 export const RG_LAYER_RECT_NAME = 'regional_guidance_layer.rect';
+export const INITIAL_IMAGE_LAYER_NAME = 'initial_image_layer';
+export const INITIAL_IMAGE_LAYER_IMAGE_NAME = 'initial_image_layer.image';
 export const LAYER_BBOX_NAME = 'layer.bbox';
 
 // Getters for non-singleton layer and object IDs
@@ -888,6 +890,7 @@ export const getRGLayerObjectGroupId = (layerId: string, groupId: string) => `${
 export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`;
 const getCALayerId = (layerId: string) => `control_adapter_layer_${layerId}`;
 export const getCALayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`;
+export const getIILayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`;
 const getIPALayerId = (layerId: string) => `ip_adapter_layer_${layerId}`;
 
 export const controlLayersPersistConfig: PersistConfig<ControlLayersState> = {
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
index 12091d3e8b..4f5def7b76 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
@@ -8,9 +8,13 @@ import {
   CA_LAYER_IMAGE_NAME,
   CA_LAYER_NAME,
   getCALayerImageId,
+  getIILayerImageId,
   getLayerBboxId,
   getRGLayerObjectGroupId,
+  INITIAL_IMAGE_LAYER_IMAGE_NAME,
+  INITIAL_IMAGE_LAYER_NAME,
   isControlAdapterLayer,
+  isInitialImageLayer,
   isRegionalGuidanceLayer,
   isRenderableLayer,
   LAYER_BBOX_NAME,
@@ -28,6 +32,7 @@ import {
 } from 'features/controlLayers/store/controlLayersSlice';
 import type {
   ControlAdapterLayer,
+  InitialImageLayer,
   Layer,
   RegionalGuidanceLayer,
   Tool,
@@ -406,6 +411,106 @@ const renderRegionalGuidanceLayer = (
   }
 };
 
+const createInitialImageLayer = (stage: Konva.Stage, reduxLayer: InitialImageLayer): Konva.Layer => {
+  const konvaLayer = new Konva.Layer({
+    id: reduxLayer.id,
+    name: INITIAL_IMAGE_LAYER_NAME,
+    imageSmoothingEnabled: true,
+  });
+  stage.add(konvaLayer);
+  return konvaLayer;
+};
+
+const createInitialImageLayerImage = (konvaLayer: Konva.Layer, image: HTMLImageElement): Konva.Image => {
+  const konvaImage = new Konva.Image({
+    name: INITIAL_IMAGE_LAYER_IMAGE_NAME,
+    image,
+  });
+  konvaLayer.add(konvaImage);
+  return konvaImage;
+};
+
+const updateInitialImageLayerImageAttrs = (
+  stage: Konva.Stage,
+  konvaImage: Konva.Image,
+  reduxLayer: InitialImageLayer
+) => {
+  const newWidth = stage.width() / stage.scaleX();
+  const newHeight = stage.height() / stage.scaleY();
+  if (
+    konvaImage.width() !== newWidth ||
+    konvaImage.height() !== newHeight ||
+    konvaImage.visible() !== reduxLayer.isEnabled
+  ) {
+    konvaImage.setAttrs({
+      // opacity: reduxLayer.opacity,
+      scaleX: 1,
+      scaleY: 1,
+      width: stage.width() / stage.scaleX(),
+      height: stage.height() / stage.scaleY(),
+      visible: reduxLayer.isEnabled,
+    });
+  }
+  // if (konvaImage.opacity() !== reduxLayer.opacity) {
+  //   konvaImage.opacity(reduxLayer.opacity);
+  // }
+};
+
+const updateInitialImageLayerImageSource = async (
+  stage: Konva.Stage,
+  konvaLayer: Konva.Layer,
+  reduxLayer: InitialImageLayer
+) => {
+  if (reduxLayer.image) {
+    const { imageName } = reduxLayer.image;
+    const req = getStore().dispatch(imagesApi.endpoints.getImageDTO.initiate(imageName));
+    const imageDTO = await req.unwrap();
+    req.unsubscribe();
+    const imageEl = new Image();
+    const imageId = getIILayerImageId(reduxLayer.id, imageName);
+    imageEl.onload = () => {
+      // Find the existing image or create a new one - must find using the name, bc the id may have just changed
+      const konvaImage =
+        konvaLayer.findOne<Konva.Image>(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`) ??
+        createInitialImageLayerImage(konvaLayer, imageEl);
+
+      // Update the image's attributes
+      konvaImage.setAttrs({
+        id: imageId,
+        image: imageEl,
+      });
+      updateInitialImageLayerImageAttrs(stage, konvaImage, reduxLayer);
+      imageEl.id = imageId;
+    };
+    imageEl.src = imageDTO.image_url;
+  } else {
+    konvaLayer.findOne(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`)?.destroy();
+  }
+};
+
+const renderInitialImageLayer = (stage: Konva.Stage, reduxLayer: InitialImageLayer) => {
+  const konvaLayer = stage.findOne<Konva.Layer>(`#${reduxLayer.id}`) ?? createInitialImageLayer(stage, reduxLayer);
+  const konvaImage = konvaLayer.findOne<Konva.Image>(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`);
+  const canvasImageSource = konvaImage?.image();
+  let imageSourceNeedsUpdate = false;
+  if (canvasImageSource instanceof HTMLImageElement) {
+    const image = reduxLayer.image;
+    if (image && canvasImageSource.id !== getCALayerImageId(reduxLayer.id, image.imageName)) {
+      imageSourceNeedsUpdate = true;
+    } else if (!image) {
+      imageSourceNeedsUpdate = true;
+    }
+  } else if (!canvasImageSource) {
+    imageSourceNeedsUpdate = true;
+  }
+
+  if (imageSourceNeedsUpdate) {
+    updateInitialImageLayerImageSource(stage, konvaLayer, reduxLayer);
+  } else if (konvaImage) {
+    updateInitialImageLayerImageAttrs(stage, konvaImage, reduxLayer);
+  }
+};
+
 const createControlNetLayer = (stage: Konva.Stage, reduxLayer: ControlAdapterLayer): Konva.Layer => {
   const konvaLayer = new Konva.Layer({
     id: reduxLayer.id,
@@ -546,6 +651,9 @@ const renderLayers = (
     if (isControlAdapterLayer(reduxLayer)) {
       renderControlNetLayer(stage, reduxLayer);
     }
+    if (isInitialImageLayer(reduxLayer)) {
+      renderInitialImageLayer(stage, reduxLayer);
+    }
   }
 };
 

From 8b6a283eabea7d2a98e3f079a4b737b79ebcd3a7 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 21:31:17 +1000
Subject: [PATCH 12/59] feat(ui): add opacity to initial image layer

---
 .../components/IILayer/IILayer.tsx            |  2 +
 .../components/IILayer/IILayerOpacity.tsx     | 98 +++++++++++++++++++
 .../controlLayers/store/controlLayersSlice.ts | 17 ++--
 .../src/features/controlLayers/store/types.ts |  1 +
 .../features/controlLayers/util/renderers.ts  |  8 +-
 5 files changed, 116 insertions(+), 10 deletions(-)
 create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayerOpacity.tsx

diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx
index 3c54bffb6c..772dbd7332 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayer.tsx
@@ -1,5 +1,6 @@
 import { Flex, Spacer, useDisclosure } from '@invoke-ai/ui-library';
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import IILayerOpacity from 'features/controlLayers/components/IILayer/IILayerOpacity';
 import { InitialImagePreview } from 'features/controlLayers/components/IILayer/InitialImagePreview';
 import { LayerDeleteButton } from 'features/controlLayers/components/LayerCommon/LayerDeleteButton';
 import { LayerMenu } from 'features/controlLayers/components/LayerCommon/LayerMenu';
@@ -60,6 +61,7 @@ export const IILayer = memo(({ layerId }: Props) => {
         <LayerVisibilityToggle layerId={layerId} />
         <LayerTitle type="initial_image_layer" />
         <Spacer />
+        <IILayerOpacity layerId={layerId} />
         <LayerMenu layerId={layerId} />
         <LayerDeleteButton layerId={layerId} />
       </Flex>
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayerOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayerOpacity.tsx
new file mode 100644
index 0000000000..e26a3634a1
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayerOpacity.tsx
@@ -0,0 +1,98 @@
+import {
+  CompositeNumberInput,
+  CompositeSlider,
+  Flex,
+  FormControl,
+  FormLabel,
+  IconButton,
+  Popover,
+  PopoverArrow,
+  PopoverBody,
+  PopoverContent,
+  PopoverTrigger,
+} from '@invoke-ai/ui-library';
+import { createSelector } from '@reduxjs/toolkit';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { stopPropagation } from 'common/util/stopPropagation';
+import {
+  iiLayerOpacityChanged,
+  isInitialImageLayer,
+  selectControlLayersSlice,
+} from 'features/controlLayers/store/controlLayersSlice';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiDropHalfFill } from 'react-icons/pi';
+import { assert } from 'tsafe';
+
+type Props = {
+  layerId: string;
+};
+
+const marks = [0, 25, 50, 75, 100];
+const formatPct = (v: number | string) => `${v} %`;
+
+const IILayerOpacity = ({ layerId }: Props) => {
+  const { t } = useTranslation();
+  const dispatch = useAppDispatch();
+  const selectOpacity = useMemo(
+    () =>
+      createSelector(selectControlLayersSlice, (controlLayers) => {
+        const layer = controlLayers.present.layers.filter(isInitialImageLayer).find((l) => l.id === layerId);
+        assert(layer, `Layer ${layerId} not found`);
+        return Math.round(layer.opacity * 100);
+      }),
+    [layerId]
+  );
+  const opacity = useAppSelector(selectOpacity);
+  const onChangeOpacity = useCallback(
+    (v: number) => {
+      dispatch(iiLayerOpacityChanged({ layerId, opacity: v / 100 }));
+    },
+    [dispatch, layerId]
+  );
+  return (
+    <Popover isLazy>
+      <PopoverTrigger>
+        <IconButton
+          aria-label={t('controlLayers.opacity')}
+          size="sm"
+          icon={<PiDropHalfFill size={16} />}
+          variant="ghost"
+          onDoubleClick={stopPropagation}
+        />
+      </PopoverTrigger>
+      <PopoverContent>
+        <PopoverArrow />
+        <PopoverBody>
+          <Flex direction="column" gap={2}>
+            <FormControl orientation="horizontal">
+              <FormLabel m={0}>{t('controlLayers.opacity')}</FormLabel>
+              <CompositeSlider
+                min={0}
+                max={100}
+                step={1}
+                value={opacity}
+                defaultValue={100}
+                onChange={onChangeOpacity}
+                marks={marks}
+                w={48}
+              />
+              <CompositeNumberInput
+                min={0}
+                max={100}
+                step={1}
+                value={opacity}
+                defaultValue={100}
+                onChange={onChangeOpacity}
+                w={24}
+                format={formatPct}
+              />
+            </FormControl>
+          </Flex>
+        </PopoverBody>
+      </PopoverContent>
+    </Popover>
+  );
+};
+
+export default memo(IILayerOpacity);
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index d8d159f19a..2cb14509d7 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -631,6 +631,7 @@ export const controlLayersSlice = createSlice({
         const layer: InitialImageLayer = {
           id: layerId,
           type: 'initial_image_layer',
+          opacity: 1,
           x: 0,
           y: 0,
           bbox: null,
@@ -652,12 +653,15 @@ export const controlLayersSlice = createSlice({
     iiLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
       const { layerId, imageDTO } = action.payload;
       const layer = selectIILayerOrThrow(state, layerId);
-      if (layer) {
-        layer.bbox = null;
-        layer.bboxNeedsUpdate = true;
-        layer.isEnabled = true;
-        layer.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null;
-      }
+      layer.bbox = null;
+      layer.bboxNeedsUpdate = true;
+      layer.isEnabled = true;
+      layer.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null;
+    },
+    iiLayerOpacityChanged: (state, action: PayloadAction<{ layerId: string; opacity: number }>) => {
+      const { layerId, opacity } = action.payload;
+      const layer = selectIILayerOrThrow(state, layerId);
+      layer.opacity = opacity;
     },
     //#endregion
 
@@ -833,6 +837,7 @@ export const {
   // II Layer
   iiLayerAdded,
   iiLayerImageChanged,
+  iiLayerOpacityChanged,
   // Globals
   positivePromptChanged,
   negativePromptChanged,
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index efcfb0f0bc..cbb986bde2 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -76,6 +76,7 @@ export type RegionalGuidanceLayer = RenderableLayerBase & {
 
 export type InitialImageLayer = RenderableLayerBase & {
   type: 'initial_image_layer';
+  opacity: number;
   image: ImageWithDims | null;
 };
 
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
index 4f5def7b76..3e34542e87 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
@@ -443,7 +443,7 @@ const updateInitialImageLayerImageAttrs = (
     konvaImage.visible() !== reduxLayer.isEnabled
   ) {
     konvaImage.setAttrs({
-      // opacity: reduxLayer.opacity,
+      opacity: reduxLayer.opacity,
       scaleX: 1,
       scaleY: 1,
       width: stage.width() / stage.scaleX(),
@@ -451,9 +451,9 @@ const updateInitialImageLayerImageAttrs = (
       visible: reduxLayer.isEnabled,
     });
   }
-  // if (konvaImage.opacity() !== reduxLayer.opacity) {
-  //   konvaImage.opacity(reduxLayer.opacity);
-  // }
+  if (konvaImage.opacity() !== reduxLayer.opacity) {
+    konvaImage.opacity(reduxLayer.opacity);
+  }
 };
 
 const updateInitialImageLayerImageSource = async (

From 209ddc2037177e1b9d8506fbb75931c94525ef15 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 21:35:30 +1000
Subject: [PATCH 13/59] fix(ui): do not toggle layers on double click of
 opacity popover

---
 .../controlLayers/components/CALayer/CALayerOpacity.tsx         | 2 +-
 .../controlLayers/components/IILayer/IILayerOpacity.tsx         | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx
index 31c8d81853..353f8e0307 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx
@@ -55,7 +55,7 @@ const CALayerOpacity = ({ layerId }: Props) => {
           onDoubleClick={stopPropagation}
         />
       </PopoverTrigger>
-      <PopoverContent>
+      <PopoverContent onDoubleClick={stopPropagation}>
         <PopoverArrow />
         <PopoverBody>
           <Flex direction="column" gap={2}>
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayerOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayerOpacity.tsx
index e26a3634a1..9918dda5b8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayerOpacity.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/IILayerOpacity.tsx
@@ -61,7 +61,7 @@ const IILayerOpacity = ({ layerId }: Props) => {
           onDoubleClick={stopPropagation}
         />
       </PopoverTrigger>
-      <PopoverContent>
+      <PopoverContent onDoubleClick={stopPropagation}>
         <PopoverArrow />
         <PopoverBody>
           <Flex direction="column" gap={2}>

From c9886796f65df0569e9dced5a69bafd93d587aa0 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 22:29:00 +1000
Subject: [PATCH 14/59] feat(ui): add image viewer overlay

- Works on txt2img, canvas and workflows tabs, img2img has its own side-by-side view
- In workflow editor, the is closeable only if you are in edit mode, else it's always there
- Press `i` to open
- Press `esc` to close
- Selecting an image or changing image selection opens the viewer
- When generating, if auto-switch to new image is enabled, the viewer opens when an image comes in

To support this change, I organized and restructured some tab stuff.
---
 invokeai/frontend/web/public/locales/en.json  | 11 ++-
 .../gallery/components/ImageViewer.tsx        | 80 +++++++++++++++++++
 .../features/gallery/store/gallerySlice.ts    |  9 ++-
 .../web/src/features/gallery/store/types.ts   |  1 +
 .../web/src/features/metadata/types.ts        |  6 +-
 .../BottomLeftPanel/BottomLeftPanel.tsx       |  2 +-
 .../flow/panels/MinimapPanel/MinimapPanel.tsx |  2 +-
 .../flow/panels/TopPanel/TopPanel.tsx         |  2 +-
 .../components/HotkeysModal/useHotkeyData.ts  | 10 +++
 .../src/features/ui/components/InvokeTabs.tsx | 37 ++++-----
 .../components/ParametersPanelTextToImage.tsx |  4 +-
 .../ui/components/tabs/ImageToImageTab.tsx    |  2 +-
 .../features/ui/components/tabs/NodesTab.tsx  | 22 ++---
 .../ui/components/tabs/TextToImageTab.tsx     | 26 +-----
 .../ui/components/tabs/UnifiedCanvasTab.tsx   |  3 +
 .../web/src/features/ui/store/tabMap.ts       |  3 -
 .../web/src/features/ui/store/tabMap.tsx      |  3 +
 .../web/src/features/ui/store/uiSelectors.ts  |  4 +-
 18 files changed, 164 insertions(+), 63 deletions(-)
 create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
 delete mode 100644 invokeai/frontend/web/src/features/ui/store/tabMap.ts
 create mode 100644 invokeai/frontend/web/src/features/ui/store/tabMap.tsx

diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index c211b4a574..cacb4dfbf4 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -361,7 +361,8 @@
         "bulkDownloadRequestFailed": "Problem Preparing Download",
         "bulkDownloadFailed": "Download Failed",
         "problemDeletingImages": "Problem Deleting Images",
-        "problemDeletingImagesDesc": "One or more images could not be deleted"
+        "problemDeletingImagesDesc": "One or more images could not be deleted",
+        "backToEditor": "Back to {{tab}} (Esc)"
     },
     "hotkeys": {
         "searchHotkeys": "Search Hotkeys",
@@ -584,6 +585,14 @@
         "upscale": {
             "desc": "Upscale the current image",
             "title": "Upscale"
+        },
+        "backToEditor": {
+            "desc": "Closes the Image Viewer and shows the Editor View (Text to Image tab only)",
+            "title": "Back to Editor"
+        },
+        "openImageViewer": {
+            "desc": "Opens the Image Viewer (Text to Image tab only)",
+            "title": "Open Image Viewer"
         }
     },
     "metadata": {
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
new file mode 100644
index 0000000000..563d1b2285
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
@@ -0,0 +1,80 @@
+import { Button, Flex } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import CurrentImageButtons from 'features/gallery/components/CurrentImage/CurrentImageButtons';
+import CurrentImagePreview from 'features/gallery/components/CurrentImage/CurrentImagePreview';
+import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
+import type { InvokeTabName } from 'features/ui/store/tabMap';
+import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
+import { memo, useCallback, useMemo } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
+import { useTranslation } from 'react-i18next';
+import { PiArrowLeftBold } from 'react-icons/pi';
+
+const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
+  txt2img: 'common.txt2img',
+  img2img: 'common.img2img',
+  unifiedCanvas: 'common.unifiedCanvas',
+  nodes: 'common.nodes',
+  modelManager: 'modelManager.modelManager',
+  queue: 'queue.queue',
+};
+
+export const ImageViewer = memo(() => {
+  const { t } = useTranslation();
+  const dispatch = useAppDispatch();
+  const isOpen = useAppSelector((s) => s.gallery.isImageViewerOpen);
+  const activeTabName = useAppSelector(activeTabNameSelector);
+  const activeTabLabel = useMemo(
+    () => t('gallery.backToEditor', { tab: t(TAB_NAME_TO_TKEY[activeTabName]) }),
+    [t, activeTabName]
+  );
+
+  const onClose = useCallback(() => {
+    dispatch(isImageViewerOpenChanged(false));
+  }, [dispatch]);
+
+  const onOpen = useCallback(() => {
+    dispatch(isImageViewerOpenChanged(true));
+  }, [dispatch]);
+
+  useHotkeys('esc', onClose, { enabled: isOpen }, [isOpen]);
+  useHotkeys('i', onOpen, { enabled: !isOpen }, [isOpen]);
+
+  if (!isOpen) {
+    return null;
+  }
+
+  return (
+    <Flex
+      layerStyle="first"
+      borderRadius="base"
+      position="absolute"
+      flexDirection="column"
+      top={0}
+      right={0}
+      bottom={0}
+      left={0}
+      p={2}
+      rowGap={4}
+      alignItems="center"
+      justifyContent="center"
+      zIndex={10} // reactflow puts its minimap at 5, so we need to be above that
+    >
+      <CurrentImageButtons />
+      <CurrentImagePreview />
+      <Button
+        aria-label={activeTabLabel}
+        tooltip={activeTabLabel}
+        onClick={onClose}
+        leftIcon={<PiArrowLeftBold />}
+        position="absolute"
+        top={2}
+        insetInlineEnd={2}
+      >
+        {t('common.back')}
+      </Button>
+    </Flex>
+  );
+});
+
+ImageViewer.displayName = 'ImageViewer';
diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
index 28435d31ae..373d946469 100644
--- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
@@ -21,6 +21,7 @@ const initialGalleryState: GalleryState = {
   boardSearchText: '',
   limit: INITIAL_IMAGE_LIMIT,
   offset: 0,
+  isImageViewerOpen: false,
 };
 
 export const gallerySlice = createSlice({
@@ -29,9 +30,11 @@ export const gallerySlice = createSlice({
   reducers: {
     imageSelected: (state, action: PayloadAction<ImageDTO | null>) => {
       state.selection = action.payload ? [action.payload] : [];
+      state.isImageViewerOpen = true;
     },
     selectionChanged: (state, action: PayloadAction<ImageDTO[]>) => {
       state.selection = uniqBy(action.payload, (i) => i.image_name);
+      state.isImageViewerOpen = true;
     },
     shouldAutoSwitchChanged: (state, action: PayloadAction<boolean>) => {
       state.shouldAutoSwitch = action.payload;
@@ -75,6 +78,9 @@ export const gallerySlice = createSlice({
     alwaysShowImageSizeBadgeChanged: (state, action: PayloadAction<boolean>) => {
       state.alwaysShowImageSizeBadge = action.payload;
     },
+    isImageViewerOpenChanged: (state, action: PayloadAction<boolean>) => {
+      state.isImageViewerOpen = action.payload;
+    },
   },
   extraReducers: (builder) => {
     builder.addMatcher(isAnyBoardDeleted, (state, action) => {
@@ -112,6 +118,7 @@ export const {
   boardSearchTextChanged,
   moreImagesLoaded,
   alwaysShowImageSizeBadgeChanged,
+  isImageViewerOpenChanged,
 } = gallerySlice.actions;
 
 const isAnyBoardDeleted = isAnyOf(
@@ -133,5 +140,5 @@ export const galleryPersistConfig: PersistConfig<GalleryState> = {
   name: gallerySlice.name,
   initialState: initialGalleryState,
   migrate: migrateGalleryState,
-  persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'offset', 'limit'],
+  persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'offset', 'limit', 'isImageViewerOpen'],
 };
diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts
index dbe91392ff..0e86d2d4be 100644
--- a/invokeai/frontend/web/src/features/gallery/store/types.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/types.ts
@@ -20,4 +20,5 @@ export type GalleryState = {
   offset: number;
   limit: number;
   alwaysShowImageSizeBadge: boolean;
+  isImageViewerOpen: boolean;
 };
diff --git a/invokeai/frontend/web/src/features/metadata/types.ts b/invokeai/frontend/web/src/features/metadata/types.ts
index 8621031896..30a34ec0c6 100644
--- a/invokeai/frontend/web/src/features/metadata/types.ts
+++ b/invokeai/frontend/web/src/features/metadata/types.ts
@@ -1,5 +1,9 @@
 import type { ControlNetConfig, IPAdapterConfig, T2IAdapterConfig } from 'features/controlAdapters/store/types';
-import type { ControlNetConfigV2, IPAdapterConfigV2, T2IAdapterConfigV2 } from 'features/controlLayers/util/controlAdapters';
+import type {
+  ControlNetConfigV2,
+  IPAdapterConfigV2,
+  T2IAdapterConfigV2,
+} from 'features/controlLayers/util/controlAdapters';
 import type { O } from 'ts-toolbelt';
 
 /**
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/BottomLeftPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/BottomLeftPanel.tsx
index 412ff00052..7f6b947258 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/BottomLeftPanel.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/BottomLeftPanel.tsx
@@ -5,7 +5,7 @@ import NodeOpacitySlider from './NodeOpacitySlider';
 import ViewportControls from './ViewportControls';
 
 const BottomLeftPanel = () => (
-  <Flex gap={2} position="absolute" bottom={2} insetInlineStart={2}>
+  <Flex gap={2} position="absolute" bottom={0} insetInlineStart={0}>
     <ViewportControls />
     <NodeOpacitySlider />
   </Flex>
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/MinimapPanel/MinimapPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/MinimapPanel/MinimapPanel.tsx
index 8c8d803cdb..b34ae11c85 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/MinimapPanel/MinimapPanel.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/MinimapPanel/MinimapPanel.tsx
@@ -19,7 +19,7 @@ const MinimapPanel = () => {
   const shouldShowMinimapPanel = useAppSelector((s) => s.nodes.shouldShowMinimapPanel);
 
   return (
-    <Flex gap={2} position="absolute" bottom={2} insetInlineEnd={2}>
+    <Flex gap={2} position="absolute" bottom={0} insetInlineEnd={0}>
       {shouldShowMinimapPanel && (
         <ChakraMiniMap
           pannable
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx
index a78024074c..93856a21c4 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx
@@ -11,7 +11,7 @@ import { memo } from 'react';
 const TopCenterPanel = () => {
   const name = useAppSelector((s) => s.workflow.name);
   return (
-    <Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="flex-start" pointerEvents="none">
+    <Flex gap={2} top={0} left={0} right={0} position="absolute" alignItems="flex-start" pointerEvents="none">
       <Flex gap="2">
         <AddNodeButton />
         <UpdateNodesButton />
diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
index 84aa632ea9..806b85ca59 100644
--- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
@@ -140,6 +140,16 @@ export const useHotkeyData = (): HotkeyGroup[] => {
           desc: t('hotkeys.nextImage.desc'),
           hotkeys: [['Arrow Right']],
         },
+        {
+          title: t('hotkeys.openImageViewer.title'),
+          desc: t('hotkeys.openImageViewer.desc'),
+          hotkeys: [['I']],
+        },
+        {
+          title: t('hotkeys.backToEditor.title'),
+          desc: t('hotkeys.backToEditor.desc'),
+          hotkeys: [['Esc']],
+        },
       ],
     }),
     [t]
diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
index 9ac5324d41..b4a2922098 100644
--- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
@@ -12,10 +12,17 @@ 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 ImageToImageTab from 'features/ui/components/tabs/ImageToImageTab';
+import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab';
+import NodesTab from 'features/ui/components/tabs/NodesTab';
+import QueueTab from 'features/ui/components/tabs/QueueTab';
+import TextToImageTab from 'features/ui/components/tabs/TextToImageTab';
+import UnifiedCanvasTab from 'features/ui/components/tabs/UnifiedCanvasTab';
 import type { UsePanelOptions } from 'features/ui/hooks/usePanel';
 import { usePanel } from 'features/ui/hooks/usePanel';
 import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
 import type { InvokeTabName } from 'features/ui/store/tabMap';
+import { TAB_NUMBER_MAP } from 'features/ui/store/tabMap';
 import { activeTabIndexSelector, activeTabNameSelector } from 'features/ui/store/uiSelectors';
 import { setActiveTab } from 'features/ui/store/uiSlice';
 import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from 'react';
@@ -28,62 +35,56 @@ import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
 import { Panel, PanelGroup } from 'react-resizable-panels';
 
 import ParametersPanel from './ParametersPanel';
-import ImageTab from './tabs/ImageToImageTab';
-import ModelManagerTab from './tabs/ModelManagerTab';
-import NodesTab from './tabs/NodesTab';
-import QueueTab from './tabs/QueueTab';
 import ResizeHandle from './tabs/ResizeHandle';
-import TextToImageTab from './tabs/TextToImageTab';
-import UnifiedCanvasTab from './tabs/UnifiedCanvasTab';
 
-interface InvokeTabInfo {
+type TabData = {
   id: InvokeTabName;
   translationKey: string;
   icon: ReactElement;
   content: ReactNode;
-}
+};
 
-const tabs: InvokeTabInfo[] = [
-  {
+const TAB_DATA: Record<InvokeTabName, TabData> = {
+  txt2img: {
     id: 'txt2img',
     translationKey: 'common.txt2img',
     icon: <RiInputMethodLine />,
     content: <TextToImageTab />,
   },
-  {
+  img2img: {
     id: 'img2img',
     translationKey: 'common.img2img',
     icon: <RiImage2Line />,
-    content: <ImageTab />,
+    content: <ImageToImageTab />,
   },
-  {
+  unifiedCanvas: {
     id: 'unifiedCanvas',
     translationKey: 'common.unifiedCanvas',
     icon: <RiBrushLine />,
     content: <UnifiedCanvasTab />,
   },
-  {
+  nodes: {
     id: 'nodes',
     translationKey: 'common.nodes',
     icon: <PiFlowArrowBold />,
     content: <NodesTab />,
   },
-  {
+  modelManager: {
     id: 'modelManager',
     translationKey: 'modelManager.modelManager',
     icon: <RiBox2Line />,
     content: <ModelManagerTab />,
   },
-  {
+  queue: {
     id: 'queue',
     translationKey: 'queue.queue',
     icon: <RiPlayList2Fill />,
     content: <QueueTab />,
   },
-];
+};
 
 const enabledTabsSelector = createMemoizedSelector(selectConfigSlice, (config) =>
-  tabs.filter((tab) => !config.disabledTabs.includes(tab.id))
+  TAB_NUMBER_MAP.map((tabName) => TAB_DATA[tabName]).filter((tab) => !config.disabledTabs.includes(tab.id))
 );
 
 const NO_GALLERY_PANEL_TABS: InvokeTabName[] = ['modelManager', 'queue'];
diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
index 2d14a50856..3073b1e66b 100644
--- a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
@@ -39,8 +39,8 @@ const ParametersPanelTextToImage = () => {
               {isSDXL ? <SDXLPrompts /> : <Prompts />}
               <Tabs variant="line" isLazy={true} display="flex" flexDir="column" w="full" h="full">
                 <TabList>
-                  <Tab>{t('common.settingsLabel')}</Tab>
-                  <Tab>{controlLayersTitle}</Tab>
+                  <Tab flexGrow={1}>{t('common.settingsLabel')}</Tab>
+                  <Tab flexGrow={1}>{controlLayersTitle}</Tab>
                 </TabList>
 
                 <TabPanels w="full" h="full">
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 07e87d202c..4303d66e2c 100644
--- a/invokeai/frontend/web/src/features/ui/components/tabs/ImageToImageTab.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/tabs/ImageToImageTab.tsx
@@ -29,7 +29,7 @@ const ImageToImageTab = () => {
   const panelStorage = usePanelStorage();
 
   return (
-    <Box w="full" h="full">
+    <Box position="relative" w="full" h="full">
       <PanelGroup
         ref={panelGroupRef}
         autoSaveId="imageTab.content"
diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx
index a707327d5d..81f0810192 100644
--- a/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx
@@ -1,6 +1,7 @@
 import { Box, Flex } from '@invoke-ai/ui-library';
 import { useAppSelector } from 'app/store/storeHooks';
 import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay';
+import { ImageViewer } from 'features/gallery/components/ImageViewer';
 import NodeEditor from 'features/nodes/components/NodeEditor';
 import { memo } from 'react';
 import { ReactFlowProvider } from 'reactflow';
@@ -9,20 +10,23 @@ const NodesTab = () => {
   const mode = useAppSelector((s) => s.workflow.mode);
 
   if (mode === 'edit') {
-    return (
-      <ReactFlowProvider>
-        <NodeEditor />
-      </ReactFlowProvider>
-    );
-  } else {
     return (
       <Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
-        <Flex w="full" h="full">
-          <CurrentImageDisplay />
-        </Flex>
+        <ReactFlowProvider>
+          <NodeEditor />
+        </ReactFlowProvider>
+        <ImageViewer />
       </Box>
     );
   }
+
+  return (
+    <Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
+      <Flex w="full" h="full">
+        <CurrentImageDisplay />
+      </Flex>
+    </Box>
+  );
 };
 
 export default memo(NodesTab);
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 f9b760bcd5..df0b8651bc 100644
--- a/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx
@@ -1,31 +1,13 @@
-import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
+import { Box } 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 { ImageViewer } from 'features/gallery/components/ImageViewer';
 import { memo } from 'react';
-import { useTranslation } from 'react-i18next';
 
 const TextToImageTab = () => {
-  const { t } = useTranslation();
-  const controlLayersTitle = useControlLayersTitle();
-
   return (
     <Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
-      <Tabs variant="line" isLazy={true} display="flex" flexDir="column" w="full" h="full">
-        <TabList>
-          <Tab>{t('common.viewer')}</Tab>
-          <Tab>{controlLayersTitle}</Tab>
-        </TabList>
-
-        <TabPanels w="full" h="full" minH={0} minW={0}>
-          <TabPanel>
-            <CurrentImageDisplay />
-          </TabPanel>
-          <TabPanel>
-            <ControlLayersEditor />
-          </TabPanel>
-        </TabPanels>
-      </Tabs>
+      <ControlLayersEditor />
+      <ImageViewer />
     </Box>
   );
 };
diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvasTab.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvasTab.tsx
index 0f594ed705..8c74685d2d 100644
--- a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvasTab.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvasTab.tsx
@@ -6,6 +6,7 @@ import { CANVAS_TAB_TESTID } from 'features/canvas/store/constants';
 import { useDroppableTypesafe } from 'features/dnd/hooks/typesafeHooks';
 import type { CanvasInitialImageDropData } from 'features/dnd/types';
 import { isValidDrop } from 'features/dnd/util/isValidDrop';
+import { ImageViewer } from 'features/gallery/components/ImageViewer';
 import { memo } from 'react';
 import { useTranslation } from 'react-i18next';
 
@@ -27,6 +28,7 @@ const UnifiedCanvasTab = () => {
 
   return (
     <Flex
+      position="relative"
       layerStyle="first"
       ref={setDroppableRef}
       flexDirection="column"
@@ -40,6 +42,7 @@ const UnifiedCanvasTab = () => {
     >
       <IAICanvasToolbar />
       <IAICanvas />
+      <ImageViewer />
       {isValidDrop(droppableData, active) && (
         <IAIDropOverlay isOver={isOver} label={t('toast.setCanvasInitialImage')} />
       )}
diff --git a/invokeai/frontend/web/src/features/ui/store/tabMap.ts b/invokeai/frontend/web/src/features/ui/store/tabMap.ts
deleted file mode 100644
index a25d98187b..0000000000
--- a/invokeai/frontend/web/src/features/ui/store/tabMap.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const tabMap = ['txt2img', 'img2img', 'unifiedCanvas', 'nodes', 'modelManager', 'queue'] as const;
-
-export type InvokeTabName = (typeof tabMap)[number];
diff --git a/invokeai/frontend/web/src/features/ui/store/tabMap.tsx b/invokeai/frontend/web/src/features/ui/store/tabMap.tsx
new file mode 100644
index 0000000000..1b8f3e4812
--- /dev/null
+++ b/invokeai/frontend/web/src/features/ui/store/tabMap.tsx
@@ -0,0 +1,3 @@
+export const TAB_NUMBER_MAP = ['txt2img', 'img2img', 'unifiedCanvas', 'nodes', 'modelManager', 'queue'] as const;
+
+export type InvokeTabName = (typeof TAB_NUMBER_MAP)[number];
diff --git a/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts b/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts
index 07f3a99224..5fbc6a41de 100644
--- a/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts
+++ b/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts
@@ -3,7 +3,7 @@ import { selectConfigSlice } from 'features/system/store/configSlice';
 import { selectUiSlice } from 'features/ui/store/uiSlice';
 import { isString } from 'lodash-es';
 
-import { tabMap } from './tabMap';
+import { TAB_NUMBER_MAP } from './tabMap';
 
 export const activeTabNameSelector = createSelector(
   selectUiSlice,
@@ -15,7 +15,7 @@ export const activeTabNameSelector = createSelector(
 );
 
 export const activeTabIndexSelector = createSelector(selectUiSlice, selectConfigSlice, (ui, config) => {
-  const tabs = tabMap.filter((t) => !config.disabledTabs.includes(t));
+  const tabs = TAB_NUMBER_MAP.filter((t) => !config.disabledTabs.includes(t));
   const idx = tabs.indexOf(ui.activeTab);
   return idx === -1 ? 0 : idx;
 });

From dc81357152764f7078dfdcf305621455a5b5c34d Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 22:29:39 +1000
Subject: [PATCH 15/59] feat(ui): add img2img via control layers to graph
 builders

---
 .../graph/addInitialImageToLinearGraph.ts     | 117 ++++++++++++++++++
 .../util/graph/addSeamlessToLinearGraph.ts    |   2 +
 .../nodes/util/graph/addVAEToGraph.ts         |   7 +-
 .../graph/buildLinearSDXLTextToImageGraph.ts  |   7 +-
 .../util/graph/buildLinearTextToImageGraph.ts |   7 +-
 .../features/nodes/util/graph/constants.ts    |   2 +
 6 files changed, 136 insertions(+), 6 deletions(-)
 create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts

diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts
new file mode 100644
index 0000000000..4334a7cd31
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts
@@ -0,0 +1,117 @@
+import type { RootState } from 'app/store/store';
+import { isInitialImageLayer } from 'features/controlLayers/store/controlLayersSlice';
+import type { ImageResizeInvocation, ImageToLatentsInvocation, NonNullableGraph } from 'services/api/types';
+import { assert } from 'tsafe';
+
+import { IMAGE_TO_LATENTS, NOISE, RESIZE } from './constants';
+
+export const addInitialImageToLinearGraph = (
+  state: RootState,
+  graph: NonNullableGraph,
+  denoiseNodeId: string
+): void => {
+  // Remove Existing UNet Connections
+  const { img2imgStrength, vaePrecision, model } = state.generation;
+  const { refinerModel, refinerStart } = state.sdxl;
+  const { width, height } = state.controlLayers.present.size;
+  const initialImageLayer = state.controlLayers.present.layers.find(isInitialImageLayer);
+  const initialImage = initialImageLayer?.isEnabled ? initialImageLayer?.image : null;
+
+  if (!initialImage) {
+    return;
+  }
+
+  const useRefinerStartEnd = model?.base === 'sdxl' && Boolean(refinerModel);
+
+  const denoiseNode = graph.nodes[denoiseNodeId];
+  assert(denoiseNode?.type === 'denoise_latents', `Missing denoise node or incorrect type: ${denoiseNode?.type}`);
+
+  denoiseNode.denoising_start = useRefinerStartEnd ? Math.min(refinerStart, 1 - img2imgStrength) : 1 - img2imgStrength;
+  denoiseNode.denoising_end = useRefinerStartEnd ? refinerStart : 1;
+
+  // We conditionally hook the image in depending on if a resize is needed
+  const i2lNode: ImageToLatentsInvocation = {
+    type: 'i2l',
+    id: IMAGE_TO_LATENTS,
+    is_intermediate: true,
+    use_cache: true,
+    fp32: vaePrecision === 'fp32',
+  };
+
+  graph.nodes[i2lNode.id] = i2lNode;
+  graph.edges.push({
+    source: {
+      node_id: IMAGE_TO_LATENTS,
+      field: 'latents',
+    },
+    destination: {
+      node_id: denoiseNode.id,
+      field: 'latents',
+    },
+  });
+
+  if (initialImage.width !== width || initialImage.height !== height) {
+    // The init image needs to be resized to the specified width and height before being passed to `IMAGE_TO_LATENTS`
+
+    // Create a resize node, explicitly setting its image
+    const resizeNode: ImageResizeInvocation = {
+      id: RESIZE,
+      type: 'img_resize',
+      image: {
+        image_name: initialImage.imageName,
+      },
+      is_intermediate: true,
+      width,
+      height,
+    };
+
+    graph.nodes[RESIZE] = resizeNode;
+
+    // The `RESIZE` node then passes its image to `IMAGE_TO_LATENTS`
+    graph.edges.push({
+      source: { node_id: RESIZE, field: 'image' },
+      destination: {
+        node_id: IMAGE_TO_LATENTS,
+        field: 'image',
+      },
+    });
+
+    // The `RESIZE` node also passes its width and height to `NOISE`
+    graph.edges.push({
+      source: { node_id: RESIZE, field: 'width' },
+      destination: {
+        node_id: NOISE,
+        field: 'width',
+      },
+    });
+
+    graph.edges.push({
+      source: { node_id: RESIZE, field: 'height' },
+      destination: {
+        node_id: NOISE,
+        field: 'height',
+      },
+    });
+  } else {
+    // We are not resizing, so we need to set the image on the `IMAGE_TO_LATENTS` node explicitly
+    i2lNode.image = {
+      image_name: initialImage.imageName,
+    };
+
+    // Pass the image's dimensions to the `NOISE` node
+    graph.edges.push({
+      source: { node_id: IMAGE_TO_LATENTS, field: 'width' },
+      destination: {
+        node_id: NOISE,
+        field: 'width',
+      },
+    });
+    graph.edges.push({
+      source: { node_id: IMAGE_TO_LATENTS, field: 'height' },
+      destination: {
+        node_id: NOISE,
+        field: 'height',
+      },
+    });
+  }
+};
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addSeamlessToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addSeamlessToLinearGraph.ts
index d986130d64..24e8be6546 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addSeamlessToLinearGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addSeamlessToLinearGraph.ts
@@ -7,6 +7,7 @@ import {
   SDXL_CANVAS_INPAINT_GRAPH,
   SDXL_CANVAS_OUTPAINT_GRAPH,
   SDXL_CANVAS_TEXT_TO_IMAGE_GRAPH,
+  SDXL_CONTROL_LAYERS_GRAPH,
   SDXL_DENOISE_LATENTS,
   SDXL_IMAGE_TO_IMAGE_GRAPH,
   SDXL_TEXT_TO_IMAGE_GRAPH,
@@ -54,6 +55,7 @@ export const addSeamlessToLinearGraph = (
   let denoisingNodeId = DENOISE_LATENTS;
 
   if (
+    graph.id === SDXL_CONTROL_LAYERS_GRAPH ||
     graph.id === SDXL_TEXT_TO_IMAGE_GRAPH ||
     graph.id === SDXL_IMAGE_TO_IMAGE_GRAPH ||
     graph.id === SDXL_CANVAS_TEXT_TO_IMAGE_GRAPH ||
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts
index 347027c539..ed705a08e6 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts
@@ -7,6 +7,7 @@ import {
   CANVAS_OUTPAINT_GRAPH,
   CANVAS_OUTPUT,
   CANVAS_TEXT_TO_IMAGE_GRAPH,
+  CONTROL_LAYERS_GRAPH,
   IMAGE_TO_IMAGE_GRAPH,
   IMAGE_TO_LATENTS,
   INPAINT_CREATE_MASK,
@@ -17,11 +18,11 @@ import {
   SDXL_CANVAS_INPAINT_GRAPH,
   SDXL_CANVAS_OUTPAINT_GRAPH,
   SDXL_CANVAS_TEXT_TO_IMAGE_GRAPH,
+  SDXL_CONTROL_LAYERS_GRAPH,
   SDXL_IMAGE_TO_IMAGE_GRAPH,
   SDXL_REFINER_SEAMLESS,
   SDXL_TEXT_TO_IMAGE_GRAPH,
   SEAMLESS,
-  TEXT_TO_IMAGE_GRAPH,
   VAE_LOADER,
 } from './constants';
 import { upsertMetadata } from './metadata';
@@ -52,7 +53,8 @@ export const addVAEToGraph = async (
   }
 
   if (
-    graph.id === TEXT_TO_IMAGE_GRAPH ||
+    graph.id === CONTROL_LAYERS_GRAPH ||
+    graph.id === SDXL_CONTROL_LAYERS_GRAPH ||
     graph.id === IMAGE_TO_IMAGE_GRAPH ||
     graph.id === SDXL_TEXT_TO_IMAGE_GRAPH ||
     graph.id === SDXL_IMAGE_TO_IMAGE_GRAPH
@@ -100,6 +102,7 @@ export const addVAEToGraph = async (
   }
 
   if (
+    graph.id === SDXL_CONTROL_LAYERS_GRAPH ||
     graph.id === IMAGE_TO_IMAGE_GRAPH ||
     graph.id === SDXL_IMAGE_TO_IMAGE_GRAPH ||
     graph.id === CANVAS_IMAGE_TO_IMAGE_GRAPH ||
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 9134ef9de7..d3ee9e4a51 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLTextToImageGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLTextToImageGraph.ts
@@ -2,6 +2,7 @@ import { logger } from 'app/logging/logger';
 import type { RootState } from 'app/store/store';
 import { fetchModelConfigWithTypeGuard } from 'features/metadata/util/modelFetchingHelpers';
 import { addControlLayersToGraph } from 'features/nodes/util/graph/addControlLayersToGraph';
+import { addInitialImageToLinearGraph } from 'features/nodes/util/graph/addInitialImageToLinearGraph';
 import { isNonRefinerMainModelConfig, type NonNullableGraph } from 'services/api/types';
 
 import { addNSFWCheckerToGraph } from './addNSFWCheckerToGraph';
@@ -15,10 +16,10 @@ import {
   NEGATIVE_CONDITIONING,
   NOISE,
   POSITIVE_CONDITIONING,
+  SDXL_CONTROL_LAYERS_GRAPH,
   SDXL_DENOISE_LATENTS,
   SDXL_MODEL_LOADER,
   SDXL_REFINER_SEAMLESS,
-  SDXL_TEXT_TO_IMAGE_GRAPH,
   SEAMLESS,
 } from './constants';
 import { getBoardField, getIsIntermediate, getSDXLStylePrompts } from './graphBuilderUtils';
@@ -70,7 +71,7 @@ export const buildLinearSDXLTextToImageGraph = async (state: RootState): Promise
 
   // copy-pasted graph from node editor, filled in with state values & friendly node ids
   const graph: NonNullableGraph = {
-    id: SDXL_TEXT_TO_IMAGE_GRAPH,
+    id: SDXL_CONTROL_LAYERS_GRAPH,
     nodes: {
       [modelLoaderNodeId]: {
         type: 'sdxl_model_loader',
@@ -241,6 +242,8 @@ export const buildLinearSDXLTextToImageGraph = async (state: RootState): Promise
     LATENTS_TO_IMAGE
   );
 
+  addInitialImageToLinearGraph(state, graph, SDXL_DENOISE_LATENTS);
+
   // Add Seamless To Graph
   if (seamlessXAxis || seamlessYAxis) {
     addSeamlessToLinearGraph(state, graph, modelLoaderNodeId);
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 340a24bca4..c2ad5384e0 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearTextToImageGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearTextToImageGraph.ts
@@ -2,6 +2,7 @@ import { logger } from 'app/logging/logger';
 import type { RootState } from 'app/store/store';
 import { fetchModelConfigWithTypeGuard } from 'features/metadata/util/modelFetchingHelpers';
 import { addControlLayersToGraph } from 'features/nodes/util/graph/addControlLayersToGraph';
+import { addInitialImageToLinearGraph } from 'features/nodes/util/graph/addInitialImageToLinearGraph';
 import { getBoardField, getIsIntermediate } from 'features/nodes/util/graph/graphBuilderUtils';
 import { isNonRefinerMainModelConfig, type NonNullableGraph } from 'services/api/types';
 
@@ -13,6 +14,7 @@ import { addVAEToGraph } from './addVAEToGraph';
 import { addWatermarkerToGraph } from './addWatermarkerToGraph';
 import {
   CLIP_SKIP,
+  CONTROL_LAYERS_GRAPH,
   DENOISE_LATENTS,
   LATENTS_TO_IMAGE,
   MAIN_MODEL_LOADER,
@@ -20,7 +22,6 @@ import {
   NOISE,
   POSITIVE_CONDITIONING,
   SEAMLESS,
-  TEXT_TO_IMAGE_GRAPH,
 } from './constants';
 import { addCoreMetadataNode, getModelMetadataField } from './metadata';
 
@@ -66,7 +67,7 @@ export const buildLinearTextToImageGraph = async (state: RootState): Promise<Non
   // copy-pasted graph from node editor, filled in with state values & friendly node ids
 
   const graph: NonNullableGraph = {
-    id: TEXT_TO_IMAGE_GRAPH,
+    id: CONTROL_LAYERS_GRAPH,
     nodes: {
       [modelLoaderNodeId]: {
         type: 'main_model_loader',
@@ -231,6 +232,8 @@ export const buildLinearTextToImageGraph = async (state: RootState): Promise<Non
     LATENTS_TO_IMAGE
   );
 
+  addInitialImageToLinearGraph(state, graph, DENOISE_LATENTS);
+
   // Add Seamless To Graph
   if (seamlessXAxis || seamlessYAxis) {
     addSeamlessToLinearGraph(state, graph, modelLoaderNodeId);
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/constants.ts b/invokeai/frontend/web/src/features/nodes/util/graph/constants.ts
index 9866d836db..fdb789dab3 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/constants.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/constants.ts
@@ -55,6 +55,8 @@ export const POSITIVE_CONDITIONING_COLLECT = 'positive_conditioning_collect';
 export const NEGATIVE_CONDITIONING_COLLECT = 'negative_conditioning_collect';
 
 // friendly graph ids
+export const CONTROL_LAYERS_GRAPH = 'control_layers_graph';
+export const SDXL_CONTROL_LAYERS_GRAPH = 'sdxl_control_layers_graph';
 export const TEXT_TO_IMAGE_GRAPH = 'text_to_image_graph';
 export const IMAGE_TO_IMAGE_GRAPH = 'image_to_image_graph';
 export const CANVAS_TEXT_TO_IMAGE_GRAPH = 'canvas_text_to_image_graph';

From 26d3ec3fced7e7303cdb1eae8a70a0744fbfb940 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 22:37:53 +1000
Subject: [PATCH 16/59] fix(ui): destroy initial image layer after deleting

---
 .../frontend/web/src/features/controlLayers/util/renderers.ts  | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
index 3e34542e87..09db523a61 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
@@ -57,7 +57,8 @@ const STAGE_BG_DATAURL =
 
 const mapId = (object: { id: string }) => object.id;
 
-const selectRenderableLayers = (n: Konva.Node) => n.name() === RG_LAYER_NAME || n.name() === CA_LAYER_NAME;
+const selectRenderableLayers = (n: Konva.Node) =>
+  n.name() === RG_LAYER_NAME || n.name() === CA_LAYER_NAME || n.name() === INITIAL_IMAGE_LAYER_NAME;
 
 const selectVectorMaskObjects = (node: Konva.Node) => {
   return node.name() === RG_LAYER_LINE_NAME || node.name() === RG_LAYER_RECT_NAME;

From 7d58908e32579ce9d06db561ed178ac7538d279b Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 23:11:25 +1000
Subject: [PATCH 17/59] fix(ui): fix img2img graphs w/ control layers

---
 .../src/features/nodes/util/graph/addVAEToGraph.ts   | 12 +++++++-----
 1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts
index ed705a08e6..5482c1c680 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts
@@ -102,11 +102,13 @@ export const addVAEToGraph = async (
   }
 
   if (
-    graph.id === SDXL_CONTROL_LAYERS_GRAPH ||
-    graph.id === IMAGE_TO_IMAGE_GRAPH ||
-    graph.id === SDXL_IMAGE_TO_IMAGE_GRAPH ||
-    graph.id === CANVAS_IMAGE_TO_IMAGE_GRAPH ||
-    graph.id === SDXL_CANVAS_IMAGE_TO_IMAGE_GRAPH
+    (graph.id === CONTROL_LAYERS_GRAPH ||
+      graph.id === SDXL_CONTROL_LAYERS_GRAPH ||
+      graph.id === IMAGE_TO_IMAGE_GRAPH ||
+      graph.id === SDXL_IMAGE_TO_IMAGE_GRAPH ||
+      graph.id === CANVAS_IMAGE_TO_IMAGE_GRAPH ||
+      graph.id === SDXL_CANVAS_IMAGE_TO_IMAGE_GRAPH) &&
+    Boolean(graph.nodes[IMAGE_TO_LATENTS])
   ) {
     graph.edges.push({
       source: {

From a6ac184211c3ed0bff4c31d74129cfd9cb0e02f4 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 2 May 2024 23:38:29 +1000
Subject: [PATCH 18/59] tidy(ui): excise img2img tab

---
 .../middleware/listenerMiddleware/index.ts    |   4 -
 .../listeners/boardAndImagesDeleted.ts        |   7 -
 .../listeners/enqueueRequestedLinear.ts       |  16 +-
 .../listeners/imageDeleted.ts                 |  11 -
 .../listeners/imageDropped.ts                 |  14 +-
 .../listeners/imageToDeleteSelected.ts        |   1 -
 .../listeners/imageUploaded.ts                |  13 +-
 .../listeners/initialImageSelected.ts         |  21 -
 .../src/common/hooks/useFullscreenDropzone.ts |   4 -
 .../web/src/common/hooks/useGlobalHotkeys.ts  |  14 +-
 .../src/common/hooks/useIsReadyToEnqueue.ts   |   6 +-
 .../controlLayers/store/controlLayersSlice.ts |   2 +-
 .../components/DeleteImageModal.tsx           |   1 -
 .../components/ImageUsageMessage.tsx          |   1 -
 .../deleteImageModal/store/selectors.ts       |   3 -
 .../features/deleteImageModal/store/types.ts  |   1 -
 .../web/src/features/dnd/types/index.ts       |   5 -
 .../web/src/features/dnd/util/isValidDrop.ts  |   2 -
 .../components/Boards/DeleteBoardModal.tsx    |   1 -
 .../CurrentImage/CurrentImageButtons.tsx      |   7 +-
 .../SingleSelectionMenuItems.tsx              |   4 +-
 .../gallery/components/ImageViewer.tsx        |   1 -
 .../src/features/metadata/util/recallers.ts   |   4 +-
 .../util/graph/addSeamlessToLinearGraph.ts    |   4 -
 .../nodes/util/graph/addVAEToGraph.ts         |  13 +-
 .../graph/buildLinearImageToImageGraph.ts     | 369 -----------------
 .../graph/buildLinearSDXLImageToImageGraph.ts | 390 ------------------
 .../features/nodes/util/graph/constants.ts    |   4 -
 .../ImageToImage/ImageToImageFit.tsx          |  34 --
 .../components/ImageToImage/InitialImage.tsx  |  61 ---
 .../ImageToImage/InitialImageDisplay.tsx      |  87 ----
 .../parameters/hooks/usePreselectedImage.ts   |   4 +-
 .../src/features/parameters/store/actions.ts  |   3 -
 .../parameters/store/generationSlice.ts       |  15 -
 .../src/features/parameters/store/types.ts    |   2 -
 .../ImageSettingsAccordion.tsx                |   4 +-
 .../src/features/ui/components/InvokeTabs.tsx |   9 +-
 .../ui/components/tabs/ImageToImageTab.tsx    |  56 ---
 .../web/src/features/ui/store/tabMap.tsx      |   2 +-
 .../web/src/features/ui/store/uiSlice.ts      |   4 -
 .../frontend/web/src/services/api/types.ts    |   5 -
 41 files changed, 25 insertions(+), 1184 deletions(-)
 delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts
 delete mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/buildLinearImageToImageGraph.ts
 delete mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLImageToImageGraph.ts
 delete mode 100644 invokeai/frontend/web/src/features/parameters/components/ImageToImage/ImageToImageFit.tsx
 delete mode 100644 invokeai/frontend/web/src/features/parameters/components/ImageToImage/InitialImage.tsx
 delete mode 100644 invokeai/frontend/web/src/features/parameters/components/ImageToImage/InitialImageDisplay.tsx
 delete mode 100644 invokeai/frontend/web/src/features/ui/components/tabs/ImageToImageTab.tsx

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 36040b5e41..0c0c8ed2bc 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
@@ -32,7 +32,6 @@ import { addImagesStarredListener } from 'app/store/middleware/listenerMiddlewar
 import { addImagesUnstarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesUnstarred';
 import { addImageToDeleteSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected';
 import { addImageUploadedFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageUploaded';
-import { addInitialImageSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/initialImageSelected';
 import { addModelSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelSelected';
 import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelsLoaded';
 import { addDynamicPromptsListener } from 'app/store/middleware/listenerMiddleware/listeners/promptChanged';
@@ -73,9 +72,6 @@ const startAppListening = listenerMiddleware.startListening as AppStartListening
 // Image uploaded
 addImageUploadedFulfilledListener(startAppListening);
 
-// Image selected
-addInitialImageSelectedListener(startAppListening);
-
 // Image deleted
 addRequestedSingleImageDeletionListener(startAppListening);
 addDeleteBoardAndImagesFulfilledListener(startAppListening);
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts
index 8e8d3f4b99..d7ab8430ca 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts
@@ -3,7 +3,6 @@ import { resetCanvas } from 'features/canvas/store/canvasSlice';
 import { controlAdaptersReset } from 'features/controlAdapters/store/controlAdaptersSlice';
 import { getImageUsage } from 'features/deleteImageModal/store/selectors';
 import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
-import { clearInitialImage } from 'features/parameters/store/generationSlice';
 import { imagesApi } from 'services/api/endpoints/images';
 
 export const addDeleteBoardAndImagesFulfilledListener = (startAppListening: AppStartListening) => {
@@ -14,7 +13,6 @@ export const addDeleteBoardAndImagesFulfilledListener = (startAppListening: AppS
 
       // Remove all deleted images from the UI
 
-      let wasInitialImageReset = false;
       let wasCanvasReset = false;
       let wasNodeEditorReset = false;
       let wereControlAdaptersReset = false;
@@ -23,11 +21,6 @@ export const addDeleteBoardAndImagesFulfilledListener = (startAppListening: AppS
       deleted_images.forEach((image_name) => {
         const imageUsage = getImageUsage(generation, canvas, nodes, controlAdapters, image_name);
 
-        if (imageUsage.isInitialImage && !wasInitialImageReset) {
-          dispatch(clearInitialImage());
-          wasInitialImageReset = true;
-        }
-
         if (imageUsage.isCanvasImage && !wasCanvasReset) {
           dispatch(resetCanvas());
           wasCanvasReset = true;
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
index f923edb99a..0cbc73de35 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
@@ -1,8 +1,6 @@
 import { enqueueRequested } from 'app/store/actions';
 import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
 import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
-import { buildLinearImageToImageGraph } from 'features/nodes/util/graph/buildLinearImageToImageGraph';
-import { buildLinearSDXLImageToImageGraph } from 'features/nodes/util/graph/buildLinearSDXLImageToImageGraph';
 import { buildLinearSDXLTextToImageGraph } from 'features/nodes/util/graph/buildLinearSDXLTextToImageGraph';
 import { buildLinearTextToImageGraph } from 'features/nodes/util/graph/buildLinearTextToImageGraph';
 import { queueApi } from 'services/api/endpoints/queue';
@@ -10,7 +8,7 @@ import { queueApi } from 'services/api/endpoints/queue';
 export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) => {
   startAppListening({
     predicate: (action): action is ReturnType<typeof enqueueRequested> =>
-      enqueueRequested.match(action) && (action.payload.tabName === 'txt2img' || action.payload.tabName === 'img2img'),
+      enqueueRequested.match(action) && action.payload.tabName === 'txt2img',
     effect: async (action, { getState, dispatch }) => {
       const state = getState();
       const model = state.generation.model;
@@ -19,17 +17,9 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
       let graph;
 
       if (model && model.base === 'sdxl') {
-        if (action.payload.tabName === 'txt2img') {
-          graph = await buildLinearSDXLTextToImageGraph(state);
-        } else {
-          graph = await buildLinearSDXLImageToImageGraph(state);
-        }
+        graph = await buildLinearSDXLTextToImageGraph(state);
       } else {
-        if (action.payload.tabName === 'txt2img') {
-          graph = await buildLinearTextToImageGraph(state);
-        } else {
-          graph = await buildLinearImageToImageGraph(state);
-        }
+        graph = await buildLinearTextToImageGraph(state);
       }
 
       const batchConfig = prepareLinearUIBatch(state, graph, prepend);
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts
index 9bbbf80263..451c26629e 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts
@@ -14,7 +14,6 @@ import { imageSelected } from 'features/gallery/store/gallerySlice';
 import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
 import { isImageFieldInputInstance } from 'features/nodes/types/field';
 import { isInvocationNode } from 'features/nodes/types/invocation';
-import { clearInitialImage } from 'features/parameters/store/generationSlice';
 import { clamp, forEach } from 'lodash-es';
 import { api } from 'services/api';
 import { imagesApi } from 'services/api/endpoints/images';
@@ -73,11 +72,6 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
       }
 
       imageDTOs.forEach((imageDTO) => {
-        // reset init image if we deleted it
-        if (getState().generation.initialImage?.imageName === imageDTO.image_name) {
-          dispatch(clearInitialImage());
-        }
-
         // reset control adapters that use the deleted images
         forEach(selectControlAdapterAll(getState().controlAdapters), (ca) => {
           if (
@@ -168,11 +162,6 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
         }
 
         imageDTOs.forEach((imageDTO) => {
-          // reset init image if we deleted it
-          if (getState().generation.initialImage?.imageName === imageDTO.image_name) {
-            dispatch(clearInitialImage());
-          }
-
           // reset control adapters that use the deleted images
           forEach(selectControlAdapterAll(getState().controlAdapters), (ca) => {
             if (
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 734867d0e1..9bc9635299 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
@@ -16,7 +16,7 @@ import {
 import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
 import { imageSelected } from 'features/gallery/store/gallerySlice';
 import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
-import { initialImageChanged, selectOptimalDimension } from 'features/parameters/store/generationSlice';
+import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
 import { imagesApi } from 'services/api/endpoints/images';
 
 export const dndDropped = createAction<{
@@ -53,18 +53,6 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
         return;
       }
 
-      /**
-       * Image dropped on initial image
-       */
-      if (
-        overData.actionType === 'SET_INITIAL_IMAGE' &&
-        activeData.payloadType === 'IMAGE_DTO' &&
-        activeData.payload.imageDTO
-      ) {
-        dispatch(initialImageChanged(activeData.payload.imageDTO));
-        return;
-      }
-
       /**
        * Image dropped on ControlNet
        */
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts
index d20c0c7c23..845c9a21f2 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts
@@ -14,7 +14,6 @@ export const addImageToDeleteSelectedListener = (startAppListening: AppStartList
 
       const isImageInUse =
         imagesUsage.some((i) => i.isCanvasImage) ||
-        imagesUsage.some((i) => i.isInitialImage) ||
         imagesUsage.some((i) => i.isControlImage) ||
         imagesUsage.some((i) => i.isNodesImage);
 
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 8f93d023ce..d5d74bf668 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
@@ -13,7 +13,7 @@ import {
   rgLayerIPAdapterImageChanged,
 } from 'features/controlLayers/store/controlLayersSlice';
 import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
-import { initialImageChanged, selectOptimalDimension } from 'features/parameters/store/generationSlice';
+import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
 import { addToast } from 'features/system/store/systemSlice';
 import { t } from 'i18next';
 import { omit } from 'lodash-es';
@@ -158,17 +158,6 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis
         );
       }
 
-      if (postUploadAction?.type === 'SET_INITIAL_IMAGE') {
-        dispatch(initialImageChanged(imageDTO));
-        dispatch(
-          addToast({
-            ...DEFAULT_UPLOADED_TOAST,
-            description: t('toast.setInitialImage'),
-          })
-        );
-        return;
-      }
-
       if (postUploadAction?.type === 'SET_NODES_IMAGE') {
         const { nodeId, fieldName } = postUploadAction;
         dispatch(fieldImageValueChanged({ nodeId, fieldName, value: imageDTO }));
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts
deleted file mode 100644
index 735ce8367a..0000000000
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
-import { initialImageSelected } from 'features/parameters/store/actions';
-import { initialImageChanged } from 'features/parameters/store/generationSlice';
-import { addToast } from 'features/system/store/systemSlice';
-import { makeToast } from 'features/system/util/makeToast';
-import { t } from 'i18next';
-
-export const addInitialImageSelectedListener = (startAppListening: AppStartListening) => {
-  startAppListening({
-    actionCreator: initialImageSelected,
-    effect: (action, { dispatch }) => {
-      if (!action.payload) {
-        dispatch(addToast(makeToast({ title: t('toast.imageNotLoadedDesc'), status: 'error' })));
-        return;
-      }
-
-      dispatch(initialImageChanged(action.payload));
-      dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
-    },
-  });
-};
diff --git a/invokeai/frontend/web/src/common/hooks/useFullscreenDropzone.ts b/invokeai/frontend/web/src/common/hooks/useFullscreenDropzone.ts
index 350e09b6e5..c51fae81d3 100644
--- a/invokeai/frontend/web/src/common/hooks/useFullscreenDropzone.ts
+++ b/invokeai/frontend/web/src/common/hooks/useFullscreenDropzone.ts
@@ -21,10 +21,6 @@ const selectPostUploadAction = createMemoizedSelector(activeTabNameSelector, (ac
     postUploadAction = { type: 'SET_CANVAS_INITIAL_IMAGE' };
   }
 
-  if (activeTabName === 'img2img') {
-    postUploadAction = { type: 'SET_INITIAL_IMAGE' };
-  }
-
   return postUploadAction;
 });
 
diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts
index bbb7897575..fe65dd5983 100644
--- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts
+++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts
@@ -75,21 +75,13 @@ export const useGlobalHotkeys = () => {
   useHotkeys(
     '2',
     () => {
-      dispatch(setActiveTab('img2img'));
+      dispatch(setActiveTab('unifiedCanvas'));
     },
     [dispatch]
   );
 
   useHotkeys(
     '3',
-    () => {
-      dispatch(setActiveTab('unifiedCanvas'));
-    },
-    [dispatch]
-  );
-
-  useHotkeys(
-    '4',
     () => {
       dispatch(setActiveTab('nodes'));
     },
@@ -97,7 +89,7 @@ export const useGlobalHotkeys = () => {
   );
 
   useHotkeys(
-    '5',
+    '4',
     () => {
       if (isModelManagerEnabled) {
         dispatch(setActiveTab('modelManager'));
@@ -107,7 +99,7 @@ export const useGlobalHotkeys = () => {
   );
 
   useHotkeys(
-    isModelManagerEnabled ? '6' : '5',
+    isModelManagerEnabled ? '5' : '4',
     () => {
       dispatch(setActiveTab('queue'));
     },
diff --git a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts
index d06fc259df..f7c980efeb 100644
--- a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts
+++ b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts
@@ -28,7 +28,7 @@ const selector = createMemoizedSelector(
     activeTabNameSelector,
   ],
   (controlAdapters, generation, system, nodes, dynamicPrompts, controlLayers, activeTabName) => {
-    const { initialImage, model } = generation;
+    const { model } = generation;
     const { positivePrompt } = controlLayers.present;
 
     const { isConnected } = system;
@@ -40,10 +40,6 @@ const selector = createMemoizedSelector(
       reasons.push(i18n.t('parameters.invoke.systemDisconnected'));
     }
 
-    if (activeTabName === 'img2img' && !initialImage) {
-      reasons.push(i18n.t('parameters.invoke.noInitialImageSelected'));
-    }
-
     if (activeTabName === 'nodes') {
       if (nodes.shouldValidateGraph) {
         if (!nodes.nodes.length) {
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index 2cb14509d7..6b3dfc5e6c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -627,7 +627,7 @@ export const controlLayersSlice = createSlice({
       reducer: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
         const { layerId, imageDTO } = action.payload;
         // Highlander! There can be only one!
-        assert(!state.layers.find(isInitialImageLayer));
+        state.layers = state.layers.filter((l) => isInitialImageLayer(l));
         const layer: InitialImageLayer = {
           id: layerId,
           type: 'initial_image_layer',
diff --git a/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx b/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx
index d5c8f11f81..e3ee0b3852 100644
--- a/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx
+++ b/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx
@@ -38,7 +38,6 @@ const selectImageUsages = createMemoizedSelector(
     );
 
     const imageUsageSummary: ImageUsage = {
-      isInitialImage: some(allImageUsage, (i) => i.isInitialImage),
       isCanvasImage: some(allImageUsage, (i) => i.isCanvasImage),
       isNodesImage: some(allImageUsage, (i) => i.isNodesImage),
       isControlImage: some(allImageUsage, (i) => i.isControlImage),
diff --git a/invokeai/frontend/web/src/features/deleteImageModal/components/ImageUsageMessage.tsx b/invokeai/frontend/web/src/features/deleteImageModal/components/ImageUsageMessage.tsx
index 5a6856f346..ec613409e7 100644
--- a/invokeai/frontend/web/src/features/deleteImageModal/components/ImageUsageMessage.tsx
+++ b/invokeai/frontend/web/src/features/deleteImageModal/components/ImageUsageMessage.tsx
@@ -29,7 +29,6 @@ const ImageUsageMessage = (props: Props) => {
     <>
       <Text>{topMessage}</Text>
       <UnorderedList paddingInlineStart={6}>
-        {imageUsage.isInitialImage && <ListItem>{t('common.img2img')}</ListItem>}
         {imageUsage.isCanvasImage && <ListItem>{t('common.unifiedCanvas')}</ListItem>}
         {imageUsage.isControlImage && <ListItem>{t('common.controlNet')}</ListItem>}
         {imageUsage.isNodesImage && <ListItem>{t('common.nodeEditor')}</ListItem>}
diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts
index f54f9a0dbb..b9540a3ecf 100644
--- a/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts
+++ b/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts
@@ -25,8 +25,6 @@ export const getImageUsage = (
   controlAdapters: ControlAdaptersState,
   image_name: string
 ) => {
-  const isInitialImage = generation.initialImage?.imageName === image_name;
-
   const isCanvasImage = canvas.layerState.objects.some((obj) => obj.kind === 'image' && obj.imageName === image_name);
 
   const isNodesImage = nodes.nodes.filter(isInvocationNode).some((node) => {
@@ -41,7 +39,6 @@ export const getImageUsage = (
   );
 
   const imageUsage: ImageUsage = {
-    isInitialImage,
     isCanvasImage,
     isNodesImage,
     isControlImage,
diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/types.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/types.ts
index cd8f3aa5eb..f0aaf7b097 100644
--- a/invokeai/frontend/web/src/features/deleteImageModal/store/types.ts
+++ b/invokeai/frontend/web/src/features/deleteImageModal/store/types.ts
@@ -6,7 +6,6 @@ export type DeleteImageState = {
 };
 
 export type ImageUsage = {
-  isInitialImage: boolean;
   isCanvasImage: boolean;
   isNodesImage: boolean;
   isControlImage: boolean;
diff --git a/invokeai/frontend/web/src/features/dnd/types/index.ts b/invokeai/frontend/web/src/features/dnd/types/index.ts
index b8d3cfe31e..4d09c759eb 100644
--- a/invokeai/frontend/web/src/features/dnd/types/index.ts
+++ b/invokeai/frontend/web/src/features/dnd/types/index.ts
@@ -22,10 +22,6 @@ type CurrentImageDropData = BaseDropData & {
   actionType: 'SET_CURRENT_IMAGE';
 };
 
-type InitialImageDropData = BaseDropData & {
-  actionType: 'SET_INITIAL_IMAGE';
-};
-
 type ControlAdapterDropData = BaseDropData & {
   actionType: 'SET_CONTROL_ADAPTER_IMAGE';
   context: {
@@ -85,7 +81,6 @@ export type RemoveFromBoardDropData = BaseDropData & {
 
 export type TypesafeDroppableData =
   | CurrentImageDropData
-  | InitialImageDropData
   | ControlAdapterDropData
   | CanvasInitialImageDropData
   | NodesImageDropData
diff --git a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts b/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts
index 757a21bd5c..b701c72947 100644
--- a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts
+++ b/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts
@@ -15,8 +15,6 @@ export const isValidDrop = (overData: TypesafeDroppableData | undefined, active:
   switch (actionType) {
     case 'SET_CURRENT_IMAGE':
       return payloadType === 'IMAGE_DTO';
-    case 'SET_INITIAL_IMAGE':
-      return payloadType === 'IMAGE_DTO';
     case 'SET_CONTROL_ADAPTER_IMAGE':
       return payloadType === 'IMAGE_DTO';
     case 'SET_CA_LAYER_IMAGE':
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx
index 6581033aaa..5f01fd9f29 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx
@@ -50,7 +50,6 @@ const DeleteBoardModal = (props: Props) => {
           );
 
           const imageUsageSummary: ImageUsage = {
-            isInitialImage: some(allImageUsage, (i) => i.isInitialImage),
             isCanvasImage: some(allImageUsage, (i) => i.isCanvasImage),
             isNodesImage: some(allImageUsage, (i) => i.isNodesImage),
             isControlImage: some(allImageUsage, (i) => i.isControlImage),
diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx
index 880fdbca6c..2e4519af5e 100644
--- a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx
@@ -4,6 +4,7 @@ import { skipToken } from '@reduxjs/toolkit/query';
 import { useAppToaster } from 'app/components/Toaster';
 import { upscaleRequested } from 'app/store/middleware/listenerMiddleware/listeners/upscaleRequested';
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { iiLayerAdded } from 'features/controlLayers/store/controlLayersSlice';
 import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
 import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
 import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
@@ -13,7 +14,6 @@ import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors
 import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
 import { parseAndRecallImageDimensions } from 'features/metadata/util/handlers';
 import ParamUpscalePopover from 'features/parameters/components/Upscale/ParamUpscaleSettings';
-import { initialImageSelected } from 'features/parameters/store/actions';
 import { useIsQueueMutationInProgress } from 'features/queue/hooks/useIsQueueMutationInProgress';
 import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
 import { selectSystemSlice } from 'features/system/store/systemSlice';
@@ -86,8 +86,11 @@ const CurrentImageButtons = () => {
   useHotkeys('d', handleUseSize, [handleUseSize]);
 
   const handleSendToImageToImage = useCallback(() => {
+    if (!imageDTO) {
+      return;
+    }
     dispatch(sentImageToImg2Img());
-    dispatch(initialImageSelected(imageDTO));
+    dispatch(iiLayerAdded(imageDTO));
   }, [dispatch, imageDTO]);
 
   useHotkeys('shift+i', handleSendToImageToImage, [imageDTO]);
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx
index aff74481ca..1dfd839b31 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx
@@ -7,10 +7,10 @@ import { useCopyImageToClipboard } from 'common/hooks/useCopyImageToClipboard';
 import { useDownloadImage } from 'common/hooks/useDownloadImage';
 import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
 import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice';
+import { iiLayerAdded } from 'features/controlLayers/store/controlLayersSlice';
 import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
 import { useImageActions } from 'features/gallery/hooks/useImageActions';
 import { sentImageToCanvas, sentImageToImg2Img } from 'features/gallery/store/actions';
-import { initialImageSelected } from 'features/parameters/store/actions';
 import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
 import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
 import { setActiveTab } from 'features/ui/store/uiSlice';
@@ -72,7 +72,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
 
   const handleSendToImageToImage = useCallback(() => {
     dispatch(sentImageToImg2Img());
-    dispatch(initialImageSelected(imageDTO));
+    dispatch(iiLayerAdded(imageDTO));
   }, [dispatch, imageDTO]);
 
   const handleSendToCanvas = useCallback(() => {
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
index 563d1b2285..92b0394abd 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
@@ -12,7 +12,6 @@ import { PiArrowLeftBold } from 'react-icons/pi';
 
 const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
   txt2img: 'common.txt2img',
-  img2img: 'common.img2img',
   unifiedCanvas: 'common.unifiedCanvas',
   nodes: 'common.nodes',
   modelManager: 'modelManager.modelManager',
diff --git a/invokeai/frontend/web/src/features/metadata/util/recallers.ts b/invokeai/frontend/web/src/features/metadata/util/recallers.ts
index c04259ac62..b29d937159 100644
--- a/invokeai/frontend/web/src/features/metadata/util/recallers.ts
+++ b/invokeai/frontend/web/src/features/metadata/util/recallers.ts
@@ -10,6 +10,7 @@ import {
   caLayerControlNetsDeleted,
   caLayerT2IAdaptersDeleted,
   heightChanged,
+  iiLayerAdded,
   ipaLayerAdded,
   ipaLayersDeleted,
   negativePrompt2Changed,
@@ -32,7 +33,6 @@ import type {
 } from 'features/metadata/types';
 import { modelSelected } from 'features/parameters/store/actions';
 import {
-  initialImageChanged,
   setCfgRescaleMultiplier,
   setCfgScale,
   setImg2imgStrength,
@@ -107,7 +107,7 @@ const recallScheduler: MetadataRecallFunc<ParameterScheduler> = (scheduler) => {
 };
 
 const recallInitialImage: MetadataRecallFunc<ImageDTO> = async (imageDTO) => {
-  getStore().dispatch(initialImageChanged(imageDTO));
+  getStore().dispatch(iiLayerAdded(imageDTO));
 };
 
 const setSizeOptions = { updateAspectRatio: true, clamp: true };
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addSeamlessToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addSeamlessToLinearGraph.ts
index 24e8be6546..d6fcd411a4 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addSeamlessToLinearGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addSeamlessToLinearGraph.ts
@@ -9,8 +9,6 @@ import {
   SDXL_CANVAS_TEXT_TO_IMAGE_GRAPH,
   SDXL_CONTROL_LAYERS_GRAPH,
   SDXL_DENOISE_LATENTS,
-  SDXL_IMAGE_TO_IMAGE_GRAPH,
-  SDXL_TEXT_TO_IMAGE_GRAPH,
   SEAMLESS,
   VAE_LOADER,
 } from './constants';
@@ -56,8 +54,6 @@ export const addSeamlessToLinearGraph = (
 
   if (
     graph.id === SDXL_CONTROL_LAYERS_GRAPH ||
-    graph.id === SDXL_TEXT_TO_IMAGE_GRAPH ||
-    graph.id === SDXL_IMAGE_TO_IMAGE_GRAPH ||
     graph.id === SDXL_CANVAS_TEXT_TO_IMAGE_GRAPH ||
     graph.id === SDXL_CANVAS_IMAGE_TO_IMAGE_GRAPH ||
     graph.id === SDXL_CANVAS_INPAINT_GRAPH ||
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts
index 5482c1c680..f464723381 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addVAEToGraph.ts
@@ -8,7 +8,6 @@ import {
   CANVAS_OUTPUT,
   CANVAS_TEXT_TO_IMAGE_GRAPH,
   CONTROL_LAYERS_GRAPH,
-  IMAGE_TO_IMAGE_GRAPH,
   IMAGE_TO_LATENTS,
   INPAINT_CREATE_MASK,
   INPAINT_IMAGE,
@@ -19,9 +18,7 @@ import {
   SDXL_CANVAS_OUTPAINT_GRAPH,
   SDXL_CANVAS_TEXT_TO_IMAGE_GRAPH,
   SDXL_CONTROL_LAYERS_GRAPH,
-  SDXL_IMAGE_TO_IMAGE_GRAPH,
   SDXL_REFINER_SEAMLESS,
-  SDXL_TEXT_TO_IMAGE_GRAPH,
   SEAMLESS,
   VAE_LOADER,
 } from './constants';
@@ -52,13 +49,7 @@ export const addVAEToGraph = async (
     };
   }
 
-  if (
-    graph.id === CONTROL_LAYERS_GRAPH ||
-    graph.id === SDXL_CONTROL_LAYERS_GRAPH ||
-    graph.id === IMAGE_TO_IMAGE_GRAPH ||
-    graph.id === SDXL_TEXT_TO_IMAGE_GRAPH ||
-    graph.id === SDXL_IMAGE_TO_IMAGE_GRAPH
-  ) {
+  if (graph.id === CONTROL_LAYERS_GRAPH || graph.id === SDXL_CONTROL_LAYERS_GRAPH) {
     graph.edges.push({
       source: {
         node_id: isSeamlessEnabled
@@ -104,8 +95,6 @@ export const addVAEToGraph = async (
   if (
     (graph.id === CONTROL_LAYERS_GRAPH ||
       graph.id === SDXL_CONTROL_LAYERS_GRAPH ||
-      graph.id === IMAGE_TO_IMAGE_GRAPH ||
-      graph.id === SDXL_IMAGE_TO_IMAGE_GRAPH ||
       graph.id === CANVAS_IMAGE_TO_IMAGE_GRAPH ||
       graph.id === SDXL_CANVAS_IMAGE_TO_IMAGE_GRAPH) &&
     Boolean(graph.nodes[IMAGE_TO_LATENTS])
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearImageToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearImageToImageGraph.ts
deleted file mode 100644
index 0ca121b667..0000000000
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearImageToImageGraph.ts
+++ /dev/null
@@ -1,369 +0,0 @@
-import { logger } from 'app/logging/logger';
-import type { RootState } from 'app/store/store';
-import { fetchModelConfigWithTypeGuard } from 'features/metadata/util/modelFetchingHelpers';
-import { getBoardField, getIsIntermediate } from 'features/nodes/util/graph/graphBuilderUtils';
-import {
-  type ImageResizeInvocation,
-  type ImageToLatentsInvocation,
-  isNonRefinerMainModelConfig,
-  type NonNullableGraph,
-} from 'services/api/types';
-
-import { addControlNetToLinearGraph } from './addControlNetToLinearGraph';
-import { addIPAdapterToLinearGraph } from './addIPAdapterToLinearGraph';
-import { addLoRAsToGraph } from './addLoRAsToGraph';
-import { addNSFWCheckerToGraph } from './addNSFWCheckerToGraph';
-import { addSeamlessToLinearGraph } from './addSeamlessToLinearGraph';
-import { addT2IAdaptersToLinearGraph } from './addT2IAdapterToLinearGraph';
-import { addVAEToGraph } from './addVAEToGraph';
-import { addWatermarkerToGraph } from './addWatermarkerToGraph';
-import {
-  CLIP_SKIP,
-  DENOISE_LATENTS,
-  IMAGE_TO_IMAGE_GRAPH,
-  IMAGE_TO_LATENTS,
-  LATENTS_TO_IMAGE,
-  MAIN_MODEL_LOADER,
-  NEGATIVE_CONDITIONING,
-  NOISE,
-  POSITIVE_CONDITIONING,
-  RESIZE,
-  SEAMLESS,
-} from './constants';
-import { addCoreMetadataNode, getModelMetadataField } from './metadata';
-
-/**
- * Builds the Image to Image tab graph.
- */
-export const buildLinearImageToImageGraph = async (state: RootState): Promise<NonNullableGraph> => {
-  const log = logger('nodes');
-  const {
-    model,
-    cfgScale: cfg_scale,
-    cfgRescaleMultiplier: cfg_rescale_multiplier,
-    scheduler,
-    seed,
-    steps,
-    initialImage,
-    img2imgStrength: strength,
-    shouldFitToWidthHeight,
-    clipSkip,
-    shouldUseCpuNoise,
-    vaePrecision,
-    seamlessXAxis,
-    seamlessYAxis,
-  } = state.generation;
-  const { positivePrompt, negativePrompt } = state.controlLayers.present;
-  const { width, height } = state.controlLayers.present.size;
-
-  /**
-   * The easiest way to build linear graphs is to do it in the node editor, then copy and paste the
-   * full graph here as a template. Then use the parameters from app state and set friendlier node
-   * ids.
-   *
-   * The only thing we need extra logic for is handling randomized seed, control net, and for img2img,
-   * the `fit` param. These are added to the graph at the end.
-   */
-
-  if (!initialImage) {
-    log.error('No initial image found in state');
-    throw new Error('No initial image found in state');
-  }
-
-  if (!model) {
-    log.error('No model found in state');
-    throw new Error('No model found in state');
-  }
-
-  const fp32 = vaePrecision === 'fp32';
-  const is_intermediate = true;
-
-  let modelLoaderNodeId = MAIN_MODEL_LOADER;
-
-  const use_cpu = shouldUseCpuNoise;
-
-  // copy-pasted graph from node editor, filled in with state values & friendly node ids
-  const graph: NonNullableGraph = {
-    id: IMAGE_TO_IMAGE_GRAPH,
-    nodes: {
-      [modelLoaderNodeId]: {
-        type: 'main_model_loader',
-        id: modelLoaderNodeId,
-        model,
-        is_intermediate,
-      },
-      [CLIP_SKIP]: {
-        type: 'clip_skip',
-        id: CLIP_SKIP,
-        skipped_layers: clipSkip,
-        is_intermediate,
-      },
-      [POSITIVE_CONDITIONING]: {
-        type: 'compel',
-        id: POSITIVE_CONDITIONING,
-        prompt: positivePrompt,
-        is_intermediate,
-      },
-      [NEGATIVE_CONDITIONING]: {
-        type: 'compel',
-        id: NEGATIVE_CONDITIONING,
-        prompt: negativePrompt,
-        is_intermediate,
-      },
-      [NOISE]: {
-        type: 'noise',
-        id: NOISE,
-        use_cpu,
-        seed,
-        is_intermediate,
-      },
-      [LATENTS_TO_IMAGE]: {
-        type: 'l2i',
-        id: LATENTS_TO_IMAGE,
-        fp32,
-        is_intermediate: getIsIntermediate(state),
-        board: getBoardField(state),
-      },
-      [DENOISE_LATENTS]: {
-        type: 'denoise_latents',
-        id: DENOISE_LATENTS,
-        cfg_scale,
-        cfg_rescale_multiplier,
-        scheduler,
-        steps,
-        denoising_start: 1 - strength,
-        denoising_end: 1,
-        is_intermediate,
-      },
-      [IMAGE_TO_LATENTS]: {
-        type: 'i2l',
-        id: IMAGE_TO_LATENTS,
-        // must be set manually later, bc `fit` parameter may require a resize node inserted
-        // image: {
-        //   image_name: initialImage.image_name,
-        // },
-        fp32,
-        is_intermediate,
-        use_cache: false,
-      },
-    },
-    edges: [
-      // Connect Model Loader to UNet and CLIP Skip
-      {
-        source: {
-          node_id: modelLoaderNodeId,
-          field: 'unet',
-        },
-        destination: {
-          node_id: DENOISE_LATENTS,
-          field: 'unet',
-        },
-      },
-      {
-        source: {
-          node_id: modelLoaderNodeId,
-          field: 'clip',
-        },
-        destination: {
-          node_id: CLIP_SKIP,
-          field: 'clip',
-        },
-      },
-      // Connect CLIP Skip to Conditioning
-      {
-        source: {
-          node_id: CLIP_SKIP,
-          field: 'clip',
-        },
-        destination: {
-          node_id: POSITIVE_CONDITIONING,
-          field: 'clip',
-        },
-      },
-      {
-        source: {
-          node_id: CLIP_SKIP,
-          field: 'clip',
-        },
-        destination: {
-          node_id: NEGATIVE_CONDITIONING,
-          field: 'clip',
-        },
-      },
-      // Connect everything to Denoise Latents
-      {
-        source: {
-          node_id: POSITIVE_CONDITIONING,
-          field: 'conditioning',
-        },
-        destination: {
-          node_id: DENOISE_LATENTS,
-          field: 'positive_conditioning',
-        },
-      },
-      {
-        source: {
-          node_id: NEGATIVE_CONDITIONING,
-          field: 'conditioning',
-        },
-        destination: {
-          node_id: DENOISE_LATENTS,
-          field: 'negative_conditioning',
-        },
-      },
-      {
-        source: {
-          node_id: NOISE,
-          field: 'noise',
-        },
-        destination: {
-          node_id: DENOISE_LATENTS,
-          field: 'noise',
-        },
-      },
-      {
-        source: {
-          node_id: IMAGE_TO_LATENTS,
-          field: 'latents',
-        },
-        destination: {
-          node_id: DENOISE_LATENTS,
-          field: 'latents',
-        },
-      },
-      // Decode denoised latents to image
-      {
-        source: {
-          node_id: DENOISE_LATENTS,
-          field: 'latents',
-        },
-        destination: {
-          node_id: LATENTS_TO_IMAGE,
-          field: 'latents',
-        },
-      },
-    ],
-  };
-
-  // handle `fit`
-  if (shouldFitToWidthHeight && (initialImage.width !== width || initialImage.height !== height)) {
-    // The init image needs to be resized to the specified width and height before being passed to `IMAGE_TO_LATENTS`
-
-    // Create a resize node, explicitly setting its image
-    const resizeNode: ImageResizeInvocation = {
-      id: RESIZE,
-      type: 'img_resize',
-      image: {
-        image_name: initialImage.imageName,
-      },
-      is_intermediate: true,
-      width,
-      height,
-    };
-
-    graph.nodes[RESIZE] = resizeNode;
-
-    // The `RESIZE` node then passes its image to `IMAGE_TO_LATENTS`
-    graph.edges.push({
-      source: { node_id: RESIZE, field: 'image' },
-      destination: {
-        node_id: IMAGE_TO_LATENTS,
-        field: 'image',
-      },
-    });
-
-    // The `RESIZE` node also passes its width and height to `NOISE`
-    graph.edges.push({
-      source: { node_id: RESIZE, field: 'width' },
-      destination: {
-        node_id: NOISE,
-        field: 'width',
-      },
-    });
-
-    graph.edges.push({
-      source: { node_id: RESIZE, field: 'height' },
-      destination: {
-        node_id: NOISE,
-        field: 'height',
-      },
-    });
-  } else {
-    // We are not resizing, so we need to set the image on the `IMAGE_TO_LATENTS` node explicitly
-    (graph.nodes[IMAGE_TO_LATENTS] as ImageToLatentsInvocation).image = {
-      image_name: initialImage.imageName,
-    };
-
-    // Pass the image's dimensions to the `NOISE` node
-    graph.edges.push({
-      source: { node_id: IMAGE_TO_LATENTS, field: 'width' },
-      destination: {
-        node_id: NOISE,
-        field: 'width',
-      },
-    });
-    graph.edges.push({
-      source: { node_id: IMAGE_TO_LATENTS, field: 'height' },
-      destination: {
-        node_id: NOISE,
-        field: 'height',
-      },
-    });
-  }
-
-  const modelConfig = await fetchModelConfigWithTypeGuard(model.key, isNonRefinerMainModelConfig);
-
-  addCoreMetadataNode(
-    graph,
-    {
-      generation_mode: 'img2img',
-      cfg_scale,
-      cfg_rescale_multiplier,
-      height,
-      width,
-      positive_prompt: positivePrompt,
-      negative_prompt: negativePrompt,
-      model: getModelMetadataField(modelConfig),
-      seed,
-      steps,
-      rand_device: use_cpu ? 'cpu' : 'cuda',
-      scheduler,
-      clip_skip: clipSkip,
-      strength,
-      init_image: initialImage.imageName,
-    },
-    LATENTS_TO_IMAGE
-  );
-
-  // Add Seamless To Graph
-  if (seamlessXAxis || seamlessYAxis) {
-    addSeamlessToLinearGraph(state, graph, modelLoaderNodeId);
-    modelLoaderNodeId = SEAMLESS;
-  }
-
-  // optionally add custom VAE
-  await addVAEToGraph(state, graph, modelLoaderNodeId);
-
-  // add LoRA support
-  await addLoRAsToGraph(state, graph, DENOISE_LATENTS, modelLoaderNodeId);
-
-  // add controlnet, mutating `graph`
-  await addControlNetToLinearGraph(state, graph, DENOISE_LATENTS);
-
-  // Add IP Adapter
-  await addIPAdapterToLinearGraph(state, graph, DENOISE_LATENTS);
-  await addT2IAdaptersToLinearGraph(state, graph, DENOISE_LATENTS);
-
-  // NSFW & watermark - must be last thing added to graph
-  if (state.system.shouldUseNSFWChecker) {
-    // must add before watermarker!
-    addNSFWCheckerToGraph(state, graph);
-  }
-
-  if (state.system.shouldUseWatermarker) {
-    // must add after nsfw checker!
-    addWatermarkerToGraph(state, graph);
-  }
-
-  return graph;
-};
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLImageToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLImageToImageGraph.ts
deleted file mode 100644
index 31c70d3dde..0000000000
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLImageToImageGraph.ts
+++ /dev/null
@@ -1,390 +0,0 @@
-import { logger } from 'app/logging/logger';
-import type { RootState } from 'app/store/store';
-import { fetchModelConfigWithTypeGuard } from 'features/metadata/util/modelFetchingHelpers';
-import {
-  type ImageResizeInvocation,
-  type ImageToLatentsInvocation,
-  isNonRefinerMainModelConfig,
-  type NonNullableGraph,
-} from 'services/api/types';
-
-import { addControlNetToLinearGraph } from './addControlNetToLinearGraph';
-import { addIPAdapterToLinearGraph } from './addIPAdapterToLinearGraph';
-import { addNSFWCheckerToGraph } from './addNSFWCheckerToGraph';
-import { addSDXLLoRAsToGraph } from './addSDXLLoRAstoGraph';
-import { addSDXLRefinerToGraph } from './addSDXLRefinerToGraph';
-import { addSeamlessToLinearGraph } from './addSeamlessToLinearGraph';
-import { addT2IAdaptersToLinearGraph } from './addT2IAdapterToLinearGraph';
-import { addVAEToGraph } from './addVAEToGraph';
-import { addWatermarkerToGraph } from './addWatermarkerToGraph';
-import {
-  IMAGE_TO_LATENTS,
-  LATENTS_TO_IMAGE,
-  NEGATIVE_CONDITIONING,
-  NOISE,
-  POSITIVE_CONDITIONING,
-  RESIZE,
-  SDXL_DENOISE_LATENTS,
-  SDXL_IMAGE_TO_IMAGE_GRAPH,
-  SDXL_MODEL_LOADER,
-  SDXL_REFINER_SEAMLESS,
-  SEAMLESS,
-} from './constants';
-import { getBoardField, getIsIntermediate, getSDXLStylePrompts } from './graphBuilderUtils';
-import { addCoreMetadataNode, getModelMetadataField } from './metadata';
-
-/**
- * Builds the Image to Image tab graph.
- */
-export const buildLinearSDXLImageToImageGraph = async (state: RootState): Promise<NonNullableGraph> => {
-  const log = logger('nodes');
-  const {
-    model,
-    cfgScale: cfg_scale,
-    cfgRescaleMultiplier: cfg_rescale_multiplier,
-    scheduler,
-    seed,
-    steps,
-    initialImage,
-    shouldFitToWidthHeight,
-    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;
-
-  /**
-   * The easiest way to build linear graphs is to do it in the node editor, then copy and paste the
-   * full graph here as a template. Then use the parameters from app state and set friendlier node
-   * ids.
-   *
-   * The only thing we need extra logic for is handling randomized seed, control net, and for img2img,
-   * the `fit` param. These are added to the graph at the end.
-   */
-
-  if (!initialImage) {
-    log.error('No initial image found in state');
-    throw new Error('No initial image found in state');
-  }
-
-  if (!model) {
-    log.error('No model found in state');
-    throw new Error('No model found in state');
-  }
-
-  const fp32 = vaePrecision === 'fp32';
-  const is_intermediate = true;
-
-  // Model Loader ID
-  let modelLoaderNodeId = SDXL_MODEL_LOADER;
-
-  const use_cpu = shouldUseCpuNoise;
-
-  // Construct Style Prompt
-  const { positiveStylePrompt, negativeStylePrompt } = getSDXLStylePrompts(state);
-
-  // copy-pasted graph from node editor, filled in with state values & friendly node ids
-  const graph: NonNullableGraph = {
-    id: SDXL_IMAGE_TO_IMAGE_GRAPH,
-    nodes: {
-      [modelLoaderNodeId]: {
-        type: 'sdxl_model_loader',
-        id: modelLoaderNodeId,
-        model,
-        is_intermediate,
-      },
-      [POSITIVE_CONDITIONING]: {
-        type: 'sdxl_compel_prompt',
-        id: POSITIVE_CONDITIONING,
-        prompt: positivePrompt,
-        style: positiveStylePrompt,
-        is_intermediate,
-      },
-      [NEGATIVE_CONDITIONING]: {
-        type: 'sdxl_compel_prompt',
-        id: NEGATIVE_CONDITIONING,
-        prompt: negativePrompt,
-        style: negativeStylePrompt,
-        is_intermediate,
-      },
-      [NOISE]: {
-        type: 'noise',
-        id: NOISE,
-        use_cpu,
-        seed,
-        is_intermediate,
-      },
-      [LATENTS_TO_IMAGE]: {
-        type: 'l2i',
-        id: LATENTS_TO_IMAGE,
-        fp32,
-        is_intermediate: getIsIntermediate(state),
-        board: getBoardField(state),
-      },
-      [SDXL_DENOISE_LATENTS]: {
-        type: 'denoise_latents',
-        id: SDXL_DENOISE_LATENTS,
-        cfg_scale,
-        cfg_rescale_multiplier,
-        scheduler,
-        steps,
-        denoising_start: refinerModel ? Math.min(refinerStart, 1 - strength) : 1 - strength,
-        denoising_end: refinerModel ? refinerStart : 1,
-        is_intermediate,
-      },
-      [IMAGE_TO_LATENTS]: {
-        type: 'i2l',
-        id: IMAGE_TO_LATENTS,
-        // must be set manually later, bc `fit` parameter may require a resize node inserted
-        // image: {
-        //   image_name: initialImage.image_name,
-        // },
-        fp32,
-        is_intermediate,
-        use_cache: false,
-      },
-    },
-    edges: [
-      // Connect Model Loader to UNet, CLIP & VAE
-      {
-        source: {
-          node_id: modelLoaderNodeId,
-          field: 'unet',
-        },
-        destination: {
-          node_id: SDXL_DENOISE_LATENTS,
-          field: 'unet',
-        },
-      },
-      {
-        source: {
-          node_id: modelLoaderNodeId,
-          field: 'clip',
-        },
-        destination: {
-          node_id: POSITIVE_CONDITIONING,
-          field: 'clip',
-        },
-      },
-      {
-        source: {
-          node_id: modelLoaderNodeId,
-          field: 'clip2',
-        },
-        destination: {
-          node_id: POSITIVE_CONDITIONING,
-          field: 'clip2',
-        },
-      },
-      {
-        source: {
-          node_id: modelLoaderNodeId,
-          field: 'clip',
-        },
-        destination: {
-          node_id: NEGATIVE_CONDITIONING,
-          field: 'clip',
-        },
-      },
-      {
-        source: {
-          node_id: modelLoaderNodeId,
-          field: 'clip2',
-        },
-        destination: {
-          node_id: NEGATIVE_CONDITIONING,
-          field: 'clip2',
-        },
-      },
-      // Connect everything to Denoise Latents
-      {
-        source: {
-          node_id: POSITIVE_CONDITIONING,
-          field: 'conditioning',
-        },
-        destination: {
-          node_id: SDXL_DENOISE_LATENTS,
-          field: 'positive_conditioning',
-        },
-      },
-      {
-        source: {
-          node_id: NEGATIVE_CONDITIONING,
-          field: 'conditioning',
-        },
-        destination: {
-          node_id: SDXL_DENOISE_LATENTS,
-          field: 'negative_conditioning',
-        },
-      },
-      {
-        source: {
-          node_id: NOISE,
-          field: 'noise',
-        },
-        destination: {
-          node_id: SDXL_DENOISE_LATENTS,
-          field: 'noise',
-        },
-      },
-      {
-        source: {
-          node_id: IMAGE_TO_LATENTS,
-          field: 'latents',
-        },
-        destination: {
-          node_id: SDXL_DENOISE_LATENTS,
-          field: 'latents',
-        },
-      },
-      // Decode Denoised Latents To Image
-      {
-        source: {
-          node_id: SDXL_DENOISE_LATENTS,
-          field: 'latents',
-        },
-        destination: {
-          node_id: LATENTS_TO_IMAGE,
-          field: 'latents',
-        },
-      },
-    ],
-  };
-
-  // handle `fit`
-  if (shouldFitToWidthHeight && (initialImage.width !== width || initialImage.height !== height)) {
-    // The init image needs to be resized to the specified width and height before being passed to `IMAGE_TO_LATENTS`
-
-    // Create a resize node, explicitly setting its image
-    const resizeNode: ImageResizeInvocation = {
-      id: RESIZE,
-      type: 'img_resize',
-      image: {
-        image_name: initialImage.imageName,
-      },
-      is_intermediate: true,
-      width,
-      height,
-    };
-
-    graph.nodes[RESIZE] = resizeNode;
-
-    // The `RESIZE` node then passes its image to `IMAGE_TO_LATENTS`
-    graph.edges.push({
-      source: { node_id: RESIZE, field: 'image' },
-      destination: {
-        node_id: IMAGE_TO_LATENTS,
-        field: 'image',
-      },
-    });
-
-    // The `RESIZE` node also passes its width and height to `NOISE`
-    graph.edges.push({
-      source: { node_id: RESIZE, field: 'width' },
-      destination: {
-        node_id: NOISE,
-        field: 'width',
-      },
-    });
-
-    graph.edges.push({
-      source: { node_id: RESIZE, field: 'height' },
-      destination: {
-        node_id: NOISE,
-        field: 'height',
-      },
-    });
-  } else {
-    // We are not resizing, so we need to set the image on the `IMAGE_TO_LATENTS` node explicitly
-    (graph.nodes[IMAGE_TO_LATENTS] as ImageToLatentsInvocation).image = {
-      image_name: initialImage.imageName,
-    };
-
-    // Pass the image's dimensions to the `NOISE` node
-    graph.edges.push({
-      source: { node_id: IMAGE_TO_LATENTS, field: 'width' },
-      destination: {
-        node_id: NOISE,
-        field: 'width',
-      },
-    });
-    graph.edges.push({
-      source: { node_id: IMAGE_TO_LATENTS, field: 'height' },
-      destination: {
-        node_id: NOISE,
-        field: 'height',
-      },
-    });
-  }
-
-  const modelConfig = await fetchModelConfigWithTypeGuard(model.key, isNonRefinerMainModelConfig);
-
-  addCoreMetadataNode(
-    graph,
-    {
-      generation_mode: 'sdxl_img2img',
-      cfg_scale,
-      cfg_rescale_multiplier,
-      height,
-      width,
-      positive_prompt: positivePrompt,
-      negative_prompt: negativePrompt,
-      model: getModelMetadataField(modelConfig),
-      seed,
-      steps,
-      rand_device: use_cpu ? 'cpu' : 'cuda',
-      scheduler,
-      strength,
-      init_image: initialImage.imageName,
-      positive_style_prompt: positiveStylePrompt,
-      negative_style_prompt: negativeStylePrompt,
-    },
-    LATENTS_TO_IMAGE
-  );
-
-  // Add Seamless To Graph
-  if (seamlessXAxis || seamlessYAxis) {
-    addSeamlessToLinearGraph(state, graph, modelLoaderNodeId);
-    modelLoaderNodeId = SEAMLESS;
-  }
-
-  // Add Refiner if enabled
-  if (refinerModel) {
-    await addSDXLRefinerToGraph(state, graph, SDXL_DENOISE_LATENTS);
-    if (seamlessXAxis || seamlessYAxis) {
-      modelLoaderNodeId = SDXL_REFINER_SEAMLESS;
-    }
-  }
-
-  // optionally add custom VAE
-  await addVAEToGraph(state, graph, modelLoaderNodeId);
-
-  // Add LoRA Support
-  await addSDXLLoRAsToGraph(state, graph, SDXL_DENOISE_LATENTS, modelLoaderNodeId);
-
-  // add controlnet, mutating `graph`
-  await addControlNetToLinearGraph(state, graph, SDXL_DENOISE_LATENTS);
-
-  // Add IP Adapter
-  await addIPAdapterToLinearGraph(state, graph, SDXL_DENOISE_LATENTS);
-
-  await addT2IAdaptersToLinearGraph(state, graph, SDXL_DENOISE_LATENTS);
-
-  // NSFW & watermark - must be last thing added to graph
-  if (state.system.shouldUseNSFWChecker) {
-    // must add before watermarker!
-    addNSFWCheckerToGraph(state, graph);
-  }
-
-  if (state.system.shouldUseWatermarker) {
-    // must add after nsfw checker!
-    addWatermarkerToGraph(state, graph);
-  }
-
-  return graph;
-};
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/constants.ts b/invokeai/frontend/web/src/features/nodes/util/graph/constants.ts
index fdb789dab3..53d7d742ab 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/constants.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/constants.ts
@@ -57,14 +57,10 @@ export const NEGATIVE_CONDITIONING_COLLECT = 'negative_conditioning_collect';
 // friendly graph ids
 export const CONTROL_LAYERS_GRAPH = 'control_layers_graph';
 export const SDXL_CONTROL_LAYERS_GRAPH = 'sdxl_control_layers_graph';
-export const TEXT_TO_IMAGE_GRAPH = 'text_to_image_graph';
-export const IMAGE_TO_IMAGE_GRAPH = 'image_to_image_graph';
 export const CANVAS_TEXT_TO_IMAGE_GRAPH = 'canvas_text_to_image_graph';
 export const CANVAS_IMAGE_TO_IMAGE_GRAPH = 'canvas_image_to_image_graph';
 export const CANVAS_INPAINT_GRAPH = 'canvas_inpaint_graph';
 export const CANVAS_OUTPAINT_GRAPH = 'canvas_outpaint_graph';
-export const SDXL_TEXT_TO_IMAGE_GRAPH = 'sdxl_text_to_image_graph';
-export const SDXL_IMAGE_TO_IMAGE_GRAPH = 'sxdl_image_to_image_graph';
 export const SDXL_CANVAS_TEXT_TO_IMAGE_GRAPH = 'sdxl_canvas_text_to_image_graph';
 export const SDXL_CANVAS_IMAGE_TO_IMAGE_GRAPH = 'sdxl_canvas_image_to_image_graph';
 export const SDXL_CANVAS_INPAINT_GRAPH = 'sdxl_canvas_inpaint_graph';
diff --git a/invokeai/frontend/web/src/features/parameters/components/ImageToImage/ImageToImageFit.tsx b/invokeai/frontend/web/src/features/parameters/components/ImageToImage/ImageToImageFit.tsx
deleted file mode 100644
index a772daa177..0000000000
--- a/invokeai/frontend/web/src/features/parameters/components/ImageToImage/ImageToImageFit.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library';
-import type { RootState } from 'app/store/store';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
-import { setShouldFitToWidthHeight } from 'features/parameters/store/generationSlice';
-import type { ChangeEvent } from 'react';
-import { memo, useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-
-const ImageToImageFit = () => {
-  const dispatch = useAppDispatch();
-
-  const shouldFitToWidthHeight = useAppSelector((state: RootState) => state.generation.shouldFitToWidthHeight);
-
-  const handleChangeFit = useCallback(
-    (e: ChangeEvent<HTMLInputElement>) => {
-      dispatch(setShouldFitToWidthHeight(e.target.checked));
-    },
-    [dispatch]
-  );
-
-  const { t } = useTranslation();
-
-  return (
-    <FormControl w="full">
-      <InformationalPopover feature="imageFit">
-        <FormLabel flexGrow={1}>{t('parameters.imageFit')}</FormLabel>
-      </InformationalPopover>
-      <Switch isChecked={shouldFitToWidthHeight} onChange={handleChangeFit} />
-    </FormControl>
-  );
-};
-
-export default memo(ImageToImageFit);
diff --git a/invokeai/frontend/web/src/features/parameters/components/ImageToImage/InitialImage.tsx b/invokeai/frontend/web/src/features/parameters/components/ImageToImage/InitialImage.tsx
deleted file mode 100644
index f70ea70616..0000000000
--- a/invokeai/frontend/web/src/features/parameters/components/ImageToImage/InitialImage.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-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 { IAINoContentFallback } from 'common/components/IAIImageFallback';
-import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
-import { clearInitialImage, selectGenerationSlice } from 'features/parameters/store/generationSlice';
-import { memo, useEffect, useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useGetImageDTOQuery } from 'services/api/endpoints/images';
-
-const selectInitialImage = createMemoizedSelector(selectGenerationSlice, (generation) => generation.initialImage);
-
-const InitialImage = () => {
-  const { t } = useTranslation();
-  const dispatch = useAppDispatch();
-  const initialImage = useAppSelector(selectInitialImage);
-  const isConnected = useAppSelector((s) => s.system.isConnected);
-
-  const { currentData: imageDTO, isError } = useGetImageDTOQuery(initialImage?.imageName ?? skipToken);
-
-  const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
-    if (imageDTO) {
-      return {
-        id: 'initial-image',
-        payloadType: 'IMAGE_DTO',
-        payload: { imageDTO },
-      };
-    }
-  }, [imageDTO]);
-
-  const droppableData = useMemo<TypesafeDroppableData | undefined>(
-    () => ({
-      id: 'initial-image',
-      actionType: 'SET_INITIAL_IMAGE',
-    }),
-    []
-  );
-
-  useEffect(() => {
-    if (isError && isConnected) {
-      // The image doesn't exist, reset init image
-      dispatch(clearInitialImage());
-    }
-  }, [dispatch, isConnected, isError]);
-
-  return (
-    <IAIDndImage
-      imageDTO={imageDTO}
-      droppableData={droppableData}
-      draggableData={draggableData}
-      isUploadDisabled={true}
-      fitContainer
-      dropLabel={t('toast.setInitialImage')}
-      noContentFallback={<IAINoContentFallback label={t('parameters.invoke.noInitialImageSelected')} />}
-      dataTestId="initial-image"
-    />
-  );
-};
-
-export default memo(InitialImage);
diff --git a/invokeai/frontend/web/src/features/parameters/components/ImageToImage/InitialImageDisplay.tsx b/invokeai/frontend/web/src/features/parameters/components/ImageToImage/InitialImageDisplay.tsx
deleted file mode 100644
index 68e981b7f5..0000000000
--- a/invokeai/frontend/web/src/features/parameters/components/ImageToImage/InitialImageDisplay.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
-import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
-import { parseAndRecallImageDimensions } from 'features/metadata/util/handlers';
-import { clearInitialImage, selectGenerationSlice } from 'features/parameters/store/generationSlice';
-import { memo, useCallback } from 'react';
-import { useHotkeys } from 'react-hotkeys-hook';
-import { useTranslation } from 'react-i18next';
-import { PiArrowCounterClockwiseBold, PiRulerBold, PiUploadSimpleBold } from 'react-icons/pi';
-import type { PostUploadAction } from 'services/api/types';
-
-import InitialImage from './InitialImage';
-
-const selectInitialImage = createMemoizedSelector(selectGenerationSlice, (generation) => generation.initialImage);
-
-const postUploadAction: PostUploadAction = {
-  type: 'SET_INITIAL_IMAGE',
-};
-
-const InitialImageDisplay = () => {
-  const { t } = useTranslation();
-  const initialImage = useAppSelector(selectInitialImage);
-  const dispatch = useAppDispatch();
-
-  const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
-    postUploadAction,
-  });
-
-  const handleReset = useCallback(() => {
-    dispatch(clearInitialImage());
-  }, [dispatch]);
-
-  const handleUseSizeInitialImage = useCallback(() => {
-    if (initialImage) {
-      parseAndRecallImageDimensions(initialImage);
-    }
-  }, [initialImage]);
-
-  useHotkeys('shift+d', handleUseSizeInitialImage, [initialImage]);
-
-  return (
-    <Flex
-      layerStyle="first"
-      position="relative"
-      flexDirection="column"
-      height="full"
-      width="full"
-      alignItems="center"
-      justifyContent="center"
-      borderRadius="base"
-      p={2}
-      gap={4}
-    >
-      <Flex w="full" flexWrap="wrap" justifyContent="center" alignItems="center" gap={2}>
-        <Text ps={2} fontWeight="semibold" userSelect="none" color="base.200">
-          {t('metadata.initImage')}
-        </Text>
-        <Spacer />
-        <IconButton
-          tooltip={t('toast.uploadInitialImage')}
-          aria-label={t('toast.uploadInitialImage')}
-          icon={<PiUploadSimpleBold />}
-          {...getUploadButtonProps()}
-        />
-        <IconButton
-          tooltip={`${t('parameters.useSize')} (Shift+D)`}
-          aria-label={`${t('parameters.useSize')} (Shift+D)`}
-          icon={<PiRulerBold />}
-          onClick={handleUseSizeInitialImage}
-          isDisabled={!initialImage}
-        />
-        <IconButton
-          tooltip={t('toast.resetInitialImage')}
-          aria-label={t('toast.resetInitialImage')}
-          icon={<PiArrowCounterClockwiseBold />}
-          onClick={handleReset}
-          isDisabled={!initialImage}
-        />
-      </Flex>
-      <InitialImage />
-      <input {...getUploadInputProps()} />
-    </Flex>
-  );
-};
-
-export default memo(InitialImageDisplay);
diff --git a/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts b/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts
index 0069bea881..ca7d00d507 100644
--- a/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts
+++ b/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts
@@ -2,8 +2,8 @@ import { skipToken } from '@reduxjs/toolkit/query';
 import { useAppToaster } from 'app/components/Toaster';
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
 import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
+import { iiLayerAdded } from 'features/controlLayers/store/controlLayersSlice';
 import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers';
-import { initialImageSelected } from 'features/parameters/store/actions';
 import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
 import { setActiveTab } from 'features/ui/store/uiSlice';
 import { t } from 'i18next';
@@ -37,7 +37,7 @@ export const usePreselectedImage = (selectedImage?: {
 
   const handleSendToImg2Img = useCallback(() => {
     if (selectedImageDto) {
-      dispatch(initialImageSelected(selectedImageDto));
+      dispatch(iiLayerAdded(selectedImageDto));
     }
   }, [dispatch, selectedImageDto]);
 
diff --git a/invokeai/frontend/web/src/features/parameters/store/actions.ts b/invokeai/frontend/web/src/features/parameters/store/actions.ts
index 3b43129720..f913245e82 100644
--- a/invokeai/frontend/web/src/features/parameters/store/actions.ts
+++ b/invokeai/frontend/web/src/features/parameters/store/actions.ts
@@ -1,8 +1,5 @@
 import { createAction } from '@reduxjs/toolkit';
 import type { ParameterModel } from 'features/parameters/types/parameterSchemas';
-import type { ImageDTO } from 'services/api/types';
-
-export const initialImageSelected = createAction<ImageDTO | undefined>('generation/initialImageSelected');
 
 export const modelSelected = createAction<ParameterModel>('generation/modelSelected');
 
diff --git a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts
index 18180455ce..573e9c1bbe 100644
--- a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts
+++ b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts
@@ -16,7 +16,6 @@ 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';
-import type { ImageDTO } from 'services/api/types';
 
 import type { GenerationState } from './types';
 
@@ -34,7 +33,6 @@ const initialGenerationState: GenerationState = {
   canvasCoherenceMinDenoise: 0,
   canvasCoherenceEdgeSize: 16,
   seed: 0,
-  shouldFitToWidthHeight: true,
   shouldRandomizeSeed: true,
   steps: 50,
   model: null,
@@ -86,15 +84,9 @@ export const generationSlice = createSlice({
     setSeamlessYAxis: (state, action: PayloadAction<boolean>) => {
       state.seamlessYAxis = action.payload;
     },
-    setShouldFitToWidthHeight: (state, action: PayloadAction<boolean>) => {
-      state.shouldFitToWidthHeight = action.payload;
-    },
     setShouldRandomizeSeed: (state, action: PayloadAction<boolean>) => {
       state.shouldRandomizeSeed = action.payload;
     },
-    clearInitialImage: (state) => {
-      state.initialImage = undefined;
-    },
     setMaskBlur: (state, action: PayloadAction<number>) => {
       state.maskBlur = action.payload;
     },
@@ -107,10 +99,6 @@ export const generationSlice = createSlice({
     setCanvasCoherenceMinDenoise: (state, action: PayloadAction<number>) => {
       state.canvasCoherenceMinDenoise = action.payload;
     },
-    initialImageChanged: (state, action: PayloadAction<ImageDTO>) => {
-      const { image_name, width, height } = action.payload;
-      state.initialImage = { imageName: image_name, width, height };
-    },
     modelChanged: {
       reducer: (
         state,
@@ -195,7 +183,6 @@ export const generationSlice = createSlice({
 });
 
 export const {
-  clearInitialImage,
   setCfgScale,
   setCfgRescaleMultiplier,
   setImg2imgStrength,
@@ -207,10 +194,8 @@ export const {
   setCanvasCoherenceEdgeSize,
   setCanvasCoherenceMinDenoise,
   setSeed,
-  setShouldFitToWidthHeight,
   setShouldRandomizeSeed,
   setSteps,
-  initialImageChanged,
   modelChanged,
   vaeSelected,
   setSeamlessXAxis,
diff --git a/invokeai/frontend/web/src/features/parameters/store/types.ts b/invokeai/frontend/web/src/features/parameters/store/types.ts
index 9314f8d076..51ab6146cf 100644
--- a/invokeai/frontend/web/src/features/parameters/store/types.ts
+++ b/invokeai/frontend/web/src/features/parameters/store/types.ts
@@ -20,7 +20,6 @@ export interface GenerationState {
   cfgRescaleMultiplier: ParameterCFGRescaleMultiplier;
   img2imgStrength: ParameterStrength;
   infillMethod: string;
-  initialImage?: { imageName: string; width: number; height: number };
   iterations: number;
   scheduler: ParameterScheduler;
   maskBlur: number;
@@ -29,7 +28,6 @@ export interface GenerationState {
   canvasCoherenceMinDenoise: ParameterStrength;
   canvasCoherenceEdgeSize: number;
   seed: ParameterSeed;
-  shouldFitToWidthHeight: boolean;
   shouldRandomizeSeed: boolean;
   steps: ParameterSteps;
   model: ParameterModel | null;
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 bb9cfd36ce..e97bdca45a 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion.tsx
@@ -9,7 +9,6 @@ import { selectHrfSlice } from 'features/hrf/store/hrfSlice';
 import ParamScaleBeforeProcessing from 'features/parameters/components/Canvas/InfillAndScaling/ParamScaleBeforeProcessing';
 import ParamScaledHeight from 'features/parameters/components/Canvas/InfillAndScaling/ParamScaledHeight';
 import ParamScaledWidth from 'features/parameters/components/Canvas/InfillAndScaling/ParamScaledWidth';
-import ImageToImageFit from 'features/parameters/components/ImageToImage/ImageToImageFit';
 import ImageToImageStrength from 'features/parameters/components/ImageToImage/ImageToImageStrength';
 import { ParamSeedNumberInput } from 'features/parameters/components/Seed/ParamSeedNumberInput';
 import { ParamSeedRandomize } from 'features/parameters/components/Seed/ParamSeedRandomize';
@@ -94,8 +93,7 @@ export const ImageSettingsAccordion = memo(() => {
               <ParamSeedShuffle />
               <ParamSeedRandomize />
             </Flex>
-            {(activeTabName === 'img2img' || activeTabName === 'unifiedCanvas') && <ImageToImageStrength />}
-            {activeTabName === 'img2img' && <ImageToImageFit />}
+            {activeTabName === 'unifiedCanvas' && <ImageToImageStrength />}
             {activeTabName === 'txt2img' && !isSDXL && <HrfSettings />}
             {activeTabName === 'unifiedCanvas' && (
               <>
diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
index b4a2922098..6503ac0893 100644
--- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
@@ -12,7 +12,6 @@ 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 ImageToImageTab from 'features/ui/components/tabs/ImageToImageTab';
 import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab';
 import NodesTab from 'features/ui/components/tabs/NodesTab';
 import QueueTab from 'features/ui/components/tabs/QueueTab';
@@ -30,7 +29,7 @@ import { memo, useCallback, useMemo, useRef } from 'react';
 import { useHotkeys } from 'react-hotkeys-hook';
 import { useTranslation } from 'react-i18next';
 import { PiFlowArrowBold } from 'react-icons/pi';
-import { RiBox2Line, RiBrushLine, RiImage2Line, RiInputMethodLine, RiPlayList2Fill } from 'react-icons/ri';
+import { RiBox2Line, RiBrushLine, RiInputMethodLine, RiPlayList2Fill } from 'react-icons/ri';
 import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
 import { Panel, PanelGroup } from 'react-resizable-panels';
 
@@ -51,12 +50,6 @@ const TAB_DATA: Record<InvokeTabName, TabData> = {
     icon: <RiInputMethodLine />,
     content: <TextToImageTab />,
   },
-  img2img: {
-    id: 'img2img',
-    translationKey: 'common.img2img',
-    icon: <RiImage2Line />,
-    content: <ImageToImageTab />,
-  },
   unifiedCanvas: {
     id: 'unifiedCanvas',
     translationKey: 'common.unifiedCanvas',
diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ImageToImageTab.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ImageToImageTab.tsx
deleted file mode 100644
index 4303d66e2c..0000000000
--- a/invokeai/frontend/web/src/features/ui/components/tabs/ImageToImageTab.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-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 { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
-import type { CSSProperties } from 'react';
-import { memo, useCallback, useRef } from 'react';
-import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
-import { Panel, PanelGroup } from 'react-resizable-panels';
-
-const panelGroupStyles: CSSProperties = {
-  height: '100%',
-  width: '100%',
-};
-const panelStyles: CSSProperties = {
-  position: 'relative',
-};
-
-const ImageToImageTab = () => {
-  const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
-
-  const handleDoubleClickHandle = useCallback(() => {
-    if (!panelGroupRef.current) {
-      return;
-    }
-    panelGroupRef.current.setLayout([50, 50]);
-  }, []);
-
-  const panelStorage = usePanelStorage();
-
-  return (
-    <Box position="relative" w="full" h="full">
-      <PanelGroup
-        ref={panelGroupRef}
-        autoSaveId="imageTab.content"
-        direction="horizontal"
-        style={panelGroupStyles}
-        storage={panelStorage}
-      >
-        <Panel id="imageTab.content.initImage" order={0} defaultSize={50} minSize={25} style={panelStyles}>
-          <InitialImageDisplay />
-        </Panel>
-        <ResizeHandle orientation="vertical" onDoubleClick={handleDoubleClickHandle} />
-        <Panel id="imageTab.content.selectedImage" order={1} defaultSize={50} minSize={25}>
-          <Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
-            <Flex w="full" h="full">
-              <CurrentImageDisplay />
-            </Flex>
-          </Box>
-        </Panel>
-      </PanelGroup>
-    </Box>
-  );
-};
-
-export default memo(ImageToImageTab);
diff --git a/invokeai/frontend/web/src/features/ui/store/tabMap.tsx b/invokeai/frontend/web/src/features/ui/store/tabMap.tsx
index 1b8f3e4812..2b249a2a87 100644
--- a/invokeai/frontend/web/src/features/ui/store/tabMap.tsx
+++ b/invokeai/frontend/web/src/features/ui/store/tabMap.tsx
@@ -1,3 +1,3 @@
-export const TAB_NUMBER_MAP = ['txt2img', 'img2img', 'unifiedCanvas', 'nodes', 'modelManager', 'queue'] as const;
+export const TAB_NUMBER_MAP = ['txt2img', 'unifiedCanvas', 'nodes', 'modelManager', 'queue'] as const;
 
 export type InvokeTabName = (typeof TAB_NUMBER_MAP)[number];
diff --git a/invokeai/frontend/web/src/features/ui/store/uiSlice.ts b/invokeai/frontend/web/src/features/ui/store/uiSlice.ts
index 69c8eaf7cf..221d2a1217 100644
--- a/invokeai/frontend/web/src/features/ui/store/uiSlice.ts
+++ b/invokeai/frontend/web/src/features/ui/store/uiSlice.ts
@@ -2,7 +2,6 @@ import type { PayloadAction } from '@reduxjs/toolkit';
 import { createSlice } from '@reduxjs/toolkit';
 import type { PersistConfig, RootState } from 'app/store/store';
 import { workflowLoadRequested } from 'features/nodes/store/actions';
-import { initialImageChanged } from 'features/parameters/store/generationSlice';
 
 import type { InvokeTabName } from './tabMap';
 import type { UIState } from './uiTypes';
@@ -43,9 +42,6 @@ export const uiSlice = createSlice({
     },
   },
   extraReducers(builder) {
-    builder.addCase(initialImageChanged, (state) => {
-      state.activeTab = 'img2img';
-    });
     builder.addCase(workflowLoadRequested, (state) => {
       state.activeTab = 'nodes';
     });
diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts
index 183b81478d..a153780712 100644
--- a/invokeai/frontend/web/src/services/api/types.ts
+++ b/invokeai/frontend/web/src/services/api/types.ts
@@ -198,10 +198,6 @@ export type IILayerImagePostUploadAction = {
   layerId: string;
 };
 
-type InitialImageAction = {
-  type: 'SET_INITIAL_IMAGE';
-};
-
 type NodesAction = {
   type: 'SET_NODES_IMAGE';
   nodeId: string;
@@ -223,7 +219,6 @@ type AddToBatchAction = {
 
 export type PostUploadAction =
   | ControlAdapterAction
-  | InitialImageAction
   | NodesAction
   | CanvasInitialImageAction
   | ToastAction

From 7c1f1076b42e9472aea4f2c616a12b41807e5f35 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 06:27:25 +1000
Subject: [PATCH 19/59] feat(ui): rename tabs

- "Text to Image" -> "Generation"
- "Unified Canvas" -> "Canvas"
- "Model Manager" -> "Models"
---
 invokeai/frontend/web/public/locales/en.json           |  9 +++++++++
 .../web/src/features/ui/components/InvokeTabs.tsx      | 10 +++++-----
 2 files changed, 14 insertions(+), 5 deletions(-)

diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index cacb4dfbf4..0ad7432ca2 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1557,5 +1557,14 @@
         "opacityFilter": "Opacity Filter",
         "clearProcessor": "Clear Processor",
         "resetProcessor": "Reset Processor to Defaults"
+    },
+    "ui": {
+        "tabs": {
+            "generation": "Generation",
+            "canvas": "Canvas",
+            "workflows": "Workflows",
+            "models": "Models",
+            "queue": "Queue"
+        }
     }
 }
diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
index 6503ac0893..471046e188 100644
--- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
@@ -46,31 +46,31 @@ type TabData = {
 const TAB_DATA: Record<InvokeTabName, TabData> = {
   txt2img: {
     id: 'txt2img',
-    translationKey: 'common.txt2img',
+    translationKey: 'ui.tabs.generation',
     icon: <RiInputMethodLine />,
     content: <TextToImageTab />,
   },
   unifiedCanvas: {
     id: 'unifiedCanvas',
-    translationKey: 'common.unifiedCanvas',
+    translationKey: 'ui.tabs.canvas',
     icon: <RiBrushLine />,
     content: <UnifiedCanvasTab />,
   },
   nodes: {
     id: 'nodes',
-    translationKey: 'common.nodes',
+    translationKey: 'ui.tabs.workflows',
     icon: <PiFlowArrowBold />,
     content: <NodesTab />,
   },
   modelManager: {
     id: 'modelManager',
-    translationKey: 'modelManager.modelManager',
+    translationKey: 'ui.tabs.models',
     icon: <RiBox2Line />,
     content: <ModelManagerTab />,
   },
   queue: {
     id: 'queue',
-    translationKey: 'queue.queue',
+    translationKey: 'ui.tabs.queue',
     icon: <RiPlayList2Fill />,
     content: <QueueTab />,
   },

From 0f7fdabe9bcf25ce06f14dd57cd575b7d996d0cd Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 06:52:15 +1000
Subject: [PATCH 20/59] feat(ui): rename tab identifiers

- "txt2img" -> "generation"
- "unifiedCanvas" -> "canvas"
- "modelManager" -> "models"
- "nodes" -> "workflows"
- Add UI slice migration setting the active tab to "generation"
---
 .../frontend/web/src/app/logging/logger.ts    |  3 +--
 .../listeners/enqueueRequestedCanvas.ts       |  2 +-
 .../listeners/enqueueRequestedLinear.ts       |  2 +-
 .../listeners/enqueueRequestedNodes.ts        |  2 +-
 .../src/common/hooks/useFullscreenDropzone.ts |  2 +-
 .../web/src/common/hooks/useGlobalHotkeys.ts  |  8 +++----
 .../src/common/hooks/useIsReadyToEnqueue.ts   |  6 ++---
 .../features/canvas/hooks/useCanvasHotkeys.ts |  4 ++--
 .../components/ControlAdapterConfig.tsx       |  2 +-
 .../components/ControlAdapterImagePreview.tsx |  2 +-
 .../ControlAdapterImagePreview.tsx            |  2 +-
 .../IPAdapterImagePreview.tsx                 |  2 +-
 .../IILayer/InitialImagePreview.tsx           |  2 +-
 .../dnd/hooks/useScaledCenteredModifer.ts     |  2 +-
 .../SingleSelectionMenuItems.tsx              |  4 ++--
 .../ImageMetadataActions.tsx                  | 12 +++++-----
 .../gallery/components/ImageViewer.tsx        | 10 ++++----
 .../gallery/hooks/useGalleryHotkeys.ts        |  2 +-
 .../features/gallery/hooks/useImageActions.ts |  4 ++--
 .../hooks/useStarterModelsToast.tsx           |  2 +-
 .../util/graph/addControlNetToLinearGraph.ts  |  4 ++--
 .../nodes/util/graph/addHrfToGraph.ts         |  2 +-
 .../util/graph/addIPAdapterToLinearGraph.ts   |  4 ++--
 .../util/graph/addT2IAdapterToLinearGraph.ts  |  4 ++--
 .../nodes/util/graph/graphBuilderUtils.ts     |  2 +-
 .../NavigateToModelManagerButton.tsx          |  4 ++--
 .../parameters/hooks/usePreselectedImage.ts   |  2 +-
 .../ImageSettingsAccordion.tsx                | 10 ++++----
 .../ImageSizeLinear.tsx                       |  2 +-
 .../src/features/ui/components/InvokeTabs.tsx | 24 +++++++++----------
 .../ui/components/ParametersPanel.tsx         |  4 ++--
 .../components/ParametersPanelTextToImage.tsx |  4 ++--
 .../web/src/features/ui/store/tabMap.tsx      |  2 +-
 .../web/src/features/ui/store/uiSelectors.ts  |  2 +-
 .../web/src/features/ui/store/uiSlice.ts      | 10 +++++---
 .../web/src/features/ui/store/uiTypes.ts      |  2 +-
 36 files changed, 80 insertions(+), 77 deletions(-)

diff --git a/invokeai/frontend/web/src/app/logging/logger.ts b/invokeai/frontend/web/src/app/logging/logger.ts
index ca7a24201a..c0de4e3685 100644
--- a/invokeai/frontend/web/src/app/logging/logger.ts
+++ b/invokeai/frontend/web/src/app/logging/logger.ts
@@ -20,8 +20,7 @@ export type LoggerNamespace =
   | 'models'
   | 'config'
   | 'canvas'
-  | 'txt2img'
-  | 'img2img'
+  | 'generation'
   | 'nodes'
   | 'system'
   | 'socketio'
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedCanvas.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedCanvas.ts
index f38020b8ea..cdcc99ade2 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedCanvas.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedCanvas.ts
@@ -30,7 +30,7 @@ import type { ImageDTO } from 'services/api/types';
 export const addEnqueueRequestedCanvasListener = (startAppListening: AppStartListening) => {
   startAppListening({
     predicate: (action): action is ReturnType<typeof enqueueRequested> =>
-      enqueueRequested.match(action) && action.payload.tabName === 'unifiedCanvas',
+      enqueueRequested.match(action) && action.payload.tabName === 'canvas',
     effect: async (action, { getState, dispatch }) => {
       const log = logger('queue');
       const { prepend } = action.payload;
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
index 0cbc73de35..bc0fcb5ef0 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
@@ -8,7 +8,7 @@ import { queueApi } from 'services/api/endpoints/queue';
 export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) => {
   startAppListening({
     predicate: (action): action is ReturnType<typeof enqueueRequested> =>
-      enqueueRequested.match(action) && action.payload.tabName === 'txt2img',
+      enqueueRequested.match(action) && action.payload.tabName === 'generation',
     effect: async (action, { getState, dispatch }) => {
       const state = getState();
       const model = state.generation.model;
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts
index e33f7c964a..8d39daaef8 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts
@@ -8,7 +8,7 @@ import type { BatchConfig } from 'services/api/types';
 export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) => {
   startAppListening({
     predicate: (action): action is ReturnType<typeof enqueueRequested> =>
-      enqueueRequested.match(action) && action.payload.tabName === 'nodes',
+      enqueueRequested.match(action) && action.payload.tabName === 'workflows',
     effect: async (action, { getState, dispatch }) => {
       const state = getState();
       const { nodes, edges } = state.nodes;
diff --git a/invokeai/frontend/web/src/common/hooks/useFullscreenDropzone.ts b/invokeai/frontend/web/src/common/hooks/useFullscreenDropzone.ts
index c51fae81d3..0334294e98 100644
--- a/invokeai/frontend/web/src/common/hooks/useFullscreenDropzone.ts
+++ b/invokeai/frontend/web/src/common/hooks/useFullscreenDropzone.ts
@@ -17,7 +17,7 @@ const accept: Accept = {
 const selectPostUploadAction = createMemoizedSelector(activeTabNameSelector, (activeTabName) => {
   let postUploadAction: PostUploadAction = { type: 'TOAST' };
 
-  if (activeTabName === 'unifiedCanvas') {
+  if (activeTabName === 'canvas') {
     postUploadAction = { type: 'SET_CANVAS_INITIAL_IMAGE' };
   }
 
diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts
index fe65dd5983..9ba044199f 100644
--- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts
+++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts
@@ -67,7 +67,7 @@ export const useGlobalHotkeys = () => {
   useHotkeys(
     '1',
     () => {
-      dispatch(setActiveTab('txt2img'));
+      dispatch(setActiveTab('generation'));
     },
     [dispatch]
   );
@@ -75,7 +75,7 @@ export const useGlobalHotkeys = () => {
   useHotkeys(
     '2',
     () => {
-      dispatch(setActiveTab('unifiedCanvas'));
+      dispatch(setActiveTab('canvas'));
     },
     [dispatch]
   );
@@ -83,7 +83,7 @@ export const useGlobalHotkeys = () => {
   useHotkeys(
     '3',
     () => {
-      dispatch(setActiveTab('nodes'));
+      dispatch(setActiveTab('workflows'));
     },
     [dispatch]
   );
@@ -92,7 +92,7 @@ export const useGlobalHotkeys = () => {
     '4',
     () => {
       if (isModelManagerEnabled) {
-        dispatch(setActiveTab('modelManager'));
+        dispatch(setActiveTab('models'));
       }
     },
     [dispatch, isModelManagerEnabled]
diff --git a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts
index f7c980efeb..2aac5b8e72 100644
--- a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts
+++ b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts
@@ -40,7 +40,7 @@ const selector = createMemoizedSelector(
       reasons.push(i18n.t('parameters.invoke.systemDisconnected'));
     }
 
-    if (activeTabName === 'nodes') {
+    if (activeTabName === 'workflows') {
       if (nodes.shouldValidateGraph) {
         if (!nodes.nodes.length) {
           reasons.push(i18n.t('parameters.invoke.noNodesInGraph'));
@@ -93,8 +93,8 @@ const selector = createMemoizedSelector(
         reasons.push(i18n.t('parameters.invoke.noModelSelected'));
       }
 
-      if (activeTabName === 'txt2img') {
-        // Handling for Control Layers - only exists on txt2img tab now
+      if (activeTabName === 'generation') {
+        // Handling for generation tab
         controlLayers.present.layers
           .filter((l) => l.isEnabled)
           .flatMap((l) => {
diff --git a/invokeai/frontend/web/src/features/canvas/hooks/useCanvasHotkeys.ts b/invokeai/frontend/web/src/features/canvas/hooks/useCanvasHotkeys.ts
index e915259201..ec833c5f3d 100644
--- a/invokeai/frontend/web/src/features/canvas/hooks/useCanvasHotkeys.ts
+++ b/invokeai/frontend/web/src/features/canvas/hooks/useCanvasHotkeys.ts
@@ -75,7 +75,7 @@ const useInpaintingCanvasHotkeys = () => {
 
   const onKeyDown = useCallback(
     (e: KeyboardEvent) => {
-      if (e.repeat || e.key !== ' ' || isInteractiveTarget(e.target) || activeTabName !== 'unifiedCanvas') {
+      if (e.repeat || e.key !== ' ' || isInteractiveTarget(e.target) || activeTabName !== 'canvas') {
         return;
       }
       if ($toolStash.get() || $tool.get() === 'move') {
@@ -90,7 +90,7 @@ const useInpaintingCanvasHotkeys = () => {
   );
   const onKeyUp = useCallback(
     (e: KeyboardEvent) => {
-      if (e.repeat || e.key !== ' ' || isInteractiveTarget(e.target) || activeTabName !== 'unifiedCanvas') {
+      if (e.repeat || e.key !== ' ' || isInteractiveTarget(e.target) || activeTabName !== 'canvas') {
         return;
       }
       if (!$toolStash.get() || $tool.get() !== 'move') {
diff --git a/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterConfig.tsx b/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterConfig.tsx
index fcc816d75f..c13783cddd 100644
--- a/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterConfig.tsx
+++ b/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterConfig.tsx
@@ -76,7 +76,7 @@ const ControlAdapterConfig = (props: { id: string; number: number }) => {
         <Box minW={0} w="full" transitionProperty="common" transitionDuration="0.1s">
           <ParamControlAdapterModel id={id} />
         </Box>
-        {activeTabName === 'unifiedCanvas' && <ControlNetCanvasImageImports id={id} />}
+        {activeTabName === 'canvas' && <ControlNetCanvasImageImports id={id} />}
         <IconButton
           size="sm"
           tooltip={t('controlnet.duplicate')}
diff --git a/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx
index 1360c76240..bf1c7dce9f 100644
--- a/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlAdapters/components/ControlAdapterImagePreview.tsx
@@ -93,7 +93,7 @@ const ControlAdapterImagePreview = ({ isSmall, id }: Props) => {
       return;
     }
 
-    if (activeTabName === 'unifiedCanvas') {
+    if (activeTabName === 'canvas') {
       dispatch(setBoundingBoxDimensions({ width: controlImage.width, height: controlImage.height }, optimalDimension));
     } else {
       const options = { updateAspectRatio: true, clamp: true };
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
index 81bd7b14f2..e6c6aae286 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
@@ -80,7 +80,7 @@ export const ControlAdapterImagePreview = memo(
         return;
       }
 
-      if (activeTabName === 'unifiedCanvas') {
+      if (activeTabName === 'canvas') {
         dispatch(
           setBoundingBoxDimensions({ width: controlImage.width, height: controlImage.height }, optimalDimension)
         );
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterImagePreview.tsx
index f73f61cbbd..83dd250cd0 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/IPAdapterImagePreview.tsx
@@ -46,7 +46,7 @@ export const IPAdapterImagePreview = memo(
         return;
       }
 
-      if (activeTabName === 'unifiedCanvas') {
+      if (activeTabName === 'canvas') {
         dispatch(
           setBoundingBoxDimensions({ width: controlImage.width, height: controlImage.height }, optimalDimension)
         );
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/InitialImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/InitialImagePreview.tsx
index 740e81cbde..e355d5db86 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IILayer/InitialImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IILayer/InitialImagePreview.tsx
@@ -43,7 +43,7 @@ export const InitialImagePreview = memo(({ image, onChangeImage, droppableData,
       return;
     }
 
-    if (activeTabName === 'unifiedCanvas') {
+    if (activeTabName === 'canvas') {
       dispatch(setBoundingBoxDimensions({ width: imageDTO.width, height: imageDTO.height }, optimalDimension));
     } else {
       const options = { updateAspectRatio: true, clamp: true };
diff --git a/invokeai/frontend/web/src/features/dnd/hooks/useScaledCenteredModifer.ts b/invokeai/frontend/web/src/features/dnd/hooks/useScaledCenteredModifer.ts
index 2816fc4830..f3f0c50f03 100644
--- a/invokeai/frontend/web/src/features/dnd/hooks/useScaledCenteredModifer.ts
+++ b/invokeai/frontend/web/src/features/dnd/hooks/useScaledCenteredModifer.ts
@@ -7,7 +7,7 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
 import { useCallback } from 'react';
 
 const selectZoom = createSelector([selectNodesSlice, activeTabNameSelector], (nodes, activeTabName) =>
-  activeTabName === 'nodes' ? nodes.viewport.zoom : 1
+  activeTabName === 'workflows' ? nodes.viewport.zoom : 1
 );
 
 /**
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx
index 1dfd839b31..7bfb4050fb 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx
@@ -45,7 +45,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
   const dispatch = useAppDispatch();
   const { t } = useTranslation();
   const toaster = useAppToaster();
-  const isCanvasEnabled = useFeatureStatus('unifiedCanvas');
+  const isCanvasEnabled = useFeatureStatus('canvas');
   const customStarUi = useStore($customStarUI);
   const { downloadImage } = useDownloadImage();
 
@@ -78,7 +78,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
   const handleSendToCanvas = useCallback(() => {
     dispatch(sentImageToCanvas());
     flushSync(() => {
-      dispatch(setActiveTab('unifiedCanvas'));
+      dispatch(setActiveTab('canvas'));
     });
     dispatch(setInitialCanvasImage(imageDTO, optimalDimension));
 
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx
index 007a89f1a1..c73f5b1817 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx
@@ -52,12 +52,12 @@ const ImageMetadataActions = (props: Props) => {
       <MetadataItem metadata={metadata} handlers={handlers.refinerStart} />
       <MetadataItem metadata={metadata} handlers={handlers.refinerSteps} />
       <MetadataLoRAs metadata={metadata} />
-      {activeTabName !== 'txt2img' && <MetadataControlNets metadata={metadata} />}
-      {activeTabName !== 'txt2img' && <MetadataT2IAdapters metadata={metadata} />}
-      {activeTabName !== 'txt2img' && <MetadataIPAdapters metadata={metadata} />}
-      {activeTabName === 'txt2img' && <MetadataControlNetsV2 metadata={metadata} />}
-      {activeTabName === 'txt2img' && <MetadataT2IAdaptersV2 metadata={metadata} />}
-      {activeTabName === 'txt2img' && <MetadataIPAdaptersV2 metadata={metadata} />}
+      {activeTabName !== 'generation' && <MetadataControlNets metadata={metadata} />}
+      {activeTabName !== 'generation' && <MetadataT2IAdapters metadata={metadata} />}
+      {activeTabName !== 'generation' && <MetadataIPAdapters metadata={metadata} />}
+      {activeTabName === 'generation' && <MetadataControlNetsV2 metadata={metadata} />}
+      {activeTabName === 'generation' && <MetadataT2IAdaptersV2 metadata={metadata} />}
+      {activeTabName === 'generation' && <MetadataIPAdaptersV2 metadata={metadata} />}
     </>
   );
 };
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
index 92b0394abd..d1cc108ef2 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
@@ -11,11 +11,11 @@ import { useTranslation } from 'react-i18next';
 import { PiArrowLeftBold } from 'react-icons/pi';
 
 const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
-  txt2img: 'common.txt2img',
-  unifiedCanvas: 'common.unifiedCanvas',
-  nodes: 'common.nodes',
-  modelManager: 'modelManager.modelManager',
-  queue: 'queue.queue',
+  generation: 'ui.tabs.generation',
+  canvas: 'ui.tabs.canvas',
+  workflows: 'ui.tabs.workflows',
+  models: 'ui.tabs.models',
+  queue: 'ui.tabs.queue',
 };
 
 export const ImageViewer = memo(() => {
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts
index 75df186dd7..1efc317e3a 100644
--- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts
+++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts
@@ -14,7 +14,7 @@ export const useGalleryHotkeys = () => {
   const isStaging = useAppSelector(isStagingSelector);
   // block navigation on Unified Canvas tab when staging new images
   const canNavigateGallery = useMemo(() => {
-    return activeTabName !== 'unifiedCanvas' || !isStaging;
+    return activeTabName !== 'canvas' || !isStaging;
   }, [activeTabName, isStaging]);
 
   const {
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts
index 3f0f0bdf91..727752d79e 100644
--- a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts
+++ b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts
@@ -43,12 +43,12 @@ export const useImageActions = (image_name?: string) => {
   }, [metadata]);
 
   const recallAll = useCallback(() => {
-    parseAndRecallAllMetadata(metadata, activeTabName === 'txt2img');
+    parseAndRecallAllMetadata(metadata, activeTabName === 'generation');
   }, [activeTabName, metadata]);
 
   const remix = useCallback(() => {
     // Recalls all metadata parameters except seed
-    parseAndRecallAllMetadata(metadata, activeTabName === 'txt2img', ['seed']);
+    parseAndRecallAllMetadata(metadata, activeTabName === 'generation', ['seed']);
   }, [activeTabName, metadata]);
 
   const recallSeed = useCallback(() => {
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
index 6abc633ac8..6d3d759883 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
@@ -39,7 +39,7 @@ const ToastDescription = () => {
   const toast = useToast();
 
   const onClick = useCallback(() => {
-    dispatch(setActiveTab('modelManager'));
+    dispatch(setActiveTab('models'));
     toast.close(TOAST_ID);
   }, [dispatch, toast]);
 
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 363d97badf..531c88335b 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addControlNetToLinearGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addControlNetToLinearGraph.ts
@@ -31,9 +31,9 @@ export const addControlNetToLinearGraph = async (
     }
   );
 
-  // The txt2img tab has special handling - its control adapters are set up in the Control Layers graph helper.
+  // The generation tab has special handling - its control adapters are set up in the Control Layers graph helper.
   const activeTabName = activeTabNameSelector(state);
-  assert(activeTabName !== 'txt2img', 'Tried to use addControlNetToLinearGraph on txt2img tab');
+  assert(activeTabName !== 'generation', 'Tried to use addControlNetToLinearGraph on generation tab');
 
   if (controlNets.length) {
     // Even though denoise_latents' control input is collection or scalar, keep it simple and always use a collect
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 5abf07740a..d6709f7058 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addHrfToGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addHrfToGraph.ts
@@ -106,7 +106,7 @@ export const addHrfToGraph = (state: RootState, graph: NonNullableGraph): void =
   if (!state.hrf.hrfEnabled || state.config.disabledSDFeatures.includes('hrf')) {
     return;
   }
-  const log = logger('txt2img');
+  const log = logger('generation');
 
   const { vae, seamlessXAxis, seamlessYAxis } = state.generation;
   const { hrfStrength, hrfEnabled, hrfMethod } = state.hrf;
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 12ba4e12a8..2cf93100eb 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addIPAdapterToLinearGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addIPAdapterToLinearGraph.ts
@@ -20,9 +20,9 @@ export const addIPAdapterToLinearGraph = async (
   graph: NonNullableGraph,
   baseNodeId: string
 ): Promise<void> => {
-  // The txt2img tab has special handling - its control adapters are set up in the Control Layers graph helper.
+  // The generation tab has special handling - its control adapters are set up in the Control Layers graph helper.
   const activeTabName = activeTabNameSelector(state);
-  assert(activeTabName !== 'txt2img', 'Tried to use addT2IAdaptersToLinearGraph on txt2img tab');
+  assert(activeTabName !== 'generation', 'Tried to use addT2IAdaptersToLinearGraph on generation tab');
 
   const ipAdapters = selectValidIPAdapters(state.controlAdapters).filter(({ model, controlImage, isEnabled }) => {
     const hasModel = Boolean(model);
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 ddd87256f4..ee21bbff1b 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addT2IAdapterToLinearGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addT2IAdapterToLinearGraph.ts
@@ -20,9 +20,9 @@ export const addT2IAdaptersToLinearGraph = async (
   graph: NonNullableGraph,
   baseNodeId: string
 ): Promise<void> => {
-  // The txt2img tab has special handling - its control adapters are set up in the Control Layers graph helper.
+  // The generation tab has special handling - its control adapters are set up in the Control Layers graph helper.
   const activeTabName = activeTabNameSelector(state);
-  assert(activeTabName !== 'txt2img', 'Tried to use addT2IAdaptersToLinearGraph on txt2img tab');
+  assert(activeTabName !== 'generation', 'Tried to use addT2IAdaptersToLinearGraph on generation tab');
 
   const t2iAdapters = selectValidT2IAdapters(state.controlAdapters).filter(
     ({ model, processedControlImage, processorType, controlImage, isEnabled }) => {
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts
index 5abdc408e8..55795a092c 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts
@@ -31,7 +31,7 @@ export const getSDXLStylePrompts = (state: RootState): { positiveStylePrompt: st
  */
 export const getIsIntermediate = (state: RootState) => {
   const activeTabName = activeTabNameSelector(state);
-  if (activeTabName === 'unifiedCanvas') {
+  if (activeTabName === 'canvas') {
     return !state.canvas.shouldAutoSave;
   }
   return false;
diff --git a/invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx b/invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx
index 733fb83826..0924c0c099 100644
--- a/invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx
@@ -10,10 +10,10 @@ export const NavigateToModelManagerButton = memo((props: Omit<IconButtonProps, '
   const { t } = useTranslation();
   const dispatch = useAppDispatch();
   const disabledTabs = useAppSelector((s) => s.config.disabledTabs);
-  const shouldShowButton = useMemo(() => !disabledTabs.includes('modelManager'), [disabledTabs]);
+  const shouldShowButton = useMemo(() => !disabledTabs.includes('models'), [disabledTabs]);
 
   const handleClick = useCallback(() => {
-    dispatch(setActiveTab('modelManager'));
+    dispatch(setActiveTab('models'));
   }, [dispatch]);
 
   if (!shouldShowButton) {
diff --git a/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts b/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts
index ca7d00d507..20d771d75d 100644
--- a/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts
+++ b/invokeai/frontend/web/src/features/parameters/hooks/usePreselectedImage.ts
@@ -25,7 +25,7 @@ export const usePreselectedImage = (selectedImage?: {
   const handleSendToCanvas = useCallback(() => {
     if (selectedImageDto) {
       dispatch(setInitialCanvasImage(selectedImageDto, optimalDimension));
-      dispatch(setActiveTab('unifiedCanvas'));
+      dispatch(setActiveTab('canvas'));
       toaster({
         title: t('toast.sentToUnifiedCanvas'),
         status: 'info',
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 e97bdca45a..e9a9263605 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion.tsx
@@ -31,7 +31,7 @@ const selector = createMemoizedSelector(
     const badges: string[] = [];
     const isSDXL = model?.base === 'sdxl';
 
-    if (activeTabName === 'unifiedCanvas') {
+    if (activeTabName === 'canvas') {
       const {
         aspectRatio,
         boundingBoxDimensions: { width, height },
@@ -85,7 +85,7 @@ export const ImageSettingsAccordion = memo(() => {
       onToggle={onToggleAccordion}
     >
       <Flex px={4} pt={4} w="full" h="full" flexDir="column" data-testid="image-settings-accordion">
-        {activeTabName === 'unifiedCanvas' ? <ImageSizeCanvas /> : <ImageSizeLinear />}
+        {activeTabName === 'canvas' ? <ImageSizeCanvas /> : <ImageSizeLinear />}
         <Expander label={t('accordions.advanced.options')} isOpen={isOpenExpander} onToggle={onToggleExpander}>
           <Flex gap={4} pb={4} flexDir="column">
             <Flex gap={4} alignItems="center">
@@ -93,9 +93,9 @@ export const ImageSettingsAccordion = memo(() => {
               <ParamSeedShuffle />
               <ParamSeedRandomize />
             </Flex>
-            {activeTabName === 'unifiedCanvas' && <ImageToImageStrength />}
-            {activeTabName === 'txt2img' && !isSDXL && <HrfSettings />}
-            {activeTabName === 'unifiedCanvas' && (
+            {activeTabName === 'canvas' && <ImageToImageStrength />}
+            {activeTabName === 'generation' && !isSDXL && <HrfSettings />}
+            {activeTabName === 'canvas' && (
               <>
                 <ParamScaleBeforeProcessing />
                 <FormControlGroup formLabelProps={scalingLabelProps}>
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 0b28200ca2..ddf4997a16 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx
@@ -50,7 +50,7 @@ export const ImageSizeLinear = memo(() => {
       aspectRatioState={aspectRatioState}
       heightComponent={<ParamHeight />}
       widthComponent={<ParamWidth />}
-      previewComponent={tab === 'txt2img' ? <AspectRatioCanvasPreview /> : <AspectRatioIconPreview />}
+      previewComponent={tab === 'generation' ? <AspectRatioCanvasPreview /> : <AspectRatioIconPreview />}
       onChangeAspectRatioState={onChangeAspectRatioState}
       onChangeWidth={onChangeWidth}
       onChangeHeight={onChangeHeight}
diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
index 471046e188..1968c64161 100644
--- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
@@ -44,26 +44,26 @@ type TabData = {
 };
 
 const TAB_DATA: Record<InvokeTabName, TabData> = {
-  txt2img: {
-    id: 'txt2img',
+  generation: {
+    id: 'generation',
     translationKey: 'ui.tabs.generation',
     icon: <RiInputMethodLine />,
     content: <TextToImageTab />,
   },
-  unifiedCanvas: {
-    id: 'unifiedCanvas',
+  canvas: {
+    id: 'canvas',
     translationKey: 'ui.tabs.canvas',
     icon: <RiBrushLine />,
     content: <UnifiedCanvasTab />,
   },
-  nodes: {
-    id: 'nodes',
+  workflows: {
+    id: 'workflows',
     translationKey: 'ui.tabs.workflows',
     icon: <PiFlowArrowBold />,
     content: <NodesTab />,
   },
-  modelManager: {
-    id: 'modelManager',
+  models: {
+    id: 'models',
     translationKey: 'ui.tabs.models',
     icon: <RiBox2Line />,
     content: <ModelManagerTab />,
@@ -80,8 +80,8 @@ const enabledTabsSelector = createMemoizedSelector(selectConfigSlice, (config) =
   TAB_NUMBER_MAP.map((tabName) => TAB_DATA[tabName]).filter((tab) => !config.disabledTabs.includes(tab.id))
 );
 
-const NO_GALLERY_PANEL_TABS: InvokeTabName[] = ['modelManager', 'queue'];
-const NO_OPTIONS_PANEL_TABS: InvokeTabName[] = ['modelManager', 'queue'];
+const NO_GALLERY_PANEL_TABS: InvokeTabName[] = ['models', 'queue'];
+const NO_OPTIONS_PANEL_TABS: InvokeTabName[] = ['models', 'queue'];
 const panelStyles: CSSProperties = { height: '100%', width: '100%' };
 const GALLERY_MIN_SIZE_PX = 310;
 const GALLERY_MIN_SIZE_PCT = 20;
@@ -291,10 +291,10 @@ export default memo(InvokeTabs);
 const ParametersPanelComponent = memo(() => {
   const activeTabName = useAppSelector(activeTabNameSelector);
 
-  if (activeTabName === 'nodes') {
+  if (activeTabName === 'workflows') {
     return <NodeEditorPanelGroup />;
   }
-  if (activeTabName === 'txt2img') {
+  if (activeTabName === 'generation') {
     return <ParametersPanelTextToImage />;
   }
   return <ParametersPanel />;
diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanel.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanel.tsx
index b8d35976e3..e8f73fd786 100644
--- a/invokeai/frontend/web/src/features/ui/components/ParametersPanel.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanel.tsx
@@ -34,8 +34,8 @@ const ParametersPanel = () => {
               {isSDXL ? <SDXLPrompts /> : <Prompts />}
               <ImageSettingsAccordion />
               <GenerationSettingsAccordion />
-              {activeTabName !== 'txt2img' && <ControlSettingsAccordion />}
-              {activeTabName === 'unifiedCanvas' && <CompositingSettingsAccordion />}
+              {activeTabName !== 'generation' && <ControlSettingsAccordion />}
+              {activeTabName === 'canvas' && <CompositingSettingsAccordion />}
               {isSDXL && <RefinerSettingsAccordion />}
               <AdvancedSettingsAccordion />
             </Flex>
diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
index 3073b1e66b..79a9b5a03c 100644
--- a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
@@ -48,8 +48,8 @@ const ParametersPanelTextToImage = () => {
                     <Flex gap={2} flexDirection="column" h="full" w="full">
                       <ImageSettingsAccordion />
                       <GenerationSettingsAccordion />
-                      {activeTabName !== 'txt2img' && <ControlSettingsAccordion />}
-                      {activeTabName === 'unifiedCanvas' && <CompositingSettingsAccordion />}
+                      {activeTabName !== 'generation' && <ControlSettingsAccordion />}
+                      {activeTabName === 'canvas' && <CompositingSettingsAccordion />}
                       {isSDXL && <RefinerSettingsAccordion />}
                       <AdvancedSettingsAccordion />
                     </Flex>
diff --git a/invokeai/frontend/web/src/features/ui/store/tabMap.tsx b/invokeai/frontend/web/src/features/ui/store/tabMap.tsx
index 2b249a2a87..526a55b069 100644
--- a/invokeai/frontend/web/src/features/ui/store/tabMap.tsx
+++ b/invokeai/frontend/web/src/features/ui/store/tabMap.tsx
@@ -1,3 +1,3 @@
-export const TAB_NUMBER_MAP = ['txt2img', 'unifiedCanvas', 'nodes', 'modelManager', 'queue'] as const;
+export const TAB_NUMBER_MAP = ['generation', 'canvas', 'workflows', 'models', 'queue'] as const;
 
 export type InvokeTabName = (typeof TAB_NUMBER_MAP)[number];
diff --git a/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts b/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts
index 5fbc6a41de..05cefe43c5 100644
--- a/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts
+++ b/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts
@@ -11,7 +11,7 @@ export const activeTabNameSelector = createSelector(
    * Previously `activeTab` was an integer, but now it's a string.
    * Default to first tab in case user has integer.
    */
-  (ui) => (isString(ui.activeTab) ? ui.activeTab : 'txt2img')
+  (ui) => (isString(ui.activeTab) ? ui.activeTab : 'generation')
 );
 
 export const activeTabIndexSelector = createSelector(selectUiSlice, selectConfigSlice, (ui, config) => {
diff --git a/invokeai/frontend/web/src/features/ui/store/uiSlice.ts b/invokeai/frontend/web/src/features/ui/store/uiSlice.ts
index 221d2a1217..2146db974c 100644
--- a/invokeai/frontend/web/src/features/ui/store/uiSlice.ts
+++ b/invokeai/frontend/web/src/features/ui/store/uiSlice.ts
@@ -7,8 +7,8 @@ import type { InvokeTabName } from './tabMap';
 import type { UIState } from './uiTypes';
 
 const initialUIState: UIState = {
-  _version: 1,
-  activeTab: 'txt2img',
+  _version: 2,
+  activeTab: 'generation',
   shouldShowImageDetails: false,
   shouldShowProgressInViewer: true,
   panels: {},
@@ -43,7 +43,7 @@ export const uiSlice = createSlice({
   },
   extraReducers(builder) {
     builder.addCase(workflowLoadRequested, (state) => {
-      state.activeTab = 'nodes';
+      state.activeTab = 'workflows';
     });
   },
 });
@@ -64,6 +64,10 @@ const migrateUIState = (state: any): any => {
   if (!('_version' in state)) {
     state._version = 1;
   }
+  if (state._version === 1) {
+    state.activeTab = 'generation';
+    state._version = 2;
+  }
   return state;
 };
 
diff --git a/invokeai/frontend/web/src/features/ui/store/uiTypes.ts b/invokeai/frontend/web/src/features/ui/store/uiTypes.ts
index 2cf9fd8aa6..c72043190f 100644
--- a/invokeai/frontend/web/src/features/ui/store/uiTypes.ts
+++ b/invokeai/frontend/web/src/features/ui/store/uiTypes.ts
@@ -4,7 +4,7 @@ export interface UIState {
   /**
    * Slice schema version.
    */
-  _version: 1;
+  _version: 2;
   /**
    * The currently active tab.
    */

From 94a73d5377c9596a85c132dcfe77db7a82a46d38 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 07:04:14 +1000
Subject: [PATCH 21/59] feat(ui): update mm-related translations

---
 invokeai/frontend/web/public/locales/en.json          | 11 +++++++++--
 .../modelManagerV2/hooks/useStarterModelsToast.tsx    |  2 +-
 .../MainModel/NavigateToModelManagerButton.tsx        |  4 ++--
 3 files changed, 12 insertions(+), 5 deletions(-)

diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 0ad7432ca2..cfcb433db2 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -93,6 +93,7 @@
         "folder": "Folder",
         "format": "format",
         "githubLabel": "Github",
+        "goTo": "Go to",
         "hotkeysLabel": "Hotkeys",
         "imageFailedToLoad": "Unable to Load Image",
         "img2img": "Image To Image",
@@ -140,7 +141,8 @@
         "blue": "Blue",
         "alpha": "Alpha",
         "selected": "Selected",
-        "viewer": "Viewer"
+        "viewer": "Viewer",
+        "tab": "Tab"
     },
     "controlnet": {
         "controlAdapter_one": "Control Adapter",
@@ -1561,10 +1563,15 @@
     "ui": {
         "tabs": {
             "generation": "Generation",
+            "generationTab": "$t(ui.tabs.generation) $t(common.tab)",
             "canvas": "Canvas",
+            "canvasTab": "$t(ui.tabs.canvas) $t(common.tab)",
             "workflows": "Workflows",
+            "workflowsTab": "$t(ui.tabs.workflows) $t(common.tab)",
             "models": "Models",
-            "queue": "Queue"
+            "modelsTab": "$t(ui.tabs.models) $t(common.tab)",
+            "queue": "Queue",
+            "queueTab": "$t(ui.tabs.queue) $t(common.tab)"
         }
     }
 }
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
index 6d3d759883..6106264b78 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
@@ -47,7 +47,7 @@ const ToastDescription = () => {
     <Text fontSize="md">
       {t('modelManager.noModelsInstalledDesc1')}{' '}
       <Button onClick={onClick} variant="link" color="base.50" flexGrow={0}>
-        {t('modelManager.modelManager')}.
+        {t('ui.tabs.modelsTab')}.
       </Button>
     </Text>
   );
diff --git a/invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx b/invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx
index 0924c0c099..268674d8c3 100644
--- a/invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx
@@ -23,8 +23,8 @@ export const NavigateToModelManagerButton = memo((props: Omit<IconButtonProps, '
   return (
     <IconButton
       icon={<PiGearSixBold />}
-      tooltip={t('modelManager.modelManager')}
-      aria-label={t('modelManager.modelManager')}
+      tooltip={`${t('common.goTo')} ${t('ui.tabs.modelsTab')}`}
+      aria-label={`${t('common.goTo')} ${t('ui.tabs.modelsTab')}`}
       onClick={handleClick}
       size="sm"
       variant="ghost"

From 5734a97c55e7f96c08dbba6a0908acaea3916336 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 08:41:03 +1000
Subject: [PATCH 22/59] fix(ui): do not attempt drawing when invalid layer type
 selected

---
 .../controlLayers/hooks/mouseEventHooks.ts    | 43 +++++++++++--------
 1 file changed, 25 insertions(+), 18 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
index 7fe81ad567..889d2c0c2e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
@@ -49,6 +49,13 @@ const BRUSH_SPACING = 20;
 export const useMouseEvents = () => {
   const dispatch = useAppDispatch();
   const selectedLayerId = useAppSelector((s) => s.controlLayers.present.selectedLayerId);
+  const selectedLayerType = useAppSelector((s) => {
+    const selectedLayer = s.controlLayers.present.layers.find((l) => l.id === s.controlLayers.present.selectedLayerId);
+    if (!selectedLayer) {
+      return null;
+    }
+    return selectedLayer.type;
+  });
   const tool = useStore($tool);
   const lastCursorPosRef = useRef<[number, number] | null>(null);
   const shouldInvertBrushSizeScrollDirection = useAppSelector((s) => s.canvas.shouldInvertBrushSizeScrollDirection);
@@ -61,13 +68,10 @@ export const useMouseEvents = () => {
         return;
       }
       const pos = syncCursorPos(stage);
-      if (!pos) {
+      if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
         return;
       }
       $lastMouseDownPos.set(pos);
-      if (!selectedLayerId) {
-        return;
-      }
       if (tool === 'brush' || tool === 'eraser') {
         dispatch(
           rgLayerLineAdded({
@@ -79,7 +83,7 @@ export const useMouseEvents = () => {
         $isDrawing.set(true);
       }
     },
-    [dispatch, selectedLayerId, tool]
+    [dispatch, selectedLayerId, selectedLayerType, tool]
   );
 
   const onMouseUp = useCallback(
@@ -89,9 +93,12 @@ export const useMouseEvents = () => {
         return;
       }
       const pos = $cursorPosition.get();
+      if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
+        return;
+      }
       const lastPos = $lastMouseDownPos.get();
       const tool = $tool.get();
-      if (pos && lastPos && selectedLayerId && tool === 'rect') {
+      if (lastPos && selectedLayerId && tool === 'rect') {
         dispatch(
           rgLayerRectAdded({
             layerId: selectedLayerId,
@@ -107,7 +114,7 @@ export const useMouseEvents = () => {
       $isDrawing.set(false);
       $lastMouseDownPos.set(null);
     },
-    [dispatch, selectedLayerId]
+    [dispatch, selectedLayerId, selectedLayerType]
   );
 
   const onMouseMove = useCallback(
@@ -117,7 +124,7 @@ export const useMouseEvents = () => {
         return;
       }
       const pos = syncCursorPos(stage);
-      if (!pos || !selectedLayerId) {
+      if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
         return;
       }
       if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) {
@@ -138,7 +145,7 @@ export const useMouseEvents = () => {
         $isDrawing.set(true);
       }
     },
-    [dispatch, selectedLayerId, tool]
+    [dispatch, selectedLayerId, selectedLayerType, tool]
   );
 
   const onMouseLeave = useCallback(
@@ -148,25 +155,25 @@ export const useMouseEvents = () => {
         return;
       }
       const pos = syncCursorPos(stage);
-      if (
-        pos &&
-        selectedLayerId &&
-        getIsFocused(stage) &&
-        getIsMouseDown(e) &&
-        (tool === 'brush' || tool === 'eraser')
-      ) {
+      if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
+        return;
+      }
+      if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) {
         dispatch(rgLayerPointsAdded({ layerId: selectedLayerId, point: [pos.x, pos.y] }));
       }
       $isDrawing.set(false);
       $cursorPosition.set(null);
     },
-    [selectedLayerId, tool, dispatch]
+    [selectedLayerId, selectedLayerType, tool, dispatch]
   );
 
   const onMouseWheel = useCallback(
     (e: KonvaEventObject<WheelEvent>) => {
       e.evt.preventDefault();
 
+      if (selectedLayerType !== 'regional_guidance_layer' || (tool !== 'brush' && tool !== 'eraser')) {
+        return;
+      }
       // checking for ctrl key is pressed or not,
       // so that brush size can be controlled using ctrl + scroll up/down
 
@@ -180,7 +187,7 @@ export const useMouseEvents = () => {
         dispatch(brushSizeChanged(calculateNewBrushSize(brushSize, delta)));
       }
     },
-    [shouldInvertBrushSizeScrollDirection, brushSize, dispatch]
+    [selectedLayerType, tool, shouldInvertBrushSizeScrollDirection, dispatch, brushSize]
   );
 
   return { onMouseDown, onMouseUp, onMouseMove, onMouseLeave, onMouseWheel };

From c05e52ebae9b5e6385ec987e875ff0c71c288d0c Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 09:27:21 +1000
Subject: [PATCH 23/59] fix(ui): do not delete all layers when using image as
 initial image

---
 .../web/src/features/controlLayers/store/controlLayersSlice.ts  | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index 6b3dfc5e6c..a1b5e0ebc8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -627,7 +627,7 @@ export const controlLayersSlice = createSlice({
       reducer: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
         const { layerId, imageDTO } = action.payload;
         // Highlander! There can be only one!
-        state.layers = state.layers.filter((l) => isInitialImageLayer(l));
+        state.layers = state.layers.filter((l) => (isInitialImageLayer(l) ? false : true));
         const layer: InitialImageLayer = {
           id: layerId,
           type: 'initial_image_layer',

From 33617fc06a229b721c5b93621730bc0445fc84df Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 09:37:52 +1000
Subject: [PATCH 24/59] feat(ui): rework image viewer

- Rework styling
- Replace "CurrentImageDisplay" entirely
- Add a super short fade to reduce jarring transition
- Make the viewer a singleton component, overlaid on everything else - reduces change when switching tabs
---
 .../CurrentImage/CurrentImageDisplay.tsx      |  24 ---
 .../gallery/components/ImageViewer.tsx        |  79 -------
 .../ImageViewer/BackToEditorButton.tsx        |  24 +++
 .../CurrentImageButtons.tsx                   | 193 +++++++-----------
 .../CurrentImagePreview.tsx                   |   3 +-
 .../components/ImageViewer/ImageViewer.tsx    |  90 ++++++++
 .../ProgressImage.tsx                         |   0
 .../ToggleMetadataViewerButton.tsx            |  42 ++++
 .../ImageViewer/ToggleProgressButton.tsx      |  29 +++
 .../components/ImageViewer/useImageViewer.tsx |  31 +++
 .../src/features/ui/components/InvokeTabs.tsx |   4 +-
 .../features/ui/components/tabs/NodesTab.tsx  |  24 +--
 .../ui/components/tabs/TextToImageTab.tsx     |   2 -
 .../ui/components/tabs/UnifiedCanvasTab.tsx   |   2 -
 14 files changed, 294 insertions(+), 253 deletions(-)
 delete mode 100644 invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageDisplay.tsx
 delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
 create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/BackToEditorButton.tsx
 rename invokeai/frontend/web/src/features/gallery/components/{CurrentImage => ImageViewer}/CurrentImageButtons.tsx (51%)
 rename invokeai/frontend/web/src/features/gallery/components/{CurrentImage => ImageViewer}/CurrentImagePreview.tsx (98%)
 create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
 rename invokeai/frontend/web/src/features/gallery/components/{CurrentImage => ImageViewer}/ProgressImage.tsx (100%)
 create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx
 create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleProgressButton.tsx
 create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.tsx

diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageDisplay.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageDisplay.tsx
deleted file mode 100644
index f4b707b859..0000000000
--- a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageDisplay.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Flex } from '@invoke-ai/ui-library';
-import { memo } from 'react';
-
-import CurrentImageButtons from './CurrentImageButtons';
-import CurrentImagePreview from './CurrentImagePreview';
-
-const CurrentImageDisplay = () => {
-  return (
-    <Flex
-      position="relative"
-      flexDirection="column"
-      height="100%"
-      width="100%"
-      rowGap={4}
-      alignItems="center"
-      justifyContent="center"
-    >
-      <CurrentImageButtons />
-      <CurrentImagePreview />
-    </Flex>
-  );
-};
-
-export default memo(CurrentImageDisplay);
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
deleted file mode 100644
index d1cc108ef2..0000000000
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { Button, Flex } from '@invoke-ai/ui-library';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import CurrentImageButtons from 'features/gallery/components/CurrentImage/CurrentImageButtons';
-import CurrentImagePreview from 'features/gallery/components/CurrentImage/CurrentImagePreview';
-import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
-import type { InvokeTabName } from 'features/ui/store/tabMap';
-import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
-import { memo, useCallback, useMemo } from 'react';
-import { useHotkeys } from 'react-hotkeys-hook';
-import { useTranslation } from 'react-i18next';
-import { PiArrowLeftBold } from 'react-icons/pi';
-
-const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
-  generation: 'ui.tabs.generation',
-  canvas: 'ui.tabs.canvas',
-  workflows: 'ui.tabs.workflows',
-  models: 'ui.tabs.models',
-  queue: 'ui.tabs.queue',
-};
-
-export const ImageViewer = memo(() => {
-  const { t } = useTranslation();
-  const dispatch = useAppDispatch();
-  const isOpen = useAppSelector((s) => s.gallery.isImageViewerOpen);
-  const activeTabName = useAppSelector(activeTabNameSelector);
-  const activeTabLabel = useMemo(
-    () => t('gallery.backToEditor', { tab: t(TAB_NAME_TO_TKEY[activeTabName]) }),
-    [t, activeTabName]
-  );
-
-  const onClose = useCallback(() => {
-    dispatch(isImageViewerOpenChanged(false));
-  }, [dispatch]);
-
-  const onOpen = useCallback(() => {
-    dispatch(isImageViewerOpenChanged(true));
-  }, [dispatch]);
-
-  useHotkeys('esc', onClose, { enabled: isOpen }, [isOpen]);
-  useHotkeys('i', onOpen, { enabled: !isOpen }, [isOpen]);
-
-  if (!isOpen) {
-    return null;
-  }
-
-  return (
-    <Flex
-      layerStyle="first"
-      borderRadius="base"
-      position="absolute"
-      flexDirection="column"
-      top={0}
-      right={0}
-      bottom={0}
-      left={0}
-      p={2}
-      rowGap={4}
-      alignItems="center"
-      justifyContent="center"
-      zIndex={10} // reactflow puts its minimap at 5, so we need to be above that
-    >
-      <CurrentImageButtons />
-      <CurrentImagePreview />
-      <Button
-        aria-label={activeTabLabel}
-        tooltip={activeTabLabel}
-        onClick={onClose}
-        leftIcon={<PiArrowLeftBold />}
-        position="absolute"
-        top={2}
-        insetInlineEnd={2}
-      >
-        {t('common.back')}
-      </Button>
-    </Flex>
-  );
-});
-
-ImageViewer.displayName = 'ImageViewer';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/BackToEditorButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/BackToEditorButton.tsx
new file mode 100644
index 0000000000..660840b568
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/BackToEditorButton.tsx
@@ -0,0 +1,24 @@
+import { Button } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
+import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiArrowLeftBold } from 'react-icons/pi';
+
+import { TAB_NAME_TO_TKEY, useImageViewer } from './useImageViewer';
+
+export const BackToEditorButton = () => {
+  const { t } = useTranslation();
+  const { onClose } = useImageViewer();
+  const activeTabName = useAppSelector(activeTabNameSelector);
+  const tooltip = useMemo(
+    () => t('gallery.backToEditor', { tab: t(TAB_NAME_TO_TKEY[activeTabName]) }),
+    [t, activeTabName]
+  );
+
+  return (
+    <Button aria-label={tooltip} tooltip={tooltip} onClick={onClose} leftIcon={<PiArrowLeftBold />} variant="ghost">
+      {t('common.back')}
+    </Button>
+  );
+};
diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx
similarity index 51%
rename from invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx
rename to invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx
index 2e4519af5e..f93f48e51b 100644
--- a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx
@@ -1,7 +1,6 @@
-import { ButtonGroup, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
+import { ButtonGroup, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
 import { createSelector } from '@reduxjs/toolkit';
 import { skipToken } from '@reduxjs/toolkit/query';
-import { useAppToaster } from 'app/components/Toaster';
 import { upscaleRequested } from 'app/store/middleware/listenerMiddleware/listeners/upscaleRequested';
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
 import { iiLayerAdded } from 'features/controlLayers/store/controlLayersSlice';
@@ -17,7 +16,6 @@ import ParamUpscalePopover from 'features/parameters/components/Upscale/ParamUps
 import { useIsQueueMutationInProgress } from 'features/queue/hooks/useIsQueueMutationInProgress';
 import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
 import { selectSystemSlice } from 'features/system/store/systemSlice';
-import { setShouldShowImageDetails, setShouldShowProgressInViewer } from 'features/ui/store/uiSlice';
 import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow';
 import { memo, useCallback } from 'react';
 import { useHotkeys } from 'react-hotkeys-hook';
@@ -27,8 +25,6 @@ import {
   PiAsteriskBold,
   PiDotsThreeOutlineFill,
   PiFlowArrowBold,
-  PiHourglassHighBold,
-  PiInfoBold,
   PiPlantBold,
   PiQuotesBold,
   PiRulerBold,
@@ -48,15 +44,12 @@ const selectShouldDisableToolbarButtons = createSelector(
 const CurrentImageButtons = () => {
   const dispatch = useAppDispatch();
   const isConnected = useAppSelector((s) => s.system.isConnected);
-  const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails);
-  const shouldShowProgressInViewer = useAppSelector((s) => s.ui.shouldShowProgressInViewer);
   const lastSelectedImage = useAppSelector(selectLastSelectedImage);
   const selection = useAppSelector((s) => s.gallery.selection);
   const shouldDisableToolbarButtons = useAppSelector(selectShouldDisableToolbarButtons);
 
   const isUpscalingEnabled = useFeatureStatus('upscaling');
   const isQueueMutationInProgress = useIsQueueMutationInProgress();
-  const toaster = useAppToaster();
   const { t } = useTranslation();
 
   const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken);
@@ -120,28 +113,6 @@ const CurrentImageButtons = () => {
     [isUpscalingEnabled, imageDTO, shouldDisableToolbarButtons, isConnected]
   );
 
-  const handleClickShowImageDetails = useCallback(
-    () => dispatch(setShouldShowImageDetails(!shouldShowImageDetails)),
-    [dispatch, shouldShowImageDetails]
-  );
-
-  useHotkeys(
-    'i',
-    () => {
-      if (imageDTO) {
-        handleClickShowImageDetails();
-      } else {
-        toaster({
-          title: t('toast.metadataLoadFailed'),
-          status: 'error',
-          duration: 2500,
-          isClosable: true,
-        });
-      }
-    },
-    [imageDTO, shouldShowImageDetails, toaster]
-  );
-
   useHotkeys(
     'delete',
     () => {
@@ -150,106 +121,80 @@ const CurrentImageButtons = () => {
     [dispatch, imageDTO]
   );
 
-  const handleClickProgressImagesToggle = useCallback(() => {
-    dispatch(setShouldShowProgressInViewer(!shouldShowProgressInViewer));
-  }, [dispatch, shouldShowProgressInViewer]);
-
   return (
     <>
-      <Flex flexWrap="wrap" justifyContent="center" alignItems="center" gap={2}>
-        <ButtonGroup isDisabled={shouldDisableToolbarButtons}>
-          <Menu isLazy>
-            <MenuButton
-              as={IconButton}
-              aria-label={t('parameters.imageActions')}
-              tooltip={t('parameters.imageActions')}
-              isDisabled={!imageDTO}
-              icon={<PiDotsThreeOutlineFill />}
-            />
-            <MenuList>{imageDTO && <SingleSelectionMenuItems imageDTO={imageDTO} />}</MenuList>
-          </Menu>
-        </ButtonGroup>
+      <ButtonGroup isDisabled={shouldDisableToolbarButtons}>
+        <Menu isLazy>
+          <MenuButton
+            as={IconButton}
+            aria-label={t('parameters.imageActions')}
+            tooltip={t('parameters.imageActions')}
+            isDisabled={!imageDTO}
+            icon={<PiDotsThreeOutlineFill />}
+          />
+          <MenuList>{imageDTO && <SingleSelectionMenuItems imageDTO={imageDTO} />}</MenuList>
+        </Menu>
+      </ButtonGroup>
 
-        <ButtonGroup isDisabled={shouldDisableToolbarButtons}>
-          <IconButton
-            icon={<PiFlowArrowBold />}
-            tooltip={`${t('nodes.loadWorkflow')} (W)`}
-            aria-label={`${t('nodes.loadWorkflow')} (W)`}
-            isDisabled={!imageDTO?.has_workflow}
-            onClick={handleLoadWorkflow}
-            isLoading={getAndLoadEmbeddedWorkflowResult.isLoading}
-          />
-          <IconButton
-            isLoading={isLoadingMetadata}
-            icon={<PiArrowsCounterClockwiseBold />}
-            tooltip={`${t('parameters.remixImage')} (R)`}
-            aria-label={`${t('parameters.remixImage')} (R)`}
-            isDisabled={!hasMetadata}
-            onClick={remix}
-          />
-          <IconButton
-            isLoading={isLoadingMetadata}
-            icon={<PiQuotesBold />}
-            tooltip={`${t('parameters.usePrompt')} (P)`}
-            aria-label={`${t('parameters.usePrompt')} (P)`}
-            isDisabled={!hasPrompts}
-            onClick={recallPrompts}
-          />
-          <IconButton
-            isLoading={isLoadingMetadata}
-            icon={<PiPlantBold />}
-            tooltip={`${t('parameters.useSeed')} (S)`}
-            aria-label={`${t('parameters.useSeed')} (S)`}
-            isDisabled={!hasSeed}
-            onClick={recallSeed}
-          />
-          <IconButton
-            isLoading={isLoadingMetadata}
-            icon={<PiRulerBold />}
-            tooltip={`${t('parameters.useSize')} (D)`}
-            aria-label={`${t('parameters.useSize')} (D)`}
-            onClick={handleUseSize}
-          />
-          <IconButton
-            isLoading={isLoadingMetadata}
-            icon={<PiAsteriskBold />}
-            tooltip={`${t('parameters.useAll')} (A)`}
-            aria-label={`${t('parameters.useAll')} (A)`}
-            isDisabled={!hasMetadata}
-            onClick={recallAll}
-          />
-        </ButtonGroup>
+      <ButtonGroup isDisabled={shouldDisableToolbarButtons}>
+        <IconButton
+          icon={<PiFlowArrowBold />}
+          tooltip={`${t('nodes.loadWorkflow')} (W)`}
+          aria-label={`${t('nodes.loadWorkflow')} (W)`}
+          isDisabled={!imageDTO?.has_workflow}
+          onClick={handleLoadWorkflow}
+          isLoading={getAndLoadEmbeddedWorkflowResult.isLoading}
+        />
+        <IconButton
+          isLoading={isLoadingMetadata}
+          icon={<PiArrowsCounterClockwiseBold />}
+          tooltip={`${t('parameters.remixImage')} (R)`}
+          aria-label={`${t('parameters.remixImage')} (R)`}
+          isDisabled={!hasMetadata}
+          onClick={remix}
+        />
+        <IconButton
+          isLoading={isLoadingMetadata}
+          icon={<PiQuotesBold />}
+          tooltip={`${t('parameters.usePrompt')} (P)`}
+          aria-label={`${t('parameters.usePrompt')} (P)`}
+          isDisabled={!hasPrompts}
+          onClick={recallPrompts}
+        />
+        <IconButton
+          isLoading={isLoadingMetadata}
+          icon={<PiPlantBold />}
+          tooltip={`${t('parameters.useSeed')} (S)`}
+          aria-label={`${t('parameters.useSeed')} (S)`}
+          isDisabled={!hasSeed}
+          onClick={recallSeed}
+        />
+        <IconButton
+          isLoading={isLoadingMetadata}
+          icon={<PiRulerBold />}
+          tooltip={`${t('parameters.useSize')} (D)`}
+          aria-label={`${t('parameters.useSize')} (D)`}
+          onClick={handleUseSize}
+        />
+        <IconButton
+          isLoading={isLoadingMetadata}
+          icon={<PiAsteriskBold />}
+          tooltip={`${t('parameters.useAll')} (A)`}
+          aria-label={`${t('parameters.useAll')} (A)`}
+          isDisabled={!hasMetadata}
+          onClick={recallAll}
+        />
+      </ButtonGroup>
 
-        {isUpscalingEnabled && (
-          <ButtonGroup isDisabled={isQueueMutationInProgress}>
-            {isUpscalingEnabled && <ParamUpscalePopover imageDTO={imageDTO} />}
-          </ButtonGroup>
-        )}
-
-        <ButtonGroup>
-          <IconButton
-            icon={<PiInfoBold />}
-            tooltip={`${t('parameters.info')} (I)`}
-            aria-label={`${t('parameters.info')} (I)`}
-            isChecked={shouldShowImageDetails}
-            onClick={handleClickShowImageDetails}
-          />
+      {isUpscalingEnabled && (
+        <ButtonGroup isDisabled={isQueueMutationInProgress}>
+          {isUpscalingEnabled && <ParamUpscalePopover imageDTO={imageDTO} />}
         </ButtonGroup>
+      )}
 
-        <ButtonGroup>
-          <IconButton
-            aria-label={t('settings.displayInProgress')}
-            tooltip={t('settings.displayInProgress')}
-            icon={<PiHourglassHighBold />}
-            isChecked={shouldShowProgressInViewer}
-            onClick={handleClickProgressImagesToggle}
-          />
-        </ButtonGroup>
-
-        <ButtonGroup>
-          <DeleteImageButton onClick={handleDelete} />
-        </ButtonGroup>
-      </Flex>
+      <ButtonGroup>
+        <DeleteImageButton onClick={handleDelete} />
+      </ButtonGroup>
     </>
   );
 };
diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
similarity index 98%
rename from invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx
rename to invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
index 02863daa2f..3f54974449 100644
--- a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
@@ -5,7 +5,6 @@ import { useAppSelector } from 'app/store/storeHooks';
 import IAIDndImage from 'common/components/IAIDndImage';
 import { IAINoContentFallback } from 'common/components/IAIImageFallback';
 import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
-import ProgressImage from 'features/gallery/components/CurrentImage/ProgressImage';
 import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
 import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
 import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
@@ -17,6 +16,8 @@ import { useTranslation } from 'react-i18next';
 import { PiImageBold } from 'react-icons/pi';
 import { useGetImageDTOQuery } from 'services/api/endpoints/images';
 
+import ProgressImage from './ProgressImage';
+
 const selectLastSelectedImageName = createSelector(
   selectLastSelectedImage,
   (lastSelectedImage) => lastSelectedImage?.image_name
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
new file mode 100644
index 0000000000..9f3e7c5902
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
@@ -0,0 +1,90 @@
+import { Flex } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
+import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton';
+import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
+import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
+import type { InvokeTabName } from 'features/ui/store/tabMap';
+import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
+import type { AnimationProps } from 'framer-motion';
+import { AnimatePresence, motion } from 'framer-motion';
+import { memo, useMemo } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
+
+import { BackToEditorButton } from './BackToEditorButton';
+import CurrentImageButtons from './CurrentImageButtons';
+import CurrentImagePreview from './CurrentImagePreview';
+
+const initial: AnimationProps['initial'] = {
+  opacity: 0,
+};
+const animate: AnimationProps['animate'] = {
+  opacity: 1,
+  transition: { duration: 0.07 },
+};
+const exit: AnimationProps['exit'] = {
+  opacity: 0,
+  transition: { duration: 0.07 },
+};
+
+const VIEWER_ENABLED_TABS: InvokeTabName[] = ['canvas', 'generation', 'workflows'];
+
+export const ImageViewer = memo(() => {
+  const { isOpen, onToggle, onClose } = useImageViewer();
+  const activeTabName = useAppSelector(activeTabNameSelector);
+  const isViewerEnabled = useMemo(() => VIEWER_ENABLED_TABS.includes(activeTabName), [activeTabName]);
+  const shouldShowViewer = useMemo(() => {
+    if (!isViewerEnabled) {
+      return false;
+    }
+    return isOpen;
+  }, [isOpen, isViewerEnabled]);
+
+  useHotkeys('shift+s', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]);
+  useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]);
+
+  return (
+    <AnimatePresence>
+      {shouldShowViewer && (
+        <Flex
+          as={motion.div}
+          initial={initial}
+          animate={animate}
+          exit={exit}
+          layerStyle="first"
+          borderRadius="base"
+          position="absolute"
+          flexDirection="column"
+          top={0}
+          right={0}
+          bottom={0}
+          left={0}
+          p={2}
+          rowGap={4}
+          alignItems="center"
+          justifyContent="center"
+          zIndex={10} // reactflow puts its minimap at 5, so we need to be above that
+        >
+          <Flex w="full" gap={2}>
+            <Flex flex={1} justifyContent="center">
+              <Flex gap={2} marginInlineEnd="auto">
+                <ToggleProgressButton />
+                <ToggleMetadataViewerButton />
+              </Flex>
+            </Flex>
+            <Flex flex={1} gap={2} justifyContent="center">
+              <CurrentImageButtons />
+            </Flex>
+            <Flex flex={1} justifyContent="center">
+              <Flex gap={2} marginInlineStart="auto">
+                <BackToEditorButton />
+              </Flex>
+            </Flex>
+          </Flex>
+          <CurrentImagePreview />
+        </Flex>
+      )}
+    </AnimatePresence>
+  );
+});
+
+ImageViewer.displayName = 'ImageViewer';
diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/ProgressImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx
similarity index 100%
rename from invokeai/frontend/web/src/features/gallery/components/CurrentImage/ProgressImage.tsx
rename to invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx
new file mode 100644
index 0000000000..a298ebda56
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx
@@ -0,0 +1,42 @@
+import { IconButton } from '@invoke-ai/ui-library';
+import { skipToken } from '@reduxjs/toolkit/query';
+import { useAppToaster } from 'app/components/Toaster';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
+import { setShouldShowImageDetails } from 'features/ui/store/uiSlice';
+import { memo, useCallback } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
+import { useTranslation } from 'react-i18next';
+import { PiInfoBold } from 'react-icons/pi';
+import { useGetImageDTOQuery } from 'services/api/endpoints/images';
+
+export const ToggleMetadataViewerButton = memo(() => {
+  const dispatch = useAppDispatch();
+  const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails);
+  const lastSelectedImage = useAppSelector(selectLastSelectedImage);
+  const toaster = useAppToaster();
+  const { t } = useTranslation();
+
+  const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken);
+
+  const toggleMetadataViewer = useCallback(
+    () => dispatch(setShouldShowImageDetails(!shouldShowImageDetails)),
+    [dispatch, shouldShowImageDetails]
+  );
+
+  useHotkeys('i', toggleMetadataViewer, { enabled: Boolean(imageDTO) }, [imageDTO, shouldShowImageDetails, toaster]);
+
+  return (
+    <IconButton
+      icon={<PiInfoBold />}
+      tooltip={`${t('parameters.info')} (I)`}
+      aria-label={`${t('parameters.info')} (I)`}
+      onClick={toggleMetadataViewer}
+      isDisabled={!imageDTO}
+      variant="outline"
+      colorScheme={shouldShowImageDetails ? 'invokeBlue' : 'base'}
+    />
+  );
+});
+
+ToggleMetadataViewerButton.displayName = 'ToggleMetadataViewerButton';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleProgressButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleProgressButton.tsx
new file mode 100644
index 0000000000..994a8bf10e
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleProgressButton.tsx
@@ -0,0 +1,29 @@
+import { IconButton } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { setShouldShowProgressInViewer } from 'features/ui/store/uiSlice';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiHourglassHighBold } from 'react-icons/pi';
+
+export const ToggleProgressButton = memo(() => {
+  const dispatch = useAppDispatch();
+  const shouldShowProgressInViewer = useAppSelector((s) => s.ui.shouldShowProgressInViewer);
+  const { t } = useTranslation();
+
+  const onClick = useCallback(() => {
+    dispatch(setShouldShowProgressInViewer(!shouldShowProgressInViewer));
+  }, [dispatch, shouldShowProgressInViewer]);
+
+  return (
+    <IconButton
+      aria-label={t('settings.displayInProgress')}
+      tooltip={t('settings.displayInProgress')}
+      icon={<PiHourglassHighBold />}
+      onClick={onClick}
+      variant="outline"
+      colorScheme={shouldShowProgressInViewer ? 'invokeBlue' : 'base'}
+    />
+  );
+});
+
+ToggleProgressButton.displayName = 'ToggleProgressButton';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.tsx
new file mode 100644
index 0000000000..17a9dc9922
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.tsx
@@ -0,0 +1,31 @@
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
+import type { InvokeTabName } from 'features/ui/store/tabMap';
+import { useCallback } from 'react';
+
+export const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
+  generation: 'ui.tabs.generation',
+  canvas: 'ui.tabs.canvas',
+  workflows: 'ui.tabs.workflows',
+  models: 'ui.tabs.models',
+  queue: 'ui.tabs.queue',
+};
+
+export const useImageViewer = () => {
+  const dispatch = useAppDispatch();
+  const isOpen = useAppSelector((s) => s.gallery.isImageViewerOpen);
+
+  const onClose = useCallback(() => {
+    dispatch(isImageViewerOpenChanged(false));
+  }, [dispatch]);
+
+  const onOpen = useCallback(() => {
+    dispatch(isImageViewerOpenChanged(true));
+  }, [dispatch]);
+
+  const onToggle = useCallback(() => {
+    dispatch(isImageViewerOpenChanged(!isOpen));
+  }, [dispatch, isOpen]);
+
+  return { isOpen, onOpen, onClose, onToggle };
+};
diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
index 1968c64161..5e37a2d8c8 100644
--- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
@@ -4,6 +4,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
 import { $customNavComponent } from 'app/store/nanostores/customNavComponent';
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
 import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent';
+import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
 import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup';
 import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent';
 import SettingsMenu from 'features/system/components/SettingsModal/SettingsMenu';
@@ -253,10 +254,11 @@ const InvokeTabs = () => {
             />
           </>
         )}
-        <Panel id="main-panel" order={1} minSize={20}>
+        <Panel style={{ position: 'relative' }} id="main-panel" order={1} minSize={20}>
           <TabPanels w="full" h="full">
             {tabPanels}
           </TabPanels>
+          <ImageViewer />
         </Panel>
         {shouldShowGalleryPanel && (
           <>
diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx
index 81f0810192..2ee21bfadf 100644
--- a/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx
@@ -1,30 +1,14 @@
-import { Box, Flex } from '@invoke-ai/ui-library';
-import { useAppSelector } from 'app/store/storeHooks';
-import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay';
-import { ImageViewer } from 'features/gallery/components/ImageViewer';
+import { Box } from '@invoke-ai/ui-library';
 import NodeEditor from 'features/nodes/components/NodeEditor';
 import { memo } from 'react';
 import { ReactFlowProvider } from 'reactflow';
 
 const NodesTab = () => {
-  const mode = useAppSelector((s) => s.workflow.mode);
-
-  if (mode === 'edit') {
-    return (
-      <Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
-        <ReactFlowProvider>
-          <NodeEditor />
-        </ReactFlowProvider>
-        <ImageViewer />
-      </Box>
-    );
-  }
-
   return (
     <Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
-      <Flex w="full" h="full">
-        <CurrentImageDisplay />
-      </Flex>
+      <ReactFlowProvider>
+        <NodeEditor />
+      </ReactFlowProvider>
     </Box>
   );
 };
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 df0b8651bc..74845a9ca9 100644
--- a/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/tabs/TextToImageTab.tsx
@@ -1,13 +1,11 @@
 import { Box } from '@invoke-ai/ui-library';
 import { ControlLayersEditor } from 'features/controlLayers/components/ControlLayersEditor';
-import { ImageViewer } from 'features/gallery/components/ImageViewer';
 import { memo } from 'react';
 
 const TextToImageTab = () => {
   return (
     <Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
       <ControlLayersEditor />
-      <ImageViewer />
     </Box>
   );
 };
diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvasTab.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvasTab.tsx
index 8c74685d2d..3e0d9b35d4 100644
--- a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvasTab.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvasTab.tsx
@@ -6,7 +6,6 @@ import { CANVAS_TAB_TESTID } from 'features/canvas/store/constants';
 import { useDroppableTypesafe } from 'features/dnd/hooks/typesafeHooks';
 import type { CanvasInitialImageDropData } from 'features/dnd/types';
 import { isValidDrop } from 'features/dnd/util/isValidDrop';
-import { ImageViewer } from 'features/gallery/components/ImageViewer';
 import { memo } from 'react';
 import { useTranslation } from 'react-i18next';
 
@@ -42,7 +41,6 @@ const UnifiedCanvasTab = () => {
     >
       <IAICanvasToolbar />
       <IAICanvas />
-      <ImageViewer />
       {isValidDrop(droppableData, active) && (
         <IAIDropOverlay isOver={isOver} label={t('toast.setCanvasInitialImage')} />
       )}

From 98664fc46f4d8b744725143e626c98a38f659840 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 09:50:20 +1000
Subject: [PATCH 25/59] fix(ui): gallery prev/next buttons animations

---
 .../ImageViewer/CurrentImagePreview.tsx       | 23 +++++++------------
 1 file changed, 8 insertions(+), 15 deletions(-)

diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
index 3f54974449..3ba3f2e372 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
@@ -24,6 +24,7 @@ const selectLastSelectedImageName = createSelector(
 );
 
 const CurrentImagePreview = () => {
+  const { t } = useTranslation();
   const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails);
   const imageName = useAppSelector(selectLastSelectedImageName);
   const hasDenoiseProgress = useAppSelector((s) => Boolean(s.system.denoiseProgress));
@@ -51,26 +52,18 @@ const CurrentImagePreview = () => {
 
   // Show and hide the next/prev buttons on mouse move
   const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState<boolean>(false);
-
   const timeoutId = useRef(0);
-
-  const { t } = useTranslation();
-
-  const handleMouseOver = useCallback(() => {
+  const onMouseMove = useCallback(() => {
     setShouldShowNextPrevButtons(true);
     window.clearTimeout(timeoutId.current);
-  }, []);
-
-  const handleMouseOut = useCallback(() => {
     timeoutId.current = window.setTimeout(() => {
       setShouldShowNextPrevButtons(false);
-    }, 500);
+    }, 1000);
   }, []);
 
   return (
     <Flex
-      onMouseOver={handleMouseOver}
-      onMouseOut={handleMouseOut}
+      onMouseMove={onMouseMove}
       width="full"
       height="full"
       alignItems="center"
@@ -93,12 +86,12 @@ const CurrentImagePreview = () => {
         />
       )}
       {shouldShowImageDetails && imageDTO && (
-        <Box position="absolute" top="0" width="full" height="full" borderRadius="base">
+        <Box position="absolute" top="0" width="full" height="full" borderRadius="base" opacity={0.8}>
           <ImageMetadataViewer image={imageDTO} />
         </Box>
       )}
       <AnimatePresence>
-        {!shouldShowImageDetails && imageDTO && shouldShowNextPrevButtons && (
+        {shouldShowNextPrevButtons && imageDTO && (
           <motion.div key="nextPrevButtons" initial={initial} animate={animate} exit={exit} style={motionStyles}>
             <NextPrevImageButtons />
           </motion.div>
@@ -115,11 +108,11 @@ const initial: AnimationProps['initial'] = {
 };
 const animate: AnimationProps['animate'] = {
   opacity: 1,
-  transition: { duration: 0.1 },
+  transition: { duration: 0.07 },
 };
 const exit: AnimationProps['exit'] = {
   opacity: 0,
-  transition: { duration: 0.1 },
+  transition: { duration: 0.07 },
 };
 const motionStyles: CSSProperties = {
   position: 'absolute',

From 20e628297cbee3ad99c73a403285e79d18cc7be0 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 09:53:47 +1000
Subject: [PATCH 26/59] fix(ui): smoother animations in current image preview

---
 .../ImageViewer/CurrentImagePreview.tsx       | 52 +++++++++++++------
 1 file changed, 36 insertions(+), 16 deletions(-)

diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
index 3ba3f2e372..37fada0b78 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
@@ -10,7 +10,6 @@ import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButto
 import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
 import type { AnimationProps } from 'framer-motion';
 import { AnimatePresence, motion } from 'framer-motion';
-import type { CSSProperties } from 'react';
 import { memo, useCallback, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { PiImageBold } from 'react-icons/pi';
@@ -85,16 +84,40 @@ const CurrentImagePreview = () => {
           dataTestId="image-preview"
         />
       )}
-      {shouldShowImageDetails && imageDTO && (
-        <Box position="absolute" top="0" width="full" height="full" borderRadius="base" opacity={0.8}>
-          <ImageMetadataViewer image={imageDTO} />
-        </Box>
-      )}
+      <AnimatePresence>
+        {shouldShowImageDetails && imageDTO && (
+          <Box
+            as={motion.div}
+            key="metadataViewer"
+            initial={initial}
+            animate={animateMetadata}
+            exit={exit}
+            position="absolute"
+            top={0}
+            width="full"
+            height="full"
+            borderRadius="base"
+          >
+            <ImageMetadataViewer image={imageDTO} />
+          </Box>
+        )}
+      </AnimatePresence>
       <AnimatePresence>
         {shouldShowNextPrevButtons && imageDTO && (
-          <motion.div key="nextPrevButtons" initial={initial} animate={animate} exit={exit} style={motionStyles}>
+          <Box
+            as={motion.div}
+            key="nextPrevButtons"
+            initial={initial}
+            animate={animateArrows}
+            exit={exit}
+            position="absolute"
+            top={0}
+            width="full"
+            height="full"
+            pointerEvents="none"
+          >
             <NextPrevImageButtons />
-          </motion.div>
+          </Box>
         )}
       </AnimatePresence>
     </Flex>
@@ -106,18 +129,15 @@ export default memo(CurrentImagePreview);
 const initial: AnimationProps['initial'] = {
   opacity: 0,
 };
-const animate: AnimationProps['animate'] = {
+const animateArrows: AnimationProps['animate'] = {
   opacity: 1,
   transition: { duration: 0.07 },
 };
+const animateMetadata: AnimationProps['animate'] = {
+  opacity: 0.8,
+  transition: { duration: 0.07 },
+};
 const exit: AnimationProps['exit'] = {
   opacity: 0,
   transition: { duration: 0.07 },
 };
-const motionStyles: CSSProperties = {
-  position: 'absolute',
-  top: '0',
-  width: '100%',
-  height: '100%',
-  pointerEvents: 'none',
-};

From e354fee4f487031ddc5c1d17c16c0d5d81f11014 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 09:58:06 +1000
Subject: [PATCH 27/59] fix(ui): add img2img metadata to graphs

---
 .../nodes/util/graph/addInitialImageToLinearGraph.ts   | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts
index 4334a7cd31..603708f15b 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts
@@ -1,5 +1,6 @@
 import type { RootState } from 'app/store/store';
 import { isInitialImageLayer } from 'features/controlLayers/store/controlLayersSlice';
+import { upsertMetadata } from 'features/nodes/util/graph/metadata';
 import type { ImageResizeInvocation, ImageToLatentsInvocation, NonNullableGraph } from 'services/api/types';
 import { assert } from 'tsafe';
 
@@ -21,7 +22,8 @@ export const addInitialImageToLinearGraph = (
     return;
   }
 
-  const useRefinerStartEnd = model?.base === 'sdxl' && Boolean(refinerModel);
+  const isSDXL = model?.base === 'sdxl';
+  const useRefinerStartEnd = isSDXL && Boolean(refinerModel);
 
   const denoiseNode = graph.nodes[denoiseNodeId];
   assert(denoiseNode?.type === 'denoise_latents', `Missing denoise node or incorrect type: ${denoiseNode?.type}`);
@@ -114,4 +116,10 @@ export const addInitialImageToLinearGraph = (
       },
     });
   }
+
+  upsertMetadata(graph, {
+    generation_mode: isSDXL ? 'sdxl_img2img' : 'img2img',
+    strength: img2imgStrength,
+    init_image: initialImage.imageName,
+  });
 };

From 4c7be037020d25150dbb198c0826e7351c026a96 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 10:05:43 +1000
Subject: [PATCH 28/59] tidy(ui): rename generation tab graph builders

---
 .../listeners/enqueueRequestedLinear.ts                   | 8 ++++----
 ...nearTextToImageGraph.ts => buildGenerationTabGraph.ts} | 2 +-
 ...TextToImageGraph.ts => buildGenerationTabSDXLGraph.ts} | 4 ++--
 3 files changed, 7 insertions(+), 7 deletions(-)
 rename invokeai/frontend/web/src/features/nodes/util/graph/{buildLinearTextToImageGraph.ts => buildGenerationTabGraph.ts} (98%)
 rename invokeai/frontend/web/src/features/nodes/util/graph/{buildLinearSDXLTextToImageGraph.ts => buildGenerationTabSDXLGraph.ts} (98%)

diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
index bc0fcb5ef0..557220c449 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
@@ -1,8 +1,8 @@
 import { enqueueRequested } from 'app/store/actions';
 import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
+import { buildGenerationTabGraph } from 'features/nodes/util/graph/buildGenerationTabGraph';
+import { buildGenerationTabSDXLGraph } from 'features/nodes/util/graph/buildGenerationTabSDXLGraph';
 import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
-import { buildLinearSDXLTextToImageGraph } from 'features/nodes/util/graph/buildLinearSDXLTextToImageGraph';
-import { buildLinearTextToImageGraph } from 'features/nodes/util/graph/buildLinearTextToImageGraph';
 import { queueApi } from 'services/api/endpoints/queue';
 
 export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) => {
@@ -17,9 +17,9 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
       let graph;
 
       if (model && model.base === 'sdxl') {
-        graph = await buildLinearSDXLTextToImageGraph(state);
+        graph = await buildGenerationTabSDXLGraph(state);
       } else {
-        graph = await buildLinearTextToImageGraph(state);
+        graph = await buildGenerationTabGraph(state);
       }
 
       const batchConfig = prepareLinearUIBatch(state, graph, prepend);
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearTextToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabGraph.ts
similarity index 98%
rename from invokeai/frontend/web/src/features/nodes/util/graph/buildLinearTextToImageGraph.ts
rename to invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabGraph.ts
index c2ad5384e0..6c04b25770 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearTextToImageGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabGraph.ts
@@ -25,7 +25,7 @@ import {
 } from './constants';
 import { addCoreMetadataNode, getModelMetadataField } from './metadata';
 
-export const buildLinearTextToImageGraph = async (state: RootState): Promise<NonNullableGraph> => {
+export const buildGenerationTabGraph = async (state: RootState): Promise<NonNullableGraph> => {
   const log = logger('nodes');
   const {
     model,
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLTextToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabSDXLGraph.ts
similarity index 98%
rename from invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLTextToImageGraph.ts
rename to invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabSDXLGraph.ts
index d3ee9e4a51..900e993602 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearSDXLTextToImageGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabSDXLGraph.ts
@@ -25,7 +25,7 @@ import {
 import { getBoardField, getIsIntermediate, getSDXLStylePrompts } from './graphBuilderUtils';
 import { addCoreMetadataNode, getModelMetadataField } from './metadata';
 
-export const buildLinearSDXLTextToImageGraph = async (state: RootState): Promise<NonNullableGraph> => {
+export const buildGenerationTabSDXLGraph = async (state: RootState): Promise<NonNullableGraph> => {
   const log = logger('nodes');
   const {
     model,
@@ -224,7 +224,7 @@ export const buildLinearSDXLTextToImageGraph = async (state: RootState): Promise
   addCoreMetadataNode(
     graph,
     {
-      generation_mode: 'sdxl_txt2img',
+      generation_mode: 'txt2img',
       cfg_scale,
       cfg_rescale_multiplier,
       height,

From 85dd78b8df4c6660d0b8c23cc26fff7c2f8a5b3f Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 10:44:07 +1000
Subject: [PATCH 29/59] fix(ui): handle deleting images in use in generation
 tab

---
 .../listeners/boardAndImagesDeleted.ts        |  11 +-
 .../listeners/imageDeleted.ts                 | 163 +++++++++---------
 .../components/DeleteImageModal.tsx           |   9 +-
 .../components/ImageUsageMessage.tsx          |   5 +-
 .../deleteImageModal/store/selectors.ts       |  37 +++-
 .../features/deleteImageModal/store/types.ts  |   1 +
 .../components/Boards/DeleteBoardModal.tsx    |   9 +-
 7 files changed, 139 insertions(+), 96 deletions(-)

diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts
index d7ab8430ca..a0b07b9419 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts
@@ -1,6 +1,7 @@
 import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
 import { resetCanvas } from 'features/canvas/store/canvasSlice';
 import { controlAdaptersReset } from 'features/controlAdapters/store/controlAdaptersSlice';
+import { allLayersDeleted } from 'features/controlLayers/store/controlLayersSlice';
 import { getImageUsage } from 'features/deleteImageModal/store/selectors';
 import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
 import { imagesApi } from 'services/api/endpoints/images';
@@ -16,10 +17,11 @@ export const addDeleteBoardAndImagesFulfilledListener = (startAppListening: AppS
       let wasCanvasReset = false;
       let wasNodeEditorReset = false;
       let wereControlAdaptersReset = false;
+      let wereControlLayersReset = false;
 
-      const { generation, canvas, nodes, controlAdapters } = getState();
+      const { canvas, nodes, controlAdapters, controlLayers } = getState();
       deleted_images.forEach((image_name) => {
-        const imageUsage = getImageUsage(generation, canvas, nodes, controlAdapters, image_name);
+        const imageUsage = getImageUsage(canvas, nodes, controlAdapters, controlLayers.present, image_name);
 
         if (imageUsage.isCanvasImage && !wasCanvasReset) {
           dispatch(resetCanvas());
@@ -35,6 +37,11 @@ export const addDeleteBoardAndImagesFulfilledListener = (startAppListening: AppS
           dispatch(controlAdaptersReset());
           wereControlAdaptersReset = true;
         }
+
+        if (imageUsage.isControlLayerImage && !wereControlLayersReset) {
+          dispatch(allLayersDeleted());
+          wereControlLayersReset = true;
+        }
       });
     },
   });
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts
index 451c26629e..95d17da653 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts
@@ -1,5 +1,6 @@
 import { logger } from 'app/logging/logger';
 import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
+import type { AppDispatch, RootState } from 'app/store/store';
 import { resetCanvas } from 'features/canvas/store/canvasSlice';
 import {
   controlAdapterImageChanged,
@@ -7,6 +8,13 @@ import {
   selectControlAdapterAll,
 } from 'features/controlAdapters/store/controlAdaptersSlice';
 import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
+import {
+  isControlAdapterLayer,
+  isInitialImageLayer,
+  isIPAdapterLayer,
+  isRegionalGuidanceLayer,
+  layerDeleted,
+} from 'features/controlLayers/store/controlLayersSlice';
 import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
 import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
 import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
@@ -17,8 +25,79 @@ import { isInvocationNode } from 'features/nodes/types/invocation';
 import { clamp, forEach } from 'lodash-es';
 import { api } from 'services/api';
 import { imagesApi } from 'services/api/endpoints/images';
+import type { ImageDTO } from 'services/api/types';
 import { imagesSelectors } from 'services/api/util';
 
+const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
+  state.nodes.nodes.forEach((node) => {
+    if (!isInvocationNode(node)) {
+      return;
+    }
+
+    forEach(node.data.inputs, (input) => {
+      if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
+        dispatch(
+          fieldImageValueChanged({
+            nodeId: node.data.id,
+            fieldName: input.name,
+            value: undefined,
+          })
+        );
+      }
+    });
+  });
+};
+
+const deleteControlAdapterImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
+  forEach(selectControlAdapterAll(state.controlAdapters), (ca) => {
+    if (
+      ca.controlImage === imageDTO.image_name ||
+      (isControlNetOrT2IAdapter(ca) && ca.processedControlImage === imageDTO.image_name)
+    ) {
+      dispatch(
+        controlAdapterImageChanged({
+          id: ca.id,
+          controlImage: null,
+        })
+      );
+      dispatch(
+        controlAdapterProcessedImageChanged({
+          id: ca.id,
+          processedControlImage: null,
+        })
+      );
+    }
+  });
+};
+
+const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
+  state.controlLayers.present.layers.forEach((l) => {
+    if (isRegionalGuidanceLayer(l)) {
+      if (l.ipAdapters.some((ipa) => ipa.image?.imageName === imageDTO.image_name)) {
+        dispatch(layerDeleted(l.id));
+      }
+    }
+    if (isControlAdapterLayer(l)) {
+      if (
+        l.controlAdapter.image?.imageName === imageDTO.image_name ||
+        l.controlAdapter.processedImage?.imageName === imageDTO.image_name
+      ) {
+        dispatch(layerDeleted(l.id));
+      }
+    }
+    if (isIPAdapterLayer(l)) {
+      if (l.ipAdapter.image?.imageName === imageDTO.image_name) {
+        dispatch(layerDeleted(l.id));
+      }
+    }
+    if (isInitialImageLayer(l)) {
+      if (l.image?.imageName === imageDTO.image_name) {
+        dispatch(layerDeleted(l.id));
+      }
+    }
+  });
+};
+
 export const addRequestedSingleImageDeletionListener = (startAppListening: AppStartListening) => {
   startAppListening({
     actionCreator: imageDeletionConfirmed,
@@ -72,45 +151,9 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
       }
 
       imageDTOs.forEach((imageDTO) => {
-        // reset control adapters that use the deleted images
-        forEach(selectControlAdapterAll(getState().controlAdapters), (ca) => {
-          if (
-            ca.controlImage === imageDTO.image_name ||
-            (isControlNetOrT2IAdapter(ca) && ca.processedControlImage === imageDTO.image_name)
-          ) {
-            dispatch(
-              controlAdapterImageChanged({
-                id: ca.id,
-                controlImage: null,
-              })
-            );
-            dispatch(
-              controlAdapterProcessedImageChanged({
-                id: ca.id,
-                processedControlImage: null,
-              })
-            );
-          }
-        });
-
-        // reset nodes that use the deleted images
-        getState().nodes.nodes.forEach((node) => {
-          if (!isInvocationNode(node)) {
-            return;
-          }
-
-          forEach(node.data.inputs, (input) => {
-            if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
-              dispatch(
-                fieldImageValueChanged({
-                  nodeId: node.data.id,
-                  fieldName: input.name,
-                  value: undefined,
-                })
-              );
-            }
-          });
-        });
+        deleteControlAdapterImages(state, dispatch, imageDTO);
+        deleteNodesImages(state, dispatch, imageDTO);
+        deleteControlLayerImages(state, dispatch, imageDTO);
       });
 
       // Delete from server
@@ -162,45 +205,9 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
         }
 
         imageDTOs.forEach((imageDTO) => {
-          // reset control adapters that use the deleted images
-          forEach(selectControlAdapterAll(getState().controlAdapters), (ca) => {
-            if (
-              ca.controlImage === imageDTO.image_name ||
-              (isControlNetOrT2IAdapter(ca) && ca.processedControlImage === imageDTO.image_name)
-            ) {
-              dispatch(
-                controlAdapterImageChanged({
-                  id: ca.id,
-                  controlImage: null,
-                })
-              );
-              dispatch(
-                controlAdapterProcessedImageChanged({
-                  id: ca.id,
-                  processedControlImage: null,
-                })
-              );
-            }
-          });
-
-          // reset nodes that use the deleted images
-          getState().nodes.nodes.forEach((node) => {
-            if (!isInvocationNode(node)) {
-              return;
-            }
-
-            forEach(node.data.inputs, (input) => {
-              if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
-                dispatch(
-                  fieldImageValueChanged({
-                    nodeId: node.data.id,
-                    fieldName: input.name,
-                    value: undefined,
-                  })
-                );
-              }
-            });
-          });
+          deleteControlAdapterImages(state, dispatch, imageDTO);
+          deleteNodesImages(state, dispatch, imageDTO);
+          deleteControlLayerImages(state, dispatch, imageDTO);
         });
       } catch {
         // no-op
diff --git a/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx b/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx
index e3ee0b3852..f4b7438dff 100644
--- a/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx
+++ b/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx
@@ -3,6 +3,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
 import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
 import { selectControlAdaptersSlice } from 'features/controlAdapters/store/controlAdaptersSlice';
+import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
 import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
 import { getImageUsage, selectImageUsage } from 'features/deleteImageModal/store/selectors';
 import {
@@ -12,7 +13,6 @@ import {
 } from 'features/deleteImageModal/store/slice';
 import type { ImageUsage } from 'features/deleteImageModal/store/types';
 import { selectNodesSlice } from 'features/nodes/store/nodesSlice';
-import { selectGenerationSlice } from 'features/parameters/store/generationSlice';
 import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
 import { some } from 'lodash-es';
 import type { ChangeEvent } from 'react';
@@ -24,23 +24,24 @@ import ImageUsageMessage from './ImageUsageMessage';
 const selectImageUsages = createMemoizedSelector(
   [
     selectDeleteImageModalSlice,
-    selectGenerationSlice,
     selectCanvasSlice,
     selectNodesSlice,
     selectControlAdaptersSlice,
+    selectControlLayersSlice,
     selectImageUsage,
   ],
-  (deleteImageModal, generation, canvas, nodes, controlAdapters, imagesUsage) => {
+  (deleteImageModal, canvas, nodes, controlAdapters, controlLayers, imagesUsage) => {
     const { imagesToDelete } = deleteImageModal;
 
     const allImageUsage = (imagesToDelete ?? []).map(({ image_name }) =>
-      getImageUsage(generation, canvas, nodes, controlAdapters, image_name)
+      getImageUsage(canvas, nodes, controlAdapters, controlLayers.present, image_name)
     );
 
     const imageUsageSummary: ImageUsage = {
       isCanvasImage: some(allImageUsage, (i) => i.isCanvasImage),
       isNodesImage: some(allImageUsage, (i) => i.isNodesImage),
       isControlImage: some(allImageUsage, (i) => i.isControlImage),
+      isControlLayerImage: some(allImageUsage, (i) => i.isControlLayerImage),
     };
 
     return {
diff --git a/invokeai/frontend/web/src/features/deleteImageModal/components/ImageUsageMessage.tsx b/invokeai/frontend/web/src/features/deleteImageModal/components/ImageUsageMessage.tsx
index ec613409e7..d76716d01d 100644
--- a/invokeai/frontend/web/src/features/deleteImageModal/components/ImageUsageMessage.tsx
+++ b/invokeai/frontend/web/src/features/deleteImageModal/components/ImageUsageMessage.tsx
@@ -29,9 +29,10 @@ const ImageUsageMessage = (props: Props) => {
     <>
       <Text>{topMessage}</Text>
       <UnorderedList paddingInlineStart={6}>
-        {imageUsage.isCanvasImage && <ListItem>{t('common.unifiedCanvas')}</ListItem>}
+        {imageUsage.isCanvasImage && <ListItem>{t('ui.tabs.canvasTab')}</ListItem>}
         {imageUsage.isControlImage && <ListItem>{t('common.controlNet')}</ListItem>}
-        {imageUsage.isNodesImage && <ListItem>{t('common.nodeEditor')}</ListItem>}
+        {imageUsage.isNodesImage && <ListItem>{t('ui.tabs.workflowsTab')}</ListItem>}
+        {imageUsage.isControlLayerImage && <ListItem>{t('ui.tabs.generationTab')}</ListItem>}
       </UnorderedList>
       <Text>{bottomMessage}</Text>
     </>
diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts
index b9540a3ecf..ce989de7b1 100644
--- a/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts
+++ b/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts
@@ -7,22 +7,28 @@ import {
 } from 'features/controlAdapters/store/controlAdaptersSlice';
 import type { ControlAdaptersState } from 'features/controlAdapters/store/types';
 import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
+import {
+  isControlAdapterLayer,
+  isInitialImageLayer,
+  isIPAdapterLayer,
+  isRegionalGuidanceLayer,
+  selectControlLayersSlice,
+} from 'features/controlLayers/store/controlLayersSlice';
+import type { ControlLayersState } from 'features/controlLayers/store/types';
 import { selectDeleteImageModalSlice } from 'features/deleteImageModal/store/slice';
 import { selectNodesSlice } from 'features/nodes/store/nodesSlice';
 import type { NodesState } from 'features/nodes/store/types';
 import { isImageFieldInputInstance } from 'features/nodes/types/field';
 import { isInvocationNode } from 'features/nodes/types/invocation';
-import { selectGenerationSlice } from 'features/parameters/store/generationSlice';
-import type { GenerationState } from 'features/parameters/store/types';
 import { some } from 'lodash-es';
 
 import type { ImageUsage } from './types';
 
 export const getImageUsage = (
-  generation: GenerationState,
   canvas: CanvasState,
   nodes: NodesState,
   controlAdapters: ControlAdaptersState,
+  controlLayers: ControlLayersState,
   image_name: string
 ) => {
   const isCanvasImage = canvas.layerState.objects.some((obj) => obj.kind === 'image' && obj.imageName === image_name);
@@ -38,10 +44,29 @@ export const getImageUsage = (
     (ca) => ca.controlImage === image_name || (isControlNetOrT2IAdapter(ca) && ca.processedControlImage === image_name)
   );
 
+  const isControlLayerImage = controlLayers.layers.some((l) => {
+    if (isRegionalGuidanceLayer(l)) {
+      return l.ipAdapters.some((ipa) => ipa.image?.imageName === image_name);
+    }
+    if (isControlAdapterLayer(l)) {
+      return (
+        l.controlAdapter.image?.imageName === image_name || l.controlAdapter.processedImage?.imageName === image_name
+      );
+    }
+    if (isIPAdapterLayer(l)) {
+      return l.ipAdapter.image?.imageName === image_name;
+    }
+    if (isInitialImageLayer(l)) {
+      return l.image?.imageName === image_name;
+    }
+    return false;
+  });
+
   const imageUsage: ImageUsage = {
     isCanvasImage,
     isNodesImage,
     isControlImage,
+    isControlLayerImage,
   };
 
   return imageUsage;
@@ -49,11 +74,11 @@ export const getImageUsage = (
 
 export const selectImageUsage = createMemoizedSelector(
   selectDeleteImageModalSlice,
-  selectGenerationSlice,
   selectCanvasSlice,
   selectNodesSlice,
   selectControlAdaptersSlice,
-  (deleteImageModal, generation, canvas, nodes, controlAdapters) => {
+  selectControlLayersSlice,
+  (deleteImageModal, canvas, nodes, controlAdapters, controlLayers) => {
     const { imagesToDelete } = deleteImageModal;
 
     if (!imagesToDelete.length) {
@@ -61,7 +86,7 @@ export const selectImageUsage = createMemoizedSelector(
     }
 
     const imagesUsage = imagesToDelete.map((i) =>
-      getImageUsage(generation, canvas, nodes, controlAdapters, i.image_name)
+      getImageUsage(canvas, nodes, controlAdapters, controlLayers.present, i.image_name)
     );
 
     return imagesUsage;
diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/types.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/types.ts
index f0aaf7b097..2cc3dd90b4 100644
--- a/invokeai/frontend/web/src/features/deleteImageModal/store/types.ts
+++ b/invokeai/frontend/web/src/features/deleteImageModal/store/types.ts
@@ -9,4 +9,5 @@ export type ImageUsage = {
   isCanvasImage: boolean;
   isNodesImage: boolean;
   isControlImage: boolean;
+  isControlLayerImage: boolean;
 };
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx
index 5f01fd9f29..377636d0d0 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx
@@ -15,11 +15,11 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
 import { useAppSelector } from 'app/store/storeHooks';
 import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
 import { selectControlAdaptersSlice } from 'features/controlAdapters/store/controlAdaptersSlice';
+import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
 import ImageUsageMessage from 'features/deleteImageModal/components/ImageUsageMessage';
 import { getImageUsage } from 'features/deleteImageModal/store/selectors';
 import type { ImageUsage } from 'features/deleteImageModal/store/types';
 import { selectNodesSlice } from 'features/nodes/store/nodesSlice';
-import { selectGenerationSlice } from 'features/parameters/store/generationSlice';
 import { some } from 'lodash-es';
 import { memo, useCallback, useMemo, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
@@ -43,16 +43,17 @@ const DeleteBoardModal = (props: Props) => {
   const selectImageUsageSummary = useMemo(
     () =>
       createMemoizedSelector(
-        [selectGenerationSlice, selectCanvasSlice, selectNodesSlice, selectControlAdaptersSlice],
-        (generation, canvas, nodes, controlAdapters) => {
+        [selectCanvasSlice, selectNodesSlice, selectControlAdaptersSlice, selectControlLayersSlice],
+        (canvas, nodes, controlAdapters, controlLayers) => {
           const allImageUsage = (boardImageNames ?? []).map((imageName) =>
-            getImageUsage(generation, canvas, nodes, controlAdapters, imageName)
+            getImageUsage(canvas, nodes, controlAdapters, controlLayers.present, imageName)
           );
 
           const imageUsageSummary: ImageUsage = {
             isCanvasImage: some(allImageUsage, (i) => i.isCanvasImage),
             isNodesImage: some(allImageUsage, (i) => i.isNodesImage),
             isControlImage: some(allImageUsage, (i) => i.isControlImage),
+            isControlLayerImage: some(allImageUsage, (i) => i.isControlLayerImage),
           };
 
           return imageUsageSummary;

From f05ac5a7a516d3f41be45ffe275516ab02fd7a9d Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 11:02:26 +1000
Subject: [PATCH 30/59] chore(ui): bump @invoke-ai/ui-library

---
 invokeai/frontend/web/package.json   |   2 +-
 invokeai/frontend/web/pnpm-lock.yaml | 586 +++++++--------------------
 2 files changed, 146 insertions(+), 442 deletions(-)

diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json
index 2730367fe5..96db090386 100644
--- a/invokeai/frontend/web/package.json
+++ b/invokeai/frontend/web/package.json
@@ -58,7 +58,7 @@
     "@dnd-kit/sortable": "^8.0.0",
     "@dnd-kit/utilities": "^3.2.2",
     "@fontsource-variable/inter": "^5.0.17",
-    "@invoke-ai/ui-library": "^0.0.21",
+    "@invoke-ai/ui-library": "^0.0.25",
     "@nanostores/react": "^0.7.2",
     "@reduxjs/toolkit": "2.2.2",
     "@roarr/browser-log-writer": "^1.3.0",
diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml
index 9910e32391..2e5442479f 100644
--- a/invokeai/frontend/web/pnpm-lock.yaml
+++ b/invokeai/frontend/web/pnpm-lock.yaml
@@ -7,7 +7,7 @@ settings:
 dependencies:
   '@chakra-ui/react':
     specifier: ^2.8.2
-    version: 2.8.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(@types/react@18.2.59)(framer-motion@11.0.6)(react-dom@18.2.0)(react@18.2.0)
+    version: 2.8.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(@types/react@18.2.73)(framer-motion@11.0.22)(react-dom@18.2.0)(react@18.2.0)
   '@chakra-ui/react-use-size':
     specifier: ^2.1.0
     version: 2.1.0(react@18.2.0)
@@ -30,8 +30,8 @@ dependencies:
     specifier: ^5.0.17
     version: 5.0.17
   '@invoke-ai/ui-library':
-    specifier: ^0.0.21
-    version: 0.0.21(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.17)(@internationalized/date@3.5.2)(@types/react@18.2.73)(i18next@23.10.1)(react-dom@18.2.0)(react@18.2.0)
+    specifier: ^0.0.25
+    version: 0.0.25(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.17)(@internationalized/date@3.5.3)(@types/react@18.2.73)(i18next@23.10.1)(react-dom@18.2.0)(react@18.2.0)
   '@nanostores/react':
     specifier: ^0.7.2
     version: 0.7.2(nanostores@0.10.0)(react@18.2.0)
@@ -306,7 +306,7 @@ packages:
       '@jridgewell/trace-mapping': 0.3.25
     dev: true
 
-  /@ark-ui/anatomy@1.3.0(@internationalized/date@3.5.2):
+  /@ark-ui/anatomy@1.3.0(@internationalized/date@3.5.3):
     resolution: {integrity: sha512-1yG2MrzUlix6KthjQMCNiHnkXrWwEdFAX6D+HqGJaNu0XvaGul2J+wDNtjsdX+gxiWu1nXXEEOAWlFVYMUf65w==}
     dependencies:
       '@zag-js/accordion': 0.32.1
@@ -318,7 +318,7 @@ packages:
       '@zag-js/color-utils': 0.32.1
       '@zag-js/combobox': 0.32.1
       '@zag-js/date-picker': 0.32.1
-      '@zag-js/date-utils': 0.32.1(@internationalized/date@3.5.2)
+      '@zag-js/date-utils': 0.32.1(@internationalized/date@3.5.3)
       '@zag-js/dialog': 0.32.1
       '@zag-js/editable': 0.32.1
       '@zag-js/file-upload': 0.32.1
@@ -345,13 +345,13 @@ packages:
       - '@internationalized/date'
     dev: false
 
-  /@ark-ui/react@1.3.0(@internationalized/date@3.5.2)(react-dom@18.2.0)(react@18.2.0):
+  /@ark-ui/react@1.3.0(@internationalized/date@3.5.3)(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-JHjNoIX50+mUCTaEGMjfGQWGGi31pKsV646jZJlR/1xohpYJigzg8BvO97cTsVk8fwtur+cm11gz3Nf7f5QUnA==}
     peerDependencies:
       react: '>=18.0.0'
       react-dom: '>=18.0.0'
     dependencies:
-      '@ark-ui/anatomy': 1.3.0(@internationalized/date@3.5.2)
+      '@ark-ui/anatomy': 1.3.0(@internationalized/date@3.5.3)
       '@zag-js/accordion': 0.32.1
       '@zag-js/avatar': 0.32.1
       '@zag-js/carousel': 0.32.1
@@ -361,7 +361,7 @@ packages:
       '@zag-js/combobox': 0.32.1
       '@zag-js/core': 0.32.1
       '@zag-js/date-picker': 0.32.1
-      '@zag-js/date-utils': 0.32.1(@internationalized/date@3.5.2)
+      '@zag-js/date-utils': 0.32.1(@internationalized/date@3.5.3)
       '@zag-js/dialog': 0.32.1
       '@zag-js/editable': 0.32.1
       '@zag-js/file-upload': 0.32.1
@@ -1681,7 +1681,7 @@ packages:
       react: 18.2.0
     dev: false
 
-  /@chakra-ui/accordion@2.3.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react@18.2.0):
+  /@chakra-ui/accordion@2.3.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.22)(react@18.2.0):
     resolution: {integrity: sha512-FSXRm8iClFyU+gVaXisOSEw0/4Q+qZbFRiuhIAkVU6Boj0FxAMrlo9a8AV5TuF77rgaHytCdHk0Ng+cyUijrag==}
     peerDependencies:
       '@chakra-ui/system': '>=2.0.0'
@@ -1694,9 +1694,9 @@ packages:
       '@chakra-ui/react-use-controllable-state': 2.1.0(react@18.2.0)
       '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.2.0)
       '@chakra-ui/shared-utils': 2.0.5
-      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react@18.2.0)
-      '@chakra-ui/transition': 2.1.0(framer-motion@11.0.6)(react@18.2.0)
-      framer-motion: 11.0.6(react-dom@18.2.0)(react@18.2.0)
+      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(react@18.2.0)
+      '@chakra-ui/transition': 2.1.0(framer-motion@11.0.22)(react@18.2.0)
+      framer-motion: 11.0.22(react-dom@18.2.0)(react@18.2.0)
       react: 18.2.0
     dev: false
 
@@ -1848,16 +1848,6 @@ packages:
       react: 18.2.0
     dev: false
 
-  /@chakra-ui/css-reset@2.3.0(@emotion/react@11.11.3)(react@18.2.0):
-    resolution: {integrity: sha512-cQwwBy5O0jzvl0K7PLTLgp8ijqLPKyuEMiDXwYzl95seD3AoeuoCLyzZcJtVqaUZ573PiBdAbY/IlZcwDOItWg==}
-    peerDependencies:
-      '@emotion/react': '>=10.0.35'
-      react: '>=18'
-    dependencies:
-      '@emotion/react': 11.11.3(@types/react@18.2.59)(react@18.2.0)
-      react: 18.2.0
-    dev: false
-
   /@chakra-ui/css-reset@2.3.0(@emotion/react@11.11.4)(react@18.2.0):
     resolution: {integrity: sha512-cQwwBy5O0jzvl0K7PLTLgp8ijqLPKyuEMiDXwYzl95seD3AoeuoCLyzZcJtVqaUZ573PiBdAbY/IlZcwDOItWg==}
     peerDependencies:
@@ -1905,18 +1895,6 @@ packages:
     resolution: {integrity: sha512-IGM/yGUHS+8TOQrZGpAKOJl/xGBrmRYJrmbHfUE7zrG3PpQyXvbLDP1M+RggkCFVgHlJi2wpYIf0QtQlU0XZfw==}
     dev: false
 
-  /@chakra-ui/focus-lock@2.1.0(@types/react@18.2.59)(react@18.2.0):
-    resolution: {integrity: sha512-EmGx4PhWGjm4dpjRqM4Aa+rCWBxP+Rq8Uc/nAVnD4YVqkEhBkrPTpui2lnjsuxqNaZ24fIAZ10cF1hlpemte/w==}
-    peerDependencies:
-      react: '>=18'
-    dependencies:
-      '@chakra-ui/dom-utils': 2.1.0
-      react: 18.2.0
-      react-focus-lock: 2.11.1(@types/react@18.2.59)(react@18.2.0)
-    transitivePeerDependencies:
-      - '@types/react'
-    dev: false
-
   /@chakra-ui/focus-lock@2.1.0(@types/react@18.2.73)(react@18.2.0):
     resolution: {integrity: sha512-EmGx4PhWGjm4dpjRqM4Aa+rCWBxP+Rq8Uc/nAVnD4YVqkEhBkrPTpui2lnjsuxqNaZ24fIAZ10cF1hlpemte/w==}
     peerDependencies:
@@ -1924,7 +1902,7 @@ packages:
     dependencies:
       '@chakra-ui/dom-utils': 2.1.0
       react: 18.2.0
-      react-focus-lock: 2.11.2(@types/react@18.2.73)(react@18.2.0)
+      react-focus-lock: 2.11.1(@types/react@18.2.73)(react@18.2.0)
     transitivePeerDependencies:
       - '@types/react'
     dev: false
@@ -2100,59 +2078,6 @@ packages:
       react: 18.2.0
     dev: false
 
-  /@chakra-ui/menu@2.2.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react@18.2.0):
-    resolution: {integrity: sha512-lJS7XEObzJxsOwWQh7yfG4H8FzFPRP5hVPN/CL+JzytEINCSBvsCDHrYPQGp7jzpCi8vnTqQQGQe0f8dwnXd2g==}
-    peerDependencies:
-      '@chakra-ui/system': '>=2.0.0'
-      framer-motion: '>=4.0.0'
-      react: '>=18'
-    dependencies:
-      '@chakra-ui/clickable': 2.1.0(react@18.2.0)
-      '@chakra-ui/descendant': 3.1.0(react@18.2.0)
-      '@chakra-ui/lazy-utils': 2.0.5
-      '@chakra-ui/popper': 3.1.0(react@18.2.0)
-      '@chakra-ui/react-children-utils': 2.0.6(react@18.2.0)
-      '@chakra-ui/react-context': 2.1.0(react@18.2.0)
-      '@chakra-ui/react-use-animation-state': 2.1.0(react@18.2.0)
-      '@chakra-ui/react-use-controllable-state': 2.1.0(react@18.2.0)
-      '@chakra-ui/react-use-disclosure': 2.1.0(react@18.2.0)
-      '@chakra-ui/react-use-focus-effect': 2.1.0(react@18.2.0)
-      '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.2.0)
-      '@chakra-ui/react-use-outside-click': 2.2.0(react@18.2.0)
-      '@chakra-ui/react-use-update-effect': 2.1.0(react@18.2.0)
-      '@chakra-ui/shared-utils': 2.0.5
-      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react@18.2.0)
-      '@chakra-ui/transition': 2.1.0(framer-motion@11.0.6)(react@18.2.0)
-      framer-motion: 11.0.6(react-dom@18.2.0)(react@18.2.0)
-      react: 18.2.0
-    dev: false
-
-  /@chakra-ui/modal@2.3.1(@chakra-ui/system@2.6.2)(@types/react@18.2.59)(framer-motion@11.0.6)(react-dom@18.2.0)(react@18.2.0):
-    resolution: {integrity: sha512-TQv1ZaiJMZN+rR9DK0snx/OPwmtaGH1HbZtlYt4W4s6CzyK541fxLRTjIXfEzIGpvNW+b6VFuFjbcR78p4DEoQ==}
-    peerDependencies:
-      '@chakra-ui/system': '>=2.0.0'
-      framer-motion: '>=4.0.0'
-      react: '>=18'
-      react-dom: '>=18'
-    dependencies:
-      '@chakra-ui/close-button': 2.1.1(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/focus-lock': 2.1.0(@types/react@18.2.59)(react@18.2.0)
-      '@chakra-ui/portal': 2.1.0(react-dom@18.2.0)(react@18.2.0)
-      '@chakra-ui/react-context': 2.1.0(react@18.2.0)
-      '@chakra-ui/react-types': 2.0.7(react@18.2.0)
-      '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.2.0)
-      '@chakra-ui/shared-utils': 2.0.5
-      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react@18.2.0)
-      '@chakra-ui/transition': 2.1.0(framer-motion@11.0.6)(react@18.2.0)
-      aria-hidden: 1.2.3
-      framer-motion: 11.0.6(react-dom@18.2.0)(react@18.2.0)
-      react: 18.2.0
-      react-dom: 18.2.0(react@18.2.0)
-      react-remove-scroll: 2.5.7(@types/react@18.2.59)(react@18.2.0)
-    transitivePeerDependencies:
-      - '@types/react'
-    dev: false
-
   /@chakra-ui/modal@2.3.1(@chakra-ui/system@2.6.2)(@types/react@18.2.73)(framer-motion@10.18.0)(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-TQv1ZaiJMZN+rR9DK0snx/OPwmtaGH1HbZtlYt4W4s6CzyK541fxLRTjIXfEzIGpvNW+b6VFuFjbcR78p4DEoQ==}
     peerDependencies:
@@ -2170,11 +2095,37 @@ packages:
       '@chakra-ui/shared-utils': 2.0.5
       '@chakra-ui/system': 2.6.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(react@18.2.0)
       '@chakra-ui/transition': 2.1.0(framer-motion@10.18.0)(react@18.2.0)
-      aria-hidden: 1.2.4
+      aria-hidden: 1.2.3
       framer-motion: 10.18.0(react-dom@18.2.0)(react@18.2.0)
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
-      react-remove-scroll: 2.5.9(@types/react@18.2.73)(react@18.2.0)
+      react-remove-scroll: 2.5.7(@types/react@18.2.73)(react@18.2.0)
+    transitivePeerDependencies:
+      - '@types/react'
+    dev: false
+
+  /@chakra-ui/modal@2.3.1(@chakra-ui/system@2.6.2)(@types/react@18.2.73)(framer-motion@11.0.22)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-TQv1ZaiJMZN+rR9DK0snx/OPwmtaGH1HbZtlYt4W4s6CzyK541fxLRTjIXfEzIGpvNW+b6VFuFjbcR78p4DEoQ==}
+    peerDependencies:
+      '@chakra-ui/system': '>=2.0.0'
+      framer-motion: '>=4.0.0'
+      react: '>=18'
+      react-dom: '>=18'
+    dependencies:
+      '@chakra-ui/close-button': 2.1.1(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/focus-lock': 2.1.0(@types/react@18.2.73)(react@18.2.0)
+      '@chakra-ui/portal': 2.1.0(react-dom@18.2.0)(react@18.2.0)
+      '@chakra-ui/react-context': 2.1.0(react@18.2.0)
+      '@chakra-ui/react-types': 2.0.7(react@18.2.0)
+      '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.2.0)
+      '@chakra-ui/shared-utils': 2.0.5
+      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(react@18.2.0)
+      '@chakra-ui/transition': 2.1.0(framer-motion@11.0.22)(react@18.2.0)
+      aria-hidden: 1.2.3
+      framer-motion: 11.0.22(react-dom@18.2.0)(react@18.2.0)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+      react-remove-scroll: 2.5.7(@types/react@18.2.73)(react@18.2.0)
     transitivePeerDependencies:
       - '@types/react'
     dev: false
@@ -2248,7 +2199,7 @@ packages:
       react: 18.2.0
     dev: false
 
-  /@chakra-ui/popover@2.2.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react@18.2.0):
+  /@chakra-ui/popover@2.2.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.22)(react@18.2.0):
     resolution: {integrity: sha512-K+2ai2dD0ljvJnlrzesCDT9mNzLifE3noGKZ3QwLqd/K34Ym1W/0aL1ERSynrcG78NKoXS54SdEzkhCZ4Gn/Zg==}
     peerDependencies:
       '@chakra-ui/system': '>=2.0.0'
@@ -2266,8 +2217,8 @@ packages:
       '@chakra-ui/react-use-focus-on-pointer-down': 2.1.0(react@18.2.0)
       '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.2.0)
       '@chakra-ui/shared-utils': 2.0.5
-      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react@18.2.0)
-      framer-motion: 11.0.6(react-dom@18.2.0)(react@18.2.0)
+      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(react@18.2.0)
+      framer-motion: 11.0.22(react-dom@18.2.0)(react@18.2.0)
       react: 18.2.0
     dev: false
 
@@ -2305,25 +2256,6 @@ packages:
       react: 18.2.0
     dev: false
 
-  /@chakra-ui/provider@2.4.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react-dom@18.2.0)(react@18.2.0):
-    resolution: {integrity: sha512-w0Tef5ZCJK1mlJorcSjItCSbyvVuqpvyWdxZiVQmE6fvSJR83wZof42ux0+sfWD+I7rHSfj+f9nzhNaEWClysw==}
-    peerDependencies:
-      '@emotion/react': ^11.0.0
-      '@emotion/styled': ^11.0.0
-      react: '>=18'
-      react-dom: '>=18'
-    dependencies:
-      '@chakra-ui/css-reset': 2.3.0(@emotion/react@11.11.3)(react@18.2.0)
-      '@chakra-ui/portal': 2.1.0(react-dom@18.2.0)(react@18.2.0)
-      '@chakra-ui/react-env': 3.1.0(react@18.2.0)
-      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react@18.2.0)
-      '@chakra-ui/utils': 2.0.15
-      '@emotion/react': 11.11.3(@types/react@18.2.59)(react@18.2.0)
-      '@emotion/styled': 11.11.0(@emotion/react@11.11.3)(@types/react@18.2.59)(react@18.2.0)
-      react: 18.2.0
-      react-dom: 18.2.0(react@18.2.0)
-    dev: false
-
   /@chakra-ui/provider@2.4.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-w0Tef5ZCJK1mlJorcSjItCSbyvVuqpvyWdxZiVQmE6fvSJR83wZof42ux0+sfWD+I7rHSfj+f9nzhNaEWClysw==}
     peerDependencies:
@@ -2554,77 +2486,6 @@ packages:
       react: 18.2.0
     dev: false
 
-  /@chakra-ui/react@2.8.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(@types/react@18.2.59)(framer-motion@11.0.6)(react-dom@18.2.0)(react@18.2.0):
-    resolution: {integrity: sha512-Hn0moyxxyCDKuR9ywYpqgX8dvjqwu9ArwpIb9wHNYjnODETjLwazgNIliCVBRcJvysGRiV51U2/JtJVrpeCjUQ==}
-    peerDependencies:
-      '@emotion/react': ^11.0.0
-      '@emotion/styled': ^11.0.0
-      framer-motion: '>=4.0.0'
-      react: '>=18'
-      react-dom: '>=18'
-    dependencies:
-      '@chakra-ui/accordion': 2.3.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react@18.2.0)
-      '@chakra-ui/alert': 2.2.2(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/avatar': 2.3.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/breadcrumb': 2.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/button': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/card': 2.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/checkbox': 2.3.2(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/close-button': 2.1.1(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/control-box': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/counter': 2.1.0(react@18.2.0)
-      '@chakra-ui/css-reset': 2.3.0(@emotion/react@11.11.3)(react@18.2.0)
-      '@chakra-ui/editable': 3.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/focus-lock': 2.1.0(@types/react@18.2.59)(react@18.2.0)
-      '@chakra-ui/form-control': 2.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/hooks': 2.2.1(react@18.2.0)
-      '@chakra-ui/icon': 3.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/image': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/input': 2.1.2(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/layout': 2.3.1(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/live-region': 2.1.0(react@18.2.0)
-      '@chakra-ui/media-query': 3.3.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/menu': 2.2.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react@18.2.0)
-      '@chakra-ui/modal': 2.3.1(@chakra-ui/system@2.6.2)(@types/react@18.2.59)(framer-motion@11.0.6)(react-dom@18.2.0)(react@18.2.0)
-      '@chakra-ui/number-input': 2.1.2(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/pin-input': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/popover': 2.2.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react@18.2.0)
-      '@chakra-ui/popper': 3.1.0(react@18.2.0)
-      '@chakra-ui/portal': 2.1.0(react-dom@18.2.0)(react@18.2.0)
-      '@chakra-ui/progress': 2.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/provider': 2.4.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react-dom@18.2.0)(react@18.2.0)
-      '@chakra-ui/radio': 2.1.2(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/react-env': 3.1.0(react@18.2.0)
-      '@chakra-ui/select': 2.1.2(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/skeleton': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/skip-nav': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/slider': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/spinner': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/stat': 2.1.1(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/stepper': 2.3.1(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/styled-system': 2.9.2
-      '@chakra-ui/switch': 2.1.2(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react@18.2.0)
-      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react@18.2.0)
-      '@chakra-ui/table': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/tabs': 3.0.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/tag': 3.1.1(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/textarea': 2.1.2(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@chakra-ui/theme': 3.3.1(@chakra-ui/styled-system@2.9.2)
-      '@chakra-ui/theme-utils': 2.0.21
-      '@chakra-ui/toast': 7.0.2(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react-dom@18.2.0)(react@18.2.0)
-      '@chakra-ui/tooltip': 2.3.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react-dom@18.2.0)(react@18.2.0)
-      '@chakra-ui/transition': 2.1.0(framer-motion@11.0.6)(react@18.2.0)
-      '@chakra-ui/utils': 2.0.15
-      '@chakra-ui/visually-hidden': 2.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
-      '@emotion/react': 11.11.3(@types/react@18.2.59)(react@18.2.0)
-      '@emotion/styled': 11.11.0(@emotion/react@11.11.3)(@types/react@18.2.59)(react@18.2.0)
-      framer-motion: 11.0.6(react-dom@18.2.0)(react@18.2.0)
-      react: 18.2.0
-      react-dom: 18.2.0(react@18.2.0)
-    transitivePeerDependencies:
-      - '@types/react'
-    dev: false
-
   /@chakra-ui/react@2.8.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(@types/react@18.2.73)(framer-motion@10.18.0)(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-Hn0moyxxyCDKuR9ywYpqgX8dvjqwu9ArwpIb9wHNYjnODETjLwazgNIliCVBRcJvysGRiV51U2/JtJVrpeCjUQ==}
     peerDependencies:
@@ -2696,6 +2557,77 @@ packages:
       - '@types/react'
     dev: false
 
+  /@chakra-ui/react@2.8.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(@types/react@18.2.73)(framer-motion@11.0.22)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-Hn0moyxxyCDKuR9ywYpqgX8dvjqwu9ArwpIb9wHNYjnODETjLwazgNIliCVBRcJvysGRiV51U2/JtJVrpeCjUQ==}
+    peerDependencies:
+      '@emotion/react': ^11.0.0
+      '@emotion/styled': ^11.0.0
+      framer-motion: '>=4.0.0'
+      react: '>=18'
+      react-dom: '>=18'
+    dependencies:
+      '@chakra-ui/accordion': 2.3.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.22)(react@18.2.0)
+      '@chakra-ui/alert': 2.2.2(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/avatar': 2.3.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/breadcrumb': 2.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/button': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/card': 2.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/checkbox': 2.3.2(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/close-button': 2.1.1(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/control-box': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/counter': 2.1.0(react@18.2.0)
+      '@chakra-ui/css-reset': 2.3.0(@emotion/react@11.11.4)(react@18.2.0)
+      '@chakra-ui/editable': 3.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/focus-lock': 2.1.0(@types/react@18.2.73)(react@18.2.0)
+      '@chakra-ui/form-control': 2.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/hooks': 2.2.1(react@18.2.0)
+      '@chakra-ui/icon': 3.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/image': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/input': 2.1.2(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/layout': 2.3.1(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/live-region': 2.1.0(react@18.2.0)
+      '@chakra-ui/media-query': 3.3.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/menu': 2.2.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.22)(react@18.2.0)
+      '@chakra-ui/modal': 2.3.1(@chakra-ui/system@2.6.2)(@types/react@18.2.73)(framer-motion@11.0.22)(react-dom@18.2.0)(react@18.2.0)
+      '@chakra-ui/number-input': 2.1.2(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/pin-input': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/popover': 2.2.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.22)(react@18.2.0)
+      '@chakra-ui/popper': 3.1.0(react@18.2.0)
+      '@chakra-ui/portal': 2.1.0(react-dom@18.2.0)(react@18.2.0)
+      '@chakra-ui/progress': 2.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/provider': 2.4.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(react-dom@18.2.0)(react@18.2.0)
+      '@chakra-ui/radio': 2.1.2(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/react-env': 3.1.0(react@18.2.0)
+      '@chakra-ui/select': 2.1.2(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/skeleton': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/skip-nav': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/slider': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/spinner': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/stat': 2.1.1(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/stepper': 2.3.1(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/styled-system': 2.9.2
+      '@chakra-ui/switch': 2.1.2(@chakra-ui/system@2.6.2)(framer-motion@11.0.22)(react@18.2.0)
+      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(react@18.2.0)
+      '@chakra-ui/table': 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/tabs': 3.0.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/tag': 3.1.1(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/textarea': 2.1.2(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@chakra-ui/theme': 3.3.1(@chakra-ui/styled-system@2.9.2)
+      '@chakra-ui/theme-utils': 2.0.21
+      '@chakra-ui/toast': 7.0.2(@chakra-ui/system@2.6.2)(framer-motion@11.0.22)(react-dom@18.2.0)(react@18.2.0)
+      '@chakra-ui/tooltip': 2.3.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.22)(react-dom@18.2.0)(react@18.2.0)
+      '@chakra-ui/transition': 2.1.0(framer-motion@11.0.22)(react@18.2.0)
+      '@chakra-ui/utils': 2.0.15
+      '@chakra-ui/visually-hidden': 2.2.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+      '@emotion/react': 11.11.4(@types/react@18.2.73)(react@18.2.0)
+      '@emotion/styled': 11.11.0(@emotion/react@11.11.4)(@types/react@18.2.73)(react@18.2.0)
+      framer-motion: 11.0.22(react-dom@18.2.0)(react@18.2.0)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    transitivePeerDependencies:
+      - '@types/react'
+    dev: false
+
   /@chakra-ui/select@2.1.2(@chakra-ui/system@2.6.2)(react@18.2.0):
     resolution: {integrity: sha512-ZwCb7LqKCVLJhru3DXvKXpZ7Pbu1TDZ7N0PdQ0Zj1oyVLJyrpef1u9HR5u0amOpqcH++Ugt0f5JSmirjNlctjA==}
     peerDependencies:
@@ -2814,7 +2746,7 @@ packages:
       react: 18.2.0
     dev: false
 
-  /@chakra-ui/switch@2.1.2(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react@18.2.0):
+  /@chakra-ui/switch@2.1.2(@chakra-ui/system@2.6.2)(framer-motion@11.0.22)(react@18.2.0):
     resolution: {integrity: sha512-pgmi/CC+E1v31FcnQhsSGjJnOE2OcND4cKPyTE+0F+bmGm48Q/b5UmKD9Y+CmZsrt/7V3h8KNczowupfuBfIHA==}
     peerDependencies:
       '@chakra-ui/system': '>=2.0.0'
@@ -2823,30 +2755,11 @@ packages:
     dependencies:
       '@chakra-ui/checkbox': 2.3.2(@chakra-ui/system@2.6.2)(react@18.2.0)
       '@chakra-ui/shared-utils': 2.0.5
-      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react@18.2.0)
-      framer-motion: 11.0.6(react-dom@18.2.0)(react@18.2.0)
+      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(react@18.2.0)
+      framer-motion: 11.0.22(react-dom@18.2.0)(react@18.2.0)
       react: 18.2.0
     dev: false
 
-  /@chakra-ui/system@2.6.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react@18.2.0):
-    resolution: {integrity: sha512-EGtpoEjLrUu4W1fHD+a62XR+hzC5YfsWm+6lO0Kybcga3yYEij9beegO0jZgug27V+Rf7vns95VPVP6mFd/DEQ==}
-    peerDependencies:
-      '@emotion/react': ^11.0.0
-      '@emotion/styled': ^11.0.0
-      react: '>=18'
-    dependencies:
-      '@chakra-ui/color-mode': 2.2.0(react@18.2.0)
-      '@chakra-ui/object-utils': 2.1.0
-      '@chakra-ui/react-utils': 2.0.12(react@18.2.0)
-      '@chakra-ui/styled-system': 2.9.2
-      '@chakra-ui/theme-utils': 2.0.21
-      '@chakra-ui/utils': 2.0.15
-      '@emotion/react': 11.11.3(@types/react@18.2.59)(react@18.2.0)
-      '@emotion/styled': 11.11.0(@emotion/react@11.11.3)(@types/react@18.2.59)(react@18.2.0)
-      react: 18.2.0
-      react-fast-compare: 3.2.2
-    dev: false
-
   /@chakra-ui/system@2.6.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(react@18.2.0):
     resolution: {integrity: sha512-EGtpoEjLrUu4W1fHD+a62XR+hzC5YfsWm+6lO0Kybcga3yYEij9beegO0jZgug27V+Rf7vns95VPVP6mFd/DEQ==}
     peerDependencies:
@@ -2975,7 +2888,7 @@ packages:
       react-dom: 18.2.0(react@18.2.0)
     dev: false
 
-  /@chakra-ui/toast@7.0.2(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react-dom@18.2.0)(react@18.2.0):
+  /@chakra-ui/toast@7.0.2(@chakra-ui/system@2.6.2)(framer-motion@11.0.22)(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-yvRP8jFKRs/YnkuE41BVTq9nB2v/KDRmje9u6dgDmE5+1bFt3bwjdf9gVbif4u5Ve7F7BGk5E093ARRVtvLvXA==}
     peerDependencies:
       '@chakra-ui/system': 2.6.2
@@ -2991,9 +2904,9 @@ packages:
       '@chakra-ui/react-use-update-effect': 2.1.0(react@18.2.0)
       '@chakra-ui/shared-utils': 2.0.5
       '@chakra-ui/styled-system': 2.9.2
-      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react@18.2.0)
+      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(react@18.2.0)
       '@chakra-ui/theme': 3.3.1(@chakra-ui/styled-system@2.9.2)
-      framer-motion: 11.0.6(react-dom@18.2.0)(react@18.2.0)
+      framer-motion: 11.0.22(react-dom@18.2.0)(react@18.2.0)
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
     dev: false
@@ -3020,7 +2933,7 @@ packages:
       react-dom: 18.2.0(react@18.2.0)
     dev: false
 
-  /@chakra-ui/tooltip@2.3.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.6)(react-dom@18.2.0)(react@18.2.0):
+  /@chakra-ui/tooltip@2.3.1(@chakra-ui/system@2.6.2)(framer-motion@11.0.22)(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-Rh39GBn/bL4kZpuEMPPRwYNnccRCL+w9OqamWHIB3Qboxs6h8cOyXfIdGxjo72lvhu1QI/a4KFqkM3St+WfC0A==}
     peerDependencies:
       '@chakra-ui/system': '>=2.0.0'
@@ -3036,8 +2949,8 @@ packages:
       '@chakra-ui/react-use-event-listener': 2.1.0(react@18.2.0)
       '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.2.0)
       '@chakra-ui/shared-utils': 2.0.5
-      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(react@18.2.0)
-      framer-motion: 11.0.6(react-dom@18.2.0)(react@18.2.0)
+      '@chakra-ui/system': 2.6.2(@emotion/react@11.11.4)(@emotion/styled@11.11.0)(react@18.2.0)
+      framer-motion: 11.0.22(react-dom@18.2.0)(react@18.2.0)
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
     dev: false
@@ -3064,17 +2977,6 @@ packages:
       react: 18.2.0
     dev: false
 
-  /@chakra-ui/transition@2.1.0(framer-motion@11.0.6)(react@18.2.0):
-    resolution: {integrity: sha512-orkT6T/Dt+/+kVwJNy7zwJ+U2xAZ3EU7M3XCs45RBvUnZDr/u9vdmaM/3D/rOpmQJWgQBwKPJleUXrYWUagEDQ==}
-    peerDependencies:
-      framer-motion: '>=4.0.0'
-      react: '>=18'
-    dependencies:
-      '@chakra-ui/shared-utils': 2.0.5
-      framer-motion: 11.0.6(react-dom@18.2.0)(react@18.2.0)
-      react: 18.2.0
-    dev: false
-
   /@chakra-ui/utils@2.0.15:
     resolution: {integrity: sha512-El4+jL0WSaYYs+rJbuYFDbjmfCcfGDmRY95GO4xwzit6YAPZBLcR65rOEwLps+XWluZTy1xdMrusg/hW0c1aAA==}
     dependencies:
@@ -3198,12 +3100,6 @@ packages:
     dev: false
     optional: true
 
-  /@emotion/is-prop-valid@1.2.1:
-    resolution: {integrity: sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==}
-    dependencies:
-      '@emotion/memoize': 0.8.1
-    dev: false
-
   /@emotion/is-prop-valid@1.2.2:
     resolution: {integrity: sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==}
     dependencies:
@@ -3220,27 +3116,6 @@ packages:
     resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==}
     dev: false
 
-  /@emotion/react@11.11.3(@types/react@18.2.59)(react@18.2.0):
-    resolution: {integrity: sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==}
-    peerDependencies:
-      '@types/react': '*'
-      react: '>=16.8.0'
-    peerDependenciesMeta:
-      '@types/react':
-        optional: true
-    dependencies:
-      '@babel/runtime': 7.23.9
-      '@emotion/babel-plugin': 11.11.0
-      '@emotion/cache': 11.11.0
-      '@emotion/serialize': 1.1.3
-      '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0)
-      '@emotion/utils': 1.2.1
-      '@emotion/weak-memoize': 0.3.1
-      '@types/react': 18.2.59
-      hoist-non-react-statics: 3.3.2
-      react: 18.2.0
-    dev: false
-
   /@emotion/react@11.11.4(@types/react@18.2.73)(react@18.2.0):
     resolution: {integrity: sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==}
     peerDependencies:
@@ -3276,27 +3151,6 @@ packages:
     resolution: {integrity: sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==}
     dev: false
 
-  /@emotion/styled@11.11.0(@emotion/react@11.11.3)(@types/react@18.2.59)(react@18.2.0):
-    resolution: {integrity: sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==}
-    peerDependencies:
-      '@emotion/react': ^11.0.0-rc.0
-      '@types/react': '*'
-      react: '>=16.8.0'
-    peerDependenciesMeta:
-      '@types/react':
-        optional: true
-    dependencies:
-      '@babel/runtime': 7.23.9
-      '@emotion/babel-plugin': 11.11.0
-      '@emotion/is-prop-valid': 1.2.1
-      '@emotion/react': 11.11.3(@types/react@18.2.59)(react@18.2.0)
-      '@emotion/serialize': 1.1.3
-      '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0)
-      '@emotion/utils': 1.2.1
-      '@types/react': 18.2.59
-      react: 18.2.0
-    dev: false
-
   /@emotion/styled@11.11.0(@emotion/react@11.11.4)(@types/react@18.2.73)(react@18.2.0):
     resolution: {integrity: sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==}
     peerDependencies:
@@ -3663,16 +3517,16 @@ packages:
     resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==}
     dev: true
 
-  /@internationalized/date@3.5.2:
-    resolution: {integrity: sha512-vo1yOMUt2hzp63IutEaTUxROdvQg1qlMRsbCvbay2AK2Gai7wIgCyK5weEX3nHkiLgo4qCXHijFNC/ILhlRpOQ==}
+  /@internationalized/date@3.5.3:
+    resolution: {integrity: sha512-X9bi8NAEHAjD8yzmPYT2pdJsbe+tYSEBAfowtlxJVJdZR3aK8Vg7ZUT1Fm5M47KLzp/M1p1VwAaeSma3RT7biw==}
     dependencies:
-      '@swc/helpers': 0.5.7
+      '@swc/helpers': 0.5.11
     dev: false
 
   /@internationalized/number@3.5.1:
     resolution: {integrity: sha512-N0fPU/nz15SwR9IbfJ5xaS9Ss/O5h1sVXMZf43vc9mxEG48ovglvvzBjF53aHlq20uoR6c+88CrIXipU/LSzwg==}
     dependencies:
-      '@swc/helpers': 0.5.7
+      '@swc/helpers': 0.5.11
     dev: false
 
   /@invoke-ai/eslint-config-react@0.0.14(eslint@8.57.0)(prettier@3.2.5)(typescript@5.4.3):
@@ -3709,14 +3563,14 @@ packages:
       prettier: 3.2.5
     dev: true
 
-  /@invoke-ai/ui-library@0.0.21(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.17)(@internationalized/date@3.5.2)(@types/react@18.2.73)(i18next@23.10.1)(react-dom@18.2.0)(react@18.2.0):
-    resolution: {integrity: sha512-tCvgkBPDt0gNq+8IcR03e/Mw7R8Mb/SMXTqx3FEIxlTQEo93A/D38dKXeDCzTdx4sQ+sknfB+JLBbHs6sg5hhQ==}
+  /@invoke-ai/ui-library@0.0.25(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.17)(@internationalized/date@3.5.3)(@types/react@18.2.73)(i18next@23.10.1)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-Fmjdlu62NXHgairYXGjcuCrxPEAl1G6Q6ban8g3excF6pDDdBeS7CmSNCyEDMxnSIOZrQlI04OhaMB17Imi9Uw==}
     peerDependencies:
       '@fontsource-variable/inter': ^5.0.16
       react: ^18.2.0
       react-dom: ^18.2.0
     dependencies:
-      '@ark-ui/react': 1.3.0(@internationalized/date@3.5.2)(react-dom@18.2.0)(react@18.2.0)
+      '@ark-ui/react': 1.3.0(@internationalized/date@3.5.3)(react-dom@18.2.0)(react@18.2.0)
       '@chakra-ui/anatomy': 2.2.2
       '@chakra-ui/icons': 2.1.1(@chakra-ui/system@2.6.2)(react@18.2.0)
       '@chakra-ui/layout': 2.3.1(@chakra-ui/system@2.6.2)(react@18.2.0)
@@ -5381,8 +5235,8 @@ packages:
     resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
     dev: true
 
-  /@swc/helpers@0.5.7:
-    resolution: {integrity: sha512-BVvNZhx362+l2tSwSuyEUV4h7+jk9raNdoTSdLfwTshXJSaGmYKluGRJznziCI3KX02Z19DdsQrdfrpXAU3Hfg==}
+  /@swc/helpers@0.5.11:
+    resolution: {integrity: sha512-YNlnKRWF2sVojTpIyzwou9XoTNbzbzONwRhOoniEioF1AtaitTvVZblaQRrAzChWQ1bLYyYSWzM18y4WwgzJ+A==}
     dependencies:
       tslib: 2.6.2
     dev: false
@@ -5844,10 +5698,6 @@ packages:
     resolution: {integrity: sha512-nj39q0wAIdhwn7DGUyT9irmsKK1tV0bd5WFEhgpqNTMFZ8cE+jieuTphCW0tfdm47S2zVT5mr09B28b1chmQMA==}
     dev: true
 
-  /@types/prop-types@15.7.11:
-    resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
-    dev: false
-
   /@types/prop-types@15.7.12:
     resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==}
 
@@ -5877,14 +5727,6 @@ packages:
       '@types/react': 18.2.73
     dev: false
 
-  /@types/react@18.2.59:
-    resolution: {integrity: sha512-DE+F6BYEC8VtajY85Qr7mmhTd/79rJKIHCg99MU9SWPB4xvLb6D1za2vYflgZfmPqQVEr6UqJTnLXEwzpVPuOg==}
-    dependencies:
-      '@types/prop-types': 15.7.11
-      '@types/scheduler': 0.16.8
-      csstype: 3.1.3
-    dev: false
-
   /@types/react@18.2.73:
     resolution: {integrity: sha512-XcGdod0Jjv84HOC7N5ziY3x+qL0AfmubvKOZ9hJjJ2yd5EE+KYjWhdOjt387e9HPheHkdggF9atTifMRtyAaRA==}
     dependencies:
@@ -5895,10 +5737,6 @@ packages:
     resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
     dev: true
 
-  /@types/scheduler@0.16.8:
-    resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==}
-    dev: false
-
   /@types/semver@7.5.8:
     resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
     dev: true
@@ -6405,10 +6243,10 @@ packages:
   /@zag-js/date-picker@0.32.1:
     resolution: {integrity: sha512-n/hYmF+/R4+NuyfPRzCgeuLT6LJihKSuKzK29STPWy3sC/tBBHiqhNv1/4UKbatHUJXdBW2XF+N8Rw08RffcFQ==}
     dependencies:
-      '@internationalized/date': 3.5.2
+      '@internationalized/date': 3.5.3
       '@zag-js/anatomy': 0.32.1
       '@zag-js/core': 0.32.1
-      '@zag-js/date-utils': 0.32.1(@internationalized/date@3.5.2)
+      '@zag-js/date-utils': 0.32.1(@internationalized/date@3.5.3)
       '@zag-js/dismissable': 0.32.1
       '@zag-js/dom-event': 0.32.1
       '@zag-js/dom-query': 0.32.1
@@ -6420,12 +6258,12 @@ packages:
       '@zag-js/utils': 0.32.1
     dev: false
 
-  /@zag-js/date-utils@0.32.1(@internationalized/date@3.5.2):
+  /@zag-js/date-utils@0.32.1(@internationalized/date@3.5.3):
     resolution: {integrity: sha512-dbBDRSVr5pRUw3rXndyGuSshZiWqQI5JQO4D2KIFGkXzorj6WzoOpcO910Z7AdM/9cCAMpCjUrka8d8o9BpJBg==}
     peerDependencies:
       '@internationalized/date': '>=3.0.0'
     dependencies:
-      '@internationalized/date': 3.5.2
+      '@internationalized/date': 3.5.3
     dev: false
 
   /@zag-js/dialog@0.32.1:
@@ -6999,13 +6837,6 @@ packages:
       tslib: 2.6.2
     dev: false
 
-  /aria-hidden@1.2.4:
-    resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==}
-    engines: {node: '>=10'}
-    dependencies:
-      tslib: 2.6.2
-    dev: false
-
   /aria-query@5.1.3:
     resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==}
     dependencies:
@@ -9026,13 +8857,6 @@ packages:
       tslib: 2.6.2
     dev: false
 
-  /focus-lock@1.3.4:
-    resolution: {integrity: sha512-Gv0N3mvej3pD+HWkNryrF8sExzEHqhQ6OSFxD4DPxm9n5HGCaHme98ZMBZroNEAJcsdtHxk+skvThGKyUeoEGA==}
-    engines: {node: '>=10'}
-    dependencies:
-      tslib: 2.6.2
-    dev: false
-
   /focus-trap@7.5.4:
     resolution: {integrity: sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==}
     dependencies:
@@ -9095,24 +8919,6 @@ packages:
       tslib: 2.6.2
     dev: false
 
-  /framer-motion@11.0.6(react-dom@18.2.0)(react@18.2.0):
-    resolution: {integrity: sha512-BpO3mWF8UwxzO3Ca5AmSkrg14QYTeJa9vKgoLOoBdBdTPj0e81i1dMwnX6EQJXRieUx20uiDBXq8bA6y7N6b8Q==}
-    peerDependencies:
-      react: ^18.0.0
-      react-dom: ^18.0.0
-    peerDependenciesMeta:
-      react:
-        optional: true
-      react-dom:
-        optional: true
-    dependencies:
-      react: 18.2.0
-      react-dom: 18.2.0(react@18.2.0)
-      tslib: 2.6.2
-    optionalDependencies:
-      '@emotion/is-prop-valid': 0.8.8
-    dev: false
-
   /framesync@6.1.2:
     resolution: {integrity: sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==}
     dependencies:
@@ -11485,7 +11291,7 @@ packages:
     resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
     dev: false
 
-  /react-focus-lock@2.11.1(@types/react@18.2.59)(react@18.2.0):
+  /react-focus-lock@2.11.1(@types/react@18.2.73)(react@18.2.0):
     resolution: {integrity: sha512-IXLwnTBrLTlKTpASZXqqXJ8oymWrgAlOfuuDYN4XCuN1YJ72dwX198UCaF1QqGUk5C3QOnlMik//n3ufcfe8Ig==}
     peerDependencies:
       '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
@@ -11495,31 +11301,12 @@ packages:
         optional: true
     dependencies:
       '@babel/runtime': 7.23.9
-      '@types/react': 18.2.59
+      '@types/react': 18.2.73
       focus-lock: 1.3.3
       prop-types: 15.8.1
       react: 18.2.0
       react-clientside-effect: 1.2.6(react@18.2.0)
-      use-callback-ref: 1.3.1(@types/react@18.2.59)(react@18.2.0)
-      use-sidecar: 1.1.2(@types/react@18.2.59)(react@18.2.0)
-    dev: false
-
-  /react-focus-lock@2.11.2(@types/react@18.2.73)(react@18.2.0):
-    resolution: {integrity: sha512-DDTbEiov0+RthESPVSTIdAWPPKic+op3sCcP+icbMRobvQNt7LuAlJ3KoarqQv5sCgKArru3kXmlmFTa27/CdQ==}
-    peerDependencies:
-      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
-      react: ^16.8.0 || ^17.0.0 || ^18.0.0
-    peerDependenciesMeta:
-      '@types/react':
-        optional: true
-    dependencies:
-      '@babel/runtime': 7.24.1
-      '@types/react': 18.2.73
-      focus-lock: 1.3.4
-      prop-types: 15.8.1
-      react: 18.2.0
-      react-clientside-effect: 1.2.6(react@18.2.0)
-      use-callback-ref: 1.3.2(@types/react@18.2.73)(react@18.2.0)
+      use-callback-ref: 1.3.1(@types/react@18.2.73)(react@18.2.0)
       use-sidecar: 1.1.2(@types/react@18.2.73)(react@18.2.0)
     dev: false
 
@@ -11634,25 +11421,9 @@ packages:
       use-sync-external-store: 1.2.0(react@18.2.0)
     dev: false
 
-  /react-remove-scroll-bar@2.3.5(@types/react@18.2.59)(react@18.2.0):
+  /react-remove-scroll-bar@2.3.5(@types/react@18.2.73)(react@18.2.0):
     resolution: {integrity: sha512-3cqjOqg6s0XbOjWvmasmqHch+RLxIEk2r/70rzGXuz3iIGQsQheEQyqYCBb5EECoD01Vo2SIbDqW4paLeLTASw==}
     engines: {node: '>=10'}
-    peerDependencies:
-      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
-      react: ^16.8.0 || ^17.0.0 || ^18.0.0
-    peerDependenciesMeta:
-      '@types/react':
-        optional: true
-    dependencies:
-      '@types/react': 18.2.59
-      react: 18.2.0
-      react-style-singleton: 2.2.1(@types/react@18.2.59)(react@18.2.0)
-      tslib: 2.6.2
-    dev: false
-
-  /react-remove-scroll-bar@2.3.6(@types/react@18.2.73)(react@18.2.0):
-    resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==}
-    engines: {node: '>=10'}
     peerDependencies:
       '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
       react: ^16.8.0 || ^17.0.0 || ^18.0.0
@@ -11666,28 +11437,9 @@ packages:
       tslib: 2.6.2
     dev: false
 
-  /react-remove-scroll@2.5.7(@types/react@18.2.59)(react@18.2.0):
+  /react-remove-scroll@2.5.7(@types/react@18.2.73)(react@18.2.0):
     resolution: {integrity: sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==}
     engines: {node: '>=10'}
-    peerDependencies:
-      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
-      react: ^16.8.0 || ^17.0.0 || ^18.0.0
-    peerDependenciesMeta:
-      '@types/react':
-        optional: true
-    dependencies:
-      '@types/react': 18.2.59
-      react: 18.2.0
-      react-remove-scroll-bar: 2.3.5(@types/react@18.2.59)(react@18.2.0)
-      react-style-singleton: 2.2.1(@types/react@18.2.59)(react@18.2.0)
-      tslib: 2.6.2
-      use-callback-ref: 1.3.1(@types/react@18.2.59)(react@18.2.0)
-      use-sidecar: 1.1.2(@types/react@18.2.59)(react@18.2.0)
-    dev: false
-
-  /react-remove-scroll@2.5.9(@types/react@18.2.73)(react@18.2.0):
-    resolution: {integrity: sha512-bvHCLBrFfM2OgcrpPY2YW84sPdS2o2HKWJUf1xGyGLnSoEnOTOBpahIarjRuYtN0ryahCeP242yf+5TrBX/pZA==}
-    engines: {node: '>=10'}
     peerDependencies:
       '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
       react: ^16.8.0 || ^17.0.0 || ^18.0.0
@@ -11697,10 +11449,10 @@ packages:
     dependencies:
       '@types/react': 18.2.73
       react: 18.2.0
-      react-remove-scroll-bar: 2.3.6(@types/react@18.2.73)(react@18.2.0)
+      react-remove-scroll-bar: 2.3.5(@types/react@18.2.73)(react@18.2.0)
       react-style-singleton: 2.2.1(@types/react@18.2.73)(react@18.2.0)
       tslib: 2.6.2
-      use-callback-ref: 1.3.2(@types/react@18.2.73)(react@18.2.0)
+      use-callback-ref: 1.3.1(@types/react@18.2.73)(react@18.2.0)
       use-sidecar: 1.1.2(@types/react@18.2.73)(react@18.2.0)
     dev: false
 
@@ -11756,23 +11508,6 @@ packages:
       - '@types/react'
     dev: false
 
-  /react-style-singleton@2.2.1(@types/react@18.2.59)(react@18.2.0):
-    resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
-    engines: {node: '>=10'}
-    peerDependencies:
-      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
-      react: ^16.8.0 || ^17.0.0 || ^18.0.0
-    peerDependenciesMeta:
-      '@types/react':
-        optional: true
-    dependencies:
-      '@types/react': 18.2.59
-      get-nonce: 1.0.1
-      invariant: 2.2.4
-      react: 18.2.0
-      tslib: 2.6.2
-    dev: false
-
   /react-style-singleton@2.2.1(@types/react@18.2.73)(react@18.2.0):
     resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
     engines: {node: '>=10'}
@@ -13288,24 +13023,9 @@ packages:
       punycode: 2.3.1
     dev: true
 
-  /use-callback-ref@1.3.1(@types/react@18.2.59)(react@18.2.0):
+  /use-callback-ref@1.3.1(@types/react@18.2.73)(react@18.2.0):
     resolution: {integrity: sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ==}
     engines: {node: '>=10'}
-    peerDependencies:
-      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
-      react: ^16.8.0 || ^17.0.0 || ^18.0.0
-    peerDependenciesMeta:
-      '@types/react':
-        optional: true
-    dependencies:
-      '@types/react': 18.2.59
-      react: 18.2.0
-      tslib: 2.6.2
-    dev: false
-
-  /use-callback-ref@1.3.2(@types/react@18.2.73)(react@18.2.0):
-    resolution: {integrity: sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==}
-    engines: {node: '>=10'}
     peerDependencies:
       '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
       react: ^16.8.0 || ^17.0.0 || ^18.0.0
@@ -13358,22 +13078,6 @@ packages:
       react: 18.2.0
     dev: false
 
-  /use-sidecar@1.1.2(@types/react@18.2.59)(react@18.2.0):
-    resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
-    engines: {node: '>=10'}
-    peerDependencies:
-      '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0
-      react: ^16.8.0 || ^17.0.0 || ^18.0.0
-    peerDependenciesMeta:
-      '@types/react':
-        optional: true
-    dependencies:
-      '@types/react': 18.2.59
-      detect-node-es: 1.1.0
-      react: 18.2.0
-      tslib: 2.6.2
-    dev: false
-
   /use-sidecar@1.1.2(@types/react@18.2.73)(react@18.2.0):
     resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
     engines: {node: '>=10'}

From c30df7ce79eabc8a67c7aaae77572bfafcaf4131 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 11:24:49 +1000
Subject: [PATCH 31/59] feat(ui): style settings/control layers tabs

---
 .../components/ParametersPanelTextToImage.tsx | 41 +++++++++++++++----
 1 file changed, 34 insertions(+), 7 deletions(-)

diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
index 79a9b5a03c..6cb9c6d762 100644
--- a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
@@ -1,3 +1,4 @@
+import type { ChakraProps } from '@invoke-ai/ui-library';
 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';
@@ -23,6 +24,29 @@ const overlayScrollbarsStyles: CSSProperties = {
   width: '100%',
 };
 
+const unselectedStyles: ChakraProps['sx'] = {
+  bg: 'none',
+  color: 'base.300',
+  fontWeight: 'semibold',
+  fontSize: 'sm',
+  w: '50%',
+  borderWidth: 1,
+  borderRadius: 'base',
+};
+
+const selectedStyles: ChakraProps['sx'] = {
+  color: 'invokeBlue.300',
+  borderColor: 'invokeBlueAlpha.400',
+  _hover: {
+    color: 'invokeBlue.200',
+  },
+};
+
+const hoverStyles: ChakraProps['sx'] = {
+  bg: 'base.850',
+  color: 'base.100',
+};
+
 const ParametersPanelTextToImage = () => {
   const { t } = useTranslation();
   const activeTabName = useAppSelector(activeTabNameSelector);
@@ -37,14 +61,17 @@ const ParametersPanelTextToImage = () => {
           <OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
             <Flex gap={2} flexDirection="column" h="full" w="full">
               {isSDXL ? <SDXLPrompts /> : <Prompts />}
-              <Tabs variant="line" isLazy={true} display="flex" flexDir="column" w="full" h="full">
-                <TabList>
-                  <Tab flexGrow={1}>{t('common.settingsLabel')}</Tab>
-                  <Tab flexGrow={1}>{controlLayersTitle}</Tab>
+              <Tabs variant="unstyled" display="flex" flexDir="column" w="full" h="full" gap={2}>
+                <TabList gap={2}>
+                  <Tab _hover={hoverStyles} _selected={selectedStyles} sx={unselectedStyles}>
+                    {t('common.settingsLabel')}
+                  </Tab>
+                  <Tab _hover={hoverStyles} _selected={selectedStyles} sx={unselectedStyles}>
+                    {controlLayersTitle}
+                  </Tab>
                 </TabList>
-
                 <TabPanels w="full" h="full">
-                  <TabPanel>
+                  <TabPanel p={0}>
                     <Flex gap={2} flexDirection="column" h="full" w="full">
                       <ImageSettingsAccordion />
                       <GenerationSettingsAccordion />
@@ -54,7 +81,7 @@ const ParametersPanelTextToImage = () => {
                       <AdvancedSettingsAccordion />
                     </Flex>
                   </TabPanel>
-                  <TabPanel>
+                  <TabPanel p={0}>
                     <ControlLayersPanelContent />
                   </TabPanel>
                 </TabPanels>

From 2baa33730a926e19fcafe066de46764bf6e1491a Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 11:28:52 +1000
Subject: [PATCH 32/59] fix(ui): fix control layer list layout

---
 .../controlLayers/components/ControlLayersPanelContent.tsx    | 4 ++--
 .../src/features/ui/components/ParametersPanelTextToImage.tsx | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
index 14bea9bc1e..b78beb3df8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
@@ -22,13 +22,13 @@ const selectLayerIdTypePairs = createMemoizedSelector(selectControlLayersSlice,
 export const ControlLayersPanelContent = memo(() => {
   const layerIdTypePairs = useAppSelector(selectLayerIdTypePairs);
   return (
-    <Flex flexDir="column" gap={4} w="full" h="full">
+    <Flex flexDir="column" gap={2} w="full" h="full">
       <Flex justifyContent="space-around">
         <AddLayerButton />
         <DeleteAllLayersButton />
       </Flex>
       <ScrollableContent>
-        <Flex flexDir="column" gap={4}>
+        <Flex flexDir="column" gap={2}>
           {layerIdTypePairs.map(({ id, type }) => (
             <LayerWrapper key={id} id={id} type={type} />
           ))}
diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
index 6cb9c6d762..abd78d00e4 100644
--- a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
@@ -71,7 +71,7 @@ const ParametersPanelTextToImage = () => {
                   </Tab>
                 </TabList>
                 <TabPanels w="full" h="full">
-                  <TabPanel p={0}>
+                  <TabPanel p={0} w="full" h="full">
                     <Flex gap={2} flexDirection="column" h="full" w="full">
                       <ImageSettingsAccordion />
                       <GenerationSettingsAccordion />
@@ -81,7 +81,7 @@ const ParametersPanelTextToImage = () => {
                       <AdvancedSettingsAccordion />
                     </Flex>
                   </TabPanel>
-                  <TabPanel p={0}>
+                  <TabPanel p={0} w="full" h="full">
                     <ControlLayersPanelContent />
                   </TabPanel>
                 </TabPanels>

From eb36e834b2a950c8bf6fc8442c2a311fcbd92b54 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 11:34:01 +1000
Subject: [PATCH 33/59] feat(ui): add fallback when no layers exist

---
 invokeai/frontend/web/public/locales/en.json  |  3 ++-
 .../components/ControlLayersPanelContent.tsx  | 20 ++++++++++++-------
 .../features/controlLayers/util/renderers.ts  |  3 ++-
 3 files changed, 17 insertions(+), 9 deletions(-)

diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index cfcb433db2..d122892d39 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1558,7 +1558,8 @@
         "globalInitialImageLayer": "$t(controlLayers.globalInitialImage) $t(unifiedCanvas.layer)",
         "opacityFilter": "Opacity Filter",
         "clearProcessor": "Clear Processor",
-        "resetProcessor": "Reset Processor to Defaults"
+        "resetProcessor": "Reset Processor to Defaults",
+        "noLayersAdded": "No Layers Added"
     },
     "ui": {
         "tabs": {
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
index b78beb3df8..1dd79d0220 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx
@@ -2,6 +2,7 @@
 import { Flex } from '@invoke-ai/ui-library';
 import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
 import { useAppSelector } from 'app/store/storeHooks';
+import { IAINoContentFallback } from 'common/components/IAIImageFallback';
 import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
 import { AddLayerButton } from 'features/controlLayers/components/AddLayerButton';
 import { CALayer } from 'features/controlLayers/components/CALayer/CALayer';
@@ -13,6 +14,7 @@ import { isRenderableLayer, selectControlLayersSlice } from 'features/controlLay
 import type { Layer } from 'features/controlLayers/store/types';
 import { partition } from 'lodash-es';
 import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
 
 const selectLayerIdTypePairs = createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
   const [renderableLayers, ipAdapterLayers] = partition(controlLayers.present.layers, isRenderableLayer);
@@ -20,6 +22,7 @@ const selectLayerIdTypePairs = createMemoizedSelector(selectControlLayersSlice,
 });
 
 export const ControlLayersPanelContent = memo(() => {
+  const { t } = useTranslation();
   const layerIdTypePairs = useAppSelector(selectLayerIdTypePairs);
   return (
     <Flex flexDir="column" gap={2} w="full" h="full">
@@ -27,13 +30,16 @@ export const ControlLayersPanelContent = memo(() => {
         <AddLayerButton />
         <DeleteAllLayersButton />
       </Flex>
-      <ScrollableContent>
-        <Flex flexDir="column" gap={2}>
-          {layerIdTypePairs.map(({ id, type }) => (
-            <LayerWrapper key={id} id={id} type={type} />
-          ))}
-        </Flex>
-      </ScrollableContent>
+      {layerIdTypePairs.length > 0 && (
+        <ScrollableContent>
+          <Flex flexDir="column" gap={2}>
+            {layerIdTypePairs.map(({ id, type }) => (
+              <LayerWrapper key={id} id={id} type={type} />
+            ))}
+          </Flex>
+        </ScrollableContent>
+      )}
+      {layerIdTypePairs.length === 0 && <IAINoContentFallback icon={null} label={t('controlLayers.noLayersAdded')} />}
     </Flex>
   );
 });
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
index 09db523a61..b437c95650 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
@@ -40,6 +40,7 @@ import type {
   VectorMaskRect,
 } from 'features/controlLayers/store/types';
 import { getLayerBboxFast, getLayerBboxPixels } from 'features/controlLayers/util/bbox';
+import { t } from 'i18next';
 import Konva from 'konva';
 import type { IRect, Vector2d } from 'konva/lib/types';
 import { debounce } from 'lodash-es';
@@ -819,7 +820,7 @@ const createNoLayersMessageLayer = (stage: Konva.Stage): Konva.Layer => {
     y: 0,
     align: 'center',
     verticalAlign: 'middle',
-    text: 'No Layers Added',
+    text: t('controlLayers.noLayersAdded'),
     fontFamily: '"Inter Variable", sans-serif',
     fontStyle: '600',
     fill: 'white',

From 2062cfe84a96f2076108b69517d22fee567a58e8 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 11:35:53 +1000
Subject: [PATCH 34/59] fix(ui): cursor when no renderable layers added

---
 .../frontend/web/src/features/controlLayers/util/renderers.ts   | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
index b437c95650..36083d2d92 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
@@ -146,7 +146,7 @@ const renderToolPreview = (
   lastMouseDownPos: Vector2d | null,
   brushSize: number
 ) => {
-  const layerCount = stage.find(`.${RG_LAYER_NAME}`).length;
+  const layerCount = stage.find(selectRenderableLayers).length;
   // Update the stage's pointer style
   if (layerCount === 0) {
     // We have no layers, so we should not render any tool

From fdfc379a84751b986c6f8dc79e21e9ffee43ad52 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 11:46:15 +1000
Subject: [PATCH 35/59] fix(ui): layer counts

---
 .../hooks/useControlLayersTitle.ts            | 41 +++++++++++++++----
 1 file changed, 32 insertions(+), 9 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useControlLayersTitle.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useControlLayersTitle.ts
index c42a27f28f..bf0fa661a9 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/useControlLayersTitle.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useControlLayersTitle.ts
@@ -1,20 +1,43 @@
 import { createSelector } from '@reduxjs/toolkit';
 import { useAppSelector } from 'app/store/storeHooks';
-import { isRegionalGuidanceLayer, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
+import {
+  isControlAdapterLayer,
+  isInitialImageLayer,
+  isIPAdapterLayer,
+  isRegionalGuidanceLayer,
+  selectControlLayersSlice,
+} from 'features/controlLayers/store/controlLayersSlice';
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 const selectValidLayerCount = createSelector(selectControlLayersSlice, (controlLayers) => {
-  const validLayers = controlLayers.present.layers
-    .filter(isRegionalGuidanceLayer)
-    .filter((l) => l.isEnabled)
-    .filter((l) => {
+  let count = 0;
+  controlLayers.present.layers.forEach((l) => {
+    if (isRegionalGuidanceLayer(l)) {
       const hasTextPrompt = Boolean(l.positivePrompt || l.negativePrompt);
-      const hasAtLeastOneImagePrompt = l.ipAdapters.length > 0;
-      return hasTextPrompt || hasAtLeastOneImagePrompt;
-    });
+      const hasAtLeastOneImagePrompt = l.ipAdapters.filter((ipa) => Boolean(ipa.image)).length > 0;
+      if (hasTextPrompt || hasAtLeastOneImagePrompt) {
+        count += 1;
+      }
+    }
+    if (isControlAdapterLayer(l)) {
+      if (l.controlAdapter.image || l.controlAdapter.processedImage) {
+        count += 1;
+      }
+    }
+    if (isIPAdapterLayer(l)) {
+      if (l.ipAdapter.image) {
+        count += 1;
+      }
+    }
+    if (isInitialImageLayer(l)) {
+      if (l.image) {
+        count += 1;
+      }
+    }
+  });
 
-  return validLayers.length;
+  return count;
 });
 
 export const useControlLayersTitle = () => {

From d9b92d19f95bf05798aec2af8958d9e9f19acf4f Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 12:13:35 +1000
Subject: [PATCH 36/59] feat(ui): clearer viewer/editor context switching

---
 invokeai/frontend/web/public/locales/en.json  |   3 +-
 .../IAICanvasToolbar/IAICanvasToolbar.tsx     | 181 ++++++++++--------
 .../components/ControlLayersToolbar.tsx       |  21 +-
 .../ImageViewer/BackToEditorButton.tsx        |  24 ---
 .../components/ImageViewer/EditorButton.tsx   |  37 ++++
 .../components/ImageViewer/ImageViewer.tsx    |   7 +-
 .../components/ImageViewer/ViewerButton.tsx   |  17 ++
 .../components/ImageViewer/useImageViewer.tsx |   9 -
 .../flow/panels/TopPanel/TopPanel.tsx         |   2 +
 9 files changed, 174 insertions(+), 127 deletions(-)
 delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/BackToEditorButton.tsx
 create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
 create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx

diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index d122892d39..a6d60fa281 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -88,6 +88,7 @@
         "negativePrompt": "Negative Prompt",
         "discordLabel": "Discord",
         "dontAskMeAgain": "Don't ask me again",
+        "editor": "Editor",
         "error": "Error",
         "file": "File",
         "folder": "Folder",
@@ -364,7 +365,7 @@
         "bulkDownloadFailed": "Download Failed",
         "problemDeletingImages": "Problem Deleting Images",
         "problemDeletingImagesDesc": "One or more images could not be deleted",
-        "backToEditor": "Back to {{tab}} (Esc)"
+        "switchTo": "Switch to {{ tab }} (Z)"
     },
     "hotkeys": {
         "searchHotkeys": "Search Hotkeys",
diff --git a/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx b/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx
index 686577b4a7..15d38b9f76 100644
--- a/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx
+++ b/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx
@@ -22,6 +22,7 @@ import {
 } from 'features/canvas/store/canvasSlice';
 import type { CanvasLayer } from 'features/canvas/store/canvasTypes';
 import { LAYER_NAMES_DICT } from 'features/canvas/store/canvasTypes';
+import { ViewerButton } from 'features/gallery/components/ImageViewer/ViewerButton';
 import { memo, useCallback, useMemo } from 'react';
 import { useHotkeys } from 'react-hotkeys-hook';
 import { useTranslation } from 'react-i18next';
@@ -219,97 +220,107 @@ const IAICanvasToolbar = () => {
   const value = useMemo(() => LAYER_NAMES_DICT.filter((o) => o.value === layer)[0], [layer]);
 
   return (
-    <Flex alignItems="center" gap={2} flexWrap="wrap">
-      <Tooltip label={`${t('unifiedCanvas.layer')} (Q)`}>
-        <FormControl isDisabled={isStaging} w="5rem">
-          <Combobox value={value} options={LAYER_NAMES_DICT} onChange={handleChangeLayer} />
-        </FormControl>
-      </Tooltip>
+    <Flex w="full" gap={2} alignItems="center">
+      <Flex flex={1} justifyContent="center">
+        <Flex gap={2} marginInlineEnd="auto" />
+      </Flex>
+      <Flex flex={1} gap={2} justifyContent="center">
+        <Tooltip label={`${t('unifiedCanvas.layer')} (Q)`}>
+          <FormControl isDisabled={isStaging} w="5rem">
+            <Combobox value={value} options={LAYER_NAMES_DICT} onChange={handleChangeLayer} />
+          </FormControl>
+        </Tooltip>
 
-      <IAICanvasMaskOptions />
-      <IAICanvasToolChooserOptions />
+        <IAICanvasMaskOptions />
+        <IAICanvasToolChooserOptions />
 
-      <ButtonGroup>
-        <IconButton
-          aria-label={`${t('unifiedCanvas.move')} (V)`}
-          tooltip={`${t('unifiedCanvas.move')} (V)`}
-          icon={<PiHandGrabbingBold />}
-          isChecked={tool === 'move' || isStaging}
-          onClick={handleSelectMoveTool}
-        />
-        <IconButton
-          aria-label={`${shouldShowBoundingBox ? t('unifiedCanvas.hideBoundingBox') : t('unifiedCanvas.showBoundingBox')} (Shift + H)`}
-          tooltip={`${shouldShowBoundingBox ? t('unifiedCanvas.hideBoundingBox') : t('unifiedCanvas.showBoundingBox')} (Shift + H)`}
-          icon={shouldShowBoundingBox ? <PiEyeBold /> : <PiEyeSlashBold />}
-          onClick={handleSetShouldShowBoundingBox}
-          isDisabled={isStaging}
-        />
-        <IconButton
-          aria-label={`${t('unifiedCanvas.resetView')} (R)`}
-          tooltip={`${t('unifiedCanvas.resetView')} (R)`}
-          icon={<PiCrosshairSimpleBold />}
-          onClick={handleClickResetCanvasView}
-        />
-      </ButtonGroup>
-
-      <ButtonGroup>
-        <IconButton
-          aria-label={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
-          tooltip={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
-          icon={<PiStackBold />}
-          onClick={handleMergeVisible}
-          isDisabled={isStaging}
-        />
-        <IconButton
-          aria-label={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
-          tooltip={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
-          icon={<PiFloppyDiskBold />}
-          onClick={handleSaveToGallery}
-          isDisabled={isStaging}
-        />
-        {isClipboardAPIAvailable && (
+        <ButtonGroup>
           <IconButton
-            aria-label={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
-            tooltip={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
-            icon={<PiCopyBold />}
-            onClick={handleCopyImageToClipboard}
+            aria-label={`${t('unifiedCanvas.move')} (V)`}
+            tooltip={`${t('unifiedCanvas.move')} (V)`}
+            icon={<PiHandGrabbingBold />}
+            isChecked={tool === 'move' || isStaging}
+            onClick={handleSelectMoveTool}
+          />
+          <IconButton
+            aria-label={`${shouldShowBoundingBox ? t('unifiedCanvas.hideBoundingBox') : t('unifiedCanvas.showBoundingBox')} (Shift + H)`}
+            tooltip={`${shouldShowBoundingBox ? t('unifiedCanvas.hideBoundingBox') : t('unifiedCanvas.showBoundingBox')} (Shift + H)`}
+            icon={shouldShowBoundingBox ? <PiEyeBold /> : <PiEyeSlashBold />}
+            onClick={handleSetShouldShowBoundingBox}
             isDisabled={isStaging}
           />
-        )}
-        <IconButton
-          aria-label={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
-          tooltip={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
-          icon={<PiDownloadSimpleBold />}
-          onClick={handleDownloadAsImage}
-          isDisabled={isStaging}
-        />
-      </ButtonGroup>
-      <ButtonGroup>
-        <IAICanvasUndoButton />
-        <IAICanvasRedoButton />
-      </ButtonGroup>
+          <IconButton
+            aria-label={`${t('unifiedCanvas.resetView')} (R)`}
+            tooltip={`${t('unifiedCanvas.resetView')} (R)`}
+            icon={<PiCrosshairSimpleBold />}
+            onClick={handleClickResetCanvasView}
+          />
+        </ButtonGroup>
 
-      <ButtonGroup>
-        <IconButton
-          aria-label={`${t('common.upload')}`}
-          tooltip={`${t('common.upload')}`}
-          icon={<PiUploadSimpleBold />}
-          isDisabled={isStaging}
-          {...getUploadButtonProps()}
-        />
-        <input {...getUploadInputProps()} />
-        <IconButton
-          aria-label={`${t('unifiedCanvas.clearCanvas')}`}
-          tooltip={`${t('unifiedCanvas.clearCanvas')}`}
-          icon={<PiTrashSimpleBold />}
-          onClick={handleResetCanvas}
-          colorScheme="error"
-          isDisabled={isStaging}
-        />
-      </ButtonGroup>
-      <ButtonGroup>
-        <IAICanvasSettingsButtonPopover />
-      </ButtonGroup>
+        <ButtonGroup>
+          <IconButton
+            aria-label={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
+            tooltip={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
+            icon={<PiStackBold />}
+            onClick={handleMergeVisible}
+            isDisabled={isStaging}
+          />
+          <IconButton
+            aria-label={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
+            tooltip={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
+            icon={<PiFloppyDiskBold />}
+            onClick={handleSaveToGallery}
+            isDisabled={isStaging}
+          />
+          {isClipboardAPIAvailable && (
+            <IconButton
+              aria-label={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
+              tooltip={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
+              icon={<PiCopyBold />}
+              onClick={handleCopyImageToClipboard}
+              isDisabled={isStaging}
+            />
+          )}
+          <IconButton
+            aria-label={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
+            tooltip={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
+            icon={<PiDownloadSimpleBold />}
+            onClick={handleDownloadAsImage}
+            isDisabled={isStaging}
+          />
+        </ButtonGroup>
+        <ButtonGroup>
+          <IAICanvasUndoButton />
+          <IAICanvasRedoButton />
+        </ButtonGroup>
+
+        <ButtonGroup>
+          <IconButton
+            aria-label={`${t('common.upload')}`}
+            tooltip={`${t('common.upload')}`}
+            icon={<PiUploadSimpleBold />}
+            isDisabled={isStaging}
+            {...getUploadButtonProps()}
+          />
+          <input {...getUploadInputProps()} />
+          <IconButton
+            aria-label={`${t('unifiedCanvas.clearCanvas')}`}
+            tooltip={`${t('unifiedCanvas.clearCanvas')}`}
+            icon={<PiTrashSimpleBold />}
+            onClick={handleResetCanvas}
+            colorScheme="error"
+            isDisabled={isStaging}
+          />
+        </ButtonGroup>
+        <ButtonGroup>
+          <IAICanvasSettingsButtonPopover />
+        </ButtonGroup>
+      </Flex>
+      <Flex flex={1} justifyContent="center">
+        <Flex gap={2} marginInlineStart="auto">
+          <ViewerButton />
+        </Flex>
+      </Flex>
     </Flex>
   );
 };
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx
index 15a74a332a..b78910700d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx
@@ -4,15 +4,26 @@ 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 { ViewerButton } from 'features/gallery/components/ImageViewer/ViewerButton';
 import { memo } from 'react';
 
 export const ControlLayersToolbar = memo(() => {
   return (
-    <Flex gap={4}>
-      <BrushSize />
-      <ToolChooser />
-      <UndoRedoButtonGroup />
-      <ControlLayersSettingsPopover />
+    <Flex w="full" gap={2}>
+      <Flex flex={1} justifyContent="center">
+        <Flex gap={2} marginInlineEnd="auto" />
+      </Flex>
+      <Flex flex={1} gap={2} justifyContent="center">
+        <BrushSize />
+        <ToolChooser />
+        <UndoRedoButtonGroup />
+        <ControlLayersSettingsPopover />
+      </Flex>
+      <Flex flex={1} justifyContent="center">
+        <Flex gap={2} marginInlineStart="auto">
+          <ViewerButton />
+        </Flex>
+      </Flex>
     </Flex>
   );
 });
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/BackToEditorButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/BackToEditorButton.tsx
deleted file mode 100644
index 660840b568..0000000000
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/BackToEditorButton.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Button } from '@invoke-ai/ui-library';
-import { useAppSelector } from 'app/store/storeHooks';
-import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
-import { useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { PiArrowLeftBold } from 'react-icons/pi';
-
-import { TAB_NAME_TO_TKEY, useImageViewer } from './useImageViewer';
-
-export const BackToEditorButton = () => {
-  const { t } = useTranslation();
-  const { onClose } = useImageViewer();
-  const activeTabName = useAppSelector(activeTabNameSelector);
-  const tooltip = useMemo(
-    () => t('gallery.backToEditor', { tab: t(TAB_NAME_TO_TKEY[activeTabName]) }),
-    [t, activeTabName]
-  );
-
-  return (
-    <Button aria-label={tooltip} tooltip={tooltip} onClick={onClose} leftIcon={<PiArrowLeftBold />} variant="ghost">
-      {t('common.back')}
-    </Button>
-  );
-};
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
new file mode 100644
index 0000000000..d6cbfbb124
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
@@ -0,0 +1,37 @@
+import { Button } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
+import type { InvokeTabName } from 'features/ui/store/tabMap';
+import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useImageViewer } from './useImageViewer';
+
+export const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
+  generation: 'ui.tabs.generationTab',
+  canvas: 'ui.tabs.canvasTab',
+  workflows: 'ui.tabs.workflowsTab',
+  models: 'ui.tabs.modelsTab',
+  queue: 'ui.tabs.queueTab',
+};
+
+export const TAB_NAME_TO_TKEY_SHORT: Record<InvokeTabName, string> = {
+  generation: 'ui.tabs.generation',
+  canvas: 'ui.tabs.canvas',
+  workflows: 'ui.tabs.workflows',
+  models: 'ui.tabs.models',
+  queue: 'ui.tabs.queue',
+};
+
+export const EditorButton = () => {
+  const { t } = useTranslation();
+  const { onClose } = useImageViewer();
+  const activeTabName = useAppSelector(activeTabNameSelector);
+  const tooltip = useMemo(() => t('gallery.switchTo', { tab: t(TAB_NAME_TO_TKEY[activeTabName]) }), [t, activeTabName]);
+
+  return (
+    <Button aria-label={tooltip} tooltip={tooltip} onClick={onClose} variant="ghost">
+      {t(TAB_NAME_TO_TKEY_SHORT[activeTabName])}
+    </Button>
+  );
+};
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
index 9f3e7c5902..874464f938 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
@@ -10,9 +10,9 @@ import { AnimatePresence, motion } from 'framer-motion';
 import { memo, useMemo } from 'react';
 import { useHotkeys } from 'react-hotkeys-hook';
 
-import { BackToEditorButton } from './BackToEditorButton';
 import CurrentImageButtons from './CurrentImageButtons';
 import CurrentImagePreview from './CurrentImagePreview';
+import { EditorButton } from './EditorButton';
 
 const initial: AnimationProps['initial'] = {
   opacity: 0,
@@ -39,13 +39,14 @@ export const ImageViewer = memo(() => {
     return isOpen;
   }, [isOpen, isViewerEnabled]);
 
-  useHotkeys('shift+s', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]);
+  useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]);
   useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]);
 
   return (
     <AnimatePresence>
       {shouldShowViewer && (
         <Flex
+          key="imageViewer"
           as={motion.div}
           initial={initial}
           animate={animate}
@@ -76,7 +77,7 @@ export const ImageViewer = memo(() => {
             </Flex>
             <Flex flex={1} justifyContent="center">
               <Flex gap={2} marginInlineStart="auto">
-                <BackToEditorButton />
+                <EditorButton />
               </Flex>
             </Flex>
           </Flex>
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx
new file mode 100644
index 0000000000..a57ae9d1ee
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx
@@ -0,0 +1,17 @@
+import { Button } from '@invoke-ai/ui-library';
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useImageViewer } from './useImageViewer';
+
+export const ViewerButton = () => {
+  const { t } = useTranslation();
+  const { onOpen } = useImageViewer();
+  const tooltip = useMemo(() => t('gallery.switchTo', { tab: t('common.viewer') }), [t]);
+
+  return (
+    <Button aria-label={tooltip} tooltip={tooltip} onClick={onOpen} variant="ghost" pointerEvents="auto">
+      {t('common.viewer')}
+    </Button>
+  );
+};
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.tsx
index 17a9dc9922..57b3697b7e 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.tsx
@@ -1,16 +1,7 @@
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
 import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
-import type { InvokeTabName } from 'features/ui/store/tabMap';
 import { useCallback } from 'react';
 
-export const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
-  generation: 'ui.tabs.generation',
-  canvas: 'ui.tabs.canvas',
-  workflows: 'ui.tabs.workflows',
-  models: 'ui.tabs.models',
-  queue: 'ui.tabs.queue',
-};
-
 export const useImageViewer = () => {
   const dispatch = useAppDispatch();
   const isOpen = useAppSelector((s) => s.gallery.isImageViewerOpen);
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx
index 93856a21c4..2a08fb840e 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx
@@ -1,5 +1,6 @@
 import { Flex, Spacer } from '@invoke-ai/ui-library';
 import { useAppSelector } from 'app/store/storeHooks';
+import { ViewerButton } from 'features/gallery/components/ImageViewer/ViewerButton';
 import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton';
 import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton';
 import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
@@ -22,6 +23,7 @@ const TopCenterPanel = () => {
       <ClearFlowButton />
       <SaveWorkflowButton />
       <WorkflowLibraryMenu />
+      <ViewerButton />
     </Flex>
   );
 };

From 36f01988e8f3a7770c1b7932464a20f3228a66ba Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 12:30:59 +1000
Subject: [PATCH 37/59] chore(ui): lint

---
 .../features/gallery/components/ImageViewer/EditorButton.tsx  | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
index d6cbfbb124..2e10d057f8 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
 
 import { useImageViewer } from './useImageViewer';
 
-export const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
+const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
   generation: 'ui.tabs.generationTab',
   canvas: 'ui.tabs.canvasTab',
   workflows: 'ui.tabs.workflowsTab',
@@ -15,7 +15,7 @@ export const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
   queue: 'ui.tabs.queueTab',
 };
 
-export const TAB_NAME_TO_TKEY_SHORT: Record<InvokeTabName, string> = {
+const TAB_NAME_TO_TKEY_SHORT: Record<InvokeTabName, string> = {
   generation: 'ui.tabs.generation',
   canvas: 'ui.tabs.canvas',
   workflows: 'ui.tabs.workflows',

From 579d436934f0dea4e6e7cb2556f6bb7199575cad Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 13:06:04 +1000
Subject: [PATCH 38/59] fix(ui): floating param/gallery buttons

---
 .../web/src/features/ui/components/FloatingGalleryButton.tsx    | 2 +-
 .../features/ui/components/FloatingParametersPanelButtons.tsx   | 1 +
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingGalleryButton.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingGalleryButton.tsx
index c2d8d9addb..6ea62981a5 100644
--- a/invokeai/frontend/web/src/features/ui/components/FloatingGalleryButton.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/FloatingGalleryButton.tsx
@@ -17,7 +17,7 @@ const FloatingGalleryButton = (props: Props) => {
 
   return (
     <Portal>
-      <Flex pos="absolute" transform="translate(0, -50%)" minW={8} top="50%" insetInlineEnd="21px">
+      <Flex pos="absolute" transform="translate(0, -50%)" minW={8} top="50%" insetInlineEnd="21px" zIndex={11}>
         <Tooltip label={t('accessibility.showGalleryPanel')} placement="start">
           <IconButton
             aria-label={t('accessibility.showGalleryPanel')}
diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx
index 926222b2aa..32611b2354 100644
--- a/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx
@@ -53,6 +53,7 @@ const FloatingSidePanelButtons = (props: Props) => {
         direction="column"
         gap={2}
         h={48}
+        zIndex={11}
       >
         <ButtonGroup orientation="vertical" flexGrow={3}>
           <IconButton

From 0787c6c74631576a6a12d2512b7b8541ee3393fb Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 13:12:11 +1000
Subject: [PATCH 39/59] Update invokeai_version.py

---
 invokeai/version/invokeai_version.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/invokeai/version/invokeai_version.py b/invokeai/version/invokeai_version.py
index 7c223b74a7..afcedcd6bb 100644
--- a/invokeai/version/invokeai_version.py
+++ b/invokeai/version/invokeai_version.py
@@ -1 +1 @@
-__version__ = "4.2.0a4"
+__version__ = "4.2.0b1"

From 960eae8255f0891d69a348d045f2014cc9a76acf Mon Sep 17 00:00:00 2001
From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com>
Date: Tue, 30 Apr 2024 09:30:11 -0400
Subject: [PATCH 40/59] Update TRAINING.md

---
 docs/features/TRAINING.md | 276 +-------------------------------------
 1 file changed, 2 insertions(+), 274 deletions(-)

diff --git a/docs/features/TRAINING.md b/docs/features/TRAINING.md
index 7be9aff0f2..47f8557889 100644
--- a/docs/features/TRAINING.md
+++ b/docs/features/TRAINING.md
@@ -4,278 +4,6 @@ title: Training
 
 # :material-file-document: Training
 
-# Textual Inversion Training
-## **Personalizing Text-to-Image Generation**
+Invoke Training has moved to its own repository, with a dedicated UI for accessing common scripts like Textual Inversion and LoRA training.
 
-You may personalize the generated images to provide your own styles or objects
-by training a new LDM checkpoint and introducing a new vocabulary to the fixed
-model as a (.pt) embeddings file. Alternatively, you may use or train
-HuggingFace Concepts embeddings files (.bin) from
-<https://huggingface.co/sd-concepts-library> and its associated
-notebooks.
-
-## **Hardware and Software Requirements**
-
-You will need a GPU to perform training in a reasonable length of
-time, and at least 12 GB of VRAM. We recommend using the [`xformers`
-library](../installation/070_INSTALL_XFORMERS.md) to accelerate the
-training process further. During training, about ~8 GB is temporarily
-needed in order to store intermediate models, checkpoints and logs.
-
-## **Preparing for Training**
-
-To train, prepare a folder that contains 3-5 images that illustrate
-the object or concept. It is good to provide a variety of examples or
-poses to avoid overtraining the system. Format these images as PNG
-(preferred) or JPG. You do not need to resize or crop the images in
-advance, but for more control you may wish to do so.
-
-Place the training images in a directory on the machine InvokeAI runs
-on. We recommend placing them in a subdirectory of the
-`text-inversion-training-data` folder located in the InvokeAI root
-directory, ordinarily `~/invokeai` (Linux/Mac), or
-`C:\Users\your_name\invokeai` (Windows). For example, to create an
-embedding for the "psychedelic" style, you'd place the training images
-into the directory
-`~invokeai/text-inversion-training-data/psychedelic`.
-
-## **Launching Training Using the Console Front End**
-
-InvokeAI 2.3 and higher comes with a text console-based training front
-end. From within the `invoke.sh`/`invoke.bat` Invoke launcher script,
-start training tool selecting choice (3):
-
-```sh
-1 "Generate images with a browser-based interface"
-2 "Explore InvokeAI nodes using a command-line interface"
-3 "Textual inversion training"
-4 "Merge models (diffusers type only)"
-5 "Download and install models"
-6 "Change InvokeAI startup options"
-7 "Re-run the configure script to fix a broken install or to complete a major upgrade"
-8 "Open the developer console"
-9 "Update InvokeAI"
-```
-
-Alternatively, you can select option (8) or from the command line, with the InvokeAI virtual environment active,
-you can then launch the front end with the command `invokeai-ti --gui`.
-
-This will launch a text-based front end that will look like this:
-
-<figure markdown>
-![ti-frontend](../assets/textual-inversion/ti-frontend.png)
-</figure>
-
-The interface is keyboard-based. Move from field to field using
-control-N (^N) to move to the next field and control-P (^P) to the
-previous one. <Tab> and <shift-TAB> work as well. Once a field is
-active, use the cursor keys. In a checkbox group, use the up and down
-cursor keys to move from choice to choice, and <space> to select a
-choice. In a scrollbar, use the left and right cursor keys to increase
-and decrease the value of the scroll. In textfields, type the desired
-values.
-
-The number of parameters may look intimidating, but in most cases the
-predefined defaults work fine. The red circled fields in the above
-illustration are the ones you will adjust most frequently.
-
-### Model Name
-
-This will list all the diffusers models that are currently
-installed. Select the one you wish to use as the basis for your
-embedding. Be aware that if you use a SD-1.X-based model for your
-training, you will only be able to use this embedding with other
-SD-1.X-based models. Similarly, if you train on SD-2.X, you will only
-be able to use the embeddings with models based on SD-2.X.
-
-### Trigger Term
-
-This is the prompt term you will use to trigger the embedding. Type a
-single word or phrase you wish to use as the trigger, example
-"psychedelic" (without angle brackets). Within InvokeAI, you will then
-be able to activate the trigger using the syntax `<psychedelic>`.
-
-### Initializer
-
-This is a single character that is used internally during the training
-process as a placeholder for the trigger term. It defaults to "*" and
-can usually be left alone.
-
-### Resume from last saved checkpoint
-
-As training proceeds, textual inversion will write a series of
-intermediate files that can be used to resume training from where it
-was left off in the case of an interruption. This checkbox will be
-automatically selected if you provide a previously used trigger term
-and at least one checkpoint file is found on disk.
-
-Note that as of 20 January 2023, resume does not seem to be working
-properly due to an issue with the upstream code.
-
-### Data Training Directory
-
-This is the location of the images to be used for training. When you
-select a trigger term like "my-trigger", the frontend will prepopulate
-this field with `~/invokeai/text-inversion-training-data/my-trigger`,
-but you can change the path to wherever you want.
-
-### Output Destination Directory
-
-This is the location of the logs, checkpoint files, and embedding
-files created during training. When you select a trigger term like
-"my-trigger", the frontend will prepopulate this field with
-`~/invokeai/text-inversion-output/my-trigger`, but you can change the
-path to wherever you want.
-
-### Image resolution
-
-The images in the training directory will be automatically scaled to
-the value you use here. For best results, you will want to use the
-same default resolution of the underlying model (512 pixels for
-SD-1.5, 768 for the larger version of SD-2.1).
-
-### Center crop images
-
-If this is selected, your images will be center cropped to make them
-square before resizing them to the desired resolution. Center cropping
-can indiscriminately cut off the top of subjects' heads for portrait
-aspect images, so if you have images like this, you may wish to use a
-photoeditor to manually crop them to a square aspect ratio.
-
-### Mixed precision
-
-Select the floating point precision for the embedding. "no" will
-result in a full 32-bit precision, "fp16" will provide 16-bit
-precision, and "bf16" will provide mixed precision (only available
-when XFormers is used).
-
-### Max training steps
-
-How many steps the training will take before the model converges. Most
-training sets will converge with 2000-3000 steps.
-
-### Batch size
-
-This adjusts how many training images are processed simultaneously in
-each step. Higher values will cause the training process to run more
-quickly, but use more memory. The default size will run with GPUs with
-as little as 12 GB.
-
-### Learning rate
-
-The rate at which the system adjusts its internal weights during
-training. Higher values risk overtraining (getting the same image each
-time), and lower values will take more steps to train a good
-model. The default of 0.0005 is conservative; you may wish to increase
-it to 0.005 to speed up training.
-
-### Scale learning rate by number of GPUs, steps and batch size
-
-If this is selected (the default) the system will adjust the provided
-learning rate to improve performance.
-
-### Use xformers acceleration
-
-This will activate XFormers memory-efficient attention. You need to
-have XFormers installed for this to have an effect.
-
-### Learning rate scheduler
-
-This adjusts how the learning rate changes over the course of
-training. The default "constant" means to use a constant learning rate
-for the entire training session. The other values scale the learning
-rate according to various formulas.
-
-Only "constant" is supported by the XFormers library.
-
-### Gradient accumulation steps
-
-This is a parameter that allows you to use bigger batch sizes than
-your GPU's VRAM would ordinarily accommodate, at the cost of some
-performance.
-
-### Warmup steps
-
-If "constant_with_warmup" is selected in the learning rate scheduler,
-then this provides the number of warmup steps. Warmup steps have a
-very low learning rate, and are one way of preventing early
-overtraining.
-
-## The training run
-
-Start the training run by advancing to the OK button (bottom right)
-and pressing <enter>. A series of progress messages will be displayed
-as the training process proceeds. This may take an hour or two,
-depending on settings and the speed of your system. Various log and
-checkpoint files will be written into the output directory (ordinarily
-`~/invokeai/text-inversion-output/my-model/`)
-
-At the end of successful training, the system will copy the file
-`learned_embeds.bin` into the InvokeAI root directory's `embeddings`
-directory, using a subdirectory named after the trigger token. For
-example, if the trigger token was `psychedelic`, then look for the
-embeddings file in
-`~/invokeai/embeddings/psychedelic/learned_embeds.bin`
-
-You may now launch InvokeAI and try out a prompt that uses the trigger
-term. For example `a plate of banana sushi in <psychedelic> style`.
-
-## **Training with the Command-Line Script**
-
-Training can also be done using a traditional command-line script. It
-can be launched from within the "developer's console", or from the
-command line after activating InvokeAI's virtual environment.
-
-It accepts a large number of arguments, which can be summarized by
-passing the `--help` argument:
-
-```sh
-invokeai-ti --help
-```
-
-Typical usage is shown here:
-```sh
-invokeai-ti \
-       --model=stable-diffusion-1.5 \
-       --resolution=512 \
-       --learnable_property=style \
-       --initializer_token='*' \
-       --placeholder_token='<psychedelic>' \
-       --train_data_dir=/home/lstein/invokeai/training-data/psychedelic \
-       --output_dir=/home/lstein/invokeai/text-inversion-training/psychedelic \
-       --scale_lr \
-       --train_batch_size=8 \
-       --gradient_accumulation_steps=4 \
-       --max_train_steps=3000 \
-       --learning_rate=0.0005 \
-       --resume_from_checkpoint=latest \
-       --lr_scheduler=constant \
-       --mixed_precision=fp16 \
-       --only_save_embeds
-```
-
-## Troubleshooting
-
-### `Cannot load embedding for <trigger>. It was trained on a model with token dimension 1024, but the current model has token dimension 768`
-
-Messages like this indicate you trained the embedding on a different base model than the currently selected one.
-
-For example, in the error above, the training was done on SD2.1 (768x768) but it was used on SD1.5 (512x512).
-
-## Reading
-
-For more information on textual inversion, please see the following
-resources:
-
-* The [textual inversion repository](https://github.com/rinongal/textual_inversion) and
-  associated paper for details and limitations.
-* [HuggingFace's textual inversion training
-  page](https://huggingface.co/docs/diffusers/training/text_inversion)
-* [HuggingFace example script
-  documentation](https://github.com/huggingface/diffusers/tree/main/examples/textual_inversion)
-  (Note that this script is similar to, but not identical, to
-  `textual_inversion`, but produces embed files that are completely compatible.
-
----
-
-copyright (c) 2023, Lincoln Stein and the InvokeAI Development Team
+You can find more by visiting the repo at https://github.com/invoke-ai/invoke-training

From af868b0ea60bd8cdd5b19aa0af4eaa3d020db2f9 Mon Sep 17 00:00:00 2001
From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com>
Date: Tue, 30 Apr 2024 21:45:10 -0400
Subject: [PATCH 41/59] Update 010_INSTALL_AUTOMATED.md

---
 docs/installation/010_INSTALL_AUTOMATED.md | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/docs/installation/010_INSTALL_AUTOMATED.md b/docs/installation/010_INSTALL_AUTOMATED.md
index 5e2db65d7b..9eb8620321 100644
--- a/docs/installation/010_INSTALL_AUTOMATED.md
+++ b/docs/installation/010_INSTALL_AUTOMATED.md
@@ -1,8 +1,10 @@
-# Automatic Install
+# Automatic Install & Updates
 
-The installer is used for both new installs and updates.
+**The same packaged installer file can be used for both new installs and updates.**
+Using the installer for updates will leave everything you've added since installation, and just update the core libraries used to run Invoke.
+Simply use the same path you installed to originally.
 
-Both release and pre-release versions can be installed using it. It also supports install a wheel if needed.
+Both release and pre-release versions can be installed using the installer. It also supports install through a wheel if needed.
 
 Be sure to review the [installation requirements] and ensure your system has everything it needs to install Invoke.
 

From ab87511a0378ab7b33813550b1682836c956822d Mon Sep 17 00:00:00 2001
From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com>
Date: Tue, 30 Apr 2024 21:49:46 -0400
Subject: [PATCH 42/59] Update INSTALLATION.md

---
 docs/installation/INSTALLATION.md | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/docs/installation/INSTALLATION.md b/docs/installation/INSTALLATION.md
index 5c121e6170..267376f197 100644
--- a/docs/installation/INSTALLATION.md
+++ b/docs/installation/INSTALLATION.md
@@ -1,4 +1,4 @@
-# Installation Overview
+# Installation and Updating Overview
 
 Before installing, review the [installation requirements] to ensure your system is set up properly.
 
@@ -6,14 +6,21 @@ See the [FAQ] for frequently-encountered installation issues.
 
 If you need more help, join our [discord] or [create an issue].
 
-<h2>Automatic Install</h2>
+<h2>Automatic Install & Updates </h2>
 
 ✅ The automatic install is the best way to run InvokeAI. Check out the [installation guide] to get started.
 
+⬆️ The same installer is also the best way to update InvokeAI - Simply rerun it for the same folder you installed to.
+
+The installation process simply manages installation for the core libraries & application dependencies that run Invoke.
+Any models, images, or other assets in the Invoke root folder won't be affected by the installation process.
+
 <h2>Manual Install</h2>
 
 If you are familiar with python and want more control over the packages that are installed, you can [install InvokeAI manually via PyPI].
 
+Updates are managed by reinstalling the latest version through PyPi.
+
 <h2>Developer Install</h2>
 
 If you want to contribute to InvokeAI, consult the [developer install guide].

From 3cba53533dd7b84e8e4c1564ef84f37b70e216ea Mon Sep 17 00:00:00 2001
From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com>
Date: Tue, 30 Apr 2024 21:50:12 -0400
Subject: [PATCH 43/59] Update README.md

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index f540e7be75..41de4882ee 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
 
 Invoke is a leading creative engine built to empower professionals and enthusiasts alike. Generate and create stunning visual media using the latest AI-driven technologies. Invoke offers an industry leading web-based UI, and serves as the foundation for multiple commercial products.
 
-[Installation][installation docs] - [Documentation and Tutorials][docs home] - [Bug Reports][github issues] - [Contributing][contributing docs]
+[Installation and Updates][installation docs] - [Documentation and Tutorials][docs home] - [Bug Reports][github issues] - [Contributing][contributing docs]
 
 <div align="center">
 

From af9f0e0963be42be97907f4ed2047dd903f98c64 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 18:43:45 +1000
Subject: [PATCH 44/59] feat(ui): cache control layer mask images

When invoking with control layers, we were creating and uploading the mask images on every enqueue, even when the mask didn't change. The mask image can be cached to greatly reduce the number of uploads.

With this change, we are a bit smarter about the mask images:
- Check if there is an uploaded mask image name
- If so, attempt to retrieve its DTO. Typically it will be in the RTKQ cache, so there is no network request, but it will make a network request if not cached to confirm the image actually exists on the server.
- If we don't have an uploaded mask image name, or the request fails, we go ahead and upload the generated blob
- Update the layer's state with a reference to this uploaded image for next time
- Continue as before

Any time we modify the mask (drawing/erasing, resetting the layer), we invalidate that cached image name (set it to null).

We now only upload images when we need to and generation starts faster.
---
 .../controlLayers/store/controlLayersSlice.ts | 12 +++++
 .../src/features/controlLayers/store/types.ts |  1 +
 .../util/graph/addControlLayersToGraph.ts     | 49 ++++++++++++-------
 3 files changed, 43 insertions(+), 19 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index a1b5e0ebc8..50023b1399 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -86,6 +86,7 @@ const resetLayer = (layer: Layer) => {
     layer.isEnabled = true;
     layer.needsPixelBbox = false;
     layer.bboxNeedsUpdate = false;
+    layer.uploadedMaskImage = null;
     return;
   }
 };
@@ -173,6 +174,7 @@ export const controlLayersSlice = createSlice({
         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.uploadedMaskImage = null;
           layer.needsPixelBbox = false;
         }
       }
@@ -456,6 +458,7 @@ export const controlLayersSlice = createSlice({
           negativePrompt: null,
           ipAdapters: [],
           isSelected: true,
+          uploadedMaskImage: null,
         };
         state.layers.push(layer);
         state.selectedLayerId = layer.id;
@@ -505,6 +508,7 @@ export const controlLayersSlice = createSlice({
           strokeWidth: state.brushSize,
         });
         layer.bboxNeedsUpdate = true;
+        layer.uploadedMaskImage = null;
         if (!layer.needsPixelBbox && tool === 'eraser') {
           layer.needsPixelBbox = true;
         }
@@ -524,6 +528,7 @@ export const controlLayersSlice = createSlice({
       // TODO: Handle this in the event listener
       lastLine.points.push(point[0] - layer.x, point[1] - layer.y);
       layer.bboxNeedsUpdate = true;
+      layer.uploadedMaskImage = null;
     },
     rgLayerRectAdded: {
       reducer: (state, action: PayloadAction<{ layerId: string; rect: IRect; rectUuid: string }>) => {
@@ -543,9 +548,15 @@ export const controlLayersSlice = createSlice({
           height: rect.height,
         });
         layer.bboxNeedsUpdate = true;
+        layer.uploadedMaskImage = null;
       },
       prepare: (payload: { layerId: string; rect: IRect }) => ({ payload: { ...payload, rectUuid: uuidv4() } }),
     },
+    rgLayerMaskImageUploaded: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO }>) => {
+      const { layerId, imageDTO } = action.payload;
+      const layer = selectRGLayerOrThrow(state, layerId);
+      layer.uploadedMaskImage = imageDTOToImageWithDims(imageDTO);
+    },
     rgLayerAutoNegativeChanged: (
       state,
       action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }>
@@ -825,6 +836,7 @@ export const {
   rgLayerLineAdded,
   rgLayerPointsAdded,
   rgLayerRectAdded,
+  rgLayerMaskImageUploaded,
   rgLayerAutoNegativeChanged,
   rgLayerIPAdapterAdded,
   rgLayerIPAdapterDeleted,
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index cbb986bde2..afb04aae37 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -72,6 +72,7 @@ export type RegionalGuidanceLayer = RenderableLayerBase & {
   previewColor: RgbColor;
   autoNegative: ParameterAutoNegative;
   needsPixelBbox: boolean; // Needs the slower pixel-based bbox calculation - set to true when an there is an eraser object
+  uploadedMaskImage: ImageWithDims | null;
 };
 
 export type InitialImageLayer = RenderableLayerBase & {
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts
index da13fed9f5..30c15fae10 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts
@@ -4,7 +4,9 @@ import {
   isControlAdapterLayer,
   isIPAdapterLayer,
   isRegionalGuidanceLayer,
+  rgLayerMaskImageUploaded,
 } from 'features/controlLayers/store/controlLayersSlice';
+import type { RegionalGuidanceLayer } from 'features/controlLayers/store/types';
 import {
   type ControlNetConfigV2,
   type ImageWithDims,
@@ -32,12 +34,13 @@ import {
 } from 'features/nodes/util/graph/constants';
 import { upsertMetadata } from 'features/nodes/util/graph/metadata';
 import { size } from 'lodash-es';
-import { imagesApi } from 'services/api/endpoints/images';
+import { getImageDTO, imagesApi } from 'services/api/endpoints/images';
 import type {
   CollectInvocation,
   ControlNetInvocation,
   CoreMetadataInvocation,
   Edge,
+  ImageDTO,
   IPAdapterInvocation,
   NonNullableGraph,
   S,
@@ -337,7 +340,6 @@ const addGlobalIPAdaptersToGraph = async (
 };
 
 export const addControlLayersToGraph = async (state: RootState, graph: NonNullableGraph, denoiseNodeId: string) => {
-  const { dispatch } = getStore();
   const mainModel = state.generation.model;
   assert(mainModel, 'Missing main model when building graph');
   const isSDXL = mainModel.base === 'sdxl';
@@ -404,10 +406,6 @@ export const addControlLayersToGraph = async (state: RootState, graph: NonNullab
       return hasTextPrompt || hasIPAdapter;
     });
 
-  const layerIds = rgLayers.map((l) => l.id);
-  const blobs = await getRegionalPromptLayerBlobs(layerIds);
-  assert(size(blobs) === size(layerIds), 'Mismatch between layer IDs and blobs');
-
   // TODO: We should probably just use conditioning collectors by default, and skip all this fanagling with re-routing
   // the existing conditioning nodes.
 
@@ -470,22 +468,15 @@ export const addControlLayersToGraph = async (state: RootState, graph: NonNullab
     },
   });
 
-  // Upload the blobs to the backend, add each to graph
-  // TODO: Store the uploaded image names in redux to reuse them, so long as the layer hasn't otherwise changed. This
-  // would be a great perf win - not only would we skip re-uploading the same image, but we'd be able to use the node
-  // cache (currently, when we re-use the same mask data, since it is a different image, the node cache is not used).
+  const layerIds = rgLayers.map((l) => l.id);
+  const blobs = await getRegionalPromptLayerBlobs(layerIds);
+  assert(size(blobs) === size(layerIds), 'Mismatch between layer IDs and blobs');
+
   for (const layer of rgLayers) {
     const blob = blobs[layer.id];
     assert(blob, `Blob for layer ${layer.id} not found`);
-
-    const file = new File([blob], `${layer.id}_mask.png`, { type: 'image/png' });
-    const req = dispatch(
-      imagesApi.endpoints.uploadImage.initiate({ file, image_category: 'mask', is_intermediate: true })
-    );
-    req.reset();
-
-    // TODO: This will raise on network error
-    const { image_name } = await req.unwrap();
+    // Upload the mask image, or get the cached image if it exists
+    const { image_name } = await getMaskImage(layer, blob);
 
     // The main mask-to-tensor node
     const maskToTensorNode: S['AlphaMaskToTensorInvocation'] = {
@@ -679,3 +670,23 @@ export const addControlLayersToGraph = async (state: RootState, graph: NonNullab
     }
   }
 };
+
+const getMaskImage = async (layer: RegionalGuidanceLayer, blob: Blob): Promise<ImageDTO> => {
+  if (layer.uploadedMaskImage) {
+    const imageDTO = await getImageDTO(layer.uploadedMaskImage.imageName);
+    if (imageDTO) {
+      return imageDTO;
+    }
+  }
+  const { dispatch } = getStore();
+  // No cached mask, or the cached image no longer exists - we need to upload the mask image
+  const file = new File([blob], `${layer.id}_mask.png`, { type: 'image/png' });
+  const req = dispatch(
+    imagesApi.endpoints.uploadImage.initiate({ file, image_category: 'mask', is_intermediate: true })
+  );
+  req.reset();
+
+  const imageDTO = await req.unwrap();
+  dispatch(rgLayerMaskImageUploaded({ layerId: layer.id, imageDTO }));
+  return imageDTO;
+};

From be7eeb576b534b45f70f5b50d50732c9a3cfaa95 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 3 May 2024 20:36:31 +1000
Subject: [PATCH 45/59] fix(ui): fix viewer getting stuck when spamming toggle

---
 .../features/gallery/components/ImageViewer/ImageViewer.tsx | 3 ++-
 .../frontend/web/src/features/ui/components/InvokeTabs.tsx  | 6 +++---
 2 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
index 874464f938..949e72fad1 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
@@ -42,8 +42,9 @@ export const ImageViewer = memo(() => {
   useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]);
   useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]);
 
+  // The AnimatePresence mode must be wait - else framer can get confused if you spam the toggle button
   return (
-    <AnimatePresence>
+    <AnimatePresence mode="wait">
       {shouldShowViewer && (
         <Flex
           key="imageViewer"
diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
index 5e37a2d8c8..42df03872c 100644
--- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
@@ -254,11 +254,11 @@ const InvokeTabs = () => {
             />
           </>
         )}
-        <Panel style={{ position: 'relative' }} id="main-panel" order={1} minSize={20}>
-          <TabPanels w="full" h="full">
+        <Panel id="main-panel" order={1} minSize={20}>
+          <TabPanels w="full" h="full" position="relative">
             {tabPanels}
+            <ImageViewer />
           </TabPanels>
-          <ImageViewer />
         </Panel>
         {shouldShowGalleryPanel && (
           <>

From f4dde883cae92001df3f4d1633929f66c7db8ea6 Mon Sep 17 00:00:00 2001
From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com>
Date: Fri, 3 May 2024 22:20:00 +0530
Subject: [PATCH 46/59] feat: improve the switch states of the control layers /
 viewer area

---
 invokeai/frontend/web/public/locales/en.json  |  1 +
 .../components/ImageViewer/EditorButton.tsx   | 30 ++++++++++++-------
 .../components/ImageViewer/ViewerButton.tsx   | 21 +++++++++++--
 3 files changed, 40 insertions(+), 12 deletions(-)

diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index a6d60fa281..826bd8ac01 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -143,6 +143,7 @@
         "alpha": "Alpha",
         "selected": "Selected",
         "viewer": "Viewer",
+        "controlLayers": "Control Layers",
         "tab": "Tab"
     },
     "controlnet": {
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
index 2e10d057f8..2e50d33da7 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
@@ -1,19 +1,21 @@
+import { IconButton } from '@chakra-ui/react';
 import { Button } from '@invoke-ai/ui-library';
 import { useAppSelector } from 'app/store/storeHooks';
 import type { InvokeTabName } from 'features/ui/store/tabMap';
 import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
+import { PiArrowsDownUpBold } from 'react-icons/pi';
 
 import { useImageViewer } from './useImageViewer';
 
-const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
-  generation: 'ui.tabs.generationTab',
-  canvas: 'ui.tabs.canvasTab',
-  workflows: 'ui.tabs.workflowsTab',
-  models: 'ui.tabs.modelsTab',
-  queue: 'ui.tabs.queueTab',
-};
+// const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
+//   generation: 'ui.tabs.generationTab',
+//   canvas: 'ui.tabs.canvasTab',
+//   workflows: 'ui.tabs.workflowsTab',
+//   models: 'ui.tabs.modelsTab',
+//   queue: 'ui.tabs.queueTab',
+// };
 
 const TAB_NAME_TO_TKEY_SHORT: Record<InvokeTabName, string> = {
   generation: 'ui.tabs.generation',
@@ -27,11 +29,19 @@ export const EditorButton = () => {
   const { t } = useTranslation();
   const { onClose } = useImageViewer();
   const activeTabName = useAppSelector(activeTabNameSelector);
-  const tooltip = useMemo(() => t('gallery.switchTo', { tab: t(TAB_NAME_TO_TKEY[activeTabName]) }), [t, activeTabName]);
+
+  const tooltip = useMemo(
+    () =>
+      t('gallery.switchTo', {
+        tab: activeTabName === 'generation' ? t('common.controlLayers') : t(TAB_NAME_TO_TKEY_SHORT[activeTabName]),
+      }),
+    [t, activeTabName]
+  );
 
   return (
-    <Button aria-label={tooltip} tooltip={tooltip} onClick={onClose} variant="ghost">
-      {t(TAB_NAME_TO_TKEY_SHORT[activeTabName])}
+    <Button aria-label={tooltip} tooltip={tooltip} onClick={onClose} variant="outline" sx={{ display: 'flex', gap: 2 }}>
+      <IconButton aria-label={tooltip} variant="ghost" size="sm" icon={<PiArrowsDownUpBold />} />
+      {activeTabName === 'generation' ? t('common.controlLayers') : t(TAB_NAME_TO_TKEY_SHORT[activeTabName])}
     </Button>
   );
 };
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx
index a57ae9d1ee..2492c8cde3 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx
@@ -1,16 +1,33 @@
+import { IconButton } from '@chakra-ui/react';
 import { Button } from '@invoke-ai/ui-library';
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
+import { PiArrowsDownUpBold } from 'react-icons/pi';
 
 import { useImageViewer } from './useImageViewer';
 
 export const ViewerButton = () => {
   const { t } = useTranslation();
   const { onOpen } = useImageViewer();
-  const tooltip = useMemo(() => t('gallery.switchTo', { tab: t('common.viewer') }), [t]);
+
+  const tooltip = useMemo(
+    () =>
+      t('gallery.switchTo', {
+        tab: t('common.viewer'),
+      }),
+    [t]
+  );
 
   return (
-    <Button aria-label={tooltip} tooltip={tooltip} onClick={onOpen} variant="ghost" pointerEvents="auto">
+    <Button
+      aria-label={tooltip}
+      tooltip={tooltip}
+      onClick={onOpen}
+      variant="outline"
+      pointerEvents="auto"
+      sx={{ display: 'flex', gap: 2 }}
+    >
+      <IconButton aria-label={tooltip} variant="ghost" size="sm" icon={<PiArrowsDownUpBold />} />
       {t('common.viewer')}
     </Button>
   );

From 68d1458c8356303a2dcdd9772bba53eb0cbba0f8 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 08:37:59 +1000
Subject: [PATCH 47/59] fix(ui): address feedback

---
 invokeai/frontend/web/public/locales/en.json  |  3 +-
 .../components/ImageViewer/EditorButton.tsx   | 28 +++++++------------
 .../components/ImageViewer/ViewerButton.tsx   | 13 ++-------
 3 files changed, 13 insertions(+), 31 deletions(-)

diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 826bd8ac01..37a2a7a5da 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -143,7 +143,6 @@
         "alpha": "Alpha",
         "selected": "Selected",
         "viewer": "Viewer",
-        "controlLayers": "Control Layers",
         "tab": "Tab"
     },
     "controlnet": {
@@ -1535,7 +1534,7 @@
         "moveForward": "Move Forward",
         "moveBackward": "Move Backward",
         "brushSize": "Brush Size",
-        "controlLayers": "Control Layers (BETA)",
+        "controlLayers": "Control Layers",
         "globalMaskOpacity": "Global Mask Opacity",
         "autoNegative": "Auto Negative",
         "toggleVisibility": "Toggle Layer Visibility",
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
index 2e50d33da7..cc2aa8c543 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx
@@ -1,4 +1,3 @@
-import { IconButton } from '@chakra-ui/react';
 import { Button } from '@invoke-ai/ui-library';
 import { useAppSelector } from 'app/store/storeHooks';
 import type { InvokeTabName } from 'features/ui/store/tabMap';
@@ -9,16 +8,8 @@ import { PiArrowsDownUpBold } from 'react-icons/pi';
 
 import { useImageViewer } from './useImageViewer';
 
-// const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
-//   generation: 'ui.tabs.generationTab',
-//   canvas: 'ui.tabs.canvasTab',
-//   workflows: 'ui.tabs.workflowsTab',
-//   models: 'ui.tabs.modelsTab',
-//   queue: 'ui.tabs.queueTab',
-// };
-
 const TAB_NAME_TO_TKEY_SHORT: Record<InvokeTabName, string> = {
-  generation: 'ui.tabs.generation',
+  generation: 'controlLayers.controlLayers',
   canvas: 'ui.tabs.canvas',
   workflows: 'ui.tabs.workflows',
   models: 'ui.tabs.models',
@@ -29,19 +20,20 @@ export const EditorButton = () => {
   const { t } = useTranslation();
   const { onClose } = useImageViewer();
   const activeTabName = useAppSelector(activeTabNameSelector);
-
   const tooltip = useMemo(
-    () =>
-      t('gallery.switchTo', {
-        tab: activeTabName === 'generation' ? t('common.controlLayers') : t(TAB_NAME_TO_TKEY_SHORT[activeTabName]),
-      }),
+    () => t('gallery.switchTo', { tab: t(TAB_NAME_TO_TKEY_SHORT[activeTabName]) }),
     [t, activeTabName]
   );
 
   return (
-    <Button aria-label={tooltip} tooltip={tooltip} onClick={onClose} variant="outline" sx={{ display: 'flex', gap: 2 }}>
-      <IconButton aria-label={tooltip} variant="ghost" size="sm" icon={<PiArrowsDownUpBold />} />
-      {activeTabName === 'generation' ? t('common.controlLayers') : t(TAB_NAME_TO_TKEY_SHORT[activeTabName])}
+    <Button
+      aria-label={tooltip}
+      tooltip={tooltip}
+      onClick={onClose}
+      variant="outline"
+      leftIcon={<PiArrowsDownUpBold />}
+    >
+      {t(TAB_NAME_TO_TKEY_SHORT[activeTabName])}
     </Button>
   );
 };
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx
index 2492c8cde3..edceb5099c 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx
@@ -1,4 +1,3 @@
-import { IconButton } from '@chakra-ui/react';
 import { Button } from '@invoke-ai/ui-library';
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
@@ -9,14 +8,7 @@ import { useImageViewer } from './useImageViewer';
 export const ViewerButton = () => {
   const { t } = useTranslation();
   const { onOpen } = useImageViewer();
-
-  const tooltip = useMemo(
-    () =>
-      t('gallery.switchTo', {
-        tab: t('common.viewer'),
-      }),
-    [t]
-  );
+  const tooltip = useMemo(() => t('gallery.switchTo', { tab: t('common.viewer') }), [t]);
 
   return (
     <Button
@@ -25,9 +17,8 @@ export const ViewerButton = () => {
       onClick={onOpen}
       variant="outline"
       pointerEvents="auto"
-      sx={{ display: 'flex', gap: 2 }}
+      leftIcon={<PiArrowsDownUpBold />}
     >
-      <IconButton aria-label={tooltip} variant="ghost" size="sm" icon={<PiArrowsDownUpBold />} />
       {t('common.viewer')}
     </Button>
   );

From 4beccea6e7008cb55810229d0d47affbf12525c9 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 09:02:27 +1000
Subject: [PATCH 48/59] fix(ui): do not run HRO if using an initial image

---
 .../nodes/util/graph/addInitialImageToLinearGraph.ts     | 9 +++++++--
 .../features/nodes/util/graph/buildGenerationTabGraph.ts | 4 ++--
 2 files changed, 9 insertions(+), 4 deletions(-)

diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts
index 603708f15b..eae45acc5b 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts
@@ -6,11 +6,14 @@ import { assert } from 'tsafe';
 
 import { IMAGE_TO_LATENTS, NOISE, RESIZE } from './constants';
 
+/**
+ * Returns true if an initial image was added, false if not.
+ */
 export const addInitialImageToLinearGraph = (
   state: RootState,
   graph: NonNullableGraph,
   denoiseNodeId: string
-): void => {
+): boolean => {
   // Remove Existing UNet Connections
   const { img2imgStrength, vaePrecision, model } = state.generation;
   const { refinerModel, refinerStart } = state.sdxl;
@@ -19,7 +22,7 @@ export const addInitialImageToLinearGraph = (
   const initialImage = initialImageLayer?.isEnabled ? initialImageLayer?.image : null;
 
   if (!initialImage) {
-    return;
+    return false;
   }
 
   const isSDXL = model?.base === 'sdxl';
@@ -122,4 +125,6 @@ export const addInitialImageToLinearGraph = (
     strength: img2imgStrength,
     init_image: initialImage.imageName,
   });
+
+  return true;
 };
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabGraph.ts
index 6c04b25770..41f9f4f748 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabGraph.ts
@@ -232,7 +232,7 @@ export const buildGenerationTabGraph = async (state: RootState): Promise<NonNull
     LATENTS_TO_IMAGE
   );
 
-  addInitialImageToLinearGraph(state, graph, DENOISE_LATENTS);
+  const didAddInitialImage = addInitialImageToLinearGraph(state, graph, DENOISE_LATENTS);
 
   // Add Seamless To Graph
   if (seamlessXAxis || seamlessYAxis) {
@@ -249,7 +249,7 @@ export const buildGenerationTabGraph = async (state: RootState): Promise<NonNull
   await addControlLayersToGraph(state, graph, DENOISE_LATENTS);
 
   // High resolution fix.
-  if (state.hrf.hrfEnabled) {
+  if (state.hrf.hrfEnabled && !didAddInitialImage) {
     addHrfToGraph(state, graph);
   }
 

From 2888845f7c9464bac02c5842b0c14ed3e780bfd6 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 09:07:24 +1000
Subject: [PATCH 49/59] fix(ui): invalidate mask cache when moving layer

---
 .../web/src/features/controlLayers/store/controlLayersSlice.ts | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index 50023b1399..8adbae6d80 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -164,6 +164,9 @@ export const controlLayersSlice = createSlice({
         layer.x = x;
         layer.y = y;
       }
+      if (isRegionalGuidanceLayer(layer)) {
+        layer.uploadedMaskImage = null;
+      }
     },
     layerBboxChanged: (state, action: PayloadAction<{ layerId: string; bbox: IRect | null }>) => {
       const { layerId, bbox } = action.payload;

From 6d2fe3b691d67ffde33b1d612b37ad5b949019d1 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 09:08:45 +1000
Subject: [PATCH 50/59] tidy(ui): clean up layer reset logic

---
 .../controlLayers/components/ToolChooser.tsx  | 10 +++++--
 .../controlLayers/store/controlLayersSlice.ts | 28 ++++++-------------
 2 files changed, 15 insertions(+), 23 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx
index 53535b4248..f97a0f35e5 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx
@@ -4,9 +4,9 @@ import { createSelector } from '@reduxjs/toolkit';
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
 import {
   $tool,
+  layerReset,
   selectControlLayersSlice,
   selectedLayerDeleted,
-  selectedLayerReset,
 } from 'features/controlLayers/store/controlLayersSlice';
 import { useCallback } from 'react';
 import { useHotkeys } from 'react-hotkeys-hook';
@@ -22,6 +22,7 @@ export const ToolChooser: React.FC = () => {
   const { t } = useTranslation();
   const dispatch = useAppDispatch();
   const isDisabled = useAppSelector(selectIsDisabled);
+  const selectedLayerId = useAppSelector((s) => s.controlLayers.present.selectedLayerId);
   const tool = useStore($tool);
 
   const setToolToBrush = useCallback(() => {
@@ -42,8 +43,11 @@ export const ToolChooser: React.FC = () => {
   useHotkeys('v', setToolToMove, { enabled: !isDisabled }, [isDisabled]);
 
   const resetSelectedLayer = useCallback(() => {
-    dispatch(selectedLayerReset());
-  }, [dispatch]);
+    if (selectedLayerId === null) {
+      return;
+    }
+    dispatch(layerReset(selectedLayerId));
+  }, [dispatch, selectedLayerId]);
   useHotkeys('shift+c', resetSelectedLayer);
 
   const deleteSelectedLayer = useCallback(() => {
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index 8adbae6d80..f6a6f0b38d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -79,17 +79,6 @@ export const isRenderableLayer = (
   layer?.type === 'regional_guidance_layer' ||
   layer?.type === 'control_adapter_layer' ||
   layer?.type === 'initial_image_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;
-    layer.uploadedMaskImage = null;
-    return;
-  }
-};
 
 export const selectCALayerOrThrow = (state: ControlLayersState, layerId: string): ControlAdapterLayer => {
   const layer = state.layers.find((l) => l.id === layerId);
@@ -184,8 +173,14 @@ export const controlLayersSlice = createSlice({
     },
     layerReset: (state, action: PayloadAction<string>) => {
       const layer = state.layers.find((l) => l.id === action.payload);
-      if (layer) {
-        resetLayer(layer);
+      // TODO(psyche): Should other layer types also have reset functionality?
+      if (isRegionalGuidanceLayer(layer)) {
+        layer.maskObjects = [];
+        layer.bbox = null;
+        layer.isEnabled = true;
+        layer.needsPixelBbox = false;
+        layer.bboxNeedsUpdate = false;
+        layer.uploadedMaskImage = null;
       }
     },
     layerDeleted: (state, action: PayloadAction<string>) => {
@@ -218,12 +213,6 @@ export const controlLayersSlice = createSlice({
       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;
@@ -806,7 +795,6 @@ export const {
   layerMovedToFront,
   layerMovedBackward,
   layerMovedToBack,
-  selectedLayerReset,
   selectedLayerDeleted,
   allLayersDeleted,
   // CA Layers

From 26613f10c724a7b4520a0a95bb387169aab22a4e Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 09:52:41 +1000
Subject: [PATCH 51/59] feat(ui): close viewer when user switches tabs

---
 .../frontend/web/src/features/gallery/store/gallerySlice.ts   | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
index 373d946469..60ed692ba2 100644
--- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
@@ -1,6 +1,7 @@
 import type { PayloadAction } from '@reduxjs/toolkit';
 import { createSlice, isAnyOf } from '@reduxjs/toolkit';
 import type { PersistConfig, RootState } from 'app/store/store';
+import { setActiveTab } from 'features/ui/store/uiSlice';
 import { uniqBy } from 'lodash-es';
 import { boardsApi } from 'services/api/endpoints/boards';
 import { imagesApi } from 'services/api/endpoints/images';
@@ -83,6 +84,9 @@ export const gallerySlice = createSlice({
     },
   },
   extraReducers: (builder) => {
+    builder.addCase(setActiveTab, (state) => {
+      state.isImageViewerOpen = false;
+    });
     builder.addMatcher(isAnyBoardDeleted, (state, action) => {
       const deletedBoardId = action.meta.arg.originalArgs;
       if (deletedBoardId === state.selectedBoardId) {

From 6bdded85dafbe73be73120c9457eb40cd0c6ef10 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 10:05:42 +1000
Subject: [PATCH 52/59] fix(ui): do not auto-hide next/prev image buttons

---
 .../components/ImageViewer/CurrentImagePreview.tsx       | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
index 37fada0b78..35abf07965 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
@@ -52,17 +52,20 @@ const CurrentImagePreview = () => {
   // Show and hide the next/prev buttons on mouse move
   const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState<boolean>(false);
   const timeoutId = useRef(0);
-  const onMouseMove = useCallback(() => {
+  const onMouseOver = useCallback(() => {
     setShouldShowNextPrevButtons(true);
     window.clearTimeout(timeoutId.current);
+  }, []);
+  const onMouseOut = useCallback(() => {
     timeoutId.current = window.setTimeout(() => {
       setShouldShowNextPrevButtons(false);
-    }, 1000);
+    }, 500);
   }, []);
 
   return (
     <Flex
-      onMouseMove={onMouseMove}
+      onMouseOver={onMouseOver}
+      onMouseOut={onMouseOut}
       width="full"
       height="full"
       alignItems="center"

From 8794b99d51b9361174ad00d2098ecc8fda6fbb4a Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 10:16:00 +1000
Subject: [PATCH 53/59] fix(ui): save upscaled images to gallery on canvas tab

---
 .../src/features/nodes/util/graph/buildAdHocUpscaleGraph.ts   | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildAdHocUpscaleGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildAdHocUpscaleGraph.ts
index 52c09b1db0..6c90dafd25 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildAdHocUpscaleGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildAdHocUpscaleGraph.ts
@@ -1,5 +1,5 @@
 import type { RootState } from 'app/store/store';
-import { getBoardField, getIsIntermediate } from 'features/nodes/util/graph/graphBuilderUtils';
+import { getBoardField } from 'features/nodes/util/graph/graphBuilderUtils';
 import type { ESRGANInvocation, Graph, NonNullableGraph } from 'services/api/types';
 
 import { ESRGAN } from './constants';
@@ -18,7 +18,7 @@ export const buildAdHocUpscaleGraph = ({ image_name, state }: Arg): Graph => {
     type: 'esrgan',
     image: { image_name },
     model_name: esrganModelName,
-    is_intermediate: getIsIntermediate(state),
+    is_intermediate: false,
     board: getBoardField(state),
   };
 

From 5cb1ff8679c075be0e2286cb1ab6de5db2d9e528 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 14:42:29 +1000
Subject: [PATCH 54/59] fix(ui): open viewer on image click, not select

---
 .../listenerMiddleware/listeners/galleryImageClicked.ts        | 3 ++-
 .../frontend/web/src/features/gallery/store/gallerySlice.ts    | 2 --
 2 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts
index 67c6d076ee..6b8c9b4ea3 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts
@@ -1,7 +1,7 @@
 import { createAction } from '@reduxjs/toolkit';
 import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
 import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
-import { selectionChanged } from 'features/gallery/store/gallerySlice';
+import { isImageViewerOpenChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
 import { imagesApi } from 'services/api/endpoints/images';
 import type { ImageDTO } from 'services/api/types';
 import { imagesSelectors } from 'services/api/util';
@@ -62,6 +62,7 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen
       } else {
         dispatch(selectionChanged([imageDTO]));
       }
+      dispatch(isImageViewerOpenChanged(true));
     },
   });
 };
diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
index 60ed692ba2..5248977825 100644
--- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
@@ -31,11 +31,9 @@ export const gallerySlice = createSlice({
   reducers: {
     imageSelected: (state, action: PayloadAction<ImageDTO | null>) => {
       state.selection = action.payload ? [action.payload] : [];
-      state.isImageViewerOpen = true;
     },
     selectionChanged: (state, action: PayloadAction<ImageDTO[]>) => {
       state.selection = uniqBy(action.payload, (i) => i.image_name);
-      state.isImageViewerOpen = true;
     },
     shouldAutoSwitchChanged: (state, action: PayloadAction<boolean>) => {
       state.shouldAutoSwitch = action.payload;

From 7ca613d41cc7d930ea9a61ea97e24fa1e4bf0f02 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 18:41:14 +1000
Subject: [PATCH 55/59] feat(ui): snap cursor pos when drawing rects

- Rects snap to stage edge when within a threshold (10 screen pixels)
- When mouse leaves stage, set last mousedown pos to null, preventing nonfunctional rect outlines

Partially addresses #6306.

There's a technical challenge to fully address the issue - mouse event are not fired when the mouse is outside the stage. While we could draw the rect even if the mouse leaves, we cannot update the rect's dimensions on mouse move, or complete the drawing on mouse up.

To fully address the issue, we'd need to a way to forward window events back to the stage, or at least handle window events. We can explore this later.
---
 .../controlLayers/hooks/mouseEventHooks.ts    | 39 ++++++++++++++++---
 .../features/controlLayers/util/renderers.ts  | 11 +++---
 2 files changed, 39 insertions(+), 11 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
index 889d2c0c2e..03595fb82d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
@@ -22,10 +22,33 @@ const getIsFocused = (stage: Konva.Stage) => {
 };
 const getIsMouseDown = (e: KonvaEventObject<MouseEvent>) => e.evt.buttons === 1;
 
+const SNAP_PX = 10;
+
+export const snapPosToStage = (pos: Vector2d, stage: Konva.Stage) => {
+  const snappedPos = { ...pos };
+  // Get the normalized threshold for snapping to the edge of the stage
+  const thresholdX = SNAP_PX / stage.scaleX();
+  const thresholdY = SNAP_PX / stage.scaleY();
+  const stageWidth = stage.width() / stage.scaleX();
+  const stageHeight = stage.height() / stage.scaleY();
+  // Snap to the edge of the stage if within threshold
+  if (pos.x - thresholdX < 0) {
+    snappedPos.x = 0;
+  } else if (pos.x + thresholdX > stageWidth) {
+    snappedPos.x = Math.floor(stageWidth);
+  }
+  if (pos.y - thresholdY < 0) {
+    snappedPos.y = 0;
+  } else if (pos.y + thresholdY > stageHeight) {
+    snappedPos.y = Math.floor(stageHeight);
+  }
+  return snappedPos;
+};
+
 export const getScaledFlooredCursorPosition = (stage: Konva.Stage) => {
   const pointerPosition = stage.getPointerPosition();
   const stageTransform = stage.getAbsoluteTransform().copy();
-  if (!pointerPosition || !stageTransform) {
+  if (!pointerPosition) {
     return;
   }
   const scaledCursorPosition = stageTransform.invert().point(pointerPosition);
@@ -71,7 +94,6 @@ export const useMouseEvents = () => {
       if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
         return;
       }
-      $lastMouseDownPos.set(pos);
       if (tool === 'brush' || tool === 'eraser') {
         dispatch(
           rgLayerLineAdded({
@@ -81,6 +103,9 @@ export const useMouseEvents = () => {
           })
         );
         $isDrawing.set(true);
+        $lastMouseDownPos.set(pos);
+      } else if (tool === 'rect') {
+        $lastMouseDownPos.set(snapPosToStage(pos, stage));
       }
     },
     [dispatch, selectedLayerId, selectedLayerType, tool]
@@ -99,14 +124,15 @@ export const useMouseEvents = () => {
       const lastPos = $lastMouseDownPos.get();
       const tool = $tool.get();
       if (lastPos && selectedLayerId && tool === 'rect') {
+        const snappedPos = snapPosToStage(pos, stage);
         dispatch(
           rgLayerRectAdded({
             layerId: selectedLayerId,
             rect: {
-              x: Math.min(pos.x, lastPos.x),
-              y: Math.min(pos.y, lastPos.y),
-              width: Math.abs(pos.x - lastPos.x),
-              height: Math.abs(pos.y - lastPos.y),
+              x: Math.min(snappedPos.x, lastPos.x),
+              y: Math.min(snappedPos.y, lastPos.y),
+              width: Math.abs(snappedPos.x - lastPos.x),
+              height: Math.abs(snappedPos.y - lastPos.y),
             },
           })
         );
@@ -163,6 +189,7 @@ export const useMouseEvents = () => {
       }
       $isDrawing.set(false);
       $cursorPosition.set(null);
+      $lastMouseDownPos.set(null);
     },
     [selectedLayerId, selectedLayerType, tool, dispatch]
   );
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
index 36083d2d92..9f24232240 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
@@ -1,6 +1,6 @@
 import { getStore } from 'app/store/nanostores/store';
 import { rgbaColorToString, rgbColorToString } from 'features/canvas/util/colorToString';
-import { getScaledFlooredCursorPosition } from 'features/controlLayers/hooks/mouseEventHooks';
+import { getScaledFlooredCursorPosition, snapPosToStage } from 'features/controlLayers/hooks/mouseEventHooks';
 import {
   $tool,
   BACKGROUND_LAYER_ID,
@@ -211,12 +211,13 @@ const renderToolPreview = (
   }
 
   if (cursorPos && lastMouseDownPos && tool === 'rect') {
+    const snappedPos = snapPosToStage(cursorPos, stage);
     const rectPreview = toolPreviewLayer.findOne<Konva.Rect>(`#${TOOL_PREVIEW_RECT_ID}`);
     rectPreview?.setAttrs({
-      x: Math.min(cursorPos.x, lastMouseDownPos.x),
-      y: Math.min(cursorPos.y, lastMouseDownPos.y),
-      width: Math.abs(cursorPos.x - lastMouseDownPos.x),
-      height: Math.abs(cursorPos.y - lastMouseDownPos.y),
+      x: Math.min(snappedPos.x, lastMouseDownPos.x),
+      y: Math.min(snappedPos.y, lastMouseDownPos.y),
+      width: Math.abs(snappedPos.x - lastMouseDownPos.x),
+      height: Math.abs(snappedPos.y - lastMouseDownPos.y),
     });
     rectPreview?.visible(true);
   } else {

From ac0b9ba290cfe96597936a470a48066c63798ae4 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 19:01:18 +1000
Subject: [PATCH 56/59] tidy(ui): `$cursorPosition` -> `$lastCursorPos`

---
 .../features/controlLayers/components/StageComponent.tsx  | 8 ++++----
 .../src/features/controlLayers/hooks/mouseEventHooks.ts   | 8 ++++----
 .../features/controlLayers/store/controlLayersSlice.ts    | 2 +-
 3 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
index c66c15d61b..d0d693a5f2 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
@@ -6,7 +6,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
 import { useMouseEvents } from 'features/controlLayers/hooks/mouseEventHooks';
 import {
-  $cursorPosition,
+  $lastCursorPos,
   $lastMouseDownPos,
   $tool,
   isRegionalGuidanceLayer,
@@ -48,7 +48,7 @@ const useStageRenderer = (
   const state = useAppSelector((s) => s.controlLayers.present);
   const tool = useStore($tool);
   const mouseEventHandlers = useMouseEvents();
-  const cursorPosition = useStore($cursorPosition);
+  const lastCursorPos = useStore($lastCursorPos);
   const lastMouseDownPos = useStore($lastMouseDownPos);
   const selectedLayerIdColor = useAppSelector(selectSelectedLayerColor);
   const selectedLayerType = useAppSelector(selectSelectedLayerType);
@@ -141,7 +141,7 @@ const useStageRenderer = (
       selectedLayerIdColor,
       selectedLayerType,
       state.globalMaskLayerOpacity,
-      cursorPosition,
+      lastCursorPos,
       lastMouseDownPos,
       state.brushSize
     );
@@ -152,7 +152,7 @@ const useStageRenderer = (
     selectedLayerIdColor,
     selectedLayerType,
     state.globalMaskLayerOpacity,
-    cursorPosition,
+    lastCursorPos,
     lastMouseDownPos,
     state.brushSize,
     renderers,
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
index 03595fb82d..b9716ba217 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
@@ -3,8 +3,8 @@ import { useStore } from '@nanostores/react';
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
 import { calculateNewBrushSize } from 'features/canvas/hooks/useCanvasZoom';
 import {
-  $cursorPosition,
   $isDrawing,
+  $lastCursorPos,
   $lastMouseDownPos,
   $tool,
   brushSizeChanged,
@@ -63,7 +63,7 @@ const syncCursorPos = (stage: Konva.Stage): Vector2d | null => {
   if (!pos) {
     return null;
   }
-  $cursorPosition.set(pos);
+  $lastCursorPos.set(pos);
   return pos;
 };
 
@@ -117,7 +117,7 @@ export const useMouseEvents = () => {
       if (!stage) {
         return;
       }
-      const pos = $cursorPosition.get();
+      const pos = $lastCursorPos.get();
       if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
         return;
       }
@@ -188,7 +188,7 @@ export const useMouseEvents = () => {
         dispatch(rgLayerPointsAdded({ layerId: selectedLayerId, point: [pos.x, pos.y] }));
       }
       $isDrawing.set(false);
-      $cursorPosition.set(null);
+      $lastCursorPos.set(null);
       $lastMouseDownPos.set(null);
     },
     [selectedLayerId, selectedLayerType, tool, dispatch]
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index f6a6f0b38d..fc1887f425 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -866,7 +866,7 @@ const migrateControlLayersState = (state: any): any => {
 export const $isDrawing = atom(false);
 export const $lastMouseDownPos = atom<Vector2d | null>(null);
 export const $tool = atom<Tool>('brush');
-export const $cursorPosition = atom<Vector2d | null>(null);
+export const $lastCursorPos = atom<Vector2d | null>(null);
 
 // IDs for singleton Konva layers and objects
 export const TOOL_PREVIEW_LAYER_ID = 'tool_preview_layer';

From 806a8f69c582dcb4c4ba54d73ad09ff4b2a64f2a Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 21:34:51 +1000
Subject: [PATCH 57/59] perf(ui): rerender of opacity sliders

---
 .../web/src/features/controlLayers/hooks/layerStateHooks.ts    | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts
index b4880d1dc6..f2054779d4 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts
@@ -1,4 +1,5 @@
 import { createSelector } from '@reduxjs/toolkit';
+import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
 import { useAppSelector } from 'app/store/storeHooks';
 import {
   isControlAdapterLayer,
@@ -69,7 +70,7 @@ export const useLayerType = (layerId: string) => {
 export const useLayerOpacity = (layerId: string) => {
   const selectLayer = useMemo(
     () =>
-      createSelector(selectControlLayersSlice, (controlLayers) => {
+      createMemoizedSelector(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 };

From b5b6a96d9465b96013859cd4b5a1bce6e278e142 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 23:41:11 +1000
Subject: [PATCH 58/59] feat(ui): dynamic brush spacing

Scaled to 10% of brush size, clamped between 5px and 15px. This makes drawing feel a bit smoother, but maintains reasonable performance.
---
 .../controlLayers/hooks/mouseEventHooks.ts        | 15 +++++++++++----
 1 file changed, 11 insertions(+), 4 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
index b9716ba217..8f69c165ca 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
@@ -15,7 +15,8 @@ import {
 import type Konva from 'konva';
 import type { KonvaEventObject } from 'konva/lib/Node';
 import type { Vector2d } from 'konva/lib/types';
-import { useCallback, useRef } from 'react';
+import { clamp } from 'lodash-es';
+import { useCallback, useMemo, useRef } from 'react';
 
 const getIsFocused = (stage: Konva.Stage) => {
   return stage.container().contains(document.activeElement);
@@ -67,7 +68,9 @@ const syncCursorPos = (stage: Konva.Stage): Vector2d | null => {
   return pos;
 };
 
-const BRUSH_SPACING = 20;
+const BRUSH_SPACING_PCT = 10;
+const MIN_BRUSH_SPACING_PX = 5;
+const MAX_BRUSH_SPACING_PX = 15;
 
 export const useMouseEvents = () => {
   const dispatch = useAppDispatch();
@@ -83,6 +86,10 @@ export const useMouseEvents = () => {
   const lastCursorPosRef = useRef<[number, number] | null>(null);
   const shouldInvertBrushSizeScrollDirection = useAppSelector((s) => s.canvas.shouldInvertBrushSizeScrollDirection);
   const brushSize = useAppSelector((s) => s.controlLayers.present.brushSize);
+  const brushSpacingPx = useMemo(
+    () => clamp(brushSize / BRUSH_SPACING_PCT, MIN_BRUSH_SPACING_PX, MAX_BRUSH_SPACING_PX),
+    [brushSize]
+  );
 
   const onMouseDown = useCallback(
     (e: KonvaEventObject<MouseEvent>) => {
@@ -158,7 +165,7 @@ export const useMouseEvents = () => {
           // Continue the last line
           if (lastCursorPosRef.current) {
             // Dispatching redux events impacts perf substantially - using brush spacing keeps dispatches to a reasonable number
-            if (Math.hypot(lastCursorPosRef.current[0] - pos.x, lastCursorPosRef.current[1] - pos.y) < BRUSH_SPACING) {
+            if (Math.hypot(lastCursorPosRef.current[0] - pos.x, lastCursorPosRef.current[1] - pos.y) < brushSpacingPx) {
               return;
             }
           }
@@ -171,7 +178,7 @@ export const useMouseEvents = () => {
         $isDrawing.set(true);
       }
     },
-    [dispatch, selectedLayerId, selectedLayerType, tool]
+    [brushSpacingPx, dispatch, selectedLayerId, selectedLayerType, tool]
   );
 
   const onMouseLeave = useCallback(

From e4a640f0a761150ca91db4250453697c47270805 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Sat, 4 May 2024 23:57:42 +1000
Subject: [PATCH 59/59] feat(ui): optimized rendering of selected layer

Instead of caching on every stroke, we can use a compositing rect when the layer is being drawn to improve performance.
---
 .../controlLayers/store/controlLayersSlice.ts |  1 +
 .../src/features/controlLayers/util/bbox.ts   |  2 +-
 .../features/controlLayers/util/renderers.ts  | 55 +++++++++++++++++--
 3 files changed, 52 insertions(+), 6 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index fc1887f425..1ef90ead3a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -889,6 +889,7 @@ export const RG_LAYER_RECT_NAME = 'regional_guidance_layer.rect';
 export const INITIAL_IMAGE_LAYER_NAME = 'initial_image_layer';
 export const INITIAL_IMAGE_LAYER_IMAGE_NAME = 'initial_image_layer.image';
 export const LAYER_BBOX_NAME = 'layer.bbox';
+export const COMPOSITING_RECT_NAME = 'compositing-rect';
 
 // Getters for non-singleton layer and object IDs
 const getRGLayerId = (layerId: string) => `${RG_LAYER_NAME}_${layerId}`;
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts b/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts
index a4c7be6886..72aefe1eb4 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts
@@ -123,7 +123,7 @@ export const getLayerBboxPixels = (layer: KonvaLayerType, preview: boolean = fal
   return correctedLayerBbox;
 };
 
-export const getLayerBboxFast = (layer: KonvaLayerType): IRect | null => {
+export const getLayerBboxFast = (layer: KonvaLayerType): IRect => {
   const bbox = layer.getClientRect(GET_CLIENT_RECT_CONFIG);
   return {
     x: Math.floor(bbox.x),
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
index 9f24232240..f58b1e3b74 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
@@ -7,6 +7,7 @@ import {
   BACKGROUND_RECT_ID,
   CA_LAYER_IMAGE_NAME,
   CA_LAYER_NAME,
+  COMPOSITING_RECT_NAME,
   getCALayerImageId,
   getIILayerImageId,
   getLayerBboxId,
@@ -324,6 +325,12 @@ const createVectorMaskRect = (reduxObject: VectorMaskRect, konvaGroup: Konva.Gro
   return vectorMaskRect;
 };
 
+const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => {
+  const compositingRect = new Konva.Rect({ name: COMPOSITING_RECT_NAME, listening: false });
+  konvaLayer.add(compositingRect);
+  return compositingRect;
+};
+
 /**
  * Renders a vector mask layer.
  * @param stage The konva stage to render on.
@@ -401,15 +408,53 @@ const renderRegionalGuidanceLayer = (
     groupNeedsCache = true;
   }
 
-  if (konvaObjectGroup.children.length === 0) {
+  if (konvaObjectGroup.getChildren().length === 0) {
     // No objects - clear the cache to reset the previous pixel data
     konvaObjectGroup.clearCache();
-  } else if (groupNeedsCache) {
-    konvaObjectGroup.cache();
+    return;
   }
 
-  // Updating group opacity does not require re-caching
-  if (konvaObjectGroup.opacity() !== globalMaskLayerOpacity) {
+  const compositingRect =
+    konvaLayer.findOne<Konva.Rect>(`.${COMPOSITING_RECT_NAME}`) ?? createCompositingRect(konvaLayer);
+
+  /**
+   * When the group is selected, we use a rect of the selected preview color, composited over the shapes. This allows
+   * shapes to render as a "raster" layer with all pixels drawn at the same color and opacity.
+   *
+   * Without this special handling, each shape is drawn individually with the given opacity, atop the other shapes. The
+   * effect is like if you have a Photoshop Group consisting of many shapes, each of which has the given opacity.
+   * Overlapping shapes will have their colors blended together, and the final color is the result of all the shapes.
+   *
+   * Instead, with the special handling, the effect is as if you drew all the shapes at 100% opacity, flattened them to
+   * a single raster image, and _then_ applied the 50% opacity.
+   */
+  if (reduxLayer.isSelected && tool !== 'move') {
+    // We must clear the cache first so Konva will re-draw the group with the new compositing rect
+    if (konvaObjectGroup.isCached()) {
+      konvaObjectGroup.clearCache();
+    }
+    // The user is allowed to reduce mask opacity to 0, but we need the opacity for the compositing rect to work
+    konvaObjectGroup.opacity(1);
+
+    compositingRect.setAttrs({
+      // The rect should be the size of the layer - use the fast method bc it's OK if the rect is larger
+      ...getLayerBboxFast(konvaLayer),
+      fill: rgbColor,
+      opacity: globalMaskLayerOpacity,
+      // Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes)
+      globalCompositeOperation: 'source-in',
+      visible: true,
+      // This rect must always be on top of all other shapes
+      zIndex: konvaObjectGroup.getChildren().length,
+    });
+  } else {
+    // The compositing rect should only be shown when the layer is selected.
+    compositingRect.visible(false);
+    // Cache only if needed - or if we are on this code path and _don't_ have a cache
+    if (groupNeedsCache || !konvaObjectGroup.isCached()) {
+      konvaObjectGroup.cache();
+    }
+    // Updating group opacity does not require re-caching
     konvaObjectGroup.opacity(globalMaskLayerOpacity);
   }
 };