From 0aa7193d3bb7c924c5e225dee454a277d14e75d3 Mon Sep 17 00:00:00 2001 From: Sergey Borisov Date: Wed, 19 Jul 2023 04:18:43 +0300 Subject: [PATCH 01/13] Load text_model.embeddings.position_ids outsude state_dict --- .../backend/model_management/convert_ckpt_to_diffusers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/invokeai/backend/model_management/convert_ckpt_to_diffusers.py b/invokeai/backend/model_management/convert_ckpt_to_diffusers.py index e3e64940de..77bcffac31 100644 --- a/invokeai/backend/model_management/convert_ckpt_to_diffusers.py +++ b/invokeai/backend/model_management/convert_ckpt_to_diffusers.py @@ -841,7 +841,9 @@ def convert_ldm_clip_checkpoint(checkpoint): key ] + position_ids = text_model_dict.pop("text_model.embeddings.position_ids") text_model.load_state_dict(text_model_dict) + text_model.text_model.embeddings.position_ids.copy_(position_ids) return text_model @@ -947,7 +949,9 @@ def convert_open_clip_checkpoint(checkpoint): text_model_dict[new_key] = checkpoint[key] + position_ids = text_model_dict.pop("text_model.embeddings.position_ids") text_model.load_state_dict(text_model_dict) + text_model.text_model.embeddings.position_ids.copy_(position_ids) return text_model From f29bafd6ec9bd63dce56315ac5ae05bdaec566ce Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Tue, 18 Jul 2023 22:10:12 -0400 Subject: [PATCH 02/13] fix Object of type PosixPath is not JSON serializable error --- invokeai/app/services/events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/app/services/events.py b/invokeai/app/services/events.py index 30d1b5e7a9..35003536e6 100644 --- a/invokeai/app/services/events.py +++ b/invokeai/app/services/events.py @@ -141,7 +141,7 @@ class EventServiceBase: model_type=model_type, submodel=submodel, hash=model_info.hash, - location=model_info.location, + location=str(model_info.location), precision=str(model_info.precision), ), ) From a690cca5b531757ce7b73c266f81bb86d58a576f Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Tue, 18 Jul 2023 22:18:13 -0400 Subject: [PATCH 03/13] make convert work with both 4.30.2 and 4.31.0 --- .../model_management/convert_ckpt_to_diffusers.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/invokeai/backend/model_management/convert_ckpt_to_diffusers.py b/invokeai/backend/model_management/convert_ckpt_to_diffusers.py index 77bcffac31..bd40c51269 100644 --- a/invokeai/backend/model_management/convert_ckpt_to_diffusers.py +++ b/invokeai/backend/model_management/convert_ckpt_to_diffusers.py @@ -841,9 +841,14 @@ def convert_ldm_clip_checkpoint(checkpoint): key ] - position_ids = text_model_dict.pop("text_model.embeddings.position_ids") - text_model.load_state_dict(text_model_dict) - text_model.text_model.embeddings.position_ids.copy_(position_ids) + try: + # transformers 4.31.0 and higher - this key no longer in state dict + position_ids = text_model_dict.pop("text_model.embeddings.position_ids") + text_model.load_state_dict(text_model_dict) + except RuntimeError: + # transformers 4.30.2 and lower - put the key back! + text_model_dict["text_model.embeddings.position_ids"] = position_ids + text_model.text_model.embeddings.position_ids.copy_(position_ids) return text_model From 2e7fc055c40e85b0d18ffab8b4cc4303df8e88fc Mon Sep 17 00:00:00 2001 From: Sergey Borisov Date: Wed, 19 Jul 2023 06:15:17 +0300 Subject: [PATCH 04/13] Support both pre and post 4.31.0 transformers --- .../convert_ckpt_to_diffusers.py | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/invokeai/backend/model_management/convert_ckpt_to_diffusers.py b/invokeai/backend/model_management/convert_ckpt_to_diffusers.py index bd40c51269..d9d4262e47 100644 --- a/invokeai/backend/model_management/convert_ckpt_to_diffusers.py +++ b/invokeai/backend/model_management/convert_ckpt_to_diffusers.py @@ -21,6 +21,7 @@ import re import warnings from pathlib import Path from typing import Union +from packaging import version import torch from safetensors.torch import load_file @@ -63,6 +64,7 @@ from diffusers.pipelines.stable_diffusion.safety_checker import ( StableDiffusionSafetyChecker, ) from diffusers.utils import is_safetensors_available +import transformers from transformers import ( AutoFeatureExtractor, BertTokenizerFast, @@ -841,14 +843,16 @@ def convert_ldm_clip_checkpoint(checkpoint): key ] - try: - # transformers 4.31.0 and higher - this key no longer in state dict - position_ids = text_model_dict.pop("text_model.embeddings.position_ids") + # transformers 4.31.0 and higher - this key no longer in state dict + if version.parse(transformers.__version__) >= version.parse("4.31.0"): + position_ids = text_model_dict.pop("text_model.embeddings.position_ids", None) text_model.load_state_dict(text_model_dict) - except RuntimeError: - # transformers 4.30.2 and lower - put the key back! - text_model_dict["text_model.embeddings.position_ids"] = position_ids - text_model.text_model.embeddings.position_ids.copy_(position_ids) + if position_ids is not None: + text_model.text_model.embeddings.position_ids.copy_(position_ids) + + # transformers 4.30.2 and lower - position_ids is part of state_dict + else: + text_model.load_state_dict(text_model_dict) return text_model @@ -954,9 +958,16 @@ def convert_open_clip_checkpoint(checkpoint): text_model_dict[new_key] = checkpoint[key] - position_ids = text_model_dict.pop("text_model.embeddings.position_ids") - text_model.load_state_dict(text_model_dict) - text_model.text_model.embeddings.position_ids.copy_(position_ids) + # transformers 4.31.0 and higher - this key no longer in state dict + if version.parse(transformers.__version__) >= version.parse("4.31.0"): + position_ids = text_model_dict.pop("text_model.embeddings.position_ids", None) + text_model.load_state_dict(text_model_dict) + if position_ids is not None: + text_model.text_model.embeddings.position_ids.copy_(position_ids) + + # transformers 4.30.2 and lower - position_ids is part of state_dict + else: + text_model.load_state_dict(text_model_dict) return text_model From 52e7e0b31bb688789c0715604dd8e98f661e8812 Mon Sep 17 00:00:00 2001 From: mickr777 <115216705+mickr777@users.noreply.github.com> Date: Wed, 19 Jul 2023 15:15:55 +1000 Subject: [PATCH 05/13] Missing def choose_torch_device --- invokeai/app/invocations/latent.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py index 2800624235..6ec62ff117 100644 --- a/invokeai/app/invocations/latent.py +++ b/invokeai/app/invocations/latent.py @@ -38,6 +38,10 @@ from diffusers.models.attention_processor import ( XFormersAttnProcessor, ) +def choose_torch_device() -> str: + # Your device selection logic here + device = "cuda" if torch.cuda.is_available() else "cpu" + return device class LatentsField(BaseModel): """A latents field used for passing latents between invocations""" From 19d67b29e7fc10281e5964a71cd9d30c7a0bc71e Mon Sep 17 00:00:00 2001 From: mickr777 <115216705+mickr777@users.noreply.github.com> Date: Wed, 19 Jul 2023 15:20:40 +1000 Subject: [PATCH 06/13] Remove not needed text --- invokeai/app/invocations/latent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py index 6ec62ff117..28613b1a4d 100644 --- a/invokeai/app/invocations/latent.py +++ b/invokeai/app/invocations/latent.py @@ -39,7 +39,6 @@ from diffusers.models.attention_processor import ( ) def choose_torch_device() -> str: - # Your device selection logic here device = "cuda" if torch.cuda.is_available() else "cpu" return device From d8db618de0d943b1d2b0caeb85de1fe89d412fa4 Mon Sep 17 00:00:00 2001 From: mickr777 <115216705+mickr777@users.noreply.github.com> Date: Wed, 19 Jul 2023 16:43:02 +1000 Subject: [PATCH 07/13] import choose_torch_device from ...backend.util.devices --- invokeai/app/invocations/latent.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py index 28613b1a4d..743a648785 100644 --- a/invokeai/app/invocations/latent.py +++ b/invokeai/app/invocations/latent.py @@ -22,7 +22,7 @@ from ...backend.stable_diffusion.diffusers_pipeline import ( from ...backend.stable_diffusion.diffusion.shared_invokeai_diffusion import \ PostprocessingSettings from ...backend.stable_diffusion.schedulers import SCHEDULER_MAP -from ...backend.util.devices import torch_dtype +from ...backend.util.devices import choose_torch_device, torch_dtype from ..models.image import ImageCategory, ImageField, ResourceOrigin from .baseinvocation import (BaseInvocation, BaseInvocationOutput, InvocationConfig, InvocationContext) @@ -38,10 +38,6 @@ from diffusers.models.attention_processor import ( XFormersAttnProcessor, ) -def choose_torch_device() -> str: - device = "cuda" if torch.cuda.is_available() else "cpu" - return device - class LatentsField(BaseModel): """A latents field used for passing latents between invocations""" From 23f0c7035c6d1c0ef7cea987262d70c2aa918e8c Mon Sep 17 00:00:00 2001 From: mickr777 <115216705+mickr777@users.noreply.github.com> Date: Wed, 19 Jul 2023 18:54:50 +1000 Subject: [PATCH 08/13] Tweaks to Image Progress Node (#3833) * Update nodesSlice.ts * Update ProgressImageNode.tsx * remove unused code * Remove Fixed Ratio I was causing issues * fix: Progress Image Node Size --------- Co-authored-by: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> --- .../nodes/components/ProgressImageNode.tsx | 46 +++++++++++-------- .../src/features/nodes/store/nodesSlice.ts | 9 ++++ 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/ProgressImageNode.tsx b/invokeai/frontend/web/src/features/nodes/components/ProgressImageNode.tsx index faaec06e49..142e2a2990 100644 --- a/invokeai/frontend/web/src/features/nodes/components/ProgressImageNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/ProgressImageNode.tsx @@ -1,33 +1,46 @@ import { Flex, Image } from '@chakra-ui/react'; -import { NodeProps } from 'reactflow'; -import { InvocationValue } from '../types/types'; - -import { useAppSelector } from 'app/store/storeHooks'; +import { RootState } from 'app/store/store'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { memo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { NodeProps, OnResize } from 'reactflow'; +import { setProgressNodeSize } from '../store/nodesSlice'; import IAINodeHeader from './IAINode/IAINodeHeader'; import IAINodeResizer from './IAINode/IAINodeResizer'; import NodeWrapper from './NodeWrapper'; -const ProgressImageNode = (props: NodeProps) => { - const progressImage = useAppSelector((state) => state.system.progressImage); +const ProgressImageNode = (props: NodeProps) => { + const progressImage = useSelector( + (state: RootState) => state.system.progressImage + ); + const progressNodeSize = useSelector( + (state: RootState) => state.nodes.progressNodeSize + ); + const dispatch = useDispatch(); const { selected } = props; + const handleResize: OnResize = (_, newSize) => { + dispatch(setProgressNodeSize(newSize)); + }; + return ( - {progressImage ? ( @@ -42,22 +55,17 @@ const ProgressImageNode = (props: NodeProps) => { ) : ( )} - + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 833a220336..997d0493dd 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -35,6 +35,7 @@ export type NodesState = { shouldShowFieldTypeLegend: boolean; shouldShowMinimapPanel: boolean; editorInstance: ReactFlowInstance | undefined; + progressNodeSize: { width: number; height: number }; }; export const initialNodesState: NodesState = { @@ -47,6 +48,7 @@ export const initialNodesState: NodesState = { shouldShowFieldTypeLegend: false, shouldShowMinimapPanel: true, editorInstance: undefined, + progressNodeSize: { width: 512, height: 512 }, }; const nodesSlice = createSlice({ @@ -157,6 +159,12 @@ const nodesSlice = createSlice({ loadFileEdges: (state, action: PayloadAction) => { state.edges = action.payload; }, + setProgressNodeSize: ( + state, + action: PayloadAction<{ width: number; height: number }> + ) => { + state.progressNodeSize = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => { @@ -182,6 +190,7 @@ export const { setEditorInstance, loadFileNodes, loadFileEdges, + setProgressNodeSize, } = nodesSlice.actions; export default nodesSlice.reducer; From f9320475fdaee13049e534738bd4f3d1f304ea59 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Wed, 19 Jul 2023 09:46:21 -0400 Subject: [PATCH 09/13] allow upgrade to transformers~=4.31.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0ff9ea1ac0..5cc6971df6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ dependencies = [ "torchvision>=0.14.1", "torchmetrics==0.11.4", "torchsde==0.2.5", - "transformers==4.30.2", + "transformers~=4.31.0", "uvicorn[standard]==0.21.1", "windows-curses; sys_platform=='win32'", ] From 9f00e055acde1777616b7bd8d21bc44eb73871f3 Mon Sep 17 00:00:00 2001 From: Mary Hipp Rogers Date: Wed, 19 Jul 2023 10:55:29 -0400 Subject: [PATCH 10/13] Maryhipp/clear intermediates (#3820) * new route to clear intermediates * UI to clear intermediates from settings modal * cleanup * PR feedback --------- Co-authored-by: Mary Hipp --- invokeai/app/api/routers/images.py | 11 ++++ invokeai/app/services/image_record_storage.py | 16 ++++-- invokeai/app/services/images.py | 32 +++++++++++ .../SettingsClearIntermediates.tsx | 57 +++++++++++++++++++ .../SettingsModal/SettingsModal.tsx | 8 ++- .../web/src/services/api/endpoints/images.ts | 6 +- .../frontend/web/src/services/api/schema.d.ts | 33 +++++++++-- 7 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 3da94df7f4..57c2a601e7 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -84,6 +84,17 @@ async def delete_image( # TODO: Does this need any exception handling at all? pass +@images_router.post("/clear-intermediates", operation_id="clear_intermediates") +async def clear_intermediates() -> int: + """Clears first 100 intermediates""" + + try: + count_deleted = ApiDependencies.invoker.services.images.delete_many(is_intermediate=True) + return count_deleted + except Exception as e: + # TODO: Does this need any exception handling at all? + pass + @images_router.patch( "/{image_name}", diff --git a/invokeai/app/services/image_record_storage.py b/invokeai/app/services/image_record_storage.py index 7b37307ce8..d28a0bc09d 100644 --- a/invokeai/app/services/image_record_storage.py +++ b/invokeai/app/services/image_record_storage.py @@ -97,8 +97,8 @@ class ImageRecordStorageBase(ABC): @abstractmethod def get_many( self, - offset: int = 0, - limit: int = 10, + offset: Optional[int] = None, + limit: Optional[int] = None, image_origin: Optional[ResourceOrigin] = None, categories: Optional[list[ImageCategory]] = None, is_intermediate: Optional[bool] = None, @@ -322,8 +322,8 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): def get_many( self, - offset: int = 0, - limit: int = 10, + offset: Optional[int] = None, + limit: Optional[int] = None, image_origin: Optional[ResourceOrigin] = None, categories: Optional[list[ImageCategory]] = None, is_intermediate: Optional[bool] = None, @@ -392,8 +392,12 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): images_query += query_conditions + query_pagination + ";" # Add all the parameters images_params = query_params.copy() - images_params.append(limit) - images_params.append(offset) + + if limit is not None: + images_params.append(limit) + if offset is not None: + images_params.append(offset) + # Build the list of images, deserializing each row self._cursor.execute(images_query, images_params) result = cast(list[sqlite3.Row], self._cursor.fetchall()) diff --git a/invokeai/app/services/images.py b/invokeai/app/services/images.py index a7d0b6ddee..5742a4cb4b 100644 --- a/invokeai/app/services/images.py +++ b/invokeai/app/services/images.py @@ -109,6 +109,13 @@ class ImageServiceABC(ABC): """Deletes an image.""" pass + @abstractmethod + def delete_many(self, is_intermediate: bool) -> int: + """Deletes many images.""" + pass + + + @abstractmethod def delete_images_on_board(self, board_id: str): """Deletes all images on a board.""" @@ -397,3 +404,28 @@ class ImageService(ImageServiceABC): except Exception as e: self._services.logger.error("Problem deleting image records and files") raise e + + def delete_many(self, is_intermediate: bool): + try: + # only clears 100 at a time + images = self._services.image_records.get_many(offset=0, limit=100, is_intermediate=is_intermediate,) + count = len(images.items) + image_name_list = list( + map( + lambda r: r.image_name, + images.items, + ) + ) + for image_name in image_name_list: + self._services.image_files.delete(image_name) + self._services.image_records.delete_many(image_name_list) + return count + except ImageRecordDeleteException: + self._services.logger.error(f"Failed to delete image records") + raise + except ImageFileDeleteException: + self._services.logger.error(f"Failed to delete image files") + raise + except Exception as e: + self._services.logger.error("Problem deleting image records and files") + raise e diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx new file mode 100644 index 0000000000..86b5753cd4 --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx @@ -0,0 +1,57 @@ +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useCallback, useEffect, useState } from 'react'; +import { StyledFlex } from './SettingsModal'; +import { Heading, Text } from '@chakra-ui/react'; +import IAIButton from '../../../../common/components/IAIButton'; +import { useClearIntermediatesMutation } from '../../../../services/api/endpoints/images'; +import { addToast } from '../../store/systemSlice'; + +export default function SettingsClearIntermediates() { + const dispatch = useAppDispatch(); + const [isDisabled, setIsDisabled] = useState(false); + + const [clearIntermediates, { isLoading: isLoadingClearIntermediates }] = + useClearIntermediatesMutation(); + + const handleClickClearIntermediates = useCallback(() => { + clearIntermediates({}) + .unwrap() + .then((response) => { + dispatch( + addToast({ + title: + response === 0 + ? `No intermediates to clear` + : `Successfully cleared ${response} intermediates`, + status: 'info', + }) + ); + if (response < 100) { + setIsDisabled(true); + } + }); + }, [clearIntermediates, dispatch]); + + return ( + + Clear Intermediates + + {isDisabled ? 'Intermediates Cleared' : 'Clear 100 Intermediates'} + + + Will permanently delete first 100 intermediates found on disk and in + database + + + Intermediate images are byproducts of generation, different from the + result images in the gallery. Purging intermediates will free disk + space. Your gallery images will not be deleted. + + + ); +} diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx index 890ceb1f48..0505689184 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx @@ -48,6 +48,7 @@ import { import { useTranslation } from 'react-i18next'; import { LogLevelName } from 'roarr'; import SettingsSchedulers from './SettingsSchedulers'; +import SettingsClearIntermediates from './SettingsClearIntermediates'; const selector = createSelector( [systemSelector, uiSelector], @@ -91,6 +92,7 @@ type ConfigOptions = { shouldShowResetWebUiText: boolean; shouldShowBetaLayout: boolean; shouldShowAdvancedOptionsSettings: boolean; + shouldShowClearIntermediates: boolean; }; type SettingsModalProps = { @@ -109,6 +111,8 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { const shouldShowResetWebUiText = config?.shouldShowResetWebUiText ?? true; const shouldShowAdvancedOptionsSettings = config?.shouldShowAdvancedOptionsSettings ?? true; + const shouldShowClearIntermediates = + config?.shouldShowClearIntermediates ?? true; useEffect(() => { if (!shouldShowDeveloperSettings) { @@ -280,6 +284,8 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { )} + {shouldShowClearIntermediates && } + {t('settings.resetWebUI')} @@ -328,7 +334,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { export default SettingsModal; -const StyledFlex = (props: PropsWithChildren) => { +export const StyledFlex = (props: PropsWithChildren) => { return ( ({ url: `images/clear-intermediates`, method: 'POST' }), + }), }), }); -export const { useGetImageDTOQuery, useGetImageMetadataQuery } = imagesApi; +export const { useGetImageDTOQuery, useGetImageMetadataQuery, useClearIntermediatesMutation } = imagesApi; diff --git a/invokeai/frontend/web/src/services/api/schema.d.ts b/invokeai/frontend/web/src/services/api/schema.d.ts index 805afe54b3..a91a81a740 100644 --- a/invokeai/frontend/web/src/services/api/schema.d.ts +++ b/invokeai/frontend/web/src/services/api/schema.d.ts @@ -164,6 +164,13 @@ export type paths = { */ patch: operations["update_image"]; }; + "/api/v1/images/clear-intermediates": { + /** + * Clear Intermediates + * @description Clears first 100 intermediates + */ + post: operations["clear_intermediates"]; + }; "/api/v1/images/{image_name}/metadata": { /** * Get Image Metadata @@ -5298,18 +5305,18 @@ export type components = { */ image?: components["schemas"]["ImageField"]; }; - /** - * StableDiffusion2ModelFormat - * @description An enumeration. - * @enum {string} - */ - StableDiffusion2ModelFormat: "checkpoint" | "diffusers"; /** * StableDiffusionXLModelFormat * @description An enumeration. * @enum {string} */ StableDiffusionXLModelFormat: "checkpoint" | "diffusers"; + /** + * StableDiffusion2ModelFormat + * @description An enumeration. + * @enum {string} + */ + StableDiffusion2ModelFormat: "checkpoint" | "diffusers"; /** * StableDiffusion1ModelFormat * @description An enumeration. @@ -6098,6 +6105,20 @@ export type operations = { }; }; }; + /** + * Clear Intermediates + * @description Clears first 100 intermediates + */ + clear_intermediates: { + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": unknown; + }; + }; + }; + }; /** * Get Image Metadata * @description Gets an image's metadata From 055f5b2d4b6f03705be993da88bc3e16996906d5 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Wed, 19 Jul 2023 11:02:33 -0400 Subject: [PATCH 11/13] clear canvas alongside intermediates --- .../components/SettingsModal/SettingsClearIntermediates.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx index 86b5753cd4..d75eb4d4c2 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx @@ -5,6 +5,7 @@ import { Heading, Text } from '@chakra-ui/react'; import IAIButton from '../../../../common/components/IAIButton'; import { useClearIntermediatesMutation } from '../../../../services/api/endpoints/images'; import { addToast } from '../../store/systemSlice'; +import { resetCanvas } from '../../../canvas/store/canvasSlice'; export default function SettingsClearIntermediates() { const dispatch = useAppDispatch(); @@ -17,6 +18,7 @@ export default function SettingsClearIntermediates() { clearIntermediates({}) .unwrap() .then((response) => { + dispatch(resetCanvas()); dispatch( addToast({ title: @@ -47,6 +49,7 @@ export default function SettingsClearIntermediates() { Will permanently delete first 100 intermediates found on disk and in database + This will also clear your canvas state. Intermediate images are byproducts of generation, different from the result images in the gallery. Purging intermediates will free disk From 0724eb9e0a56cfd6f9cefb96a67ffaff75ff7c10 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 20 Jul 2023 02:06:38 +1000 Subject: [PATCH 12/13] feat(ui): another go at gallery (#3791) * feat(ui): migrate listImages to RTK query using createEntityAdapter - see comments in `endpoints/images.ts` for explanation of the caching - so far, only manually updating `all` images when new image is generated. no other manual cache updates are implemented, but will be needed. - fixed some weirdness with loading state components (like the spinners in gallery) - added `useThumbnailFallback` for `IAIDndImage`, this displays the tiny webp thumbnail while the full-size images load - comment out some old thunk related stuff in gallerySlice, which is no longer needed * feat(ui): add manual cache updates for board changes (wip) - update RTK Query caches when adding/removing single image to/from board - work more on migrating all image-related operations to RTK Query * update AddImagesToBoardContext so that it works when user uses context menu + modal * handle case where no image is selected * get assets working for main list and boards - dnd only * feat(ui): migrate image uploads to RTK Query - minor refactor of `ImageUploader` and `useImageUploadButton` hooks, simplify some logic - style filesystem upload overlay to match existing UI - replace all old `imageUploaded` thunks with `uploadImage` RTK Query calls, update associated logic including canvas related uploads - simplify `PostUploadAction`s that only need to display user input * feat(ui): remove `receivedPageOfImages` thunks * feat(ui): remove `receivedImageUrls` thunk * feat(ui): finish removing all images thunks stuff now broken: - image usage - delete board images - on first load, no image selected * feat(ui): simplify `updateImage` cache manipulation - we don't actually ever change categories, so we can remove a lot of logic * feat(ui): simplify canvas autosave - instead of using a network request to set the canvas generation as not intermediate, we can just do that in the graph * feat(ui): simplify & handle edge cases in cache updates * feat(db, api): support `board_id='none'` for `get_many` images queries This allows us to get all images that are not on a board. * chore(ui): regen types * feat(ui): add `All Assets`, `No Board` boards Restructure boards: - `all images` is all images - `all assets` is all assets - `no board` is all images/assets without a board set - user boards may have images and assets Update caching logic - much simpler without every board having sub-views of images and assets - update drag and drop operations for all possible interactions * chore(ui): regen types * feat(ui): move download to top of context menu * feat(ui): improve drop overlay styles * fix(ui): fix image not selected on first load - listen for first load of all images board, then select the first image * feat(ui): refactor board deletion api changes: - add route to list all image names for a board. this is required to handle board + image deletion. we need to know every image in the board to determine the image usage across the app. this is fetched only when the delete board and images modal is opened so it's as efficient as it can be. - update the delete board route to respond with a list of deleted `board_images` and `images`, as image names. this is needed to perform accurate clientside state & cache updates after deleting. db changes: - remove unused `board_images` service method to get paginated images dtos for a board. this is now done thru the list images endpoint & images service. needs a small logic change on `images.delete_images_on_board` ui changes: - simplify the delete board modal - no context, just minor prop drilling. this is feasible for boards only because the components that need to trigger and manipulate the modal are very close together in the tree - add cache updates for `deleteBoard` & `deleteBoardAndImages` mutations - the only thing we cannot do directly is on `deleteBoardAndImages`, update the `No Board` board. we'd need to insert image dtos that we may not have loaded. instead, i am just invalidating the tags for that `listImages` cache. so when you `deleteBoardAndImages`, the `No Board` will re-fetch the initial image limit. i think this is more efficient than e.g. fetching all image dtos to insert then inserting them. - handle image usage for `deleteBoardAndImages` - update all (i think/hope) the little bits and pieces in the UI to accomodate these changes * fix(ui): fix board selection logic * feat(ui): add delete board modal loading state * fix(ui): use thumbnails for board cover images * fix(ui): fix race condition with board selection when selecting a board that doesn't have any images loaded, we need to wait until the images haveloaded before selecting the first image. this logic is debounced to ~1000ms. * feat(ui): name 'No Board' correctly, change icon * fix(ui): do not cache listAllImageNames query if we cache it, we can end up with stale image usage during deletion. we could of course manually update the cache as we are doing elsewhere. but because this is a relatively infrequent network request, i'd like to trade increased cache mgmt complexity here for increased resource usage. * feat(ui): reduce drag preview opacity, remove border * fix(ui): fix incorrect queryArg used in `deleteImage` and `updateImage` cache updates * fix(ui): fix doubled open in new tab * fix(ui): fix new generations not getting added to 'No Board' * fix(ui): fix board id not changing on new image when autosave enabled * fix(ui): context menu when selection is 0 need to revise how context menu is triggered later, when we approach multi select * fix(ui): fix deleting does not update counts for all images and all assets * fix(ui): fix all assets board name in boards list collapse button * fix(ui): ensure we never go under 0 for total board count * fix(ui): fix text overflow on board names --------- Co-authored-by: Mary Hipp --- invokeai/app/api/routers/board_images.py | 30 +- invokeai/app/api/routers/boards.py | 55 +- invokeai/app/api/routers/images.py | 8 +- .../services/board_image_record_storage.py | 26 +- invokeai/app/services/board_images.py | 31 +- invokeai/app/services/image_record_storage.py | 13 +- invokeai/app/services/images.py | 13 +- .../frontend/web/src/app/components/App.tsx | 2 - .../app/components/ImageDnd/DragPreview.tsx | 5 +- .../components/ImageDnd/ImageDndContext.tsx | 8 +- .../app/components/ImageDnd/typesafeDnd.tsx | 35 +- .../web/src/app/components/InvokeAIUI.tsx | 5 +- .../app/contexts/AddImageToBoardContext.tsx | 18 +- .../app/contexts/DeleteBoardImagesContext.tsx | 170 ----- .../middleware/listenerMiddleware/index.ts | 34 +- .../addFirstListImagesListener.ts.ts | 43 ++ .../listeners/appStarted.ts | 29 +- .../listeners/boardAndImagesDeleted.ts | 48 ++ .../listeners/boardIdSelected.ts | 88 ++- .../listeners/boardImagesDeleted.ts | 82 --- .../listeners/canvasMerged.ts | 32 +- .../listeners/canvasSavedToGallery.ts | 24 +- .../listeners/controlNetImageProcessed.ts | 13 +- .../listeners/imageAddedToBoard.ts | 19 +- .../listeners/imageDeleted.ts | 56 +- .../listeners/imageDropped.ts | 199 +++--- .../listeners/imageMetadataReceived.ts | 51 -- .../listeners/imageRemovedFromBoard.ts | 6 +- .../listeners/imageUpdated.ts | 20 +- .../listeners/imageUploaded.ts | 123 +++- .../listeners/imageUrlsReceived.ts | 37 -- .../listeners/initialImageSelected.ts | 32 +- .../listeners/receivedPageOfImages.ts | 40 -- .../socketio/socketInvocationComplete.ts | 86 ++- .../listeners/stagingAreaImageSaved.ts | 57 +- .../listeners/updateImageUrlsOnConnect.ts | 91 --- .../listeners/userInvokedCanvas.ts | 48 +- .../web/src/common/components/IAIDndImage.tsx | 21 +- .../src/common/components/IAIDropOverlay.tsx | 24 +- .../src/common/components/IAIDroppable.tsx | 4 +- .../common/components/IAIImageFallback.tsx | 1 + .../common/components/ImageUploadOverlay.tsx | 64 +- .../src/common/components/ImageUploader.tsx | 101 +-- .../common/components/ImageUploaderButton.tsx | 49 -- .../components/ImageUploaderIconButton.tsx | 20 - .../src/common/hooks/useImageUploadButton.tsx | 28 +- .../web/src/common/hooks/useImageUploader.ts | 23 - .../IAICanvasStagingAreaToolbar.tsx | 19 +- .../IAICanvasToolbar/IAICanvasToolbar.tsx | 9 +- .../web/src/features/canvas/store/actions.ts | 3 +- .../components/ControlNetImagePreview.tsx | 2 +- .../controlNet/store/controlNetSlice.ts | 35 +- .../Boards/BoardsList/AllAssetsBoard.tsx | 50 ++ .../Boards/BoardsList/AllImagesBoard.tsx | 41 +- .../Boards/BoardsList/BoardsList.tsx | 171 +++-- .../Boards/BoardsList/BoardsSearch.tsx | 66 ++ .../Boards/BoardsList/GalleryBoard.tsx | 343 +++++----- .../Boards/BoardsList/GenericBoard.tsx | 24 +- .../Boards/BoardsList/NoBoardBoard.tsx | 53 ++ .../Boards/DeleteBoardImagesModal.tsx | 114 ---- .../components/Boards/DeleteBoardModal.tsx | 181 ++++++ .../CurrentImage/CurrentImagePreview.tsx | 6 + .../gallery/components/GalleryBoardName.tsx | 91 +++ .../gallery/components/GalleryPinButton.tsx | 44 ++ .../components/GallerySettingsPopover.tsx | 76 +++ .../ImageContextMenu/ImageContextMenu.tsx | 43 +- .../SingleSelectionMenuItems.tsx | 32 +- .../components/ImageGalleryContent.tsx | 192 +----- .../components/ImageGrid/GalleryImage.tsx | 11 +- .../components/ImageGrid/GalleryImageGrid.tsx | 214 +++---- .../ImageMetadataActions.tsx | 3 - .../gallery/hooks/useNextPrevImage.ts | 74 ++- .../gallery/store/gallerySelectors.ts | 136 +--- .../features/gallery/store/gallerySlice.ts | 206 ++---- .../web/src/features/gallery/store/util.ts | 54 ++ .../components/ImageUsageMessage.tsx | 20 +- .../imageDeletion/store/imageDeletionSlice.ts | 63 +- .../fields/ImageInputFieldComponent.tsx | 2 +- .../addControlNetToLinearGraph.ts | 2 + .../graphBuilders/addDynamicPromptsToGraph.ts | 6 + .../util/graphBuilders/addLoRAsToGraph.ts | 1 + .../nodes/util/graphBuilders/addVAEToGraph.ts | 1 + .../util/graphBuilders/buildCanvasGraph.ts | 11 +- .../buildCanvasImageToImageGraph.ts | 10 + .../graphBuilders/buildCanvasInpaintGraph.ts | 13 +- .../buildCanvasTextToImageGraph.ts | 9 + .../ImageToImage/InitialImageDisplay.tsx | 9 +- .../parameters/hooks/useRecallParameters.ts | 18 +- .../src/features/parameters/store/actions.ts | 2 +- .../src/features/system/store/systemSlice.ts | 22 - .../subpanels/ModelManagerPanel/ModelList.tsx | 12 +- .../UnifiedCanvasFileUploader.tsx | 24 +- .../src/services/api/endpoints/boardImages.ts | 37 +- .../web/src/services/api/endpoints/boards.ts | 171 ++++- .../web/src/services/api/endpoints/images.ts | 590 +++++++++++++++++- .../web/src/services/api/endpoints/util.ts | 51 ++ .../frontend/web/src/services/api/index.ts | 9 +- .../frontend/web/src/services/api/schema.d.ts | 116 ++-- .../web/src/services/api/thunks/image.ts | 330 ---------- .../frontend/web/src/services/api/types.d.ts | 39 ++ 100 files changed, 3059 insertions(+), 2814 deletions(-) delete mode 100644 invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx create mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts create mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUrlsReceived.ts delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/receivedPageOfImages.ts delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateImageUrlsOnConnect.ts delete mode 100644 invokeai/frontend/web/src/common/components/ImageUploaderButton.tsx delete mode 100644 invokeai/frontend/web/src/common/components/ImageUploaderIconButton.tsx delete mode 100644 invokeai/frontend/web/src/common/hooks/useImageUploader.ts create mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllAssetsBoard.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardImagesModal.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/GalleryPinButton.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/store/util.ts create mode 100644 invokeai/frontend/web/src/services/api/endpoints/util.ts delete mode 100644 invokeai/frontend/web/src/services/api/thunks/image.ts diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index b206ab500d..651310af24 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -24,11 +24,14 @@ async def create_board_image( ): """Creates a board_image""" try: - result = ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name) + result = ApiDependencies.invoker.services.board_images.add_image_to_board( + board_id=board_id, image_name=image_name + ) return result except Exception as e: raise HTTPException(status_code=500, detail="Failed to add to board") - + + @board_images_router.delete( "/", operation_id="remove_board_image", @@ -43,27 +46,10 @@ async def remove_board_image( ): """Deletes a board_image""" try: - result = ApiDependencies.invoker.services.board_images.remove_image_from_board(board_id=board_id, image_name=image_name) + result = ApiDependencies.invoker.services.board_images.remove_image_from_board( + board_id=board_id, image_name=image_name + ) return result except Exception as e: raise HTTPException(status_code=500, detail="Failed to update board") - - -@board_images_router.get( - "/{board_id}", - operation_id="list_board_images", - response_model=OffsetPaginatedResults[ImageDTO], -) -async def list_board_images( - board_id: str = Path(description="The id of the board"), - offset: int = Query(default=0, description="The page offset"), - limit: int = Query(default=10, description="The number of boards per page"), -) -> OffsetPaginatedResults[ImageDTO]: - """Gets a list of images for a board""" - - results = ApiDependencies.invoker.services.board_images.get_images_for_board( - board_id, - ) - return results - diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index 94d8667ae4..f3de7f4952 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -1,16 +1,28 @@ from typing import Optional, Union + from fastapi import Body, HTTPException, Path, Query from fastapi.routing import APIRouter +from pydantic import BaseModel, Field + from invokeai.app.services.board_record_storage import BoardChanges from invokeai.app.services.image_record_storage import OffsetPaginatedResults from invokeai.app.services.models.board_record import BoardDTO - from ..dependencies import ApiDependencies boards_router = APIRouter(prefix="/v1/boards", tags=["boards"]) +class DeleteBoardResult(BaseModel): + board_id: str = Field(description="The id of the board that was deleted.") + deleted_board_images: list[str] = Field( + description="The image names of the board-images relationships that were deleted." + ) + deleted_images: list[str] = Field( + description="The names of the images that were deleted." + ) + + @boards_router.post( "/", operation_id="create_board", @@ -69,25 +81,42 @@ async def update_board( raise HTTPException(status_code=500, detail="Failed to update board") -@boards_router.delete("/{board_id}", operation_id="delete_board") +@boards_router.delete( + "/{board_id}", operation_id="delete_board", response_model=DeleteBoardResult +) async def delete_board( board_id: str = Path(description="The id of board to delete"), include_images: Optional[bool] = Query( description="Permanently delete all images on the board", default=False ), -) -> None: +) -> DeleteBoardResult: """Deletes a board""" try: if include_images is True: + deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( + board_id=board_id + ) ApiDependencies.invoker.services.images.delete_images_on_board( board_id=board_id ) ApiDependencies.invoker.services.boards.delete(board_id=board_id) + return DeleteBoardResult( + board_id=board_id, + deleted_board_images=[], + deleted_images=deleted_images, + ) else: + deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( + board_id=board_id + ) ApiDependencies.invoker.services.boards.delete(board_id=board_id) + return DeleteBoardResult( + board_id=board_id, + deleted_board_images=deleted_board_images, + deleted_images=[], + ) except Exception as e: - # TODO: Does this need any exception handling at all? - pass + raise HTTPException(status_code=500, detail="Failed to delete board") @boards_router.get( @@ -115,3 +144,19 @@ async def list_boards( status_code=400, detail="Invalid request: Must provide either 'all' or both 'offset' and 'limit'", ) + + +@boards_router.get( + "/{board_id}/image_names", + operation_id="list_all_board_image_names", + response_model=list[str], +) +async def list_all_board_image_names( + board_id: str = Path(description="The id of the board"), +) -> list[str]: + """Gets a list of images for a board""" + + image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( + board_id, + ) + return image_names diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 57c2a601e7..559afa3b37 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -245,16 +245,16 @@ async def get_image_urls( ) async def list_image_dtos( image_origin: Optional[ResourceOrigin] = Query( - default=None, description="The origin of images to list" + default=None, description="The origin of images to list." ), categories: Optional[list[ImageCategory]] = Query( - default=None, description="The categories of image to include" + default=None, description="The categories of image to include." ), is_intermediate: Optional[bool] = Query( - default=None, description="Whether to list intermediate images" + default=None, description="Whether to list intermediate images." ), board_id: Optional[str] = Query( - default=None, description="The board id to filter by" + default=None, description="The board id to filter by. Use 'none' to find images without a board." ), offset: int = Query(default=0, description="The page offset"), limit: int = Query(default=10, description="The number of images per page"), diff --git a/invokeai/app/services/board_image_record_storage.py b/invokeai/app/services/board_image_record_storage.py index 197a639157..491972bd32 100644 --- a/invokeai/app/services/board_image_record_storage.py +++ b/invokeai/app/services/board_image_record_storage.py @@ -32,11 +32,11 @@ class BoardImageRecordStorageBase(ABC): pass @abstractmethod - def get_images_for_board( + def get_all_board_image_names_for_board( self, board_id: str, - ) -> OffsetPaginatedResults[ImageRecord]: - """Gets images for a board.""" + ) -> list[str]: + """Gets all board images for a board, as a list of the image names.""" pass @abstractmethod @@ -211,6 +211,26 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase): items=images, offset=offset, limit=limit, total=count ) + def get_all_board_image_names_for_board(self, board_id: str) -> list[str]: + try: + self._lock.acquire() + self._cursor.execute( + """--sql + SELECT image_name + FROM board_images + WHERE board_id = ?; + """, + (board_id,), + ) + result = cast(list[sqlite3.Row], self._cursor.fetchall()) + image_names = list(map(lambda r: r[0], result)) + return image_names + except sqlite3.Error as e: + self._conn.rollback() + raise e + finally: + self._lock.release() + def get_board_for_image( self, image_name: str, diff --git a/invokeai/app/services/board_images.py b/invokeai/app/services/board_images.py index 1ba225338b..b9f9663603 100644 --- a/invokeai/app/services/board_images.py +++ b/invokeai/app/services/board_images.py @@ -38,11 +38,11 @@ class BoardImagesServiceABC(ABC): pass @abstractmethod - def get_images_for_board( + def get_all_board_image_names_for_board( self, board_id: str, - ) -> OffsetPaginatedResults[ImageDTO]: - """Gets images for a board.""" + ) -> list[str]: + """Gets all board images for a board, as a list of the image names.""" pass @abstractmethod @@ -98,30 +98,13 @@ class BoardImagesService(BoardImagesServiceABC): ) -> None: self._services.board_image_records.remove_image_from_board(board_id, image_name) - def get_images_for_board( + def get_all_board_image_names_for_board( self, board_id: str, - ) -> OffsetPaginatedResults[ImageDTO]: - image_records = self._services.board_image_records.get_images_for_board( + ) -> list[str]: + return self._services.board_image_records.get_all_board_image_names_for_board( board_id ) - image_dtos = list( - map( - lambda r: image_record_to_dto( - r, - self._services.urls.get_image_url(r.image_name), - self._services.urls.get_image_url(r.image_name, True), - board_id, - ), - image_records.items, - ) - ) - return OffsetPaginatedResults[ImageDTO]( - items=image_dtos, - offset=image_records.offset, - limit=image_records.limit, - total=image_records.total, - ) def get_board_for_image( self, @@ -136,7 +119,7 @@ def board_record_to_dto( ) -> BoardDTO: """Converts a board record to a board DTO.""" return BoardDTO( - **board_record.dict(exclude={'cover_image_name'}), + **board_record.dict(exclude={"cover_image_name"}), cover_image_name=cover_image_name, image_count=image_count, ) diff --git a/invokeai/app/services/image_record_storage.py b/invokeai/app/services/image_record_storage.py index d28a0bc09d..09c3bdcc3e 100644 --- a/invokeai/app/services/image_record_storage.py +++ b/invokeai/app/services/image_record_storage.py @@ -10,7 +10,10 @@ from pydantic.generics import GenericModel from invokeai.app.models.image import ImageCategory, ResourceOrigin from invokeai.app.services.models.image_record import ( - ImageRecord, ImageRecordChanges, deserialize_image_record) + ImageRecord, + ImageRecordChanges, + deserialize_image_record, +) T = TypeVar("T", bound=BaseModel) @@ -377,11 +380,15 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): query_params.append(is_intermediate) - if board_id is not None: + # board_id of "none" is reserved for images without a board + if board_id == "none": + query_conditions += """--sql + AND board_images.board_id IS NULL + """ + elif board_id is not None: query_conditions += """--sql AND board_images.board_id = ? """ - query_params.append(board_id) query_pagination = """--sql diff --git a/invokeai/app/services/images.py b/invokeai/app/services/images.py index 5742a4cb4b..13c6c04719 100644 --- a/invokeai/app/services/images.py +++ b/invokeai/app/services/images.py @@ -11,7 +11,6 @@ from invokeai.app.models.image import (ImageCategory, InvalidOriginException, ResourceOrigin) from invokeai.app.services.board_image_record_storage import \ BoardImageRecordStorageBase -from invokeai.app.services.graph import Graph from invokeai.app.services.image_file_storage import ( ImageFileDeleteException, ImageFileNotFoundException, ImageFileSaveException, ImageFileStorageBase) @@ -385,16 +384,14 @@ class ImageService(ImageServiceABC): def delete_images_on_board(self, board_id: str): try: - images = self._services.board_image_records.get_images_for_board(board_id) - image_name_list = list( - map( - lambda r: r.image_name, - images.items, + image_names = ( + self._services.board_image_records.get_all_board_image_names_for_board( + board_id ) ) - for image_name in image_name_list: + for image_name in image_names: self._services.image_files.delete(image_name) - self._services.image_records.delete_many(image_name_list) + self._services.image_records.delete_many(image_names) except ImageRecordDeleteException: self._services.logger.error(f"Failed to delete image records") raise diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index a05266d5f2..092f1b0f89 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -15,7 +15,6 @@ import InvokeTabs from 'features/ui/components/InvokeTabs'; import ParametersDrawer from 'features/ui/components/ParametersDrawer'; import i18n from 'i18n'; import { ReactNode, memo, useEffect } from 'react'; -import DeleteBoardImagesModal from '../../features/gallery/components/Boards/DeleteBoardImagesModal'; import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal'; import GlobalHotkeys from './GlobalHotkeys'; import Toaster from './Toaster'; @@ -84,7 +83,6 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => { - diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx index 942365848e..e8fa949e9a 100644 --- a/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx +++ b/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx @@ -15,10 +15,7 @@ const STYLES: ChakraProps['sx'] = { maxH: BOX_SIZE, shadow: 'dark-lg', borderRadius: 'lg', - borderWidth: 2, - borderStyle: 'dashed', - borderColor: 'base.100', - opacity: 0.5, + opacity: 0.3, bg: 'base.800', color: 'base.50', _dark: { diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx index 6ce9b06bd9..91e274930c 100644 --- a/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx +++ b/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx @@ -28,6 +28,7 @@ const ImageDndContext = (props: ImageDndContextProps) => { const dispatch = useAppDispatch(); const handleDragStart = useCallback((event: DragStartEvent) => { + console.log('dragStart', event.active.data.current); const activeData = event.active.data.current; if (!activeData) { return; @@ -37,15 +38,16 @@ const ImageDndContext = (props: ImageDndContextProps) => { const handleDragEnd = useCallback( (event: DragEndEvent) => { + console.log('dragEnd', event.active.data.current); const activeData = event.active.data.current; const overData = event.over?.data.current; - if (!activeData || !overData) { + if (!activeDragData || !overData) { return; } - dispatch(dndDropped({ overData, activeData })); + dispatch(dndDropped({ overData, activeData: activeDragData })); setActiveDragData(null); }, - [dispatch] + [activeDragData, dispatch] ); const mouseSensor = useSensor(MouseSensor, { diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx index 003142390f..af4b5bbe3b 100644 --- a/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx +++ b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx @@ -11,6 +11,7 @@ import { useDraggable as useOriginalDraggable, useDroppable as useOriginalDroppable, } from '@dnd-kit/core'; +import { BoardId } from 'features/gallery/store/gallerySlice'; import { ImageDTO } from 'services/api/types'; type BaseDropData = { @@ -55,7 +56,7 @@ export type AddToBatchDropData = BaseDropData & { export type MoveBoardDropData = BaseDropData & { actionType: 'MOVE_BOARD'; - context: { boardId: string | null }; + context: { boardId: BoardId }; }; export type TypesafeDroppableData = @@ -158,8 +159,36 @@ export const isValidDrop = ( return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; case 'ADD_TO_BATCH': return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; - case 'MOVE_BOARD': - return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; + case 'MOVE_BOARD': { + // If the board is the same, don't allow the drop + + // Check the payload types + const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; + if (!isPayloadValid) { + return false; + } + + // Check if the image's board is the board we are dragging onto + if (payloadType === 'IMAGE_DTO') { + const { imageDTO } = active.data.current.payload; + const currentBoard = imageDTO.board_id; + const destinationBoard = overData.context.boardId; + + const isSameBoard = currentBoard === destinationBoard; + const isDestinationValid = !currentBoard + ? destinationBoard !== 'no_board' + : true; + + return !isSameBoard && isDestinationValid; + } + + if (payloadType === 'IMAGE_NAMES') { + // TODO (multi-select) + return false; + } + + return true; + } default: return false; } diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index 105f8f18d7..3136354730 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -18,7 +18,6 @@ import { Middleware } from '@reduxjs/toolkit'; import ImageDndContext from './ImageDnd/ImageDndContext'; import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext'; import { $authToken, $baseUrl } from 'services/api/client'; -import { DeleteBoardImagesContextProvider } from '../contexts/DeleteBoardImagesContext'; const App = lazy(() => import('./App')); const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider')); @@ -78,9 +77,7 @@ const InvokeAIUI = ({ - - - + diff --git a/invokeai/frontend/web/src/app/contexts/AddImageToBoardContext.tsx b/invokeai/frontend/web/src/app/contexts/AddImageToBoardContext.tsx index f37f06d4b1..d5b3b746f1 100644 --- a/invokeai/frontend/web/src/app/contexts/AddImageToBoardContext.tsx +++ b/invokeai/frontend/web/src/app/contexts/AddImageToBoardContext.tsx @@ -1,7 +1,8 @@ import { useDisclosure } from '@chakra-ui/react'; import { PropsWithChildren, createContext, useCallback, useState } from 'react'; import { ImageDTO } from 'services/api/types'; -import { useAddImageToBoardMutation } from 'services/api/endpoints/boardImages'; +import { imagesApi } from 'services/api/endpoints/images'; +import { useAppDispatch } from '../store/storeHooks'; export type ImageUsage = { isInitialImage: boolean; @@ -40,8 +41,7 @@ type Props = PropsWithChildren; export const AddImageToBoardContextProvider = (props: Props) => { const [imageToMove, setImageToMove] = useState(); const { isOpen, onOpen, onClose } = useDisclosure(); - - const [addImageToBoard, result] = useAddImageToBoardMutation(); + const dispatch = useAppDispatch(); // Clean up after deleting or dismissing the modal const closeAndClearImageToDelete = useCallback(() => { @@ -63,14 +63,16 @@ export const AddImageToBoardContextProvider = (props: Props) => { const handleAddToBoard = useCallback( (boardId: string) => { if (imageToMove) { - addImageToBoard({ - board_id: boardId, - image_name: imageToMove.image_name, - }); + dispatch( + imagesApi.endpoints.addImageToBoard.initiate({ + imageDTO: imageToMove, + board_id: boardId, + }) + ); closeAndClearImageToDelete(); } }, - [addImageToBoard, closeAndClearImageToDelete, imageToMove] + [dispatch, closeAndClearImageToDelete, imageToMove] ); return ( diff --git a/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx b/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx deleted file mode 100644 index 15f9fab282..0000000000 --- a/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useDisclosure } from '@chakra-ui/react'; -import { PropsWithChildren, createContext, useCallback, useState } from 'react'; -import { BoardDTO } from 'services/api/types'; -import { useDeleteBoardMutation } from '../../services/api/endpoints/boards'; -import { defaultSelectorOptions } from '../store/util/defaultMemoizeOptions'; -import { createSelector } from '@reduxjs/toolkit'; -import { some } from 'lodash-es'; -import { canvasSelector } from 'features/canvas/store/canvasSelectors'; -import { controlNetSelector } from 'features/controlNet/store/controlNetSlice'; -import { selectImagesById } from 'features/gallery/store/gallerySlice'; -import { nodesSelector } from 'features/nodes/store/nodesSlice'; -import { generationSelector } from 'features/parameters/store/generationSelectors'; -import { RootState } from '../store/store'; -import { useAppDispatch, useAppSelector } from '../store/storeHooks'; -import { ImageUsage } from './DeleteImageContext'; -import { requestedBoardImagesDeletion } from 'features/gallery/store/actions'; - -export const selectBoardImagesUsage = createSelector( - [ - (state: RootState) => state, - generationSelector, - canvasSelector, - nodesSelector, - controlNetSelector, - (state: RootState, board_id?: string) => board_id, - ], - (state, generation, canvas, nodes, controlNet, board_id) => { - const initialImage = generation.initialImage - ? selectImagesById(state, generation.initialImage.imageName) - : undefined; - const isInitialImage = initialImage?.board_id === board_id; - - const isCanvasImage = canvas.layerState.objects.some((obj) => { - if (obj.kind === 'image') { - const image = selectImagesById(state, obj.imageName); - return image?.board_id === board_id; - } - return false; - }); - - const isNodesImage = nodes.nodes.some((node) => { - return some(node.data.inputs, (input) => { - if (input.type === 'image' && input.value) { - const image = selectImagesById(state, input.value.image_name); - return image?.board_id === board_id; - } - return false; - }); - }); - - const isControlNetImage = some(controlNet.controlNets, (c) => { - const controlImage = c.controlImage - ? selectImagesById(state, c.controlImage) - : undefined; - const processedControlImage = c.processedControlImage - ? selectImagesById(state, c.processedControlImage) - : undefined; - return ( - controlImage?.board_id === board_id || - processedControlImage?.board_id === board_id - ); - }); - - const imageUsage: ImageUsage = { - isInitialImage, - isCanvasImage, - isNodesImage, - isControlNetImage, - }; - - return imageUsage; - }, - defaultSelectorOptions -); - -type DeleteBoardImagesContextValue = { - /** - * Whether the move image dialog is open. - */ - isOpen: boolean; - /** - * Closes the move image dialog. - */ - onClose: () => void; - imagesUsage?: ImageUsage; - board?: BoardDTO; - onClickDeleteBoardImages: (board: BoardDTO) => void; - handleDeleteBoardImages: (boardId: string) => void; - handleDeleteBoardOnly: (boardId: string) => void; -}; - -export const DeleteBoardImagesContext = - createContext({ - isOpen: false, - onClose: () => undefined, - onClickDeleteBoardImages: () => undefined, - handleDeleteBoardImages: () => undefined, - handleDeleteBoardOnly: () => undefined, - }); - -type Props = PropsWithChildren; - -export const DeleteBoardImagesContextProvider = (props: Props) => { - const [boardToDelete, setBoardToDelete] = useState(); - const { isOpen, onOpen, onClose } = useDisclosure(); - const dispatch = useAppDispatch(); - - // Check where the board images to be deleted are used (eg init image, controlnet, etc.) - const imagesUsage = useAppSelector((state) => - selectBoardImagesUsage(state, boardToDelete?.board_id) - ); - - const [deleteBoard] = useDeleteBoardMutation(); - - // Clean up after deleting or dismissing the modal - const closeAndClearBoardToDelete = useCallback(() => { - setBoardToDelete(undefined); - onClose(); - }, [onClose]); - - const onClickDeleteBoardImages = useCallback( - (board?: BoardDTO) => { - console.log({ board }); - if (!board) { - return; - } - setBoardToDelete(board); - onOpen(); - }, - [setBoardToDelete, onOpen] - ); - - const handleDeleteBoardImages = useCallback( - (boardId: string) => { - if (boardToDelete) { - dispatch( - requestedBoardImagesDeletion({ board: boardToDelete, imagesUsage }) - ); - closeAndClearBoardToDelete(); - } - }, - [dispatch, closeAndClearBoardToDelete, boardToDelete, imagesUsage] - ); - - const handleDeleteBoardOnly = useCallback( - (boardId: string) => { - if (boardToDelete) { - deleteBoard(boardId); - closeAndClearBoardToDelete(); - } - }, - [deleteBoard, closeAndClearBoardToDelete, boardToDelete] - ); - - return ( - - {props.children} - - ); -}; 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 35bfde2bff..6c3a4508b4 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -11,7 +11,7 @@ import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingA import { addAppConfigReceivedListener } from './listeners/appConfigReceived'; import { addAppStartedListener } from './listeners/appStarted'; import { addBoardIdSelectedListener } from './listeners/boardIdSelected'; -import { addRequestedBoardImageDeletionListener } from './listeners/boardImagesDeleted'; +import { addDeleteBoardAndImagesFulfilledListener } from './listeners/boardAndImagesDeleted'; import { addCanvasCopiedToClipboardListener } from './listeners/canvasCopiedToClipboard'; import { addCanvasDownloadedAsImageListener } from './listeners/canvasDownloadedAsImage'; import { addCanvasMergedListener } from './listeners/canvasMerged'; @@ -29,10 +29,6 @@ import { addRequestedImageDeletionListener, } from './listeners/imageDeleted'; import { addImageDroppedListener } from './listeners/imageDropped'; -import { - addImageMetadataReceivedFulfilledListener, - addImageMetadataReceivedRejectedListener, -} from './listeners/imageMetadataReceived'; import { addImageRemovedFromBoardFulfilledListener, addImageRemovedFromBoardRejectedListener, @@ -46,18 +42,10 @@ import { addImageUploadedFulfilledListener, addImageUploadedRejectedListener, } from './listeners/imageUploaded'; -import { - addImageUrlsReceivedFulfilledListener, - addImageUrlsReceivedRejectedListener, -} from './listeners/imageUrlsReceived'; import { addInitialImageSelectedListener } from './listeners/initialImageSelected'; import { addModelSelectedListener } from './listeners/modelSelected'; import { addModelsLoadedListener } from './listeners/modelsLoaded'; import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema'; -import { - addReceivedPageOfImagesFulfilledListener, - addReceivedPageOfImagesRejectedListener, -} from './listeners/receivedPageOfImages'; import { addSessionCanceledFulfilledListener, addSessionCanceledPendingListener, @@ -91,6 +79,7 @@ import { addUserInvokedTextToImageListener } from './listeners/userInvokedTextTo import { addModelLoadStartedEventListener } from './listeners/socketio/socketModelLoadStarted'; import { addModelLoadCompletedEventListener } from './listeners/socketio/socketModelLoadCompleted'; import { addUpscaleRequestedListener } from './listeners/upscaleRequested'; +import { addFirstListImagesListener } from './listeners/addFirstListImagesListener.ts'; export const listenerMiddleware = createListenerMiddleware(); @@ -132,17 +121,9 @@ addRequestedImageDeletionListener(); addImageDeletedPendingListener(); addImageDeletedFulfilledListener(); addImageDeletedRejectedListener(); -addRequestedBoardImageDeletionListener(); +addDeleteBoardAndImagesFulfilledListener(); addImageToDeleteSelectedListener(); -// Image metadata -addImageMetadataReceivedFulfilledListener(); -addImageMetadataReceivedRejectedListener(); - -// Image URLs -addImageUrlsReceivedFulfilledListener(); -addImageUrlsReceivedRejectedListener(); - // User Invoked addUserInvokedCanvasListener(); addUserInvokedNodesListener(); @@ -198,17 +179,10 @@ addSessionCanceledPendingListener(); addSessionCanceledFulfilledListener(); addSessionCanceledRejectedListener(); -// Fetching images -addReceivedPageOfImagesFulfilledListener(); -addReceivedPageOfImagesRejectedListener(); - // ControlNet addControlNetImageProcessedListener(); addControlNetAutoProcessListener(); -// Update image URLs on connect -// addUpdateImageUrlsOnConnectListener(); - // Boards addImageAddedToBoardFulfilledListener(); addImageAddedToBoardRejectedListener(); @@ -229,5 +203,7 @@ addModelSelectedListener(); addAppStartedListener(); addModelsLoadedListener(); addAppConfigReceivedListener(); +addFirstListImagesListener(); +// Ad-hoc upscale workflwo addUpscaleRequestedListener(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts new file mode 100644 index 0000000000..d01a6440a8 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts @@ -0,0 +1,43 @@ +import { createAction } from '@reduxjs/toolkit'; +import { + IMAGE_CATEGORIES, + imageSelected, +} from 'features/gallery/store/gallerySlice'; +import { + ImageCache, + getListImagesUrl, + imagesApi, +} from 'services/api/endpoints/images'; +import { startAppListening } from '..'; + +export const appStarted = createAction('app/appStarted'); + +export const addFirstListImagesListener = () => { + startAppListening({ + matcher: imagesApi.endpoints.listImages.matchFulfilled, + effect: async ( + action, + { getState, dispatch, unsubscribe, cancelActiveListeners } + ) => { + // Only run this listener on the first listImages request for `images` categories + if ( + action.meta.arg.queryCacheKey !== + getListImagesUrl({ categories: IMAGE_CATEGORIES }) + ) { + return; + } + + // this should only run once + cancelActiveListeners(); + unsubscribe(); + + // TODO: figure out how to type the predicate + const data = action.payload as ImageCache; + + if (data.ids.length > 0) { + // Select the first image + dispatch(imageSelected(data.ids[0] as string)); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts index 9f7085db6f..cfe9fd4a1c 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts @@ -1,11 +1,4 @@ import { createAction } from '@reduxjs/toolkit'; -import { - ASSETS_CATEGORIES, - IMAGE_CATEGORIES, - INITIAL_IMAGE_LIMIT, - isLoadingChanged, -} from 'features/gallery/store/gallerySlice'; -import { receivedPageOfImages } from 'services/api/thunks/image'; import { startAppListening } from '..'; export const appStarted = createAction('app/appStarted'); @@ -17,29 +10,9 @@ export const addAppStartedListener = () => { action, { getState, dispatch, unsubscribe, cancelActiveListeners } ) => { + // this should only run once cancelActiveListeners(); unsubscribe(); - // fill up the gallery tab with images - await dispatch( - receivedPageOfImages({ - categories: IMAGE_CATEGORIES, - is_intermediate: false, - offset: 0, - limit: INITIAL_IMAGE_LIMIT, - }) - ); - - // fill up the assets tab with images - await dispatch( - receivedPageOfImages({ - categories: ASSETS_CATEGORIES, - is_intermediate: false, - offset: 0, - limit: INITIAL_IMAGE_LIMIT, - }) - ); - - dispatch(isLoadingChanged(false)); }, }); }; 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 new file mode 100644 index 0000000000..8c5572f399 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts @@ -0,0 +1,48 @@ +import { resetCanvas } from 'features/canvas/store/canvasSlice'; +import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; +import { getImageUsage } from 'features/imageDeletion/store/imageDeletionSlice'; +import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; +import { clearInitialImage } from 'features/parameters/store/generationSlice'; +import { startAppListening } from '..'; +import { boardsApi } from '../../../../../services/api/endpoints/boards'; + +export const addDeleteBoardAndImagesFulfilledListener = () => { + startAppListening({ + matcher: boardsApi.endpoints.deleteBoardAndImages.matchFulfilled, + effect: async (action, { dispatch, getState, condition }) => { + const { board_id, deleted_board_images, deleted_images } = action.payload; + + // Remove all deleted images from the UI + + let wasInitialImageReset = false; + let wasCanvasReset = false; + let wasNodeEditorReset = false; + let wasControlNetReset = false; + + const state = getState(); + deleted_images.forEach((image_name) => { + const imageUsage = getImageUsage(state, image_name); + + if (imageUsage.isInitialImage && !wasInitialImageReset) { + dispatch(clearInitialImage()); + wasInitialImageReset = true; + } + + if (imageUsage.isCanvasImage && !wasCanvasReset) { + dispatch(resetCanvas()); + wasCanvasReset = true; + } + + if (imageUsage.isNodesImage && !wasNodeEditorReset) { + dispatch(nodeEditorReset()); + wasNodeEditorReset = true; + } + + if (imageUsage.isControlNetImage && !wasControlNetReset) { + dispatch(controlNetReset()); + wasControlNetReset = true; + } + }); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index 9ce17e3099..c3e789ff6e 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -1,17 +1,13 @@ import { log } from 'app/logging/useLogger'; -import { selectFilteredImages } from 'features/gallery/store/gallerySelectors'; import { - ASSETS_CATEGORIES, - IMAGE_CATEGORIES, boardIdSelected, imageSelected, - selectImagesAll, } from 'features/gallery/store/gallerySlice'; -import { boardsApi } from 'services/api/endpoints/boards'; import { - IMAGES_PER_PAGE, - receivedPageOfImages, -} from 'services/api/thunks/image'; + getBoardIdQueryParamForBoard, + getCategoriesQueryParamForBoard, +} from 'features/gallery/store/util'; +import { imagesApi } from 'services/api/endpoints/images'; import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'boards' }); @@ -19,54 +15,44 @@ const moduleLog = log.child({ namespace: 'boards' }); export const addBoardIdSelectedListener = () => { startAppListening({ actionCreator: boardIdSelected, - effect: (action, { getState, dispatch }) => { - const board_id = action.payload; + effect: async ( + action, + { getState, dispatch, condition, cancelActiveListeners } + ) => { + // Cancel any in-progress instances of this listener, we don't want to select an image from a previous board + cancelActiveListeners(); - // we need to check if we need to fetch more images + const _board_id = action.payload; + // when a board is selected, we need to wait until the board has loaded *some* images, then select the first one - const state = getState(); - const allImages = selectImagesAll(state); + const categories = getCategoriesQueryParamForBoard(_board_id); + const board_id = getBoardIdQueryParamForBoard(_board_id); + const queryArgs = { board_id, categories }; - if (board_id === 'all') { - // Selected all images - dispatch(imageSelected(allImages[0]?.image_name ?? null)); - return; - } + // wait until the board has some images - maybe it already has some from a previous fetch + // must use getState() to ensure we do not have stale state + const isSuccess = await condition( + () => + imagesApi.endpoints.listImages.select(queryArgs)(getState()) + .isSuccess, + 1000 + ); - if (board_id === 'batch') { - // Selected the batch - dispatch(imageSelected(state.gallery.batchImageNames[0] ?? null)); - return; - } + if (isSuccess) { + // the board was just changed - we can select the first image + const { data: boardImagesData } = imagesApi.endpoints.listImages.select( + queryArgs + )(getState()); - const filteredImages = selectFilteredImages(state); - - const categories = - state.gallery.galleryView === 'images' - ? IMAGE_CATEGORIES - : ASSETS_CATEGORIES; - - // get the board from the cache - const { data: boards } = - boardsApi.endpoints.listAllBoards.select()(state); - const board = boards?.find((b) => b.board_id === board_id); - - if (!board) { - // can't find the board in cache... - dispatch(boardIdSelected('all')); - return; - } - - dispatch(imageSelected(board.cover_image_name ?? null)); - - // if we haven't loaded one full page of images from this board, load more - if ( - filteredImages.length < board.image_count && - filteredImages.length < IMAGES_PER_PAGE - ) { - dispatch( - receivedPageOfImages({ categories, board_id, is_intermediate: false }) - ); + if (boardImagesData?.ids.length) { + dispatch(imageSelected((boardImagesData.ids[0] as string) ?? null)); + } else { + // board has no images - deselect + dispatch(imageSelected(null)); + } + } else { + // fallback - deselect + dispatch(imageSelected(null)); } }, }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts deleted file mode 100644 index 4b48aa4626..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { requestedBoardImagesDeletion } from 'features/gallery/store/actions'; -import { startAppListening } from '..'; -import { - imageSelected, - imagesRemoved, - selectImagesAll, - selectImagesById, -} from 'features/gallery/store/gallerySlice'; -import { resetCanvas } from 'features/canvas/store/canvasSlice'; -import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; -import { clearInitialImage } from 'features/parameters/store/generationSlice'; -import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; -import { LIST_TAG, api } from 'services/api'; -import { boardsApi } from '../../../../../services/api/endpoints/boards'; - -export const addRequestedBoardImageDeletionListener = () => { - startAppListening({ - actionCreator: requestedBoardImagesDeletion, - effect: async (action, { dispatch, getState, condition }) => { - const { board, imagesUsage } = action.payload; - - const { board_id } = board; - - const state = getState(); - const selectedImageName = - state.gallery.selection[state.gallery.selection.length - 1]; - - const selectedImage = selectedImageName - ? selectImagesById(state, selectedImageName) - : undefined; - - if (selectedImage && selectedImage.board_id === board_id) { - dispatch(imageSelected(null)); - } - - // We need to reset the features where the board images are in use - none of these work if their image(s) don't exist - - if (imagesUsage.isCanvasImage) { - dispatch(resetCanvas()); - } - - if (imagesUsage.isControlNetImage) { - dispatch(controlNetReset()); - } - - if (imagesUsage.isInitialImage) { - dispatch(clearInitialImage()); - } - - if (imagesUsage.isNodesImage) { - dispatch(nodeEditorReset()); - } - - // Preemptively remove from gallery - const images = selectImagesAll(state).reduce((acc: string[], img) => { - if (img.board_id === board_id) { - acc.push(img.image_name); - } - return acc; - }, []); - dispatch(imagesRemoved(images)); - - // Delete from server - dispatch(boardsApi.endpoints.deleteBoardAndImages.initiate(board_id)); - const result = - boardsApi.endpoints.deleteBoardAndImages.select(board_id)(state); - const { isSuccess } = result; - - // Wait for successful deletion, then trigger boards to re-fetch - const wasBoardDeleted = await condition(() => !!isSuccess, 30000); - - if (wasBoardDeleted) { - dispatch( - api.util.invalidateTags([ - { type: 'Board', id: board_id }, - { type: 'Image', id: LIST_TAG }, - ]) - ); - } - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMerged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMerged.ts index ce135ab3d0..0d0192143f 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMerged.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMerged.ts @@ -1,11 +1,11 @@ -import { canvasMerged } from 'features/canvas/store/actions'; -import { startAppListening } from '..'; import { log } from 'app/logging/useLogger'; -import { addToast } from 'features/system/store/systemSlice'; -import { imageUploaded } from 'services/api/thunks/image'; +import { canvasMerged } from 'features/canvas/store/actions'; import { setMergedCanvas } from 'features/canvas/store/canvasSlice'; -import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider'; import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob'; +import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider'; +import { addToast } from 'features/system/store/systemSlice'; +import { imagesApi } from 'services/api/endpoints/images'; +import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' }); @@ -46,27 +46,28 @@ export const addCanvasMergedListener = () => { }); const imageUploadedRequest = dispatch( - imageUploaded({ + imagesApi.endpoints.uploadImage.initiate({ file: new File([blob], 'mergedCanvas.png', { type: 'image/png', }), image_category: 'general', is_intermediate: true, postUploadAction: { - type: 'TOAST_CANVAS_MERGED', + type: 'TOAST', + toastOptions: { title: 'Canvas Merged' }, }, }) ); const [{ payload }] = await take( - ( - uploadedImageAction - ): uploadedImageAction is ReturnType => - imageUploaded.fulfilled.match(uploadedImageAction) && + (uploadedImageAction) => + imagesApi.endpoints.uploadImage.matchFulfilled(uploadedImageAction) && uploadedImageAction.meta.requestId === imageUploadedRequest.requestId ); - const { image_name } = payload; + // TODO: I can't figure out how to do the type narrowing in the `take()` so just brute forcing it here + const { image_name } = + payload as typeof imagesApi.endpoints.uploadImage.Types.ResultType; dispatch( setMergedCanvas({ @@ -76,13 +77,6 @@ export const addCanvasMergedListener = () => { ...baseLayerRect, }) ); - - dispatch( - addToast({ - title: 'Canvas Merged', - status: 'success', - }) - ); }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts index 610d89873f..3b7b8e7b75 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts @@ -1,10 +1,9 @@ -import { canvasSavedToGallery } from 'features/canvas/store/actions'; -import { startAppListening } from '..'; import { log } from 'app/logging/useLogger'; -import { imageUploaded } from 'services/api/thunks/image'; +import { canvasSavedToGallery } from 'features/canvas/store/actions'; import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; import { addToast } from 'features/system/store/systemSlice'; -import { imageUpserted } from 'features/gallery/store/gallerySlice'; +import { imagesApi } from 'services/api/endpoints/images'; +import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' }); @@ -28,28 +27,19 @@ export const addCanvasSavedToGalleryListener = () => { return; } - const imageUploadedRequest = dispatch( - imageUploaded({ + dispatch( + imagesApi.endpoints.uploadImage.initiate({ file: new File([blob], 'savedCanvas.png', { type: 'image/png', }), image_category: 'general', is_intermediate: false, postUploadAction: { - type: 'TOAST_CANVAS_SAVED_TO_GALLERY', + type: 'TOAST', + toastOptions: { title: 'Canvas Saved to Gallery' }, }, }) ); - - const [{ payload: uploadedImageDTO }] = await take( - ( - uploadedImageAction - ): uploadedImageAction is ReturnType => - imageUploaded.fulfilled.match(uploadedImageAction) && - uploadedImageAction.meta.requestId === imageUploadedRequest.requestId - ); - - dispatch(imageUpserted(uploadedImageDTO)); }, }); }; 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 42387b8078..8d369a021f 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 @@ -2,10 +2,10 @@ import { log } from 'app/logging/useLogger'; import { controlNetImageProcessed } from 'features/controlNet/store/actions'; import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice'; import { sessionReadyToInvoke } from 'features/system/store/actions'; +import { imagesApi } from 'services/api/endpoints/images'; import { isImageOutput } from 'services/api/guards'; -import { imageDTOReceived } from 'services/api/thunks/image'; import { sessionCreated } from 'services/api/thunks/session'; -import { Graph } from 'services/api/types'; +import { Graph, ImageDTO } from 'services/api/types'; import { socketInvocationComplete } from 'services/events/actions'; import { startAppListening } from '..'; @@ -62,12 +62,13 @@ export const addControlNetImageProcessedListener = () => { invocationCompleteAction.payload.data.result.image; // Wait for the ImageDTO to be received - const [imageMetadataReceivedAction] = await take( - (action): action is ReturnType => - imageDTOReceived.fulfilled.match(action) && + const [{ payload }] = await take( + (action) => + imagesApi.endpoints.getImageDTO.matchFulfilled(action) && action.payload.image_name === image_name ); - const processedControlImage = imageMetadataReceivedAction.payload; + + const processedControlImage = payload as ImageDTO; moduleLog.debug( { data: { arg: action.payload, processedControlImage } }, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts index c92eeac0db..6e1c34a04d 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts @@ -1,31 +1,30 @@ import { log } from 'app/logging/useLogger'; -import { boardImagesApi } from 'services/api/endpoints/boardImages'; +import { imagesApi } from 'services/api/endpoints/images'; import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'boards' }); export const addImageAddedToBoardFulfilledListener = () => { startAppListening({ - matcher: boardImagesApi.endpoints.addImageToBoard.matchFulfilled, + matcher: imagesApi.endpoints.addImageToBoard.matchFulfilled, effect: (action, { getState, dispatch }) => { - const { board_id, image_name } = action.meta.arg.originalArgs; + const { board_id, imageDTO } = action.meta.arg.originalArgs; - moduleLog.debug( - { data: { board_id, image_name } }, - 'Image added to board' - ); + // TODO: update listImages cache for this board + + moduleLog.debug({ data: { board_id, imageDTO } }, 'Image added to board'); }, }); }; export const addImageAddedToBoardRejectedListener = () => { startAppListening({ - matcher: boardImagesApi.endpoints.addImageToBoard.matchRejected, + matcher: imagesApi.endpoints.addImageToBoard.matchRejected, effect: (action, { getState, dispatch }) => { - const { board_id, image_name } = action.meta.arg.originalArgs; + const { board_id, imageDTO } = action.meta.arg.originalArgs; moduleLog.debug( - { data: { board_id, image_name } }, + { data: { board_id, imageDTO } }, 'Problem adding image to board' ); }, 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 c90c08d94a..f179530045 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,19 +1,17 @@ import { log } from 'app/logging/useLogger'; import { resetCanvas } from 'features/canvas/store/canvasSlice'; import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; -import { selectNextImageToSelect } from 'features/gallery/store/gallerySelectors'; -import { - imageRemoved, - imageSelected, -} from 'features/gallery/store/gallerySlice'; +import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { imageSelected } from 'features/gallery/store/gallerySlice'; import { imageDeletionConfirmed, isModalOpenChanged, } from 'features/imageDeletion/store/imageDeletionSlice'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; import { clearInitialImage } from 'features/parameters/store/generationSlice'; +import { clamp } from 'lodash-es'; import { api } from 'services/api'; -import { imageDeleted } from 'services/api/thunks/image'; +import { imagesApi } from 'services/api/endpoints/images'; import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'image' }); @@ -36,10 +34,28 @@ export const addRequestedImageDeletionListener = () => { state.gallery.selection[state.gallery.selection.length - 1]; if (lastSelectedImage === image_name) { - const newSelectedImageId = selectNextImageToSelect(state, image_name); + const baseQueryArgs = selectListImagesBaseQueryArgs(state); + const { data } = + imagesApi.endpoints.listImages.select(baseQueryArgs)(state); + + const ids = data?.ids ?? []; + + const deletedImageIndex = ids.findIndex( + (result) => result.toString() === image_name + ); + + const filteredIds = ids.filter((id) => id.toString() !== image_name); + + const newSelectedImageIndex = clamp( + deletedImageIndex, + 0, + filteredIds.length - 1 + ); + + const newSelectedImageId = filteredIds[newSelectedImageIndex]; if (newSelectedImageId) { - dispatch(imageSelected(newSelectedImageId)); + dispatch(imageSelected(newSelectedImageId as string)); } else { dispatch(imageSelected(null)); } @@ -63,16 +79,15 @@ export const addRequestedImageDeletionListener = () => { dispatch(nodeEditorReset()); } - // Preemptively remove from gallery - dispatch(imageRemoved(image_name)); - // Delete from server - const { requestId } = dispatch(imageDeleted({ image_name })); + const { requestId } = dispatch( + imagesApi.endpoints.deleteImage.initiate(imageDTO) + ); // Wait for successful deletion, then trigger boards to re-fetch const wasImageDeleted = await condition( - (action): action is ReturnType => - imageDeleted.fulfilled.match(action) && + (action) => + imagesApi.endpoints.deleteImage.matchFulfilled(action) && action.meta.requestId === requestId, 30000 ); @@ -91,7 +106,7 @@ export const addRequestedImageDeletionListener = () => { */ export const addImageDeletedPendingListener = () => { startAppListening({ - actionCreator: imageDeleted.pending, + matcher: imagesApi.endpoints.deleteImage.matchPending, effect: (action, { dispatch, getState }) => { // }, @@ -103,9 +118,12 @@ export const addImageDeletedPendingListener = () => { */ export const addImageDeletedFulfilledListener = () => { startAppListening({ - actionCreator: imageDeleted.fulfilled, + matcher: imagesApi.endpoints.deleteImage.matchFulfilled, effect: (action, { dispatch, getState }) => { - moduleLog.debug({ data: { image: action.meta.arg } }, 'Image deleted'); + moduleLog.debug( + { data: { image: action.meta.arg.originalArgs } }, + 'Image deleted' + ); }, }); }; @@ -115,10 +133,10 @@ export const addImageDeletedFulfilledListener = () => { */ export const addImageDeletedRejectedListener = () => { startAppListening({ - actionCreator: imageDeleted.rejected, + matcher: imagesApi.endpoints.deleteImage.matchRejected, effect: (action, { dispatch, getState }) => { moduleLog.debug( - { data: { image: action.meta.arg } }, + { data: { image: action.meta.arg.originalArgs } }, 'Unable to delete image' ); }, 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 51894d50de..4da7264cbb 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 @@ -10,12 +10,9 @@ import { imageSelected, imagesAddedToBatch, } from 'features/gallery/store/gallerySlice'; -import { - fieldValueChanged, - imageCollectionFieldValueChanged, -} from 'features/nodes/store/nodesSlice'; +import { fieldValueChanged } from 'features/nodes/store/nodesSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice'; -import { boardImagesApi } from 'services/api/endpoints/boardImages'; +import { imagesApi } from 'services/api/endpoints/images'; import { startAppListening } from '../'; const moduleLog = log.child({ namespace: 'dnd' }); @@ -137,23 +134,23 @@ export const addImageDroppedListener = () => { return; } - // set multiple nodes images (multiple images handler) - if ( - overData.actionType === 'SET_MULTI_NODES_IMAGE' && - activeData.payloadType === 'IMAGE_NAMES' - ) { - const { fieldName, nodeId } = overData.context; - dispatch( - imageCollectionFieldValueChanged({ - nodeId, - fieldName, - value: activeData.payload.image_names.map((image_name) => ({ - image_name, - })), - }) - ); - return; - } + // // set multiple nodes images (multiple images handler) + // if ( + // overData.actionType === 'SET_MULTI_NODES_IMAGE' && + // activeData.payloadType === 'IMAGE_NAMES' + // ) { + // const { fieldName, nodeId } = overData.context; + // dispatch( + // imageCollectionFieldValueChanged({ + // nodeId, + // fieldName, + // value: activeData.payload.image_names.map((image_name) => ({ + // image_name, + // })), + // }) + // ); + // return; + // } // add image to board if ( @@ -162,97 +159,95 @@ export const addImageDroppedListener = () => { activeData.payload.imageDTO && overData.context.boardId ) { - const { image_name } = activeData.payload.imageDTO; + const { imageDTO } = activeData.payload; const { boardId } = overData.context; + + // if the board is "No Board", this is a remove action + if (boardId === 'no_board') { + dispatch( + imagesApi.endpoints.removeImageFromBoard.initiate({ + imageDTO, + }) + ); + return; + } + + // Handle adding image to batch + if (boardId === 'batch') { + // TODO + } + + // Otherwise, add the image to the board dispatch( - boardImagesApi.endpoints.addImageToBoard.initiate({ - image_name, + imagesApi.endpoints.addImageToBoard.initiate({ + imageDTO, board_id: boardId, }) ); return; } - // remove image from board - if ( - overData.actionType === 'MOVE_BOARD' && - activeData.payloadType === 'IMAGE_DTO' && - activeData.payload.imageDTO && - overData.context.boardId === null - ) { - const { image_name, board_id } = activeData.payload.imageDTO; - if (board_id) { - dispatch( - boardImagesApi.endpoints.removeImageFromBoard.initiate({ - image_name, - board_id, - }) - ); - } - return; - } + // // add gallery selection to board + // if ( + // overData.actionType === 'MOVE_BOARD' && + // activeData.payloadType === 'IMAGE_NAMES' && + // overData.context.boardId + // ) { + // console.log('adding gallery selection to board'); + // const board_id = overData.context.boardId; + // dispatch( + // boardImagesApi.endpoints.addManyBoardImages.initiate({ + // board_id, + // image_names: activeData.payload.image_names, + // }) + // ); + // return; + // } - // add gallery selection to board - if ( - overData.actionType === 'MOVE_BOARD' && - activeData.payloadType === 'IMAGE_NAMES' && - overData.context.boardId - ) { - console.log('adding gallery selection to board'); - const board_id = overData.context.boardId; - dispatch( - boardImagesApi.endpoints.addManyBoardImages.initiate({ - board_id, - image_names: activeData.payload.image_names, - }) - ); - return; - } + // // remove gallery selection from board + // if ( + // overData.actionType === 'MOVE_BOARD' && + // activeData.payloadType === 'IMAGE_NAMES' && + // overData.context.boardId === null + // ) { + // console.log('removing gallery selection to board'); + // dispatch( + // boardImagesApi.endpoints.deleteManyBoardImages.initiate({ + // image_names: activeData.payload.image_names, + // }) + // ); + // return; + // } - // remove gallery selection from board - if ( - overData.actionType === 'MOVE_BOARD' && - activeData.payloadType === 'IMAGE_NAMES' && - overData.context.boardId === null - ) { - console.log('removing gallery selection to board'); - dispatch( - boardImagesApi.endpoints.deleteManyBoardImages.initiate({ - image_names: activeData.payload.image_names, - }) - ); - return; - } + // // add batch selection to board + // if ( + // overData.actionType === 'MOVE_BOARD' && + // activeData.payloadType === 'IMAGE_NAMES' && + // overData.context.boardId + // ) { + // const board_id = overData.context.boardId; + // dispatch( + // boardImagesApi.endpoints.addManyBoardImages.initiate({ + // board_id, + // image_names: activeData.payload.image_names, + // }) + // ); + // return; + // } - // add batch selection to board - if ( - overData.actionType === 'MOVE_BOARD' && - activeData.payloadType === 'IMAGE_NAMES' && - overData.context.boardId - ) { - const board_id = overData.context.boardId; - dispatch( - boardImagesApi.endpoints.addManyBoardImages.initiate({ - board_id, - image_names: activeData.payload.image_names, - }) - ); - return; - } - - // remove batch selection from board - if ( - overData.actionType === 'MOVE_BOARD' && - activeData.payloadType === 'IMAGE_NAMES' && - overData.context.boardId === null - ) { - dispatch( - boardImagesApi.endpoints.deleteManyBoardImages.initiate({ - image_names: activeData.payload.image_names, - }) - ); - return; - } + // // remove batch selection from board + // if ( + // overData.actionType === 'MOVE_BOARD' && + // activeData.payloadType === 'IMAGE_NAMES' && + // overData.context.boardId === null + // ) { + // dispatch( + // boardImagesApi.endpoints.deleteManyBoardImages.initiate({ + // image_names: activeData.payload.image_names, + // }) + // ); + // return; + // } }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts deleted file mode 100644 index 8a6d069ab0..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { log } from 'app/logging/useLogger'; -import { imageUpserted } from 'features/gallery/store/gallerySlice'; -import { imageDTOReceived, imageUpdated } from 'services/api/thunks/image'; -import { startAppListening } from '..'; - -const moduleLog = log.child({ namespace: 'image' }); - -export const addImageMetadataReceivedFulfilledListener = () => { - startAppListening({ - actionCreator: imageDTOReceived.fulfilled, - effect: (action, { getState, dispatch }) => { - const image = action.payload; - - const state = getState(); - - if ( - image.session_id === state.canvas.layerState.stagingArea.sessionId && - state.canvas.shouldAutoSave - ) { - dispatch( - imageUpdated({ - image_name: image.image_name, - is_intermediate: image.is_intermediate, - }) - ); - } else if (image.is_intermediate) { - // No further actions needed for intermediate images - moduleLog.trace( - { data: { image } }, - 'Image metadata received (intermediate), skipping' - ); - return; - } - - moduleLog.debug({ data: { image } }, 'Image metadata received'); - dispatch(imageUpserted(image)); - }, - }); -}; - -export const addImageMetadataReceivedRejectedListener = () => { - startAppListening({ - actionCreator: imageDTOReceived.rejected, - effect: (action, { getState, dispatch }) => { - moduleLog.debug( - { data: { image: action.meta.arg } }, - 'Problem receiving image metadata' - ); - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard.ts index 3c6731bb31..a9dd6eda3c 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard.ts @@ -1,12 +1,12 @@ import { log } from 'app/logging/useLogger'; -import { boardImagesApi } from 'services/api/endpoints/boardImages'; +import { imagesApi } from 'services/api/endpoints/images'; import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'boards' }); export const addImageRemovedFromBoardFulfilledListener = () => { startAppListening({ - matcher: boardImagesApi.endpoints.removeImageFromBoard.matchFulfilled, + matcher: imagesApi.endpoints.removeImageFromBoard.matchFulfilled, effect: (action, { getState, dispatch }) => { const { board_id, image_name } = action.meta.arg.originalArgs; @@ -20,7 +20,7 @@ export const addImageRemovedFromBoardFulfilledListener = () => { export const addImageRemovedFromBoardRejectedListener = () => { startAppListening({ - matcher: boardImagesApi.endpoints.removeImageFromBoard.matchRejected, + matcher: imagesApi.endpoints.removeImageFromBoard.matchRejected, effect: (action, { getState, dispatch }) => { const { board_id, image_name } = action.meta.arg.originalArgs; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUpdated.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUpdated.ts index 2e235aeb33..d6a24cda24 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUpdated.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUpdated.ts @@ -1,15 +1,20 @@ -import { startAppListening } from '..'; -import { imageUpdated } from 'services/api/thunks/image'; import { log } from 'app/logging/useLogger'; +import { imagesApi } from 'services/api/endpoints/images'; +import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'image' }); export const addImageUpdatedFulfilledListener = () => { startAppListening({ - actionCreator: imageUpdated.fulfilled, + matcher: imagesApi.endpoints.updateImage.matchFulfilled, effect: (action, { dispatch, getState }) => { moduleLog.debug( - { oldImage: action.meta.arg, updatedImage: action.payload }, + { + data: { + oldImage: action.meta.arg.originalArgs, + updatedImage: action.payload, + }, + }, 'Image updated' ); }, @@ -18,9 +23,12 @@ export const addImageUpdatedFulfilledListener = () => { export const addImageUpdatedRejectedListener = () => { startAppListening({ - actionCreator: imageUpdated.rejected, + matcher: imagesApi.endpoints.updateImage.matchRejected, effect: (action, { dispatch }) => { - moduleLog.debug({ oldImage: action.meta.arg }, 'Image update failed'); + moduleLog.debug( + { data: action.meta.arg.originalArgs }, + 'Image update failed' + ); }, }); }; 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 cca01354b5..1f24bdba2a 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 @@ -1,49 +1,87 @@ +import { UseToastOptions } from '@chakra-ui/react'; import { log } from 'app/logging/useLogger'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice'; -import { - imageUpserted, - imagesAddedToBatch, -} from 'features/gallery/store/gallerySlice'; +import { imagesAddedToBatch } from 'features/gallery/store/gallerySlice'; import { fieldValueChanged } from 'features/nodes/store/nodesSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { addToast } from 'features/system/store/systemSlice'; -import { imageUploaded } from 'services/api/thunks/image'; +import { boardsApi } from 'services/api/endpoints/boards'; import { startAppListening } from '..'; +import { + SYSTEM_BOARDS, + imagesApi, +} from '../../../../../services/api/endpoints/images'; const moduleLog = log.child({ namespace: 'image' }); +const DEFAULT_UPLOADED_TOAST: UseToastOptions = { + title: 'Image Uploaded', + status: 'success', +}; + export const addImageUploadedFulfilledListener = () => { startAppListening({ - actionCreator: imageUploaded.fulfilled, + matcher: imagesApi.endpoints.uploadImage.matchFulfilled, effect: (action, { dispatch, getState }) => { - const image = action.payload; + const imageDTO = action.payload; + const state = getState(); + const { selectedBoardId } = state.gallery; - moduleLog.debug({ arg: '', image }, 'Image uploaded'); + moduleLog.debug({ arg: '', imageDTO }, 'Image uploaded'); - if (action.payload.is_intermediate) { - // No further actions needed for intermediate images + const { postUploadAction } = action.meta.arg.originalArgs; + + if ( + // No further actions needed for intermediate images, + action.payload.is_intermediate && + // unless they have an explicit post-upload action + !postUploadAction + ) { return; } - dispatch(imageUpserted(image)); + // default action - just upload and alert user + if (postUploadAction?.type === 'TOAST') { + const { toastOptions } = postUploadAction; + if (SYSTEM_BOARDS.includes(selectedBoardId)) { + dispatch(addToast({ ...DEFAULT_UPLOADED_TOAST, ...toastOptions })); + } else { + // Add this image to the board + dispatch( + imagesApi.endpoints.addImageToBoard.initiate({ + board_id: selectedBoardId, + imageDTO, + }) + ); - const { postUploadAction } = action.meta.arg; + // Attempt to get the board's name for the toast + const { data } = boardsApi.endpoints.listAllBoards.select()(state); - if (postUploadAction?.type === 'TOAST_CANVAS_SAVED_TO_GALLERY') { - dispatch( - addToast({ title: 'Canvas Saved to Gallery', status: 'success' }) - ); - return; - } + // Fall back to just the board id if we can't find the board for some reason + const board = data?.find((b) => b.board_id === selectedBoardId); + const description = board + ? `Added to board ${board.board_name}` + : `Added to board ${selectedBoardId}`; - if (postUploadAction?.type === 'TOAST_CANVAS_MERGED') { - dispatch(addToast({ title: 'Canvas Merged', status: 'success' })); + dispatch( + addToast({ + ...DEFAULT_UPLOADED_TOAST, + description, + }) + ); + } return; } if (postUploadAction?.type === 'SET_CANVAS_INITIAL_IMAGE') { - dispatch(setInitialCanvasImage(image)); + dispatch(setInitialCanvasImage(imageDTO)); + dispatch( + addToast({ + ...DEFAULT_UPLOADED_TOAST, + description: 'Set as canvas initial image', + }) + ); return; } @@ -52,30 +90,49 @@ export const addImageUploadedFulfilledListener = () => { dispatch( controlNetImageChanged({ controlNetId, - controlImage: image.image_name, + controlImage: imageDTO.image_name, + }) + ); + dispatch( + addToast({ + ...DEFAULT_UPLOADED_TOAST, + description: 'Set as control image', }) ); return; } if (postUploadAction?.type === 'SET_INITIAL_IMAGE') { - dispatch(initialImageChanged(image)); + dispatch(initialImageChanged(imageDTO)); + dispatch( + addToast({ + ...DEFAULT_UPLOADED_TOAST, + description: 'Set as initial image', + }) + ); return; } if (postUploadAction?.type === 'SET_NODES_IMAGE') { const { nodeId, fieldName } = postUploadAction; - dispatch(fieldValueChanged({ nodeId, fieldName, value: image })); - return; - } - - if (postUploadAction?.type === 'TOAST_UPLOADED') { - dispatch(addToast({ title: 'Image Uploaded', status: 'success' })); + dispatch(fieldValueChanged({ nodeId, fieldName, value: imageDTO })); + dispatch( + addToast({ + ...DEFAULT_UPLOADED_TOAST, + description: `Set as node field ${fieldName}`, + }) + ); return; } if (postUploadAction?.type === 'ADD_TO_BATCH') { - dispatch(imagesAddedToBatch([image.image_name])); + dispatch(imagesAddedToBatch([imageDTO.image_name])); + dispatch( + addToast({ + ...DEFAULT_UPLOADED_TOAST, + description: 'Added to batch', + }) + ); return; } }, @@ -84,10 +141,10 @@ export const addImageUploadedFulfilledListener = () => { export const addImageUploadedRejectedListener = () => { startAppListening({ - actionCreator: imageUploaded.rejected, + matcher: imagesApi.endpoints.uploadImage.matchRejected, effect: (action, { dispatch }) => { - const { formData, ...rest } = action.meta.arg; - const sanitizedData = { arg: { ...rest, formData: { file: '' } } }; + const { file, postUploadAction, ...rest } = action.meta.arg.originalArgs; + const sanitizedData = { arg: { ...rest, file: '' } }; moduleLog.error({ data: sanitizedData }, 'Image upload failed'); dispatch( addToast({ diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUrlsReceived.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUrlsReceived.ts deleted file mode 100644 index 0d8aa3d7c9..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUrlsReceived.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { log } from 'app/logging/useLogger'; -import { startAppListening } from '..'; -import { imageUrlsReceived } from 'services/api/thunks/image'; -import { imageUpdatedOne } from 'features/gallery/store/gallerySlice'; - -const moduleLog = log.child({ namespace: 'image' }); - -export const addImageUrlsReceivedFulfilledListener = () => { - startAppListening({ - actionCreator: imageUrlsReceived.fulfilled, - effect: (action, { getState, dispatch }) => { - const image = action.payload; - moduleLog.debug({ data: { image } }, 'Image URLs received'); - - const { image_name, image_url, thumbnail_url } = image; - - dispatch( - imageUpdatedOne({ - id: image_name, - changes: { image_url, thumbnail_url }, - }) - ); - }, - }); -}; - -export const addImageUrlsReceivedRejectedListener = () => { - startAppListening({ - actionCreator: imageUrlsReceived.rejected, - effect: (action, { getState, dispatch }) => { - moduleLog.debug( - { data: { image: action.meta.arg } }, - 'Problem getting image URLs' - ); - }, - }); -}; 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 index fe1a9bd806..0cd68cf6fa 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts @@ -1,11 +1,9 @@ -import { initialImageChanged } from 'features/parameters/store/generationSlice'; -import { t } from 'i18next'; -import { addToast } from 'features/system/store/systemSlice'; -import { startAppListening } from '..'; -import { initialImageSelected } from 'features/parameters/store/actions'; import { makeToast } from 'app/components/Toaster'; -import { selectImagesById } from 'features/gallery/store/gallerySlice'; -import { isImageDTO } from 'services/api/guards'; +import { initialImageSelected } from 'features/parameters/store/actions'; +import { initialImageChanged } from 'features/parameters/store/generationSlice'; +import { addToast } from 'features/system/store/systemSlice'; +import { t } from 'i18next'; +import { startAppListening } from '..'; export const addInitialImageSelectedListener = () => { startAppListening({ @@ -20,25 +18,7 @@ export const addInitialImageSelectedListener = () => { return; } - if (isImageDTO(action.payload)) { - dispatch(initialImageChanged(action.payload)); - dispatch(addToast(makeToast(t('toast.sentToImageToImage')))); - return; - } - - const imageName = action.payload; - const image = selectImagesById(getState(), imageName); - - if (!image) { - dispatch( - addToast( - makeToast({ title: t('toast.imageNotLoadedDesc'), status: 'error' }) - ) - ); - return; - } - - dispatch(initialImageChanged(image)); + dispatch(initialImageChanged(action.payload)); dispatch(addToast(makeToast(t('toast.sentToImageToImage')))); }, }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/receivedPageOfImages.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/receivedPageOfImages.ts deleted file mode 100644 index 3c11916be0..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/receivedPageOfImages.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { log } from 'app/logging/useLogger'; -import { startAppListening } from '..'; -import { serializeError } from 'serialize-error'; -import { receivedPageOfImages } from 'services/api/thunks/image'; -import { imagesApi } from 'services/api/endpoints/images'; - -const moduleLog = log.child({ namespace: 'gallery' }); - -export const addReceivedPageOfImagesFulfilledListener = () => { - startAppListening({ - actionCreator: receivedPageOfImages.fulfilled, - effect: (action, { getState, dispatch }) => { - const { items } = action.payload; - moduleLog.debug( - { data: { payload: action.payload } }, - `Received ${items.length} images` - ); - - items.forEach((image) => { - dispatch( - imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image) - ); - }); - }, - }); -}; - -export const addReceivedPageOfImagesRejectedListener = () => { - startAppListening({ - actionCreator: receivedPageOfImages.rejected, - effect: (action, { getState, dispatch }) => { - if (action.payload) { - moduleLog.debug( - { data: { error: serializeError(action.payload) } }, - 'Problem receiving images' - ); - } - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts index 2d091af0b6..c2c57e0913 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts @@ -1,9 +1,17 @@ import { log } from 'app/logging/useLogger'; import { addImageToStagingArea } from 'features/canvas/store/canvasSlice'; +import { + IMAGE_CATEGORIES, + boardIdSelected, + imageSelected, +} from 'features/gallery/store/gallerySlice'; import { progressImageSet } from 'features/system/store/systemSlice'; -import { boardImagesApi } from 'services/api/endpoints/boardImages'; +import { + SYSTEM_BOARDS, + imagesAdapter, + imagesApi, +} from 'services/api/endpoints/images'; import { isImageOutput } from 'services/api/guards'; -import { imageDTOReceived } from 'services/api/thunks/image'; import { sessionCanceled } from 'services/api/thunks/session'; import { appSocketInvocationComplete, @@ -22,7 +30,6 @@ export const addInvocationCompleteEventListener = () => { { data: action.payload }, `Invocation complete (${action.payload.data.node.type})` ); - const session_id = action.payload.data.graph_execution_state_id; const { cancelType, isCancelScheduled, boardIdToAddTo } = @@ -39,33 +46,70 @@ export const addInvocationCompleteEventListener = () => { // This complete event has an associated image output if (isImageOutput(result) && !nodeDenylist.includes(node.type)) { const { image_name } = result.image; + const { canvas, gallery } = getState(); - // Get its metadata - dispatch( - imageDTOReceived({ - image_name, - }) - ); + const imageDTO = await dispatch( + imagesApi.endpoints.getImageDTO.initiate(image_name) + ).unwrap(); - const [{ payload: imageDTO }] = await take( - imageDTOReceived.fulfilled.match - ); - - // Handle canvas image + // Add canvas images to the staging area if ( - graph_execution_state_id === - getState().canvas.layerState.stagingArea.sessionId + graph_execution_state_id === canvas.layerState.stagingArea.sessionId ) { dispatch(addImageToStagingArea(imageDTO)); } - if (boardIdToAddTo && !imageDTO.is_intermediate) { + if (!imageDTO.is_intermediate) { + // update the cache for 'All Images' dispatch( - boardImagesApi.endpoints.addImageToBoard.initiate({ - board_id: boardIdToAddTo, - image_name, - }) + imagesApi.util.updateQueryData( + 'listImages', + { + categories: IMAGE_CATEGORIES, + }, + (draft) => { + imagesAdapter.addOne(draft, imageDTO); + draft.total = draft.total + 1; + } + ) ); + + // update the cache for 'No Board' + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + { + board_id: 'none', + }, + (draft) => { + imagesAdapter.addOne(draft, imageDTO); + draft.total = draft.total + 1; + } + ) + ); + + // add image to the board if we had one selected + if (boardIdToAddTo && !SYSTEM_BOARDS.includes(boardIdToAddTo)) { + dispatch( + imagesApi.endpoints.addImageToBoard.initiate({ + board_id: boardIdToAddTo, + imageDTO, + }) + ); + } + + const { selectedBoardId } = gallery; + + if (boardIdToAddTo && boardIdToAddTo !== selectedBoardId) { + dispatch(boardIdSelected(boardIdToAddTo)); + } else if (!boardIdToAddTo) { + dispatch(boardIdSelected('all')); + } + + // If auto-switch is enabled, select the new image + if (getState().gallery.shouldAutoSwitch) { + dispatch(imageSelected(imageDTO.image_name)); + } } dispatch(progressImageSet(null)); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/stagingAreaImageSaved.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/stagingAreaImageSaved.ts index 36840e5de1..903d2472b2 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/stagingAreaImageSaved.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/stagingAreaImageSaved.ts @@ -1,9 +1,8 @@ -import { stagingAreaImageSaved } from 'features/canvas/store/actions'; -import { startAppListening } from '..'; import { log } from 'app/logging/useLogger'; -import { imageUpdated } from 'services/api/thunks/image'; -import { imageUpserted } from 'features/gallery/store/gallerySlice'; +import { stagingAreaImageSaved } from 'features/canvas/store/actions'; import { addToast } from 'features/system/store/systemSlice'; +import { imagesApi } from 'services/api/endpoints/images'; +import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'canvas' }); @@ -11,41 +10,27 @@ export const addStagingAreaImageSavedListener = () => { startAppListening({ actionCreator: stagingAreaImageSaved, effect: async (action, { dispatch, getState, take }) => { - const { imageName } = action.payload; + const { imageDTO } = action.payload; dispatch( - imageUpdated({ - image_name: imageName, - is_intermediate: false, + imagesApi.endpoints.updateImage.initiate({ + imageDTO, + changes: { is_intermediate: false }, }) - ); - - const [imageUpdatedAction] = await take( - (action) => - (imageUpdated.fulfilled.match(action) || - imageUpdated.rejected.match(action)) && - action.meta.arg.image_name === imageName - ); - - if (imageUpdated.rejected.match(imageUpdatedAction)) { - moduleLog.error( - { data: { arg: imageUpdatedAction.meta.arg } }, - 'Image saving failed' - ); - dispatch( - addToast({ - title: 'Image Saving Failed', - description: imageUpdatedAction.error.message, - status: 'error', - }) - ); - return; - } - - if (imageUpdated.fulfilled.match(imageUpdatedAction)) { - dispatch(imageUpserted(imageUpdatedAction.payload)); - dispatch(addToast({ title: 'Image Saved', status: 'success' })); - } + ) + .unwrap() + .then((image) => { + dispatch(addToast({ title: 'Image Saved', status: 'success' })); + }) + .catch((error) => { + dispatch( + addToast({ + title: 'Image Saving Failed', + description: error.message, + status: 'error', + }) + ); + }); }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateImageUrlsOnConnect.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateImageUrlsOnConnect.ts deleted file mode 100644 index 490d99290d..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateImageUrlsOnConnect.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { socketConnected } from 'services/events/actions'; -import { startAppListening } from '..'; -import { createSelector } from '@reduxjs/toolkit'; -import { generationSelector } from 'features/parameters/store/generationSelectors'; -import { canvasSelector } from 'features/canvas/store/canvasSelectors'; -import { nodesSelector } from 'features/nodes/store/nodesSlice'; -import { controlNetSelector } from 'features/controlNet/store/controlNetSlice'; -import { forEach, uniqBy } from 'lodash-es'; -import { imageUrlsReceived } from 'services/api/thunks/image'; -import { log } from 'app/logging/useLogger'; -import { selectImagesEntities } from 'features/gallery/store/gallerySlice'; - -const moduleLog = log.child({ namespace: 'images' }); - -const selectAllUsedImages = createSelector( - [ - generationSelector, - canvasSelector, - nodesSelector, - controlNetSelector, - selectImagesEntities, - ], - (generation, canvas, nodes, controlNet, imageEntities) => { - const allUsedImages: string[] = []; - - if (generation.initialImage) { - allUsedImages.push(generation.initialImage.imageName); - } - - canvas.layerState.objects.forEach((obj) => { - if (obj.kind === 'image') { - allUsedImages.push(obj.imageName); - } - }); - - nodes.nodes.forEach((node) => { - forEach(node.data.inputs, (input) => { - if (input.type === 'image' && input.value) { - allUsedImages.push(input.value.image_name); - } - }); - }); - - forEach(controlNet.controlNets, (c) => { - if (c.controlImage) { - allUsedImages.push(c.controlImage); - } - if (c.processedControlImage) { - allUsedImages.push(c.processedControlImage); - } - }); - - forEach(imageEntities, (image) => { - if (image) { - allUsedImages.push(image.image_name); - } - }); - - const uniqueImages = uniqBy(allUsedImages, 'image_name'); - - return uniqueImages; - } -); - -export const addUpdateImageUrlsOnConnectListener = () => { - startAppListening({ - actionCreator: socketConnected, - effect: async (action, { dispatch, getState, take }) => { - const state = getState(); - - if (!state.config.shouldUpdateImagesOnConnect) { - return; - } - - const allUsedImages = selectAllUsedImages(state); - - moduleLog.trace( - { data: allUsedImages }, - `Fetching new image URLs for ${allUsedImages.length} images` - ); - - allUsedImages.forEach((image_name) => { - dispatch( - imageUrlsReceived({ - image_name, - }) - ); - }); - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/userInvokedCanvas.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/userInvokedCanvas.ts index 1f9f773392..afddaf8bea 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/userInvokedCanvas.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/userInvokedCanvas.ts @@ -1,20 +1,20 @@ -import { startAppListening } from '..'; -import { sessionCreated } from 'services/api/thunks/session'; -import { buildCanvasGraph } from 'features/nodes/util/graphBuilders/buildCanvasGraph'; import { log } from 'app/logging/useLogger'; -import { canvasGraphBuilt } from 'features/nodes/store/actions'; -import { imageUpdated, imageUploaded } from 'services/api/thunks/image'; -import { ImageDTO } from 'services/api/types'; +import { userInvoked } from 'app/store/actions'; +import openBase64ImageInTab from 'common/util/openBase64ImageInTab'; import { canvasSessionIdChanged, stagingAreaInitialized, } from 'features/canvas/store/canvasSlice'; -import { userInvoked } from 'app/store/actions'; +import { blobToDataURL } from 'features/canvas/util/blobToDataURL'; import { getCanvasData } from 'features/canvas/util/getCanvasData'; import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode'; -import { blobToDataURL } from 'features/canvas/util/blobToDataURL'; -import openBase64ImageInTab from 'common/util/openBase64ImageInTab'; +import { canvasGraphBuilt } from 'features/nodes/store/actions'; +import { buildCanvasGraph } from 'features/nodes/util/graphBuilders/buildCanvasGraph'; import { sessionReadyToInvoke } from 'features/system/store/actions'; +import { imagesApi } from 'services/api/endpoints/images'; +import { sessionCreated } from 'services/api/thunks/session'; +import { ImageDTO } from 'services/api/types'; +import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'invoke' }); @@ -74,7 +74,7 @@ export const addUserInvokedCanvasListener = () => { if (['img2img', 'inpaint', 'outpaint'].includes(generationMode)) { // upload the image, saving the request id const { requestId: initImageUploadedRequestId } = dispatch( - imageUploaded({ + imagesApi.endpoints.uploadImage.initiate({ file: new File([baseBlob], 'canvasInitImage.png', { type: 'image/png', }), @@ -85,19 +85,20 @@ export const addUserInvokedCanvasListener = () => { // Wait for the image to be uploaded, matching by request id const [{ payload }] = await take( - (action): action is ReturnType => - imageUploaded.fulfilled.match(action) && + // TODO: figure out how to narrow this action's type + (action) => + imagesApi.endpoints.uploadImage.matchFulfilled(action) && action.meta.requestId === initImageUploadedRequestId ); - canvasInitImage = payload; + canvasInitImage = payload as ImageDTO; } // For inpaint/outpaint, we also need to upload the mask layer if (['inpaint', 'outpaint'].includes(generationMode)) { // upload the image, saving the request id const { requestId: maskImageUploadedRequestId } = dispatch( - imageUploaded({ + imagesApi.endpoints.uploadImage.initiate({ file: new File([maskBlob], 'canvasMaskImage.png', { type: 'image/png', }), @@ -108,12 +109,13 @@ export const addUserInvokedCanvasListener = () => { // Wait for the image to be uploaded, matching by request id const [{ payload }] = await take( - (action): action is ReturnType => - imageUploaded.fulfilled.match(action) && + // TODO: figure out how to narrow this action's type + (action) => + imagesApi.endpoints.uploadImage.matchFulfilled(action) && action.meta.requestId === maskImageUploadedRequestId ); - canvasMaskImage = payload; + canvasMaskImage = payload as ImageDTO; } const graph = buildCanvasGraph( @@ -144,9 +146,9 @@ export const addUserInvokedCanvasListener = () => { // Associate the init image with the session, now that we have the session ID if (['img2img', 'inpaint'].includes(generationMode) && canvasInitImage) { dispatch( - imageUpdated({ - image_name: canvasInitImage.image_name, - session_id: sessionId, + imagesApi.endpoints.updateImage.initiate({ + imageDTO: canvasInitImage, + changes: { session_id: sessionId }, }) ); } @@ -154,9 +156,9 @@ export const addUserInvokedCanvasListener = () => { // Associate the mask image with the session, now that we have the session ID if (['inpaint'].includes(generationMode) && canvasMaskImage) { dispatch( - imageUpdated({ - image_name: canvasMaskImage.image_name, - session_id: sessionId, + imagesApi.endpoints.updateImage.initiate({ + imageDTO: canvasMaskImage, + changes: { session_id: sessionId }, }) ); } diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx index c024622d2e..6082843c55 100644 --- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx @@ -11,13 +11,15 @@ import { TypesafeDroppableData, } from 'app/components/ImageDnd/typesafeDnd'; import IAIIconButton from 'common/components/IAIIconButton'; -import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { + IAILoadingImageFallback, + IAINoContentFallback, +} from 'common/components/IAIImageFallback'; import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react'; import { FaImage, FaUndo, FaUpload } from 'react-icons/fa'; -import { PostUploadAction } from 'services/api/thunks/image'; -import { ImageDTO } from 'services/api/types'; +import { ImageDTO, PostUploadAction } from 'services/api/types'; import { mode } from 'theme/util/mode'; import IAIDraggable from './IAIDraggable'; import IAIDroppable from './IAIDroppable'; @@ -46,6 +48,7 @@ type IAIDndImageProps = { isSelected?: boolean; thumbnail?: boolean; noContentFallback?: ReactElement; + useThumbailFallback?: boolean; }; const IAIDndImage = (props: IAIDndImageProps) => { @@ -71,6 +74,7 @@ const IAIDndImage = (props: IAIDndImageProps) => { resetTooltip = 'Reset', resetIcon = , noContentFallback = , + useThumbailFallback, } = props; const { colorMode } = useColorMode(); @@ -126,9 +130,14 @@ const IAIDndImage = (props: IAIDndImageProps) => { } + fallbackSrc={ + useThumbailFallback ? imageDTO.thumbnail_url : undefined + } + fallback={ + useThumbailFallback ? undefined : ( + + ) + } width={imageDTO.width} height={imageDTO.height} onError={onError} diff --git a/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx b/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx index 573a900fef..7601758409 100644 --- a/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx @@ -1,12 +1,12 @@ import { Flex, Text, useColorMode } from '@chakra-ui/react'; import { motion } from 'framer-motion'; -import { memo, useRef } from 'react'; +import { ReactNode, memo, useRef } from 'react'; import { mode } from 'theme/util/mode'; import { v4 as uuidv4 } from 'uuid'; type Props = { isOver: boolean; - label?: string; + label?: ReactNode; }; export const IAIDropOverlay = (props: Props) => { @@ -57,16 +57,16 @@ export const IAIDropOverlay = (props: Props) => { { sx={{ fontSize: '2xl', fontWeight: 600, - transform: isOver ? 'scale(1.02)' : 'scale(1)', + transform: isOver ? 'scale(1.1)' : 'scale(1)', color: isOver ? mode('base.50', 'base.50')(colorMode) - : mode('base.100', 'base.200')(colorMode), + : mode('base.200', 'base.300')(colorMode), transitionProperty: 'common', transitionDuration: '0.1s', }} diff --git a/invokeai/frontend/web/src/common/components/IAIDroppable.tsx b/invokeai/frontend/web/src/common/components/IAIDroppable.tsx index 98093d04e4..1038f36840 100644 --- a/invokeai/frontend/web/src/common/components/IAIDroppable.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDroppable.tsx @@ -5,12 +5,12 @@ import { useDroppable, } from 'app/components/ImageDnd/typesafeDnd'; import { AnimatePresence } from 'framer-motion'; -import { memo, useRef } from 'react'; +import { ReactNode, memo, useRef } from 'react'; import { v4 as uuidv4 } from 'uuid'; import IAIDropOverlay from './IAIDropOverlay'; type IAIDroppableProps = { - dropLabel?: string; + dropLabel?: ReactNode; disabled?: boolean; data?: TypesafeDroppableData; }; diff --git a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx index a07071ee79..2057525b7a 100644 --- a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx +++ b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx @@ -68,6 +68,7 @@ export const IAINoContentFallback = (props: IAINoImageFallbackProps) => { flexDir: 'column', gap: 2, userSelect: 'none', + opacity: 0.7, color: 'base.700', _dark: { color: 'base.500', diff --git a/invokeai/frontend/web/src/common/components/ImageUploadOverlay.tsx b/invokeai/frontend/web/src/common/components/ImageUploadOverlay.tsx index 862d806eb1..b2d5ddb2da 100644 --- a/invokeai/frontend/web/src/common/components/ImageUploadOverlay.tsx +++ b/invokeai/frontend/web/src/common/components/ImageUploadOverlay.tsx @@ -32,27 +32,57 @@ const ImageUploadOverlay = (props: ImageUploadOverlayProps) => { > + - {isDragAccept ? ( - Drop to Upload - ) : ( - <> - Invalid Upload - Must be single JPEG or PNG image - - )} + + {isDragAccept ? ( + Drop to Upload + ) : ( + <> + Invalid Upload + Must be single JPEG or PNG image + + )} + ); diff --git a/invokeai/frontend/web/src/common/components/ImageUploader.tsx b/invokeai/frontend/web/src/common/components/ImageUploader.tsx index d3565ff5ec..dbdaf26c5b 100644 --- a/invokeai/frontend/web/src/common/components/ImageUploader.tsx +++ b/invokeai/frontend/web/src/common/components/ImageUploader.tsx @@ -1,35 +1,43 @@ import { Box } from '@chakra-ui/react'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import useImageUploader from 'common/hooks/useImageUploader'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppToaster } from 'app/components/Toaster'; +import { useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { selectIsBusy } from 'features/system/store/systemSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { KeyboardEvent, - memo, ReactNode, + memo, useCallback, useEffect, useState, } from 'react'; import { FileRejection, useDropzone } from 'react-dropzone'; import { useTranslation } from 'react-i18next'; -import { imageUploaded } from 'services/api/thunks/image'; +import { useUploadImageMutation } from 'services/api/endpoints/images'; +import { PostUploadAction } from 'services/api/types'; import ImageUploadOverlay from './ImageUploadOverlay'; -import { useAppToaster } from 'app/components/Toaster'; -import { createSelector } from '@reduxjs/toolkit'; -import { systemSelector } from 'features/system/store/systemSelectors'; +import { AnimatePresence, motion } from 'framer-motion'; const selector = createSelector( - [systemSelector, activeTabNameSelector], - (system, activeTabName) => { - const { isConnected, isUploading } = system; + [activeTabNameSelector], + (activeTabName) => { + let postUploadAction: PostUploadAction = { type: 'TOAST' }; - const isUploaderDisabled = !isConnected || isUploading; + if (activeTabName === 'unifiedCanvas') { + postUploadAction = { type: 'SET_CANVAS_INITIAL_IMAGE' }; + } + + if (activeTabName === 'img2img') { + postUploadAction = { type: 'SET_INITIAL_IMAGE' }; + } return { - isUploaderDisabled, - activeTabName, + postUploadAction, }; - } + }, + defaultSelectorOptions ); type ImageUploaderProps = { @@ -38,12 +46,13 @@ type ImageUploaderProps = { const ImageUploader = (props: ImageUploaderProps) => { const { children } = props; - const dispatch = useAppDispatch(); - const { isUploaderDisabled, activeTabName } = useAppSelector(selector); + const { postUploadAction } = useAppSelector(selector); + const isBusy = useAppSelector(selectIsBusy); const toaster = useAppToaster(); const { t } = useTranslation(); const [isHandlingUpload, setIsHandlingUpload] = useState(false); - const { setOpenUploaderFunction } = useImageUploader(); + + const [uploadImage] = useUploadImageMutation(); const fileRejectionCallback = useCallback( (rejection: FileRejection) => { @@ -60,16 +69,14 @@ const ImageUploader = (props: ImageUploaderProps) => { const fileAcceptedCallback = useCallback( async (file: File) => { - dispatch( - imageUploaded({ - file, - image_category: 'user', - is_intermediate: false, - postUploadAction: { type: 'TOAST_UPLOADED' }, - }) - ); + uploadImage({ + file, + image_category: 'user', + is_intermediate: false, + postUploadAction, + }); }, - [dispatch] + [postUploadAction, uploadImage] ); const onDrop = useCallback( @@ -101,13 +108,12 @@ const ImageUploader = (props: ImageUploaderProps) => { isDragReject, isDragActive, inputRef, - open, } = useDropzone({ accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] }, noClick: true, onDrop, onDragOver: () => setIsHandlingUpload(true), - disabled: isUploaderDisabled, + disabled: isBusy, multiple: false, }); @@ -126,19 +132,13 @@ const ImageUploader = (props: ImageUploaderProps) => { } }; - // Set the open function so we can open the uploader from anywhere - setOpenUploaderFunction(open); - // Add the paste event listener document.addEventListener('paste', handlePaste); return () => { document.removeEventListener('paste', handlePaste); - setOpenUploaderFunction(() => { - return; - }); }; - }, [inputRef, open, setOpenUploaderFunction]); + }, [inputRef]); return ( { > {children} - {isDragActive && isHandlingUpload && ( - - )} + + {isDragActive && isHandlingUpload && ( + + + + )} + ); }; diff --git a/invokeai/frontend/web/src/common/components/ImageUploaderButton.tsx b/invokeai/frontend/web/src/common/components/ImageUploaderButton.tsx deleted file mode 100644 index bb24ce6e18..0000000000 --- a/invokeai/frontend/web/src/common/components/ImageUploaderButton.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Flex, Heading, Icon } from '@chakra-ui/react'; -import useImageUploader from 'common/hooks/useImageUploader'; -import { FaUpload } from 'react-icons/fa'; - -type ImageUploaderButtonProps = { - styleClass?: string; -}; - -const ImageUploaderButton = (props: ImageUploaderButtonProps) => { - const { styleClass } = props; - const { openUploader } = useImageUploader(); - - return ( - - - - Click or Drag and Drop - - - ); -}; - -export default ImageUploaderButton; diff --git a/invokeai/frontend/web/src/common/components/ImageUploaderIconButton.tsx b/invokeai/frontend/web/src/common/components/ImageUploaderIconButton.tsx deleted file mode 100644 index af5eb8dbb5..0000000000 --- a/invokeai/frontend/web/src/common/components/ImageUploaderIconButton.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { FaUpload } from 'react-icons/fa'; -import IAIIconButton from './IAIIconButton'; -import useImageUploader from 'common/hooks/useImageUploader'; - -const ImageUploaderIconButton = () => { - const { t } = useTranslation(); - const { openUploader } = useImageUploader(); - - return ( - } - onClick={openUploader} - /> - ); -}; - -export default ImageUploaderIconButton; diff --git a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx index 0712daf742..fad6deb350 100644 --- a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx +++ b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx @@ -1,7 +1,7 @@ -import { useAppDispatch } from 'app/store/storeHooks'; import { useCallback } from 'react'; import { useDropzone } from 'react-dropzone'; -import { PostUploadAction, imageUploaded } from 'services/api/thunks/image'; +import { useUploadImageMutation } from 'services/api/endpoints/images'; +import { PostUploadAction } from 'services/api/types'; type UseImageUploadButtonArgs = { postUploadAction?: PostUploadAction; @@ -12,7 +12,7 @@ type UseImageUploadButtonArgs = { * Provides image uploader functionality to any component. * * @example - * const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({ + * const { getUploadButtonProps, getUploadInputProps, openUploader } = useImageUploadButton({ * postUploadAction: { * type: 'SET_CONTROLNET_IMAGE', * controlNetId: '12345', @@ -20,6 +20,9 @@ type UseImageUploadButtonArgs = { * isDisabled: getIsUploadDisabled(), * }); * + * // open the uploaded directly + * const handleSomething = () => { openUploader() } + * * // in the render function *