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 3da94df7f4..559afa3b37 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}", @@ -234,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/invocations/latent.py b/invokeai/app/invocations/latent.py index 2800624235..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,7 +38,6 @@ from diffusers.models.attention_processor import ( XFormersAttnProcessor, ) - class LatentsField(BaseModel): """A latents field used for passing latents between invocations""" 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/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), ), ) diff --git a/invokeai/app/services/image_record_storage.py b/invokeai/app/services/image_record_storage.py index 7b37307ce8..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) @@ -97,8 +100,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 +325,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, @@ -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 @@ -392,8 +399,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..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) @@ -109,6 +108,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.""" @@ -378,16 +384,39 @@ 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 + 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 + + 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 diff --git a/invokeai/backend/model_management/convert_ckpt_to_diffusers.py b/invokeai/backend/model_management/convert_ckpt_to_diffusers.py index e3e64940de..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,7 +843,16 @@ def convert_ldm_clip_checkpoint(checkpoint): key ] - text_model.load_state_dict(text_model_dict) + # 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 @@ -947,7 +958,16 @@ def convert_open_clip_checkpoint(checkpoint): text_model_dict[new_key] = checkpoint[key] - text_model.load_state_dict(text_model_dict) + # 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 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 *