diff --git a/invokeai/app/cli/commands.py b/invokeai/app/cli/commands.py index 10d1ead677..bffb2988dc 100644 --- a/invokeai/app/cli/commands.py +++ b/invokeai/app/cli/commands.py @@ -47,7 +47,7 @@ def add_parsers( commands: list[type], command_field: str = "type", exclude_fields: list[str] = ["id", "type"], - add_arguments: Callable[[argparse.ArgumentParser], None]|None = None + add_arguments: Union[Callable[[argparse.ArgumentParser], None],None] = None ): """Adds parsers for each command to the subparsers""" @@ -72,7 +72,7 @@ def add_parsers( def add_graph_parsers( subparsers, graphs: list[LibraryGraph], - add_arguments: Callable[[argparse.ArgumentParser], None]|None = None + add_arguments: Union[Callable[[argparse.ArgumentParser], None], None] = None ): for graph in graphs: command_parser = subparsers.add_parser(graph.name, help=graph.description) diff --git a/invokeai/app/cli_app.py b/invokeai/app/cli_app.py index 07193c8500..1f734b1f4f 100644 --- a/invokeai/app/cli_app.py +++ b/invokeai/app/cli_app.py @@ -1,7 +1,6 @@ # Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) import argparse -import os import re import shlex import sys @@ -348,7 +347,7 @@ def invoke_cli(): # Parse invocation command: CliCommand = None # type:ignore - system_graph: LibraryGraph|None = None + system_graph: Union[LibraryGraph,None] = None if args['type'] in system_graph_names: system_graph = next(filter(lambda g: g.name == args['type'], system_graphs)) invocation = GraphInvocation(graph=system_graph.graph, id=str(current_id)) diff --git a/invokeai/app/invocations/baseinvocation.py b/invokeai/app/invocations/baseinvocation.py index 4ce3e839b6..1bf9353368 100644 --- a/invokeai/app/invocations/baseinvocation.py +++ b/invokeai/app/invocations/baseinvocation.py @@ -97,6 +97,7 @@ class UIConfig(TypedDict, total=False): "latents", "model", "control", + "image_collection", ], ] tags: List[str] diff --git a/invokeai/app/invocations/collections.py b/invokeai/app/invocations/collections.py index 891f217317..33bde42d69 100644 --- a/invokeai/app/invocations/collections.py +++ b/invokeai/app/invocations/collections.py @@ -4,13 +4,16 @@ from typing import Literal import numpy as np from pydantic import Field, validator +from invokeai.app.models.image import ImageField from invokeai.app.util.misc import SEED_MAX, get_random_seed from .baseinvocation import ( BaseInvocation, + InvocationConfig, InvocationContext, BaseInvocationOutput, + UIConfig, ) @@ -22,6 +25,7 @@ class IntCollectionOutput(BaseInvocationOutput): # Outputs collection: list[int] = Field(default=[], description="The int collection") + class FloatCollectionOutput(BaseInvocationOutput): """A collection of floats""" @@ -31,6 +35,18 @@ class FloatCollectionOutput(BaseInvocationOutput): collection: list[float] = Field(default=[], description="The float collection") +class ImageCollectionOutput(BaseInvocationOutput): + """A collection of images""" + + type: Literal["image_collection"] = "image_collection" + + # Outputs + collection: list[ImageField] = Field(default=[], description="The output images") + + class Config: + schema_extra = {"required": ["type", "collection"]} + + class RangeInvocation(BaseInvocation): """Creates a range of numbers from start to stop with step""" @@ -92,3 +108,27 @@ class RandomRangeInvocation(BaseInvocation): return IntCollectionOutput( collection=list(rng.integers(low=self.low, high=self.high, size=self.size)) ) + + +class ImageCollectionInvocation(BaseInvocation): + """Load a collection of images and provide it as output.""" + + # fmt: off + type: Literal["image_collection"] = "image_collection" + + # Inputs + images: list[ImageField] = Field( + default=[], description="The image collection to load" + ) + # fmt: on + def invoke(self, context: InvocationContext) -> ImageCollectionOutput: + return ImageCollectionOutput(collection=self.images) + + class Config(InvocationConfig): + schema_extra = { + "ui": { + "type_hints": { + "images": "image_collection", + } + }, + } diff --git a/invokeai/app/services/board_images.py b/invokeai/app/services/board_images.py index 072effbfae..aea8a53876 100644 --- a/invokeai/app/services/board_images.py +++ b/invokeai/app/services/board_images.py @@ -132,7 +132,7 @@ class BoardImagesService(BoardImagesServiceABC): def board_record_to_dto( - board_record: BoardRecord, cover_image_name: str | None, image_count: int + board_record: BoardRecord, cover_image_name: Union[str, None], image_count: int ) -> BoardDTO: """Converts a board record to a board DTO.""" return BoardDTO( diff --git a/invokeai/app/services/events.py b/invokeai/app/services/events.py index e578a24006..61acf1f499 100644 --- a/invokeai/app/services/events.py +++ b/invokeai/app/services/events.py @@ -1,6 +1,6 @@ # Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) -from typing import Any +from typing import Any, Union from invokeai.app.models.image import ProgressImage from invokeai.app.util.misc import get_timestamp from invokeai.app.services.model_manager_service import BaseModelType, ModelType, SubModelType, ModelInfo @@ -28,7 +28,7 @@ class EventServiceBase: graph_execution_state_id: str, node: dict, source_node_id: str, - progress_image: ProgressImage | None, + progress_image: Union[ProgressImage, None], step: int, total_steps: int, ) -> None: diff --git a/invokeai/app/services/graph.py b/invokeai/app/services/graph.py index e3cd3d47ce..f019ab1f70 100644 --- a/invokeai/app/services/graph.py +++ b/invokeai/app/services/graph.py @@ -3,7 +3,6 @@ import copy import itertools import uuid -from types import NoneType from typing import ( Annotated, Any, @@ -26,6 +25,8 @@ from ..invocations.baseinvocation import ( InvocationContext, ) +# in 3.10 this would be "from types import NoneType" +NoneType = type(None) class EdgeConnection(BaseModel): node_id: str = Field(description="The id of the node for this edge connection") @@ -846,7 +847,7 @@ class GraphExecutionState(BaseModel): ] } - def next(self) -> BaseInvocation | None: + def next(self) -> Union[BaseInvocation, None]: """Gets the next node ready to execute.""" # TODO: enable multiple nodes to execute simultaneously by tracking currently executing nodes diff --git a/invokeai/app/services/image_file_storage.py b/invokeai/app/services/image_file_storage.py index f30499ea26..23ea6fca7e 100644 --- a/invokeai/app/services/image_file_storage.py +++ b/invokeai/app/services/image_file_storage.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from pathlib import Path from queue import Queue -from typing import Dict, Optional +from typing import Dict, Optional, Union from PIL.Image import Image as PILImageType from PIL import Image, PngImagePlugin @@ -80,7 +80,7 @@ class DiskImageFileStorage(ImageFileStorageBase): __cache: Dict[Path, PILImageType] __max_cache_size: int - def __init__(self, output_folder: str | Path): + def __init__(self, output_folder: Union[str, Path]): self.__cache = dict() self.__cache_ids = Queue() self.__max_cache_size = 10 # TODO: get this from config @@ -164,7 +164,7 @@ class DiskImageFileStorage(ImageFileStorageBase): return path - def validate_path(self, path: str | Path) -> bool: + def validate_path(self, path: Union[str, Path]) -> bool: """Validates the path given for an image or thumbnail.""" path = path if isinstance(path, Path) else Path(path) return path.exists() @@ -175,7 +175,7 @@ class DiskImageFileStorage(ImageFileStorageBase): for folder in folders: folder.mkdir(parents=True, exist_ok=True) - def __get_cache(self, image_name: Path) -> PILImageType | None: + def __get_cache(self, image_name: Path) -> Union[PILImageType, None]: return None if image_name not in self.__cache else self.__cache[image_name] def __set_cache(self, image_name: Path, image: PILImageType): diff --git a/invokeai/app/services/image_record_storage.py b/invokeai/app/services/image_record_storage.py index 066e6f8d5f..dc975fc9fc 100644 --- a/invokeai/app/services/image_record_storage.py +++ b/invokeai/app/services/image_record_storage.py @@ -116,7 +116,7 @@ class ImageRecordStorageBase(ABC): pass @abstractmethod - def get_most_recent_image_for_board(self, board_id: str) -> ImageRecord | None: + def get_most_recent_image_for_board(self, board_id: str) -> Union[ImageRecord, None]: """Gets the most recent image for a board.""" pass diff --git a/invokeai/app/services/invocation_queue.py b/invokeai/app/services/invocation_queue.py index acfda6b90b..d100161bc1 100644 --- a/invokeai/app/services/invocation_queue.py +++ b/invokeai/app/services/invocation_queue.py @@ -5,6 +5,7 @@ from abc import ABC, abstractmethod from queue import Queue from pydantic import BaseModel, Field +from typing import Union class InvocationQueueItem(BaseModel): @@ -22,7 +23,7 @@ class InvocationQueueABC(ABC): pass @abstractmethod - def put(self, item: InvocationQueueItem | None) -> None: + def put(self, item: Union[InvocationQueueItem, None]) -> None: pass @abstractmethod @@ -57,7 +58,7 @@ class MemoryInvocationQueue(InvocationQueueABC): return item - def put(self, item: InvocationQueueItem | None) -> None: + def put(self, item: Union[InvocationQueueItem, None]) -> None: self.__queue.put(item) def cancel(self, graph_execution_state_id: str) -> None: diff --git a/invokeai/app/services/invoker.py b/invokeai/app/services/invoker.py index f12ba79c15..c8883a057e 100644 --- a/invokeai/app/services/invoker.py +++ b/invokeai/app/services/invoker.py @@ -2,6 +2,7 @@ from abc import ABC from threading import Event, Thread +from typing import Union from ..invocations.baseinvocation import InvocationContext from .graph import Graph, GraphExecutionState @@ -21,7 +22,7 @@ class Invoker: def invoke( self, graph_execution_state: GraphExecutionState, invoke_all: bool = False - ) -> str | None: + ) -> Union[str, None]: """Determines the next node to invoke and enqueues it, preparing if needed. Returns the id of the queued node, or `None` if there are no nodes left to enqueue.""" @@ -45,7 +46,7 @@ class Invoker: return invocation.id - def create_execution_state(self, graph: Graph | None = None) -> GraphExecutionState: + def create_execution_state(self, graph: Union[Graph, None] = None) -> GraphExecutionState: """Creates a new execution state for the given graph""" new_state = GraphExecutionState(graph=Graph() if graph is None else graph) self.services.graph_execution_manager.set(new_state) diff --git a/invokeai/app/services/latent_storage.py b/invokeai/app/services/latent_storage.py index 17d35d7c33..9836460319 100644 --- a/invokeai/app/services/latent_storage.py +++ b/invokeai/app/services/latent_storage.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from pathlib import Path from queue import Queue -from typing import Dict +from typing import Dict, Union import torch @@ -55,7 +55,7 @@ class ForwardCacheLatentsStorage(LatentsStorageBase): if name in self.__cache: del self.__cache[name] - def __get_cache(self, name: str) -> torch.Tensor|None: + def __get_cache(self, name: str) -> Union[torch.Tensor, None]: return None if name not in self.__cache else self.__cache[name] def __set_cache(self, name: str, data: torch.Tensor): @@ -69,9 +69,9 @@ class ForwardCacheLatentsStorage(LatentsStorageBase): class DiskLatentsStorage(LatentsStorageBase): """Stores latents in a folder on disk without caching""" - __output_folder: str | Path + __output_folder: Union[str, Path] - def __init__(self, output_folder: str | Path): + def __init__(self, output_folder: Union[str, Path]): self.__output_folder = output_folder if isinstance(output_folder, Path) else Path(output_folder) self.__output_folder.mkdir(parents=True, exist_ok=True) @@ -91,4 +91,4 @@ class DiskLatentsStorage(LatentsStorageBase): def get_path(self, name: str) -> Path: return self.__output_folder / name - \ No newline at end of file + diff --git a/invokeai/backend/generator/base.py b/invokeai/backend/generator/base.py index 462b1a4f4b..485a99412c 100644 --- a/invokeai/backend/generator/base.py +++ b/invokeai/backend/generator/base.py @@ -21,7 +21,7 @@ from PIL import Image, ImageChops, ImageFilter from accelerate.utils import set_seed from diffusers import DiffusionPipeline from tqdm import trange -from typing import Callable, List, Iterator, Optional, Type +from typing import Callable, List, Iterator, Optional, Type, Union from dataclasses import dataclass, field from diffusers.schedulers import SchedulerMixin as Scheduler @@ -178,7 +178,7 @@ class InvokeAIGenerator(metaclass=ABCMeta): # ------------------------------------ class Img2Img(InvokeAIGenerator): def generate(self, - init_image: Image.Image | torch.FloatTensor, + init_image: Union[Image.Image, torch.FloatTensor], strength: float=0.75, **keyword_args )->Iterator[InvokeAIGeneratorOutput]: @@ -195,7 +195,7 @@ class Img2Img(InvokeAIGenerator): # Takes all the arguments of Img2Img and adds the mask image and the seam/infill stuff class Inpaint(Img2Img): def generate(self, - mask_image: Image.Image | torch.FloatTensor, + mask_image: Union[Image.Image, torch.FloatTensor], # Seam settings - when 0, doesn't fill seam seam_size: int = 96, seam_blur: int = 16, diff --git a/invokeai/backend/generator/inpaint.py b/invokeai/backend/generator/inpaint.py index eaf4047109..fc9b9d4373 100644 --- a/invokeai/backend/generator/inpaint.py +++ b/invokeai/backend/generator/inpaint.py @@ -203,8 +203,8 @@ class Inpaint(Img2Img): cfg_scale, ddim_eta, conditioning, - init_image: Image.Image | torch.FloatTensor, - mask_image: Image.Image | torch.FloatTensor, + init_image: Union[Image.Image, torch.FloatTensor], + mask_image: Union[Image.Image, torch.FloatTensor], strength: float, mask_blur_radius: int = 8, # Seam settings - when 0, doesn't fill seam diff --git a/invokeai/backend/model_management/models/__init__.py b/invokeai/backend/model_management/models/__init__.py index 87b0ad3c4e..10884b6a84 100644 --- a/invokeai/backend/model_management/models/__init__.py +++ b/invokeai/backend/model_management/models/__init__.py @@ -68,7 +68,11 @@ def get_model_config_enums(): enums = list() for model_config in MODEL_CONFIGS: - fields = inspect.get_annotations(model_config) + + if hasattr(inspect,'get_annotations'): + fields = inspect.get_annotations(model_config) + else: + fields = model_config.__annotations__ try: field = fields["model_format"] except: diff --git a/invokeai/backend/stable_diffusion/diffusers_pipeline.py b/invokeai/backend/stable_diffusion/diffusers_pipeline.py index 8493b4286f..457f4edbb0 100644 --- a/invokeai/backend/stable_diffusion/diffusers_pipeline.py +++ b/invokeai/backend/stable_diffusion/diffusers_pipeline.py @@ -7,7 +7,7 @@ import secrets from collections.abc import Sequence from dataclasses import dataclass, field from typing import Any, Callable, Generic, List, Optional, Type, TypeVar, Union -from pydantic import BaseModel, Field +from pydantic import Field import einops import PIL.Image @@ -17,12 +17,11 @@ import psutil import torch import torchvision.transforms as T from diffusers.models import AutoencoderKL, UNet2DConditionModel -from diffusers.models.controlnet import ControlNetModel, ControlNetOutput +from diffusers.models.controlnet import ControlNetModel from diffusers.pipelines.stable_diffusion import StableDiffusionPipelineOutput from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion import ( StableDiffusionPipeline, ) -from diffusers.pipelines.controlnet import MultiControlNetModel from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion_img2img import ( StableDiffusionImg2ImgPipeline, @@ -46,7 +45,7 @@ from .diffusion import ( InvokeAIDiffuserComponent, PostprocessingSettings, ) -from .offloading import FullyLoadedModelGroup, LazilyLoadedModelGroup, ModelGroup +from .offloading import FullyLoadedModelGroup, ModelGroup @dataclass class PipelineIntermediateState: @@ -105,7 +104,7 @@ class AddsMaskGuidance: _debug: Optional[Callable] = None def __call__( - self, step_output: BaseOutput | SchedulerOutput, t: torch.Tensor, conditioning + self, step_output: Union[BaseOutput, SchedulerOutput], t: torch.Tensor, conditioning ) -> BaseOutput: output_class = step_output.__class__ # We'll create a new one with masked data. diff --git a/invokeai/backend/stable_diffusion/offloading.py b/invokeai/backend/stable_diffusion/offloading.py index 5fc3f765ae..d36b65872a 100644 --- a/invokeai/backend/stable_diffusion/offloading.py +++ b/invokeai/backend/stable_diffusion/offloading.py @@ -4,7 +4,7 @@ import warnings import weakref from abc import ABCMeta, abstractmethod from collections.abc import MutableMapping -from typing import Callable +from typing import Callable, Union import torch from accelerate.utils import send_to_device @@ -117,7 +117,7 @@ class LazilyLoadedModelGroup(ModelGroup): """ _hooks: MutableMapping[torch.nn.Module, RemovableHandle] - _current_model_ref: Callable[[], torch.nn.Module | _NoModel] + _current_model_ref: Callable[[], Union[torch.nn.Module, _NoModel]] def __init__(self, execution_device: torch.device): super().__init__(execution_device) diff --git a/invokeai/backend/util/devices.py b/invokeai/backend/util/devices.py index 615209d98d..de808972f0 100644 --- a/invokeai/backend/util/devices.py +++ b/invokeai/backend/util/devices.py @@ -4,6 +4,7 @@ from contextlib import nullcontext import torch from torch import autocast +from typing import Union from invokeai.app.services.config import InvokeAIAppConfig CPU_DEVICE = torch.device("cpu") @@ -49,7 +50,7 @@ def choose_autocast(precision): return nullcontext -def normalize_device(device: str | torch.device) -> torch.device: +def normalize_device(device: Union[str, torch.device]) -> torch.device: """Ensure device has a device index defined, if appropriate.""" device = torch.device(device) if device.index is None: diff --git a/invokeai/frontend/web/.eslintrc.js b/invokeai/frontend/web/.eslintrc.js index b1a2b6a7e4..34db9d466b 100644 --- a/invokeai/frontend/web/.eslintrc.js +++ b/invokeai/frontend/web/.eslintrc.js @@ -36,6 +36,12 @@ module.exports = { ], 'prettier/prettier': ['error', { endOfLine: 'auto' }], '@typescript-eslint/ban-ts-comment': 'warn', + '@typescript-eslint/no-empty-interface': [ + 'error', + { + allowSingleExtends: true, + }, + ], }, settings: { react: { diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 8c66222584..d89f141e33 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -83,7 +83,7 @@ "konva": "^9.2.0", "lodash-es": "^4.17.21", "nanostores": "^0.9.2", - "openapi-fetch": "^0.4.0", + "openapi-fetch": "0.4.0", "overlayscrollbars": "^2.2.0", "overlayscrollbars-react": "^0.5.0", "patch-package": "^7.0.0", diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 1b3b790222..ab5d536f0c 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -52,6 +52,7 @@ "unifiedCanvas": "Unified Canvas", "linear": "Linear", "nodes": "Node Editor", + "batch": "Batch Manager", "postprocessing": "Post Processing", "nodesDesc": "A node based system for the generation of images is under development currently. Stay tuned for updates about this amazing feature.", "postProcessing": "Post Processing", diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 5b3cf5925f..2b0e247d48 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -1,67 +1,40 @@ -import { Box, Flex, Grid, Portal } from '@chakra-ui/react'; +import { Flex, Grid, Portal } from '@chakra-ui/react'; import { useLogger } from 'app/logging/useLogger'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { PartialAppConfig } from 'app/types/invokeai'; import ImageUploader from 'common/components/ImageUploader'; -import Loading from 'common/components/Loading/Loading'; import GalleryDrawer from 'features/gallery/components/GalleryPanel'; import Lightbox from 'features/lightbox/components/Lightbox'; import SiteHeader from 'features/system/components/SiteHeader'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; -import { useIsApplicationReady } from 'features/system/hooks/useIsApplicationReady'; import { configChanged } from 'features/system/store/configSlice'; import { languageSelector } from 'features/system/store/systemSelectors'; import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton'; import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons'; import InvokeTabs from 'features/ui/components/InvokeTabs'; import ParametersDrawer from 'features/ui/components/ParametersDrawer'; -import { AnimatePresence, motion } from 'framer-motion'; import i18n from 'i18n'; -import { ReactNode, memo, useCallback, useEffect, useState } from 'react'; -import { APP_HEIGHT, APP_WIDTH } from 'theme/util/constants'; +import { ReactNode, memo, useEffect } from 'react'; import GlobalHotkeys from './GlobalHotkeys'; import Toaster from './Toaster'; -import DeleteImageModal from 'features/gallery/components/DeleteImageModal'; -import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal'; -import { useListModelsQuery } from 'services/api/endpoints/models'; import DeleteBoardImagesModal from '../../features/gallery/components/Boards/DeleteBoardImagesModal'; +import DeleteImageModal from 'features/imageDeletion/components/DeleteImageModal'; const DEFAULT_CONFIG = {}; interface Props { config?: PartialAppConfig; headerComponent?: ReactNode; - setIsReady?: (isReady: boolean) => void; } -const App = ({ - config = DEFAULT_CONFIG, - headerComponent, - setIsReady, -}: Props) => { +const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => { const language = useAppSelector(languageSelector); const log = useLogger(); const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled; - const isApplicationReady = useIsApplicationReady(); - - const { data: pipelineModels } = useListModelsQuery({ - model_type: 'main', - }); - const { data: controlnetModels } = useListModelsQuery({ - model_type: 'controlnet', - }); - const { data: vaeModels } = useListModelsQuery({ model_type: 'vae' }); - const { data: loraModels } = useListModelsQuery({ model_type: 'lora' }); - const { data: embeddingModels } = useListModelsQuery({ - model_type: 'embedding', - }); - - const [loadingOverridden, setLoadingOverridden] = useState(false); - const dispatch = useAppDispatch(); useEffect(() => { @@ -73,27 +46,6 @@ const App = ({ dispatch(configChanged(config)); }, [dispatch, config, log]); - const handleOverrideClicked = useCallback(() => { - setLoadingOverridden(true); - }, []); - - useEffect(() => { - if (isApplicationReady && setIsReady) { - setIsReady(true); - } - - if (isApplicationReady) { - // TODO: This is a jank fix for canvas not filling the screen on first load - setTimeout(() => { - dispatch(requestCanvasRescale()); - }, 200); - } - - return () => { - setIsReady && setIsReady(false); - }; - }, [dispatch, isApplicationReady, setIsReady]); - return ( <> @@ -123,33 +75,6 @@ const App = ({ - - - {!isApplicationReady && !loadingOverridden && ( - - - - - - - )} - - diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx new file mode 100644 index 0000000000..5b6142d748 --- /dev/null +++ b/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx @@ -0,0 +1,82 @@ +import { Box, ChakraProps, Flex, Heading, Image } from '@chakra-ui/react'; +import { memo } from 'react'; +import { TypesafeDraggableData } from './typesafeDnd'; + +type OverlayDragImageProps = { + dragData: TypesafeDraggableData | null; +}; + +const BOX_SIZE = 28; + +const STYLES: ChakraProps['sx'] = { + w: BOX_SIZE, + h: BOX_SIZE, + maxW: BOX_SIZE, + maxH: BOX_SIZE, + shadow: 'dark-lg', + borderRadius: 'lg', + borderWidth: 2, + borderStyle: 'dashed', + borderColor: 'base.100', + opacity: 0.5, + bg: 'base.800', + color: 'base.50', + _dark: { + borderColor: 'base.200', + bg: 'base.900', + color: 'base.100', + }, +}; + +const DragPreview = (props: OverlayDragImageProps) => { + if (!props.dragData) { + return; + } + + if (props.dragData.payloadType === 'IMAGE_DTO') { + return ( + + + + ); + } + + if (props.dragData.payloadType === 'IMAGE_NAMES') { + return ( + + {props.dragData.payload.imageNames.length} + Images + + ); + } + + return null; +}; + +export default memo(DragPreview); diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx index 6150259f66..1b8687bf8e 100644 --- a/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx +++ b/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx @@ -1,8 +1,5 @@ import { - DndContext, - DragEndEvent, DragOverlay, - DragStartEvent, MouseSensor, TouchSensor, pointerWithin, @@ -10,33 +7,45 @@ import { useSensors, } from '@dnd-kit/core'; import { PropsWithChildren, memo, useCallback, useState } from 'react'; -import OverlayDragImage from './OverlayDragImage'; -import { ImageDTO } from 'services/api/types'; -import { isImageDTO } from 'services/api/guards'; +import DragPreview from './DragPreview'; import { snapCenterToCursor } from '@dnd-kit/modifiers'; import { AnimatePresence, motion } from 'framer-motion'; +import { + DndContext, + DragEndEvent, + DragStartEvent, + TypesafeDraggableData, +} from './typesafeDnd'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { imageDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped'; type ImageDndContextProps = PropsWithChildren; const ImageDndContext = (props: ImageDndContextProps) => { - const [draggedImage, setDraggedImage] = useState(null); + const [activeDragData, setActiveDragData] = + useState(null); + + const dispatch = useAppDispatch(); const handleDragStart = useCallback((event: DragStartEvent) => { - const dragData = event.active.data.current; - if (dragData && 'image' in dragData && isImageDTO(dragData.image)) { - setDraggedImage(dragData.image); + const activeData = event.active.data.current; + if (!activeData) { + return; } + setActiveDragData(activeData); }, []); const handleDragEnd = useCallback( (event: DragEndEvent) => { - const handleDrop = event.over?.data.current?.handleDrop; - if (handleDrop && typeof handleDrop === 'function' && draggedImage) { - handleDrop(draggedImage); + const activeData = event.active.data.current; + const overData = event.over?.data.current; + if (!activeData || !overData) { + return; } - setDraggedImage(null); + dispatch(imageDropped({ overData, activeData })); + setActiveDragData(null); }, - [draggedImage] + [dispatch] ); const mouseSensor = useSensor(MouseSensor, { @@ -46,6 +55,7 @@ const ImageDndContext = (props: ImageDndContextProps) => { const touchSensor = useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 }, }); + // TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos // Alternatively, fix `rectIntersection` collection detection to work with the drag overlay // (currently the drag element collision rect is not correctly calculated) @@ -63,7 +73,7 @@ const ImageDndContext = (props: ImageDndContextProps) => { {props.children} - {draggedImage && ( + {activeDragData && ( { transition: { duration: 0.1 }, }} > - + )} diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/OverlayDragImage.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/OverlayDragImage.tsx deleted file mode 100644 index 611d1ceee9..0000000000 --- a/invokeai/frontend/web/src/app/components/ImageDnd/OverlayDragImage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Box, Image } from '@chakra-ui/react'; -import { memo } from 'react'; -import { ImageDTO } from 'services/api/types'; - -type OverlayDragImageProps = { - image: ImageDTO; -}; - -const OverlayDragImage = (props: OverlayDragImageProps) => { - return ( - - - - ); -}; - -export default memo(OverlayDragImage); diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx new file mode 100644 index 0000000000..e744a70750 --- /dev/null +++ b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx @@ -0,0 +1,195 @@ +// type-safe dnd from https://github.com/clauderic/dnd-kit/issues/935 +import { + Active, + Collision, + DndContextProps, + DndContext as OriginalDndContext, + Over, + Translate, + UseDraggableArguments, + UseDroppableArguments, + useDraggable as useOriginalDraggable, + useDroppable as useOriginalDroppable, +} from '@dnd-kit/core'; +import { ImageDTO } from 'services/api/types'; + +type BaseDropData = { + id: string; +}; + +export type CurrentImageDropData = BaseDropData & { + actionType: 'SET_CURRENT_IMAGE'; +}; + +export type InitialImageDropData = BaseDropData & { + actionType: 'SET_INITIAL_IMAGE'; +}; + +export type ControlNetDropData = BaseDropData & { + actionType: 'SET_CONTROLNET_IMAGE'; + context: { + controlNetId: string; + }; +}; + +export type CanvasInitialImageDropData = BaseDropData & { + actionType: 'SET_CANVAS_INITIAL_IMAGE'; +}; + +export type NodesImageDropData = BaseDropData & { + actionType: 'SET_NODES_IMAGE'; + context: { + nodeId: string; + fieldName: string; + }; +}; + +export type NodesMultiImageDropData = BaseDropData & { + actionType: 'SET_MULTI_NODES_IMAGE'; + context: { nodeId: string; fieldName: string }; +}; + +export type AddToBatchDropData = BaseDropData & { + actionType: 'ADD_TO_BATCH'; +}; + +export type MoveBoardDropData = BaseDropData & { + actionType: 'MOVE_BOARD'; + context: { boardId: string | null }; +}; + +export type TypesafeDroppableData = + | CurrentImageDropData + | InitialImageDropData + | ControlNetDropData + | CanvasInitialImageDropData + | NodesImageDropData + | AddToBatchDropData + | NodesMultiImageDropData + | MoveBoardDropData; + +type BaseDragData = { + id: string; +}; + +export type ImageDraggableData = BaseDragData & { + payloadType: 'IMAGE_DTO'; + payload: { imageDTO: ImageDTO }; +}; + +export type ImageNamesDraggableData = BaseDragData & { + payloadType: 'IMAGE_NAMES'; + payload: { imageNames: string[] }; +}; + +export type TypesafeDraggableData = + | ImageDraggableData + | ImageNamesDraggableData; + +interface UseDroppableTypesafeArguments + extends Omit { + data?: TypesafeDroppableData; +} + +type UseDroppableTypesafeReturnValue = Omit< + ReturnType, + 'active' | 'over' +> & { + active: TypesafeActive | null; + over: TypesafeOver | null; +}; + +export function useDroppable(props: UseDroppableTypesafeArguments) { + return useOriginalDroppable(props) as UseDroppableTypesafeReturnValue; +} + +interface UseDraggableTypesafeArguments + extends Omit { + data?: TypesafeDraggableData; +} + +type UseDraggableTypesafeReturnValue = Omit< + ReturnType, + 'active' | 'over' +> & { + active: TypesafeActive | null; + over: TypesafeOver | null; +}; + +export function useDraggable(props: UseDraggableTypesafeArguments) { + return useOriginalDraggable(props) as UseDraggableTypesafeReturnValue; +} + +interface TypesafeActive extends Omit { + data: React.MutableRefObject; +} + +interface TypesafeOver extends Omit { + data: React.MutableRefObject; +} + +export const isValidDrop = ( + overData: TypesafeDroppableData | undefined, + active: TypesafeActive | null +) => { + if (!overData || !active?.data.current) { + return false; + } + + const { actionType } = overData; + const { payloadType } = active.data.current; + + if (overData.id === active.data.current.id) { + return false; + } + + switch (actionType) { + case 'SET_CURRENT_IMAGE': + return payloadType === 'IMAGE_DTO'; + case 'SET_INITIAL_IMAGE': + return payloadType === 'IMAGE_DTO'; + case 'SET_CONTROLNET_IMAGE': + return payloadType === 'IMAGE_DTO'; + case 'SET_CANVAS_INITIAL_IMAGE': + return payloadType === 'IMAGE_DTO'; + case 'SET_NODES_IMAGE': + return payloadType === 'IMAGE_DTO'; + case 'SET_MULTI_NODES_IMAGE': + 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'; + default: + return false; + } +}; + +interface DragEvent { + activatorEvent: Event; + active: TypesafeActive; + collisions: Collision[] | null; + delta: Translate; + over: TypesafeOver | null; +} + +export interface DragStartEvent extends Pick {} +export interface DragMoveEvent extends DragEvent {} +export interface DragOverEvent extends DragMoveEvent {} +export interface DragEndEvent extends DragEvent {} +export interface DragCancelEvent extends DragEndEvent {} + +export interface DndContextTypesafeProps + extends Omit< + DndContextProps, + 'onDragStart' | 'onDragMove' | 'onDragOver' | 'onDragEnd' | 'onDragCancel' + > { + onDragStart?(event: DragStartEvent): void; + onDragMove?(event: DragMoveEvent): void; + onDragOver?(event: DragOverEvent): void; + onDragEnd?(event: DragEndEvent): void; + onDragCancel?(event: DragCancelEvent): void; +} +export function DndContext(props: DndContextTypesafeProps) { + return ; +} diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index 7259f6105d..105f8f18d7 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -7,7 +7,6 @@ import React, { } from 'react'; import { Provider } from 'react-redux'; import { store } from 'app/store/store'; -// import { OpenAPI } from 'services/api/types'; import Loading from '../../common/components/Loading/Loading'; import { addMiddleware, resetMiddlewares } from 'redux-dynamic-middlewares'; @@ -17,11 +16,6 @@ import '../../i18n'; import { socketMiddleware } from 'services/events/middleware'; import { Middleware } from '@reduxjs/toolkit'; import ImageDndContext from './ImageDnd/ImageDndContext'; -import { - DeleteImageContext, - DeleteImageContextProvider, -} from 'app/contexts/DeleteImageContext'; -import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal'; import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext'; import { $authToken, $baseUrl } from 'services/api/client'; import { DeleteBoardImagesContextProvider } from '../contexts/DeleteBoardImagesContext'; @@ -34,7 +28,6 @@ interface Props extends PropsWithChildren { token?: string; config?: PartialAppConfig; headerComponent?: ReactNode; - setIsReady?: (isReady: boolean) => void; middleware?: Middleware[]; } @@ -43,7 +36,6 @@ const InvokeAIUI = ({ token, config, headerComponent, - setIsReady, middleware, }: Props) => { useEffect(() => { @@ -85,17 +77,11 @@ const InvokeAIUI = ({ }> - - - - - - - + + + + + diff --git a/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx b/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx index 38c89bfcf9..15f9fab282 100644 --- a/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx +++ b/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx @@ -5,15 +5,15 @@ 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/imagesSlice'; -import { nodesSelector } from '../../features/nodes/store/nodesSlice'; -import { generationSelector } from '../../features/parameters/store/generationSelectors'; +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'; +import { requestedBoardImagesDeletion } from 'features/gallery/store/actions'; export const selectBoardImagesUsage = createSelector( [ diff --git a/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx b/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx deleted file mode 100644 index 6f4af7608f..0000000000 --- a/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { useDisclosure } from '@chakra-ui/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { requestedImageDeletion } from 'features/gallery/store/actions'; -import { systemSelector } from 'features/system/store/systemSelectors'; -import { - PropsWithChildren, - createContext, - useCallback, - useEffect, - useState, -} from 'react'; -import { ImageDTO } from 'services/api/types'; -import { RootState } from 'app/store/store'; -import { canvasSelector } from 'features/canvas/store/canvasSelectors'; -import { controlNetSelector } from 'features/controlNet/store/controlNetSlice'; -import { nodesSelector } from 'features/nodes/store/nodesSlice'; -import { generationSelector } from 'features/parameters/store/generationSelectors'; -import { some } from 'lodash-es'; - -export type ImageUsage = { - isInitialImage: boolean; - isCanvasImage: boolean; - isNodesImage: boolean; - isControlNetImage: boolean; -}; - -export const selectImageUsage = createSelector( - [ - generationSelector, - canvasSelector, - nodesSelector, - controlNetSelector, - (state: RootState, image_name?: string) => image_name, - ], - (generation, canvas, nodes, controlNet, image_name) => { - const isInitialImage = generation.initialImage?.imageName === image_name; - - const isCanvasImage = canvas.layerState.objects.some( - (obj) => obj.kind === 'image' && obj.imageName === image_name - ); - - const isNodesImage = nodes.nodes.some((node) => { - return some( - node.data.inputs, - (input) => input.type === 'image' && input.value === image_name - ); - }); - - const isControlNetImage = some( - controlNet.controlNets, - (c) => - c.controlImage === image_name || c.processedControlImage === image_name - ); - - const imageUsage: ImageUsage = { - isInitialImage, - isCanvasImage, - isNodesImage, - isControlNetImage, - }; - - return imageUsage; - }, - defaultSelectorOptions -); - -type DeleteImageContextValue = { - /** - * Whether the delete image dialog is open. - */ - isOpen: boolean; - /** - * Closes the delete image dialog. - */ - onClose: () => void; - /** - * Opens the delete image dialog and handles all deletion-related checks. - */ - onDelete: (image?: ImageDTO) => void; - /** - * The image pending deletion - */ - image?: ImageDTO; - /** - * The features in which this image is used - */ - imageUsage?: ImageUsage; - /** - * Immediately deletes an image. - * - * You probably don't want to use this - use `onDelete` instead. - */ - onImmediatelyDelete: () => void; -}; - -export const DeleteImageContext = createContext({ - isOpen: false, - onClose: () => undefined, - onImmediatelyDelete: () => undefined, - onDelete: () => undefined, -}); - -const selector = createSelector( - [systemSelector], - (system) => { - const { isProcessing, isConnected, shouldConfirmOnDelete } = system; - - return { - canDeleteImage: isConnected && !isProcessing, - shouldConfirmOnDelete, - }; - }, - defaultSelectorOptions -); - -type Props = PropsWithChildren; - -export const DeleteImageContextProvider = (props: Props) => { - const { canDeleteImage, shouldConfirmOnDelete } = useAppSelector(selector); - const [imageToDelete, setImageToDelete] = useState(); - const dispatch = useAppDispatch(); - const { isOpen, onOpen, onClose } = useDisclosure(); - - // Check where the image to be deleted is used (eg init image, controlnet, etc.) - const imageUsage = useAppSelector((state) => - selectImageUsage(state, imageToDelete?.image_name) - ); - - // Clean up after deleting or dismissing the modal - const closeAndClearImageToDelete = useCallback(() => { - setImageToDelete(undefined); - onClose(); - }, [onClose]); - - // Dispatch the actual deletion action, to be handled by listener middleware - const handleActualDeletion = useCallback( - (image: ImageDTO) => { - dispatch(requestedImageDeletion({ image, imageUsage })); - closeAndClearImageToDelete(); - }, - [closeAndClearImageToDelete, dispatch, imageUsage] - ); - - // This is intended to be called by the delete button in the dialog - const onImmediatelyDelete = useCallback(() => { - if (canDeleteImage && imageToDelete) { - handleActualDeletion(imageToDelete); - } - closeAndClearImageToDelete(); - }, [ - canDeleteImage, - imageToDelete, - closeAndClearImageToDelete, - handleActualDeletion, - ]); - - const handleGatedDeletion = useCallback( - (image: ImageDTO) => { - if (shouldConfirmOnDelete || some(imageUsage)) { - // If we should confirm on delete, or if the image is in use, open the dialog - onOpen(); - } else { - handleActualDeletion(image); - } - }, - [imageUsage, shouldConfirmOnDelete, onOpen, handleActualDeletion] - ); - - // Consumers of the context call this to delete an image - const onDelete = useCallback((image?: ImageDTO) => { - if (!image) { - return; - } - // Set the image to delete, then let the effect call the actual deletion - setImageToDelete(image); - }, []); - - useEffect(() => { - // We need to use an effect here to trigger the image usage selector, else we get a stale value - if (imageToDelete) { - handleGatedDeletion(imageToDelete); - } - }, [handleGatedDeletion, imageToDelete]); - - return ( - - {props.children} - - ); -}; diff --git a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/unserialize.ts b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/unserialize.ts index 8f40b0bb59..23e6448987 100644 --- a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/unserialize.ts +++ b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/unserialize.ts @@ -1,7 +1,6 @@ import { initialCanvasState } from 'features/canvas/store/canvasSlice'; import { initialControlNetState } from 'features/controlNet/store/controlNetSlice'; import { initialGalleryState } from 'features/gallery/store/gallerySlice'; -import { initialImagesState } from 'features/gallery/store/imagesSlice'; import { initialLightboxState } from 'features/lightbox/store/lightboxSlice'; import { initialNodesState } from 'features/nodes/store/nodesSlice'; import { initialGenerationState } from 'features/parameters/store/generationSlice'; @@ -26,7 +25,6 @@ const initialStates: { config: initialConfigState, ui: initialUIState, hotkeys: initialHotkeysState, - images: initialImagesState, controlNet: initialControlNetState, }; 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 a36141fafc..900fabfee9 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -72,7 +72,6 @@ import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingA import { addImageCategoriesChangedListener } from './listeners/imageCategoriesChanged'; import { addControlNetImageProcessedListener } from './listeners/controlNetImageProcessed'; import { addControlNetAutoProcessListener } from './listeners/controlNetAutoProcess'; -import { addUpdateImageUrlsOnConnectListener } from './listeners/updateImageUrlsOnConnect'; import { addImageAddedToBoardFulfilledListener, addImageAddedToBoardRejectedListener, @@ -84,6 +83,9 @@ import { } from './listeners/imageRemovedFromBoard'; import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema'; import { addRequestedBoardImageDeletionListener } from './listeners/boardImagesDeleted'; +import { addSelectionAddedToBatchListener } from './listeners/selectionAddedToBatch'; +import { addImageDroppedListener } from './listeners/imageDropped'; +import { addImageToDeleteSelectedListener } from './listeners/imageToDeleteSelected'; export const listenerMiddleware = createListenerMiddleware(); @@ -126,6 +128,7 @@ addImageDeletedPendingListener(); addImageDeletedFulfilledListener(); addImageDeletedRejectedListener(); addRequestedBoardImageDeletionListener(); +addImageToDeleteSelectedListener(); // Image metadata addImageMetadataReceivedFulfilledListener(); @@ -211,3 +214,9 @@ addBoardIdSelectedListener(); // Node schemas addReceivedOpenAPISchemaListener(); + +// Batches +addSelectionAddedToBatchListener(); + +// DND +addImageDroppedListener(); 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 1c96c5700d..6ce6665cc5 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,12 +1,14 @@ import { log } from 'app/logging/useLogger'; import { startAppListening } from '..'; -import { boardIdSelected } from 'features/gallery/store/boardSlice'; -import { selectImagesAll } from 'features/gallery/store/imagesSlice'; +import { + imageSelected, + selectImagesAll, + boardIdSelected, +} from 'features/gallery/store/gallerySlice'; import { IMAGES_PER_PAGE, receivedPageOfImages, } from 'services/api/thunks/image'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; import { boardsApi } from 'services/api/endpoints/boards'; const moduleLog = log.child({ namespace: 'boards' }); @@ -28,7 +30,7 @@ export const addBoardIdSelectedListener = () => { return; } - const { categories } = state.images; + const { categories } = state.gallery; const filteredImages = allImages.filter((i) => { const isInCategory = categories.includes(i.image_category); @@ -47,7 +49,7 @@ export const addBoardIdSelectedListener = () => { return; } - dispatch(imageSelected(board.cover_image_name)); + dispatch(imageSelected(board.cover_image_name ?? null)); // if we haven't loaded one full page of images from this board, load more if ( @@ -77,7 +79,7 @@ export const addBoardIdSelected_changeSelectedImage_listener = () => { return; } - const { categories } = state.images; + const { categories } = state.gallery; const filteredImages = selectImagesAll(state).filter((i) => { const isInCategory = categories.includes(i.image_category); 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 index c4d3c5f0ba..4b48aa4626 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts @@ -1,11 +1,11 @@ import { requestedBoardImagesDeletion } from 'features/gallery/store/actions'; import { startAppListening } from '..'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; import { + imageSelected, imagesRemoved, selectImagesAll, selectImagesById, -} from 'features/gallery/store/imagesSlice'; +} 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'; @@ -22,12 +22,15 @@ export const addRequestedBoardImageDeletionListener = () => { const { board_id } = board; const state = getState(); - const selectedImage = state.gallery.selectedImage - ? selectImagesById(state, state.gallery.selectedImage) + 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()); + 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 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 af55a1382e..610d89873f 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 @@ -4,7 +4,7 @@ import { log } from 'app/logging/useLogger'; import { imageUploaded } from 'services/api/thunks/image'; import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; import { addToast } from 'features/system/store/systemSlice'; -import { imageUpserted } from 'features/gallery/store/imagesSlice'; +import { imageUpserted } from 'features/gallery/store/gallerySlice'; const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageCategoriesChanged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageCategoriesChanged.ts index 25b7b7c11f..178cb3c835 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageCategoriesChanged.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageCategoriesChanged.ts @@ -3,8 +3,8 @@ import { startAppListening } from '..'; import { receivedPageOfImages } from 'services/api/thunks/image'; import { imageCategoriesChanged, - selectFilteredImagesAsArray, -} from 'features/gallery/store/imagesSlice'; + selectFilteredImages, +} from 'features/gallery/store/gallerySlice'; const moduleLog = log.child({ namespace: 'gallery' }); @@ -13,7 +13,7 @@ export const addImageCategoriesChangedListener = () => { actionCreator: imageCategoriesChanged, effect: (action, { getState, dispatch }) => { const state = getState(); - const filteredImagesCount = selectFilteredImagesAsArray(state).length; + const filteredImagesCount = selectFilteredImages(state).length; if (!filteredImagesCount) { dispatch( 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 91cd509ca6..ca20170c5d 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,18 +1,21 @@ -import { requestedImageDeletion } from 'features/gallery/store/actions'; import { startAppListening } from '..'; import { imageDeleted } from 'services/api/thunks/image'; import { log } from 'app/logging/useLogger'; import { clamp } from 'lodash-es'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; import { + imageSelected, imageRemoved, selectImagesIds, -} from 'features/gallery/store/imagesSlice'; +} 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 { api } from 'services/api'; +import { + imageDeletionConfirmed, + isModalOpenChanged, +} from 'features/imageDeletion/store/imageDeletionSlice'; const moduleLog = log.child({ namespace: 'image' }); @@ -21,16 +24,19 @@ const moduleLog = log.child({ namespace: 'image' }); */ export const addRequestedImageDeletionListener = () => { startAppListening({ - actionCreator: requestedImageDeletion, + actionCreator: imageDeletionConfirmed, effect: async (action, { dispatch, getState, condition }) => { - const { image, imageUsage } = action.payload; + const { imageDTO, imageUsage } = action.payload; - const { image_name } = image; + dispatch(isModalOpenChanged(false)); + + const { image_name } = imageDTO; const state = getState(); - const selectedImage = state.gallery.selectedImage; + const lastSelectedImage = + state.gallery.selection[state.gallery.selection.length - 1]; - if (selectedImage === image_name) { + if (lastSelectedImage === image_name) { const ids = selectImagesIds(state); const deletedImageIndex = ids.findIndex( @@ -50,7 +56,7 @@ export const addRequestedImageDeletionListener = () => { if (newSelectedImageId) { dispatch(imageSelected(newSelectedImageId as string)); } else { - dispatch(imageSelected()); + dispatch(imageSelected(null)); } } @@ -88,7 +94,7 @@ export const addRequestedImageDeletionListener = () => { if (wasImageDeleted) { dispatch( - api.util.invalidateTags([{ type: 'Board', id: image.board_id }]) + api.util.invalidateTags([{ type: 'Board', id: imageDTO.board_id }]) ); } }, 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 new file mode 100644 index 0000000000..56f660a653 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts @@ -0,0 +1,188 @@ +import { createAction } from '@reduxjs/toolkit'; +import { startAppListening } from '../'; +import { log } from 'app/logging/useLogger'; +import { + TypesafeDraggableData, + TypesafeDroppableData, +} from 'app/components/ImageDnd/typesafeDnd'; +import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { initialImageChanged } from 'features/parameters/store/generationSlice'; +import { + imageAddedToBatch, + imagesAddedToBatch, +} from 'features/batch/store/batchSlice'; +import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice'; +import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; +import { + fieldValueChanged, + imageCollectionFieldValueChanged, +} from 'features/nodes/store/nodesSlice'; +import { boardsApi } from 'services/api/endpoints/boards'; +import { boardImagesApi } from 'services/api/endpoints/boardImages'; + +const moduleLog = log.child({ namespace: 'dnd' }); + +export const imageDropped = createAction<{ + overData: TypesafeDroppableData; + activeData: TypesafeDraggableData; +}>('dnd/imageDropped'); + +export const addImageDroppedListener = () => { + startAppListening({ + actionCreator: imageDropped, + effect: (action, { dispatch, getState }) => { + const { activeData, overData } = action.payload; + const { actionType } = overData; + + // set current image + if ( + actionType === 'SET_CURRENT_IMAGE' && + activeData.payloadType === 'IMAGE_DTO' && + activeData.payload.imageDTO + ) { + dispatch(imageSelected(activeData.payload.imageDTO.image_name)); + } + + // set initial image + if ( + actionType === 'SET_INITIAL_IMAGE' && + activeData.payloadType === 'IMAGE_DTO' && + activeData.payload.imageDTO + ) { + dispatch(initialImageChanged(activeData.payload.imageDTO)); + } + + // add image to batch + if ( + actionType === 'ADD_TO_BATCH' && + activeData.payloadType === 'IMAGE_DTO' && + activeData.payload.imageDTO + ) { + dispatch(imageAddedToBatch(activeData.payload.imageDTO.image_name)); + } + + // add multiple images to batch + if ( + actionType === 'ADD_TO_BATCH' && + activeData.payloadType === 'IMAGE_NAMES' + ) { + dispatch(imagesAddedToBatch(activeData.payload.imageNames)); + } + + // set control image + if ( + actionType === 'SET_CONTROLNET_IMAGE' && + activeData.payloadType === 'IMAGE_DTO' && + activeData.payload.imageDTO + ) { + const { controlNetId } = overData.context; + dispatch( + controlNetImageChanged({ + controlImage: activeData.payload.imageDTO.image_name, + controlNetId, + }) + ); + } + + // set canvas image + if ( + actionType === 'SET_CANVAS_INITIAL_IMAGE' && + activeData.payloadType === 'IMAGE_DTO' && + activeData.payload.imageDTO + ) { + dispatch(setInitialCanvasImage(activeData.payload.imageDTO)); + } + + // set nodes image + if ( + actionType === 'SET_NODES_IMAGE' && + activeData.payloadType === 'IMAGE_DTO' && + activeData.payload.imageDTO + ) { + const { fieldName, nodeId } = overData.context; + dispatch( + fieldValueChanged({ + nodeId, + fieldName, + value: activeData.payload.imageDTO, + }) + ); + } + + // set multiple nodes images (single image handler) + if ( + actionType === 'SET_MULTI_NODES_IMAGE' && + activeData.payloadType === 'IMAGE_DTO' && + activeData.payload.imageDTO + ) { + const { fieldName, nodeId } = overData.context; + dispatch( + fieldValueChanged({ + nodeId, + fieldName, + value: [activeData.payload.imageDTO], + }) + ); + } + + // set multiple nodes images (multiple images handler) + if ( + actionType === 'SET_MULTI_NODES_IMAGE' && + activeData.payloadType === 'IMAGE_NAMES' + ) { + const { fieldName, nodeId } = overData.context; + dispatch( + imageCollectionFieldValueChanged({ + nodeId, + fieldName, + value: activeData.payload.imageNames.map((image_name) => ({ + image_name, + })), + }) + ); + } + + // remove image from board + // TODO: remove board_id from `removeImageFromBoard()` endpoint + // TODO: handle multiple images + // if ( + // actionType === 'MOVE_BOARD' && + // activeData.payloadType === 'IMAGE_DTO' && + // activeData.payload.imageDTO && + // overData.boardId !== null + // ) { + // const { image_name } = activeData.payload.imageDTO; + // dispatch( + // boardImagesApi.endpoints.removeImageFromBoard.initiate({ image_name }) + // ); + // } + + // add image to board + if ( + actionType === 'MOVE_BOARD' && + activeData.payloadType === 'IMAGE_DTO' && + activeData.payload.imageDTO && + overData.context.boardId + ) { + const { image_name } = activeData.payload.imageDTO; + const { boardId } = overData.context; + dispatch( + boardImagesApi.endpoints.addImageToBoard.initiate({ + image_name, + board_id: boardId, + }) + ); + } + + // add multiple images to board + // TODO: add endpoint + // if ( + // actionType === 'ADD_TO_BATCH' && + // activeData.payloadType === 'IMAGE_NAMES' && + // activeData.payload.imageDTONames + // ) { + // dispatch(boardImagesApi.endpoints.addImagesToBoard.intiate({})); + // } + }, + }); +}; 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 index 24265faaa9..19af5b24c3 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts @@ -1,7 +1,7 @@ import { log } from 'app/logging/useLogger'; import { startAppListening } from '..'; import { imageMetadataReceived, imageUpdated } from 'services/api/thunks/image'; -import { imageUpserted } from 'features/gallery/store/imagesSlice'; +import { imageUpserted } from 'features/gallery/store/gallerySlice'; const moduleLog = log.child({ namespace: 'image' }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts new file mode 100644 index 0000000000..531981126a --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts @@ -0,0 +1,40 @@ +import { startAppListening } from '..'; +import { log } from 'app/logging/useLogger'; +import { + imageDeletionConfirmed, + imageToDeleteSelected, + isModalOpenChanged, + selectImageUsage, +} from 'features/imageDeletion/store/imageDeletionSlice'; + +const moduleLog = log.child({ namespace: 'image' }); + +export const addImageToDeleteSelectedListener = () => { + startAppListening({ + actionCreator: imageToDeleteSelected, + effect: async (action, { dispatch, getState, condition }) => { + const imageDTO = action.payload; + const state = getState(); + const { shouldConfirmOnDelete } = state.system; + const imageUsage = selectImageUsage(getState()); + + if (!imageUsage) { + // should never happen + return; + } + + const isImageInUse = + imageUsage.isCanvasImage || + imageUsage.isInitialImage || + imageUsage.isControlNetImage || + imageUsage.isNodesImage; + + if (shouldConfirmOnDelete || isImageInUse) { + dispatch(isModalOpenChanged(true)); + return; + } + + dispatch(imageDeletionConfirmed({ imageDTO, imageUsage })); + }, + }); +}; 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 f55ed11c8f..0cd852c3de 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 @@ -2,11 +2,12 @@ import { startAppListening } from '..'; import { imageUploaded } from 'services/api/thunks/image'; import { addToast } from 'features/system/store/systemSlice'; import { log } from 'app/logging/useLogger'; -import { imageUpserted } from 'features/gallery/store/imagesSlice'; +import { imageUpserted } from 'features/gallery/store/gallerySlice'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { fieldValueChanged } from 'features/nodes/store/nodesSlice'; +import { imageAddedToBatch } from 'features/batch/store/batchSlice'; const moduleLog = log.child({ namespace: 'image' }); @@ -70,6 +71,11 @@ export const addImageUploadedFulfilledListener = () => { dispatch(addToast({ title: 'Image Uploaded', status: 'success' })); return; } + + if (postUploadAction?.type === 'ADD_TO_BATCH') { + dispatch(imageAddedToBatch(image.image_name)); + return; + } }, }); }; 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 index c663c64361..0d8aa3d7c9 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUrlsReceived.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUrlsReceived.ts @@ -1,7 +1,7 @@ import { log } from 'app/logging/useLogger'; import { startAppListening } from '..'; import { imageUrlsReceived } from 'services/api/thunks/image'; -import { imageUpdatedOne } from 'features/gallery/store/imagesSlice'; +import { imageUpdatedOne } from 'features/gallery/store/gallerySlice'; const moduleLog = log.child({ namespace: 'image' }); 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 9aca82a32b..fe1a9bd806 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 @@ -4,7 +4,7 @@ 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/imagesSlice'; +import { selectImagesById } from 'features/gallery/store/gallerySlice'; import { isImageDTO } from 'services/api/guards'; export const addInitialImageSelectedListener = () => { 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 index e357d38dc3..3c11916be0 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/receivedPageOfImages.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/receivedPageOfImages.ts @@ -2,6 +2,7 @@ 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' }); @@ -9,11 +10,17 @@ export const addReceivedPageOfImagesFulfilledListener = () => { startAppListening({ actionCreator: receivedPageOfImages.fulfilled, effect: (action, { getState, dispatch }) => { - const page = action.payload; + const { items } = action.payload; moduleLog.debug( { data: { payload: action.payload } }, - `Received ${page.items.length} images` + `Received ${items.length} images` ); + + items.forEach((image) => { + dispatch( + imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image) + ); + }); }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/selectionAddedToBatch.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/selectionAddedToBatch.ts new file mode 100644 index 0000000000..dae72d92e7 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/selectionAddedToBatch.ts @@ -0,0 +1,19 @@ +import { startAppListening } from '..'; +import { log } from 'app/logging/useLogger'; +import { + imagesAddedToBatch, + selectionAddedToBatch, +} from 'features/batch/store/batchSlice'; + +const moduleLog = log.child({ namespace: 'batch' }); + +export const addSelectionAddedToBatchListener = () => { + startAppListening({ + actionCreator: selectionAddedToBatch, + effect: (action, { dispatch, getState }) => { + const { selection } = getState().gallery; + + dispatch(imagesAddedToBatch(selection)); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketConnected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketConnected.ts index 976c1558d0..cab4738373 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketConnected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketConnected.ts @@ -14,11 +14,11 @@ export const addSocketConnectedEventListener = () => { moduleLog.debug({ timestamp }, 'Connected'); - const { nodes, config, images } = getState(); + const { nodes, config, gallery } = getState(); const { disabledTabs } = config; - if (!images.ids.length) { + if (!gallery.ids.length) { dispatch( receivedPageOfImages({ categories: ['general'], 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 bc2c1d1c27..36840e5de1 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 @@ -2,7 +2,7 @@ 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/imagesSlice'; +import { imageUpserted } from 'features/gallery/store/gallerySlice'; import { addToast } from 'features/system/store/systemSlice'; const moduleLog = log.child({ namespace: 'canvas' }); 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 index 670d762d24..490d99290d 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateImageUrlsOnConnect.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateImageUrlsOnConnect.ts @@ -8,7 +8,7 @@ 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/imagesSlice'; +import { selectImagesEntities } from 'features/gallery/store/gallerySlice'; const moduleLog = log.child({ namespace: 'images' }); @@ -36,7 +36,7 @@ const selectAllUsedImages = createSelector( nodes.nodes.forEach((node) => { forEach(node.data.inputs, (input) => { if (input.type === 'image' && input.value) { - allUsedImages.push(input.value); + allUsedImages.push(input.value.image_name); } }); }); diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index e92a422d68..2fd071bd23 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -11,18 +11,18 @@ import { rememberEnhancer, rememberReducer } from 'redux-remember'; import canvasReducer from 'features/canvas/store/canvasSlice'; import controlNetReducer from 'features/controlNet/store/controlNetSlice'; import galleryReducer from 'features/gallery/store/gallerySlice'; -import imagesReducer from 'features/gallery/store/imagesSlice'; import lightboxReducer from 'features/lightbox/store/lightboxSlice'; import generationReducer from 'features/parameters/store/generationSlice'; import postprocessingReducer from 'features/parameters/store/postprocessingSlice'; import systemReducer from 'features/system/store/systemSlice'; -// import sessionReducer from 'features/system/store/sessionSlice'; import nodesReducer from 'features/nodes/store/nodesSlice'; import boardsReducer from 'features/gallery/store/boardSlice'; import configReducer from 'features/system/store/configSlice'; import hotkeysReducer from 'features/ui/store/hotkeysSlice'; import uiReducer from 'features/ui/store/uiSlice'; import dynamicPromptsReducer from 'features/dynamicPrompts/store/slice'; +import batchReducer from 'features/batch/store/batchSlice'; +import imageDeletionReducer from 'features/imageDeletion/store/imageDeletionSlice'; import { listenerMiddleware } from './middleware/listenerMiddleware'; @@ -45,11 +45,11 @@ const allReducers = { config: configReducer, ui: uiReducer, hotkeys: hotkeysReducer, - images: imagesReducer, controlNet: controlNetReducer, boards: boardsReducer, - // session: sessionReducer, dynamicPrompts: dynamicPromptsReducer, + batch: batchReducer, + imageDeletion: imageDeletionReducer, [api.reducerPath]: api.reducer, }; @@ -68,6 +68,7 @@ const rememberedKeys: (keyof typeof allReducers)[] = [ 'ui', 'controlNet', 'dynamicPrompts', + 'batch', // 'boards', // 'hotkeys', // 'config', diff --git a/invokeai/frontend/web/src/common/components/IAIButton.tsx b/invokeai/frontend/web/src/common/components/IAIButton.tsx index 3efae76d1e..d1e77537cc 100644 --- a/invokeai/frontend/web/src/common/components/IAIButton.tsx +++ b/invokeai/frontend/web/src/common/components/IAIButton.tsx @@ -15,10 +15,25 @@ export interface IAIButtonProps extends ButtonProps { } const IAIButton = forwardRef((props: IAIButtonProps, forwardedRef) => { - const { children, tooltip = '', tooltipProps, isChecked, ...rest } = props; + const { + children, + tooltip = '', + tooltipProps: { placement = 'top', hasArrow = true, ...tooltipProps } = {}, + isChecked, + ...rest + } = props; return ( - - diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx index d0652dc8b9..959a70bc29 100644 --- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx @@ -1,19 +1,20 @@ import { - Box, ChakraProps, Flex, Icon, - IconButtonProps, Image, useColorMode, + useColorModeValue, } from '@chakra-ui/react'; -import { useDraggable, useDroppable } from '@dnd-kit/core'; import { useCombinedRefs } from '@dnd-kit/utilities'; import IAIIconButton from 'common/components/IAIIconButton'; -import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback'; +import { + IAILoadingImageFallback, + IAINoContentFallback, +} from 'common/components/IAIImageFallback'; import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; import { AnimatePresence } from 'framer-motion'; -import { ReactElement, SyntheticEvent } from 'react'; +import { MouseEvent, ReactElement, SyntheticEvent } from 'react'; import { memo, useRef } from 'react'; import { FaImage, FaUndo, FaUpload } from 'react-icons/fa'; import { ImageDTO } from 'services/api/types'; @@ -22,81 +23,97 @@ import IAIDropOverlay from './IAIDropOverlay'; import { PostUploadAction } from 'services/api/thunks/image'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { mode } from 'theme/util/mode'; +import { + TypesafeDraggableData, + TypesafeDroppableData, + isValidDrop, + useDraggable, + useDroppable, +} from 'app/components/ImageDnd/typesafeDnd'; type IAIDndImageProps = { - image: ImageDTO | null | undefined; - onDrop: (droppedImage: ImageDTO) => void; - onReset?: () => void; + imageDTO: ImageDTO | undefined; onError?: (event: SyntheticEvent) => void; onLoad?: (event: SyntheticEvent) => void; - resetIconSize?: IconButtonProps['size']; + onClick?: (event: MouseEvent) => void; + onClickReset?: (event: MouseEvent) => void; withResetIcon?: boolean; + resetIcon?: ReactElement; + resetTooltip?: string; withMetadataOverlay?: boolean; isDragDisabled?: boolean; isDropDisabled?: boolean; isUploadDisabled?: boolean; - fallback?: ReactElement; - payloadImage?: ImageDTO | null | undefined; minSize?: number; postUploadAction?: PostUploadAction; imageSx?: ChakraProps['sx']; fitContainer?: boolean; + droppableData?: TypesafeDroppableData; + draggableData?: TypesafeDraggableData; + dropLabel?: string; + isSelected?: boolean; + thumbnail?: boolean; + noContentFallback?: ReactElement; }; const IAIDndImage = (props: IAIDndImageProps) => { const { - image, - onDrop, - onReset, + imageDTO, + onClickReset, onError, - resetIconSize = 'md', + onClick, withResetIcon = false, withMetadataOverlay = false, isDropDisabled = false, isDragDisabled = false, isUploadDisabled = false, - fallback = , - payloadImage, minSize = 24, postUploadAction, imageSx, fitContainer = false, + droppableData, + draggableData, + dropLabel, + isSelected = false, + thumbnail = false, + resetTooltip = 'Reset', + resetIcon = , + noContentFallback = , } = props; - const dndId = useRef(uuidv4()); const { colorMode } = useColorMode(); - const { - isOver, - setNodeRef: setDroppableRef, - active: isDropActive, - } = useDroppable({ - id: dndId.current, - disabled: isDropDisabled, - data: { - handleDrop: onDrop, - }, - }); + const dndId = useRef(uuidv4()); const { attributes, listeners, setNodeRef: setDraggableRef, isDragging, + active, } = useDraggable({ id: dndId.current, - data: { - image: payloadImage ? payloadImage : image, - }, - disabled: isDragDisabled || !image, + disabled: isDragDisabled || !imageDTO, + data: draggableData, }); + const { isOver, setNodeRef: setDroppableRef } = useDroppable({ + id: dndId.current, + disabled: isDropDisabled, + data: droppableData, + }); + + const setDndRef = useCombinedRefs(setDroppableRef, setDraggableRef); + const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({ postUploadAction, isDisabled: isUploadDisabled, }); - const setNodeRef = useCombinedRefs(setDroppableRef, setDraggableRef); + const resetIconShadow = useColorModeValue( + `drop-shadow(0px 0px 0.1rem var(--invokeai-colors-base-600))`, + `drop-shadow(0px 0px 0.1rem var(--invokeai-colors-base-800))` + ); const uploadButtonStyles = isUploadDisabled ? {} @@ -117,16 +134,16 @@ const IAIDndImage = (props: IAIDndImageProps) => { alignItems: 'center', justifyContent: 'center', position: 'relative', - minW: minSize, - minH: minSize, + minW: minSize ? minSize : undefined, + minH: minSize ? minSize : undefined, userSelect: 'none', - cursor: isDragDisabled || !image ? 'auto' : 'grab', + cursor: isDragDisabled || !imageDTO ? 'default' : 'pointer', }} {...attributes} {...listeners} - ref={setNodeRef} + ref={setDndRef} > - {image && ( + {imageDTO && ( { }} > } onError={onError} - objectFit="contain" draggable={false} sx={{ + objectFit: 'contain', maxW: 'full', maxH: 'full', borderRadius: 'base', + shadow: isSelected ? 'selected.light' : undefined, + _dark: { shadow: isSelected ? 'selected.dark' : undefined }, ...imageSx, }} /> - {withMetadataOverlay && } - {onReset && withResetIcon && ( - } + {onClickReset && withResetIcon && ( + - } - onClick={onReset} - /> - + /> )} - - {isDropActive && } - )} - {!image && ( + {!imageDTO && !isUploadDisabled && ( <> { > - - {isDropActive && } - )} + {!imageDTO && isUploadDisabled && noContentFallback} + + {isValidDrop(droppableData, active) && !isDragging && ( + + )} + ); }; diff --git a/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx b/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx index 8ae54c30ab..573a900fef 100644 --- a/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx @@ -62,7 +62,7 @@ export const IAIDropOverlay = (props: Props) => { w: 'full', h: 'full', opacity: 1, - borderWidth: 2, + borderWidth: 3, borderColor: isOver ? mode('base.50', 'base.200')(colorMode) : mode('base.100', 'base.500')(colorMode), @@ -78,10 +78,10 @@ export const IAIDropOverlay = (props: Props) => { sx={{ fontSize: '2xl', fontWeight: 600, - transform: isOver ? 'scale(1.1)' : 'scale(1)', + transform: isOver ? 'scale(1.02)' : 'scale(1)', color: isOver - ? mode('base.100', 'base.100')(colorMode) - : mode('base.200', 'base.500')(colorMode), + ? mode('base.50', 'base.50')(colorMode) + : mode('base.100', 'base.200')(colorMode), transitionProperty: 'common', transitionDuration: '0.1s', }} diff --git a/invokeai/frontend/web/src/common/components/IAIIconButton.tsx b/invokeai/frontend/web/src/common/components/IAIIconButton.tsx index 8ea06a1328..ed1514055e 100644 --- a/invokeai/frontend/web/src/common/components/IAIIconButton.tsx +++ b/invokeai/frontend/web/src/common/components/IAIIconButton.tsx @@ -29,7 +29,7 @@ const IAIIconButton = forwardRef((props: IAIIconButtonProps, forwardedRef) => { diff --git a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx index 4cff351aee..a07071ee79 100644 --- a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx +++ b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx @@ -1,73 +1,82 @@ import { As, + ChakraProps, Flex, - FlexProps, Icon, - IconProps, + Skeleton, Spinner, - SpinnerProps, - useColorMode, + StyleProps, + Text, } from '@chakra-ui/react'; import { FaImage } from 'react-icons/fa'; -import { mode } from 'theme/util/mode'; +import { ImageDTO } from 'services/api/types'; -type Props = FlexProps & { - spinnerProps?: SpinnerProps; -}; +type Props = { image: ImageDTO | undefined }; + +export const IAILoadingImageFallback = (props: Props) => { + if (props.image) { + return ( + + ); + } -export const IAIImageLoadingFallback = (props: Props) => { - const { spinnerProps, ...rest } = props; - const { sx, ...restFlexProps } = rest; - const { colorMode } = useColorMode(); return ( - + ); }; type IAINoImageFallbackProps = { - flexProps?: FlexProps; - iconProps?: IconProps; - as?: As; + label?: string; + icon?: As; + boxSize?: StyleProps['boxSize']; + sx?: ChakraProps['sx']; }; -export const IAINoImageFallback = (props: IAINoImageFallbackProps) => { - const { sx: flexSx, ...restFlexProps } = props.flexProps ?? { sx: {} }; - const { sx: iconSx, ...restIconProps } = props.iconProps ?? { sx: {} }; - const { colorMode } = useColorMode(); +export const IAINoContentFallback = (props: IAINoImageFallbackProps) => { + const { icon = FaImage, boxSize = 16 } = props; return ( - + + {props.label && {props.label}} ); }; diff --git a/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts b/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts index d410c3917c..c75041eb6c 100644 --- a/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts +++ b/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts @@ -1,4 +1,5 @@ import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { validateSeedWeights } from 'common/util/seedWeightPairs'; @@ -7,17 +8,26 @@ import { systemSelector } from 'features/system/store/systemSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; const readinessSelector = createSelector( - [generationSelector, systemSelector, activeTabNameSelector], - (generation, system, activeTabName) => { + [stateSelector, activeTabNameSelector], + ({ generation, system, batch }, activeTabName) => { const { shouldGenerateVariations, seedWeights, initialImage, seed } = generation; const { isProcessing, isConnected } = system; + const { + isEnabled: isBatchEnabled, + asInitialImage, + imageNames: batchImageNames, + } = batch; let isReady = true; const reasonsWhyNotReady: string[] = []; - if (activeTabName === 'img2img' && !initialImage) { + if ( + activeTabName === 'img2img' && + !initialImage && + !(asInitialImage && batchImageNames.length > 1) + ) { isReady = false; reasonsWhyNotReady.push('No initial image selected'); } diff --git a/invokeai/frontend/web/src/features/batch/components/BatchControlNet.tsx b/invokeai/frontend/web/src/features/batch/components/BatchControlNet.tsx new file mode 100644 index 0000000000..4231c84bec --- /dev/null +++ b/invokeai/frontend/web/src/features/batch/components/BatchControlNet.tsx @@ -0,0 +1,67 @@ +import { + Flex, + FormControl, + FormLabel, + Heading, + Spacer, + Switch, + Text, +} from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAISwitch from 'common/components/IAISwitch'; +import { ControlNetConfig } from 'features/controlNet/store/controlNetSlice'; +import { ChangeEvent, memo, useCallback } from 'react'; +import { controlNetToggled } from '../store/batchSlice'; + +type Props = { + controlNet: ControlNetConfig; +}; + +const selector = createSelector( + [stateSelector, (state, controlNetId: string) => controlNetId], + (state, controlNetId) => { + const isControlNetEnabled = state.batch.controlNets.includes(controlNetId); + return { isControlNetEnabled }; + }, + defaultSelectorOptions +); + +const BatchControlNet = (props: Props) => { + const dispatch = useAppDispatch(); + const { isControlNetEnabled } = useAppSelector((state) => + selector(state, props.controlNet.controlNetId) + ); + const { processorType, model } = props.controlNet; + + const handleChangeAsControlNet = useCallback(() => { + dispatch(controlNetToggled(props.controlNet.controlNetId)); + }, [dispatch, props.controlNet.controlNetId]); + + return ( + + + + + ControlNet + + + + + + + Model: {model} + + + Processor: {processorType} + + + ); +}; + +export default memo(BatchControlNet); diff --git a/invokeai/frontend/web/src/features/batch/components/BatchImage.tsx b/invokeai/frontend/web/src/features/batch/components/BatchImage.tsx new file mode 100644 index 0000000000..822b1cf183 --- /dev/null +++ b/invokeai/frontend/web/src/features/batch/components/BatchImage.tsx @@ -0,0 +1,115 @@ +import { Box, Icon, Skeleton } from '@chakra-ui/react'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { FaExclamationCircle } from 'react-icons/fa'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { MouseEvent, memo, useCallback, useMemo } from 'react'; +import { + batchImageRangeEndSelected, + batchImageSelected, + batchImageSelectionToggled, + imageRemovedFromBatch, +} from 'features/batch/store/batchSlice'; +import IAIDndImage from 'common/components/IAIDndImage'; +import { createSelector } from '@reduxjs/toolkit'; +import { RootState, stateSelector } from 'app/store/store'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; + +const isSelectedSelector = createSelector( + [stateSelector, (state: RootState, imageName: string) => imageName], + (state, imageName) => ({ + selection: state.batch.selection, + isSelected: state.batch.selection.includes(imageName), + }), + defaultSelectorOptions +); + +type BatchImageProps = { + imageName: string; +}; + +const BatchImage = (props: BatchImageProps) => { + const { + currentData: imageDTO, + isFetching, + isError, + isSuccess, + } = useGetImageDTOQuery(props.imageName); + const dispatch = useAppDispatch(); + + const { isSelected, selection } = useAppSelector((state) => + isSelectedSelector(state, props.imageName) + ); + + const handleClickRemove = useCallback(() => { + dispatch(imageRemovedFromBatch(props.imageName)); + }, [dispatch, props.imageName]); + + const handleClick = useCallback( + (e: MouseEvent) => { + if (e.shiftKey) { + dispatch(batchImageRangeEndSelected(props.imageName)); + } else if (e.ctrlKey || e.metaKey) { + dispatch(batchImageSelectionToggled(props.imageName)); + } else { + dispatch(batchImageSelected(props.imageName)); + } + }, + [dispatch, props.imageName] + ); + + const draggableData = useMemo(() => { + if (selection.length > 1) { + return { + id: 'batch', + payloadType: 'IMAGE_NAMES', + payload: { + imageNames: selection, + }, + }; + } + + if (imageDTO) { + return { + id: 'batch', + payloadType: 'IMAGE_DTO', + payload: { imageDTO }, + }; + } + }, [imageDTO, selection]); + + if (isError) { + return ; + } + + if (isFetching) { + return ( + + + + ); + } + + return ( + + + + ); +}; + +export default memo(BatchImage); diff --git a/invokeai/frontend/web/src/features/batch/components/BatchImageContainer.tsx b/invokeai/frontend/web/src/features/batch/components/BatchImageContainer.tsx new file mode 100644 index 0000000000..09e6b8afd7 --- /dev/null +++ b/invokeai/frontend/web/src/features/batch/components/BatchImageContainer.tsx @@ -0,0 +1,31 @@ +import { Box } from '@chakra-ui/react'; +import BatchImageGrid from './BatchImageGrid'; +import IAIDropOverlay from 'common/components/IAIDropOverlay'; +import { + AddToBatchDropData, + isValidDrop, + useDroppable, +} from 'app/components/ImageDnd/typesafeDnd'; + +const droppableData: AddToBatchDropData = { + id: 'batch', + actionType: 'ADD_TO_BATCH', +}; + +const BatchImageContainer = () => { + const { isOver, setNodeRef, active } = useDroppable({ + id: 'batch-manager', + data: droppableData, + }); + + return ( + + + {isValidDrop(droppableData, active) && ( + + )} + + ); +}; + +export default BatchImageContainer; diff --git a/invokeai/frontend/web/src/features/batch/components/BatchImageGrid.tsx b/invokeai/frontend/web/src/features/batch/components/BatchImageGrid.tsx new file mode 100644 index 0000000000..f61d27d4cf --- /dev/null +++ b/invokeai/frontend/web/src/features/batch/components/BatchImageGrid.tsx @@ -0,0 +1,54 @@ +import { FaImages } from 'react-icons/fa'; +import { Grid, GridItem } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import BatchImage from './BatchImage'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; + +const selector = createSelector( + stateSelector, + (state) => { + const imageNames = state.batch.imageNames.concat().reverse(); + + return { imageNames }; + }, + defaultSelectorOptions +); + +const BatchImageGrid = () => { + const { imageNames } = useAppSelector(selector); + + if (imageNames.length === 0) { + return ( + + ); + } + + return ( + + {imageNames.map((imageName) => ( + + + + ))} + + ); +}; + +export default BatchImageGrid; diff --git a/invokeai/frontend/web/src/features/batch/components/BatchManager.tsx b/invokeai/frontend/web/src/features/batch/components/BatchManager.tsx new file mode 100644 index 0000000000..d7855dd4e2 --- /dev/null +++ b/invokeai/frontend/web/src/features/batch/components/BatchManager.tsx @@ -0,0 +1,103 @@ +import { Flex, Heading, Spacer } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useCallback } from 'react'; +import IAISwitch from 'common/components/IAISwitch'; +import { + asInitialImageToggled, + batchReset, + isEnabledChanged, +} from 'features/batch/store/batchSlice'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIButton from 'common/components/IAIButton'; +import BatchImageContainer from './BatchImageGrid'; +import { map } from 'lodash-es'; +import BatchControlNet from './BatchControlNet'; + +const selector = createSelector( + stateSelector, + (state) => { + const { controlNets } = state.controlNet; + const { + imageNames, + asInitialImage, + controlNets: batchControlNets, + isEnabled, + } = state.batch; + + return { + imageCount: imageNames.length, + asInitialImage, + controlNets, + batchControlNets, + isEnabled, + }; + }, + defaultSelectorOptions +); + +const BatchManager = () => { + const dispatch = useAppDispatch(); + const { imageCount, isEnabled, controlNets, batchControlNets } = + useAppSelector(selector); + + const handleResetBatch = useCallback(() => { + dispatch(batchReset()); + }, [dispatch]); + + const handleToggle = useCallback(() => { + dispatch(isEnabledChanged(!isEnabled)); + }, [dispatch, isEnabled]); + + const handleChangeAsInitialImage = useCallback(() => { + dispatch(asInitialImageToggled()); + }, [dispatch]); + + return ( + + + + {imageCount || 'No'} images + + + Reset + + + + {map(controlNets, (controlNet) => { + return ( + + ); + })} + + + + ); +}; + +export default BatchManager; diff --git a/invokeai/frontend/web/src/features/batch/store/batchSlice.ts b/invokeai/frontend/web/src/features/batch/store/batchSlice.ts new file mode 100644 index 0000000000..6a96361d3f --- /dev/null +++ b/invokeai/frontend/web/src/features/batch/store/batchSlice.ts @@ -0,0 +1,142 @@ +import { PayloadAction, createAction, createSlice } from '@reduxjs/toolkit'; +import { uniq } from 'lodash-es'; +import { imageDeleted } from 'services/api/thunks/image'; + +type BatchState = { + isEnabled: boolean; + imageNames: string[]; + asInitialImage: boolean; + controlNets: string[]; + selection: string[]; +}; + +export const initialBatchState: BatchState = { + isEnabled: false, + imageNames: [], + asInitialImage: false, + controlNets: [], + selection: [], +}; + +const batch = createSlice({ + name: 'batch', + initialState: initialBatchState, + reducers: { + isEnabledChanged: (state, action: PayloadAction) => { + state.isEnabled = action.payload; + }, + imageAddedToBatch: (state, action: PayloadAction) => { + state.imageNames = uniq(state.imageNames.concat(action.payload)); + }, + imagesAddedToBatch: (state, action: PayloadAction) => { + state.imageNames = uniq(state.imageNames.concat(action.payload)); + }, + imageRemovedFromBatch: (state, action: PayloadAction) => { + state.imageNames = state.imageNames.filter( + (imageName) => action.payload !== imageName + ); + state.selection = state.selection.filter( + (imageName) => action.payload !== imageName + ); + }, + imagesRemovedFromBatch: (state, action: PayloadAction) => { + state.imageNames = state.imageNames.filter( + (imageName) => !action.payload.includes(imageName) + ); + state.selection = state.selection.filter( + (imageName) => !action.payload.includes(imageName) + ); + }, + batchImageRangeEndSelected: (state, action: PayloadAction) => { + const rangeEndImageName = action.payload; + const lastSelectedImage = state.selection[state.selection.length - 1]; + const lastClickedIndex = state.imageNames.findIndex( + (n) => n === lastSelectedImage + ); + const currentClickedIndex = state.imageNames.findIndex( + (n) => n === rangeEndImageName + ); + if (lastClickedIndex > -1 && currentClickedIndex > -1) { + // We have a valid range! + const start = Math.min(lastClickedIndex, currentClickedIndex); + const end = Math.max(lastClickedIndex, currentClickedIndex); + + const imagesToSelect = state.imageNames.slice(start, end + 1); + state.selection = uniq(state.selection.concat(imagesToSelect)); + } + }, + batchImageSelectionToggled: (state, action: PayloadAction) => { + if ( + state.selection.includes(action.payload) && + state.selection.length > 1 + ) { + state.selection = state.selection.filter( + (imageName) => imageName !== action.payload + ); + } else { + state.selection = uniq(state.selection.concat(action.payload)); + } + }, + batchImageSelected: (state, action: PayloadAction) => { + state.selection = action.payload + ? [action.payload] + : [String(state.imageNames[0])]; + }, + batchReset: (state) => { + state.imageNames = []; + state.selection = []; + }, + asInitialImageToggled: (state) => { + state.asInitialImage = !state.asInitialImage; + }, + controlNetAddedToBatch: (state, action: PayloadAction) => { + state.controlNets = uniq(state.controlNets.concat(action.payload)); + }, + controlNetRemovedFromBatch: (state, action: PayloadAction) => { + state.controlNets = state.controlNets.filter( + (controlNetId) => controlNetId !== action.payload + ); + }, + controlNetToggled: (state, action: PayloadAction) => { + if (state.controlNets.includes(action.payload)) { + state.controlNets = state.controlNets.filter( + (controlNetId) => controlNetId !== action.payload + ); + } else { + state.controlNets = uniq(state.controlNets.concat(action.payload)); + } + }, + }, + extraReducers: (builder) => { + builder.addCase(imageDeleted.fulfilled, (state, action) => { + state.imageNames = state.imageNames.filter( + (imageName) => imageName !== action.meta.arg.image_name + ); + state.selection = state.selection.filter( + (imageName) => imageName !== action.meta.arg.image_name + ); + }); + }, +}); + +export const { + isEnabledChanged, + imageAddedToBatch, + imagesAddedToBatch, + imageRemovedFromBatch, + imagesRemovedFromBatch, + asInitialImageToggled, + controlNetAddedToBatch, + controlNetRemovedFromBatch, + batchReset, + controlNetToggled, + batchImageRangeEndSelected, + batchImageSelectionToggled, + batchImageSelected, +} = batch.actions; + +export default batch.reducer; + +export const selectionAddedToBatch = createAction( + 'batch/selectionAddedToBatch' +); diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx index 36d82dc2ee..df73f1141d 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { ImageDTO } from 'services/api/types'; import { ControlNetConfig, @@ -10,11 +10,16 @@ import { Box, Flex, SystemStyleObject } from '@chakra-ui/react'; import IAIDndImage from 'common/components/IAIDndImage'; import { createSelector } from '@reduxjs/toolkit'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback'; +import { IAILoadingImageFallback } from 'common/components/IAIImageFallback'; import IAIIconButton from 'common/components/IAIIconButton'; import { FaUndo } from 'react-icons/fa'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { skipToken } from '@reduxjs/toolkit/dist/query'; +import { + TypesafeDraggableData, + TypesafeDroppableData, +} from 'app/components/ImageDnd/typesafeDnd'; +import { PostUploadAction } from 'services/api/thunks/image'; const selector = createSelector( controlNetSelector, @@ -57,22 +62,6 @@ const ControlNetImagePreview = (props: Props) => { isSuccess: isSuccessProcessedControlImage, } = useGetImageDTOQuery(processedControlImageName ?? skipToken); - const handleDrop = useCallback( - (droppedImage: ImageDTO) => { - if (controlImageName === droppedImage.image_name) { - return; - } - setIsMouseOverImage(false); - dispatch( - controlNetImageChanged({ - controlNetId, - controlImage: droppedImage.image_name, - }) - ); - }, - [controlImageName, controlNetId, dispatch] - ); - const handleResetControlImage = useCallback(() => { dispatch(controlNetImageChanged({ controlNetId, controlImage: null })); }, [controlNetId, dispatch]); @@ -84,6 +73,31 @@ const ControlNetImagePreview = (props: Props) => { setIsMouseOverImage(false); }, []); + const draggableData = useMemo(() => { + if (controlImage) { + return { + id: controlNetId, + payloadType: 'IMAGE_DTO', + payload: { imageDTO: controlImage }, + }; + } + }, [controlImage, controlNetId]); + + const droppableData = useMemo(() => { + if (controlNetId) { + return { + id: controlNetId, + actionType: 'SET_CONTROLNET_IMAGE', + context: { controlNetId }, + }; + } + }, [controlNetId]); + + const postUploadAction = useMemo( + () => ({ type: 'SET_CONTROLNET_IMAGE', controlNetId }), + [controlNetId] + ); + const shouldShowProcessedImage = controlImage && processedControlImage && @@ -104,14 +118,14 @@ const ControlNetImagePreview = (props: Props) => { }} > { }} > {pendingControlImages.includes(controlNetId) && ( @@ -145,27 +158,12 @@ const ControlNetImagePreview = (props: Props) => { insetInlineStart: 0, w: 'full', h: 'full', + objectFit: 'contain', }} > - + )} - {controlImage && ( - - } - variant="link" - sx={{ - p: 2, - color: 'base.50', - }} - /> - - )} ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/AllImagesBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/AllImagesBoard.tsx index 858329ead6..918e9390f9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/AllImagesBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/AllImagesBoard.tsx @@ -1,16 +1,16 @@ -import { Flex, Text, useColorMode } from '@chakra-ui/react'; +import { Flex, useColorMode } from '@chakra-ui/react'; import { FaImages } from 'react-icons/fa'; -import { boardIdSelected } from '../../store/boardSlice'; +import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { useDispatch } from 'react-redux'; -import { IAINoImageFallback } from 'common/components/IAIImageFallback'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { AnimatePresence } from 'framer-motion'; -import { SelectedItemOverlay } from '../SelectedItemOverlay'; -import { useCallback } from 'react'; -import { ImageDTO } from 'services/api/types'; -import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages'; -import { useDroppable } from '@dnd-kit/core'; import IAIDropOverlay from 'common/components/IAIDropOverlay'; import { mode } from 'theme/util/mode'; +import { + MoveBoardDropData, + isValidDrop, + useDroppable, +} from 'app/components/ImageDnd/typesafeDnd'; const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => { const dispatch = useDispatch(); @@ -20,31 +20,15 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => { dispatch(boardIdSelected()); }; - const [removeImageFromBoard, { isLoading }] = - useRemoveImageFromBoardMutation(); + const droppableData: MoveBoardDropData = { + id: 'all-images-board', + actionType: 'MOVE_BOARD', + context: { boardId: null }, + }; - const handleDrop = useCallback( - (droppedImage: ImageDTO) => { - if (!droppedImage.board_id) { - return; - } - removeImageFromBoard({ - board_id: droppedImage.board_id, - image_name: droppedImage.image_name, - }); - }, - [removeImageFromBoard] - ); - - const { - isOver, - setNodeRef, - active: isDropActive, - } = useDroppable({ + const { isOver, setNodeRef, active } = useDroppable({ id: `board_droppable_all_images`, - data: { - handleDrop, - }, + data: droppableData, }); return ( @@ -58,10 +42,10 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => { h: 'full', borderRadius: 'base', }} - onClick={handleAllImagesBoardClick} > { borderRadius: 'base', w: 'full', aspectRatio: '1/1', + overflow: 'hidden', + shadow: isSelected ? 'selected.light' : undefined, + _dark: { shadow: isSelected ? 'selected.dark' : undefined }, + flexShrink: 0, }} > - + - {isSelected && } - - - {isDropActive && } + {isValidDrop(droppableData, active) && ( + + )} - { }} > All Images - + ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx index fb095b9f42..5618c5c5c2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx @@ -2,6 +2,7 @@ import { Collapse, Flex, Grid, + GridItem, IconButton, Input, InputGroup, @@ -10,10 +11,7 @@ import { import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { - boardsSelector, - setBoardSearchText, -} from 'features/gallery/store/boardSlice'; +import { setBoardSearchText } from 'features/gallery/store/boardSlice'; import { memo, useState } from 'react'; import HoverableBoard from './HoverableBoard'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; @@ -21,11 +19,13 @@ import AddBoardButton from './AddBoardButton'; import AllImagesBoard from './AllImagesBoard'; import { CloseIcon } from '@chakra-ui/icons'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; +import { stateSelector } from 'app/store/store'; const selector = createSelector( - [boardsSelector], - (boardsState) => { - const { selectedBoardId, searchText } = boardsState; + [stateSelector], + ({ boards, gallery }) => { + const { searchText } = boards; + const { selectedBoardId } = gallery; return { selectedBoardId, searchText }; }, defaultSelectorOptions @@ -109,20 +109,24 @@ const BoardsList = (props: Props) => { - {!searchMode && } + {!searchMode && ( + + + + )} {filteredBoards && filteredBoards.map((board) => ( - + + + ))} diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx index 118484f305..035ee77f18 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx @@ -15,10 +15,9 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { memo, useCallback, useContext } from 'react'; import { FaFolder, FaTrash } from 'react-icons/fa'; import { ContextMenu } from 'chakra-ui-contextmenu'; -import { BoardDTO, ImageDTO } from 'services/api/types'; -import { IAINoImageFallback } from 'common/components/IAIImageFallback'; -import { boardIdSelected } from 'features/gallery/store/boardSlice'; -import { useAddImageToBoardMutation } from 'services/api/endpoints/boardImages'; +import { BoardDTO } from 'services/api/types'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { useDeleteBoardMutation, useUpdateBoardMutation, @@ -26,12 +25,15 @@ import { import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { skipToken } from '@reduxjs/toolkit/dist/query'; -import { useDroppable } from '@dnd-kit/core'; import { AnimatePresence } from 'framer-motion'; import IAIDropOverlay from 'common/components/IAIDropOverlay'; -import { SelectedItemOverlay } from '../SelectedItemOverlay'; import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext'; import { mode } from 'theme/util/mode'; +import { + MoveBoardDropData, + isValidDrop, + useDroppable, +} from 'app/components/ImageDnd/typesafeDnd'; interface HoverableBoardProps { board: BoardDTO; @@ -61,9 +63,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { const [deleteBoard, { isLoading: isDeleteBoardLoading }] = useDeleteBoardMutation(); - const [addImageToBoard, { isLoading: isAddImageToBoardLoading }] = - useAddImageToBoardMutation(); - const handleUpdateBoardName = (newBoardName: string) => { updateBoard({ board_id, changes: { board_name: newBoardName } }); }; @@ -77,29 +76,19 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { onClickDeleteBoardImages(board); }, [board, onClickDeleteBoardImages]); - const handleDrop = useCallback( - (droppedImage: ImageDTO) => { - if (droppedImage.board_id === board_id) { - return; - } - addImageToBoard({ board_id, image_name: droppedImage.image_name }); - }, - [addImageToBoard, board_id] - ); + const droppableData: MoveBoardDropData = { + id: board_id, + actionType: 'MOVE_BOARD', + context: { boardId: board_id }, + }; - const { - isOver, - setNodeRef, - active: isDropActive, - } = useDroppable({ + const { isOver, setNodeRef, active } = useDroppable({ id: `board_droppable_${board_id}`, - data: { - handleDrop, - }, + data: droppableData, }); return ( - + menuProps={{ size: 'sm', isLazy: true }} renderMenu={() => ( @@ -148,13 +137,25 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { w: 'full', aspectRatio: '1/1', overflow: 'hidden', + shadow: isSelected ? 'selected.light' : undefined, + _dark: { shadow: isSelected ? 'selected.dark' : undefined }, + flexShrink: 0, }} > {board.cover_image_name && coverImage?.image_url && ( )} {!(board.cover_image_name && coverImage?.image_url) && ( - + )} { {board.image_count} - {isSelected && } - - - {isDropActive && } + {isValidDrop(droppableData, active) && ( + + )} - + { }} /> - + )} diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx index 169a965be0..b4a3296f04 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx @@ -38,8 +38,7 @@ import { FaShare, FaShareAlt, } from 'react-icons/fa'; -import { gallerySelector } from '../store/gallerySelectors'; -import { useCallback, useContext } from 'react'; +import { useCallback } from 'react'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; @@ -49,22 +48,15 @@ import FaceRestoreSettings from 'features/parameters/components/Parameters/FaceR import UpscaleSettings from 'features/parameters/components/Parameters/Upscale/UpscaleSettings'; import { useAppToaster } from 'app/components/Toaster'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; -import { DeleteImageContext } from 'app/contexts/DeleteImageContext'; -import { DeleteImageButton } from './DeleteImageModal'; -import { selectImagesById } from '../store/imagesSlice'; -import { RootState } from 'app/store/store'; +import { stateSelector } from 'app/store/store'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { skipToken } from '@reduxjs/toolkit/dist/query'; +import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; +import { DeleteImageButton } from 'features/imageDeletion/components/DeleteImageButton'; const currentImageButtonsSelector = createSelector( - [ - (state: RootState) => state, - systemSelector, - gallerySelector, - postprocessingSelector, - uiSelector, - lightboxSelector, - activeTabNameSelector, - ], - (state, system, gallery, postprocessing, ui, lightbox, activeTabName) => { + [stateSelector, activeTabNameSelector], + ({ gallery, system, postprocessing, ui, lightbox }, activeTabName) => { const { isProcessing, isConnected, @@ -84,9 +76,7 @@ const currentImageButtonsSelector = createSelector( shouldShowProgressInViewer, } = ui; - const imageDTO = selectImagesById(state, gallery.selectedImage ?? ''); - - const { selectedImage } = gallery; + const lastSelectedImage = gallery.selection[gallery.selection.length - 1]; return { canDeleteImage: isConnected && !isProcessing, @@ -97,16 +87,13 @@ const currentImageButtonsSelector = createSelector( isESRGANAvailable, upscalingLevel, facetoolStrength, - shouldDisableToolbarButtons: Boolean(progressImage) || !selectedImage, + shouldDisableToolbarButtons: Boolean(progressImage) || !lastSelectedImage, shouldShowImageDetails, activeTabName, isLightboxOpen, shouldHidePreview, - image: imageDTO, - seed: imageDTO?.metadata?.seed, - prompt: imageDTO?.metadata?.positive_conditioning, - negativePrompt: imageDTO?.metadata?.negative_conditioning, shouldShowProgressInViewer, + lastSelectedImage, }; }, { @@ -132,7 +119,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { isLightboxOpen, activeTabName, shouldHidePreview, - image, + lastSelectedImage, shouldShowProgressInViewer, } = useAppSelector(currentImageButtonsSelector); @@ -147,7 +134,9 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { const { recallBothPrompts, recallSeed, recallAllParameters } = useRecallParameters(); - const { onDelete } = useContext(DeleteImageContext); + const { currentData: image } = useGetImageDTOQuery( + lastSelectedImage ?? skipToken + ); // const handleCopyImage = useCallback(async () => { // if (!image?.url) { @@ -248,8 +237,11 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { }, []); const handleDelete = useCallback(() => { - onDelete(image); - }, [image, onDelete]); + if (!image) { + return; + } + dispatch(imageToDeleteSelected(image)); + }, [dispatch, image]); useHotkeys( 'Shift+U', @@ -371,7 +363,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { }} {...props} > - + { } isChecked={isLightboxOpen} onClick={handleLightBox} + isDisabled={shouldDisableToolbarButtons} /> )} - + } tooltip={`${t('parameters.usePrompt')} (P)`} @@ -478,7 +471,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { {(isUpscalingEnabled || isFaceRestoreEnabled) && ( - + {isFaceRestoreEnabled && ( { )} - + } tooltip={`${t('parameters.info')} (I)`} @@ -553,7 +549,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { /> - + { - + diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageDisplay.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImageDisplay.tsx index 2da5185fe5..1d8863f4d8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImageDisplay.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImageDisplay.tsx @@ -1,29 +1,9 @@ import { Flex } from '@chakra-ui/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { useAppSelector } from 'app/store/storeHooks'; -import { systemSelector } from 'features/system/store/systemSelectors'; -import { gallerySelector } from '../store/gallerySelectors'; import CurrentImageButtons from './CurrentImageButtons'; import CurrentImagePreview from './CurrentImagePreview'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; - -export const currentImageDisplaySelector = createSelector( - [systemSelector, gallerySelector], - (system, gallery) => { - const { progressImage } = system; - - return { - hasSelectedImage: Boolean(gallery.selectedImage), - hasProgressImage: Boolean(progressImage), - }; - }, - defaultSelectorOptions -); const CurrentImageDisplay = () => { - const { hasSelectedImage } = useAppSelector(currentImageDisplaySelector); - return ( { justifyContent: 'center', }} > - {hasSelectedImage && } + ); diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx index fac19b347e..112129ffa2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx @@ -1,35 +1,33 @@ import { Box, Flex, Image } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { uiSelector } from 'features/ui/store/uiSelectors'; +import { useAppSelector } from 'app/store/storeHooks'; import { isEqual } from 'lodash-es'; - -import { gallerySelector } from '../store/gallerySelectors'; import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer'; import NextPrevImageButtons from './NextPrevImageButtons'; -import { memo, useCallback } from 'react'; -import { systemSelector } from 'features/system/store/systemSelectors'; -import { imageSelected } from '../store/gallerySlice'; +import { memo, useMemo } from 'react'; import IAIDndImage from 'common/components/IAIDndImage'; -import { ImageDTO } from 'services/api/types'; -import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { skipToken } from '@reduxjs/toolkit/dist/query'; +import { stateSelector } from 'app/store/store'; +import { selectLastSelectedImage } from 'features/gallery/store/gallerySlice'; +import { + TypesafeDraggableData, + TypesafeDroppableData, +} from 'app/components/ImageDnd/typesafeDnd'; export const imagesSelector = createSelector( - [uiSelector, gallerySelector, systemSelector], - (ui, gallery, system) => { + [stateSelector, selectLastSelectedImage], + ({ ui, system }, lastSelectedImage) => { const { shouldShowImageDetails, shouldHidePreview, shouldShowProgressInViewer, } = ui; - const { selectedImage } = gallery; const { progressImage, shouldAntialiasProgressImage } = system; return { shouldShowImageDetails, shouldHidePreview, - selectedImage, + imageName: lastSelectedImage, progressImage, shouldShowProgressInViewer, shouldAntialiasProgressImage, @@ -45,29 +43,35 @@ export const imagesSelector = createSelector( const CurrentImagePreview = () => { const { shouldShowImageDetails, - selectedImage, + imageName, progressImage, shouldShowProgressInViewer, shouldAntialiasProgressImage, } = useAppSelector(imagesSelector); const { - currentData: image, + currentData: imageDTO, isLoading, isError, isSuccess, - } = useGetImageDTOQuery(selectedImage ?? skipToken); + } = useGetImageDTOQuery(imageName ?? skipToken); - const dispatch = useAppDispatch(); + const draggableData = useMemo(() => { + if (imageDTO) { + return { + id: 'current-image', + payloadType: 'IMAGE_DTO', + payload: { imageDTO }, + }; + } + }, [imageDTO]); - const handleDrop = useCallback( - (droppedImage: ImageDTO) => { - if (droppedImage.image_name === image?.image_name) { - return; - } - dispatch(imageSelected(droppedImage.image_name)); - }, - [dispatch, image?.image_name] + const droppableData = useMemo( + () => ({ + id: 'current-image', + actionType: 'SET_CURRENT_IMAGE', + }), + [] ); return ( @@ -98,14 +102,15 @@ const CurrentImagePreview = () => { /> ) : ( } + imageDTO={imageDTO} + droppableData={droppableData} + draggableData={draggableData} isUploadDisabled={true} fitContainer + dropLabel="Set as Current Image" /> )} - {shouldShowImageDetails && image && ( + {shouldShowImageDetails && imageDTO && ( { overflow: 'scroll', }} > - + )} - {!shouldShowImageDetails && image && ( + {!shouldShowImageDetails && imageDTO && ( { - const { shouldConfirmOnDelete } = system; - const { canRestoreDeletedImagesFromBin } = config; - - return { - shouldConfirmOnDelete, - canRestoreDeletedImagesFromBin, - }; - }, - defaultSelectorOptions -); - -const ImageInUseMessage = (props: { imageUsage?: ImageUsage }) => { - const { imageUsage } = props; - - if (!imageUsage) { - return null; - } - - if (!some(imageUsage)) { - return null; - } - - return ( - <> - This image is currently in use in the following features: - - {imageUsage.isInitialImage && Image to Image} - {imageUsage.isCanvasImage && Unified Canvas} - {imageUsage.isControlNetImage && ControlNet} - {imageUsage.isNodesImage && Node Editor} - - - If you delete this image, those features will immediately be reset. - - - ); -}; - -const DeleteImageModal = () => { - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - - const { isOpen, onClose, onImmediatelyDelete, image, imageUsage } = - useContext(DeleteImageContext); - - const { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin } = - useAppSelector(selector); - - const handleChangeShouldConfirmOnDelete = useCallback( - (e: ChangeEvent) => - dispatch(setShouldConfirmOnDelete(!e.target.checked)), - [dispatch] - ); - - const cancelRef = useRef(null); - - return ( - - - - - {t('gallery.deleteImage')} - - - - - - - - {canRestoreDeletedImagesFromBin - ? t('gallery.deleteImageBin') - : t('gallery.deleteImagePermanent')} - - {t('common.areYouSure')} - - - - - - Cancel - - - Delete - - - - - - ); -}; - -export default memo(DeleteImageModal); - -const deleteImageButtonsSelector = createSelector( - [systemSelector], - (system) => { - const { isProcessing, isConnected } = system; - - return isConnected && !isProcessing; - } -); - -type DeleteImageButtonProps = { - onClick: () => void; -}; - -export const DeleteImageButton = (props: DeleteImageButtonProps) => { - const { onClick } = props; - const { t } = useTranslation(); - const canDeleteImage = useAppSelector(deleteImageButtonsSelector); - - return ( - } - tooltip={`${t('gallery.deleteImage')} (Del)`} - aria-label={`${t('gallery.deleteImage')} (Del)`} - isDisabled={!canDeleteImage} - colorScheme="error" - /> - ); -}; diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImage.tsx new file mode 100644 index 0000000000..30e1c5abf3 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImage.tsx @@ -0,0 +1,131 @@ +import { Box } from '@chakra-ui/react'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { MouseEvent, memo, useCallback, useMemo } from 'react'; +import { FaTrash } from 'react-icons/fa'; +import { useTranslation } from 'react-i18next'; +import { createSelector } from '@reduxjs/toolkit'; +import { ImageDTO } from 'services/api/types'; +import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; +import { stateSelector } from 'app/store/store'; +import ImageContextMenu from './ImageContextMenu'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIDndImage from 'common/components/IAIDndImage'; +import { + imageRangeEndSelected, + imageSelected, + imageSelectionToggled, +} from '../store/gallerySlice'; +import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; + +export const selector = createSelector( + [stateSelector, (state, { image_name }: ImageDTO) => image_name], + ({ gallery }, image_name) => { + const isSelected = gallery.selection.includes(image_name); + const selection = gallery.selection; + return { + isSelected, + selection, + }; + }, + defaultSelectorOptions +); + +interface HoverableImageProps { + imageDTO: ImageDTO; +} + +/** + * Gallery image component with delete/use all/use seed buttons on hover. + */ +const GalleryImage = (props: HoverableImageProps) => { + const { isSelected, selection } = useAppSelector((state) => + selector(state, props.imageDTO) + ); + + const { imageDTO } = props; + const { image_url, thumbnail_url, image_name } = imageDTO; + + const dispatch = useAppDispatch(); + + const { t } = useTranslation(); + + const handleClick = useCallback( + (e: MouseEvent) => { + if (e.shiftKey) { + dispatch(imageRangeEndSelected(props.imageDTO.image_name)); + } else if (e.ctrlKey || e.metaKey) { + dispatch(imageSelectionToggled(props.imageDTO.image_name)); + } else { + dispatch(imageSelected(props.imageDTO.image_name)); + } + }, + [dispatch, props.imageDTO.image_name] + ); + + const handleDelete = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + if (!imageDTO) { + return; + } + dispatch(imageToDeleteSelected(imageDTO)); + }, + [dispatch, imageDTO] + ); + + const draggableData = useMemo(() => { + if (selection.length > 1) { + return { + id: 'gallery-image', + payloadType: 'IMAGE_NAMES', + payload: { imageNames: selection }, + }; + } + + if (imageDTO) { + return { + id: 'gallery-image', + payloadType: 'IMAGE_DTO', + payload: { imageDTO }, + }; + } + }, [imageDTO, selection]); + + return ( + + + {(ref) => ( + + } + resetTooltip="Delete image" + imageSx={{ w: 'full', h: 'full' }} + withResetIcon + isDropDisabled={true} + isUploadDisabled={true} + /> + + )} + + + ); +}; + +export default memo(GalleryImage); diff --git a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx deleted file mode 100644 index 91648d8df0..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import { Box, Flex, Icon, Image, MenuItem, MenuList } from '@chakra-ui/react'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useContext, useState } from 'react'; -import { - FaCheck, - FaExpand, - FaFolder, - FaImage, - FaShare, - FaTrash, -} from 'react-icons/fa'; -import { ContextMenu } from 'chakra-ui-contextmenu'; -import { - resizeAndScaleCanvas, - setInitialCanvasImage, -} from 'features/canvas/store/canvasSlice'; -import { gallerySelector } from 'features/gallery/store/gallerySelectors'; -import { setActiveTab } from 'features/ui/store/uiSlice'; -import { useTranslation } from 'react-i18next'; -import IAIIconButton from 'common/components/IAIIconButton'; -import { ExternalLinkIcon } from '@chakra-ui/icons'; -import { IoArrowUndoCircleOutline } from 'react-icons/io5'; -import { createSelector } from '@reduxjs/toolkit'; -import { systemSelector } from 'features/system/store/systemSelectors'; -import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors'; -import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; -import { isEqual } from 'lodash-es'; -import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; -import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; -import { initialImageSelected } from 'features/parameters/store/actions'; -import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions'; -import { useAppToaster } from 'app/components/Toaster'; -import { ImageDTO } from 'services/api/types'; -import { useDraggable } from '@dnd-kit/core'; -import { DeleteImageContext } from 'app/contexts/DeleteImageContext'; -import { AddImageToBoardContext } from '../../../app/contexts/AddImageToBoardContext'; -import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages'; - -export const selector = createSelector( - [gallerySelector, systemSelector, lightboxSelector, activeTabNameSelector], - (gallery, system, lightbox, activeTabName) => { - const { - galleryImageObjectFit, - galleryImageMinimumWidth, - shouldUseSingleGalleryColumn, - } = gallery; - - const { isLightboxOpen } = lightbox; - const { isConnected, isProcessing, shouldConfirmOnDelete } = system; - - return { - canDeleteImage: isConnected && !isProcessing, - shouldConfirmOnDelete, - galleryImageObjectFit, - galleryImageMinimumWidth, - shouldUseSingleGalleryColumn, - activeTabName, - isLightboxOpen, - }; - }, - { - memoizeOptions: { - resultEqualityCheck: isEqual, - }, - } -); - -interface HoverableImageProps { - image: ImageDTO; - isSelected: boolean; -} - -/** - * Gallery image component with delete/use all/use seed buttons on hover. - */ -const HoverableImage = (props: HoverableImageProps) => { - const dispatch = useAppDispatch(); - const { - activeTabName, - galleryImageObjectFit, - galleryImageMinimumWidth, - canDeleteImage, - shouldUseSingleGalleryColumn, - } = useAppSelector(selector); - - const { image, isSelected } = props; - const { image_url, thumbnail_url, image_name } = image; - - const [isHovered, setIsHovered] = useState(false); - const toaster = useAppToaster(); - - const { t } = useTranslation(); - const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled; - const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled; - - const { onDelete } = useContext(DeleteImageContext); - const { onClickAddToBoard } = useContext(AddImageToBoardContext); - const handleDelete = useCallback(() => { - onDelete(image); - }, [image, onDelete]); - const { recallBothPrompts, recallSeed, recallAllParameters } = - useRecallParameters(); - - const { attributes, listeners, setNodeRef } = useDraggable({ - id: `galleryImage_${image_name}`, - data: { - image, - }, - }); - - const [removeFromBoard] = useRemoveImageFromBoardMutation(); - - const handleMouseOver = () => setIsHovered(true); - const handleMouseOut = () => setIsHovered(false); - - const handleSelectImage = useCallback(() => { - dispatch(imageSelected(image.image_name)); - }, [image, dispatch]); - - // Recall parameters handlers - const handleRecallPrompt = useCallback(() => { - recallBothPrompts( - image.metadata?.positive_conditioning, - image.metadata?.negative_conditioning - ); - }, [ - image.metadata?.negative_conditioning, - image.metadata?.positive_conditioning, - recallBothPrompts, - ]); - - const handleRecallSeed = useCallback(() => { - recallSeed(image.metadata?.seed); - }, [image, recallSeed]); - - const handleSendToImageToImage = useCallback(() => { - dispatch(sentImageToImg2Img()); - dispatch(initialImageSelected(image)); - }, [dispatch, image]); - - // const handleRecallInitialImage = useCallback(() => { - // recallInitialImage(image.metadata.invokeai?.node?.image); - // }, [image, recallInitialImage]); - - /** - * TODO: the rest of these - */ - const handleSendToCanvas = () => { - dispatch(sentImageToCanvas()); - dispatch(setInitialCanvasImage(image)); - - dispatch(resizeAndScaleCanvas()); - - if (activeTabName !== 'unifiedCanvas') { - dispatch(setActiveTab('unifiedCanvas')); - } - - toaster({ - title: t('toast.sentToUnifiedCanvas'), - status: 'success', - duration: 2500, - isClosable: true, - }); - }; - - const handleUseAllParameters = useCallback(() => { - recallAllParameters(image); - }, [image, recallAllParameters]); - - const handleLightBox = () => { - // dispatch(setCurrentImage(image)); - // dispatch(setIsLightboxOpen(true)); - }; - - const handleAddToBoard = useCallback(() => { - onClickAddToBoard(image); - }, [image, onClickAddToBoard]); - - const handleRemoveFromBoard = useCallback(() => { - if (!image.board_id) { - return; - } - removeFromBoard({ board_id: image.board_id, image_name: image.image_name }); - }, [image.board_id, image.image_name, removeFromBoard]); - - const handleOpenInNewTab = () => { - window.open(image.image_url, '_blank'); - }; - - return ( - - - menuProps={{ size: 'sm', isLazy: true }} - renderMenu={() => ( - - } - onClickCapture={handleOpenInNewTab} - > - {t('common.openInNewTab')} - - {isLightboxEnabled && ( - } onClickCapture={handleLightBox}> - {t('parameters.openInViewer')} - - )} - } - onClickCapture={handleRecallPrompt} - isDisabled={image?.metadata?.positive_conditioning === undefined} - > - {t('parameters.usePrompt')} - - - } - onClickCapture={handleRecallSeed} - isDisabled={image?.metadata?.seed === undefined} - > - {t('parameters.useSeed')} - - {/* } - onClickCapture={handleRecallInitialImage} - isDisabled={image?.metadata?.type !== 'img2img'} - > - {t('parameters.useInitImg')} - */} - } - onClickCapture={handleUseAllParameters} - isDisabled={ - // what should these be - !['t2l', 'l2l', 'inpaint'].includes( - String(image?.metadata?.type) - ) - } - > - {t('parameters.useAll')} - - } - onClickCapture={handleSendToImageToImage} - id="send-to-img2img" - > - {t('parameters.sendToImg2Img')} - - {isCanvasEnabled && ( - } - onClickCapture={handleSendToCanvas} - id="send-to-canvas" - > - {t('parameters.sendToUnifiedCanvas')} - - )} - } onClickCapture={handleAddToBoard}> - {image.board_id ? 'Change Board' : 'Add to Board'} - - {image.board_id && ( - } - onClickCapture={handleRemoveFromBoard} - > - Remove from Board - - )} - } - onClickCapture={handleDelete} - > - {t('gallery.deleteImage')} - - - )} - > - {(ref) => ( - - } - sx={{ - width: '100%', - height: '100%', - maxWidth: '100%', - maxHeight: '100%', - }} - /> - {isSelected && ( - - - - )} - {isHovered && galleryImageMinimumWidth >= 100 && ( - - } - size="xs" - fontSize={14} - isDisabled={!canDeleteImage} - /> - - )} - - )} - - - ); -}; - -export default memo(HoverableImage); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu.tsx new file mode 100644 index 0000000000..1e5f95ab0d --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu.tsx @@ -0,0 +1,278 @@ +import { MenuItem, MenuList } from '@chakra-ui/react'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { memo, useCallback, useContext } from 'react'; +import { + FaExpand, + FaFolder, + FaFolderPlus, + FaShare, + FaTrash, +} from 'react-icons/fa'; +import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; +import { + resizeAndScaleCanvas, + setInitialCanvasImage, +} from 'features/canvas/store/canvasSlice'; +import { setActiveTab } from 'features/ui/store/uiSlice'; +import { useTranslation } from 'react-i18next'; +import { ExternalLinkIcon } from '@chakra-ui/icons'; +import { IoArrowUndoCircleOutline } from 'react-icons/io5'; +import { createSelector } from '@reduxjs/toolkit'; +import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; +import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; +import { initialImageSelected } from 'features/parameters/store/actions'; +import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions'; +import { useAppToaster } from 'app/components/Toaster'; +import { AddImageToBoardContext } from '../../../app/contexts/AddImageToBoardContext'; +import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages'; +import { ImageDTO } from 'services/api/types'; +import { RootState, stateSelector } from 'app/store/store'; +import { + imagesAddedToBatch, + selectionAddedToBatch, +} from 'features/batch/store/batchSlice'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; + +const selector = createSelector( + [stateSelector, (state: RootState, imageDTO: ImageDTO) => imageDTO], + ({ gallery, batch }, imageDTO) => { + const selectionCount = gallery.selection.length; + const isInBatch = batch.imageNames.includes(imageDTO.image_name); + + return { selectionCount, isInBatch }; + }, + defaultSelectorOptions +); + +type Props = { + image: ImageDTO; + children: ContextMenuProps['children']; +}; + +const ImageContextMenu = ({ image, children }: Props) => { + const { selectionCount, isInBatch } = useAppSelector((state) => + selector(state, image) + ); + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + const toaster = useAppToaster(); + + const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled; + const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled; + + const { onClickAddToBoard } = useContext(AddImageToBoardContext); + + const handleDelete = useCallback(() => { + if (!image) { + return; + } + dispatch(imageToDeleteSelected(image)); + }, [dispatch, image]); + + const { recallBothPrompts, recallSeed, recallAllParameters } = + useRecallParameters(); + + const [removeFromBoard] = useRemoveImageFromBoardMutation(); + + // Recall parameters handlers + const handleRecallPrompt = useCallback(() => { + recallBothPrompts( + image.metadata?.positive_conditioning, + image.metadata?.negative_conditioning + ); + }, [ + image.metadata?.negative_conditioning, + image.metadata?.positive_conditioning, + recallBothPrompts, + ]); + + const handleRecallSeed = useCallback(() => { + recallSeed(image.metadata?.seed); + }, [image, recallSeed]); + + const handleSendToImageToImage = useCallback(() => { + dispatch(sentImageToImg2Img()); + dispatch(initialImageSelected(image)); + }, [dispatch, image]); + + // const handleRecallInitialImage = useCallback(() => { + // recallInitialImage(image.metadata.invokeai?.node?.image); + // }, [image, recallInitialImage]); + + const handleSendToCanvas = () => { + dispatch(sentImageToCanvas()); + dispatch(setInitialCanvasImage(image)); + dispatch(resizeAndScaleCanvas()); + dispatch(setActiveTab('unifiedCanvas')); + + toaster({ + title: t('toast.sentToUnifiedCanvas'), + status: 'success', + duration: 2500, + isClosable: true, + }); + }; + + const handleUseAllParameters = useCallback(() => { + recallAllParameters(image); + }, [image, recallAllParameters]); + + const handleLightBox = () => { + // dispatch(setCurrentImage(image)); + // dispatch(setIsLightboxOpen(true)); + }; + + const handleAddToBoard = useCallback(() => { + onClickAddToBoard(image); + }, [image, onClickAddToBoard]); + + const handleRemoveFromBoard = useCallback(() => { + if (!image.board_id) { + return; + } + removeFromBoard({ board_id: image.board_id, image_name: image.image_name }); + }, [image.board_id, image.image_name, removeFromBoard]); + + const handleOpenInNewTab = () => { + window.open(image.image_url, '_blank'); + }; + + const handleAddSelectionToBatch = useCallback(() => { + dispatch(selectionAddedToBatch()); + }, [dispatch]); + + const handleAddToBatch = useCallback(() => { + dispatch(imagesAddedToBatch([image.image_name])); + }, [dispatch, image.image_name]); + + return ( + + menuProps={{ size: 'sm', isLazy: true }} + renderMenu={() => ( + + {selectionCount === 1 ? ( + <> + } + onClickCapture={handleOpenInNewTab} + > + {t('common.openInNewTab')} + + {isLightboxEnabled && ( + } onClickCapture={handleLightBox}> + {t('parameters.openInViewer')} + + )} + } + onClickCapture={handleRecallPrompt} + isDisabled={ + image?.metadata?.positive_conditioning === undefined + } + > + {t('parameters.usePrompt')} + + + } + onClickCapture={handleRecallSeed} + isDisabled={image?.metadata?.seed === undefined} + > + {t('parameters.useSeed')} + + {/* } + onClickCapture={handleRecallInitialImage} + isDisabled={image?.metadata?.type !== 'img2img'} + > + {t('parameters.useInitImg')} + */} + } + onClickCapture={handleUseAllParameters} + isDisabled={ + // what should these be + !['t2l', 'l2l', 'inpaint'].includes( + String(image?.metadata?.type) + ) + } + > + {t('parameters.useAll')} + + } + onClickCapture={handleSendToImageToImage} + id="send-to-img2img" + > + {t('parameters.sendToImg2Img')} + + {isCanvasEnabled && ( + } + onClickCapture={handleSendToCanvas} + id="send-to-canvas" + > + {t('parameters.sendToUnifiedCanvas')} + + )} + {/* } + isDisabled={isInBatch} + onClickCapture={handleAddToBatch} + > + Add to Batch + */} + } onClickCapture={handleAddToBoard}> + {image.board_id ? 'Change Board' : 'Add to Board'} + + {image.board_id && ( + } + onClickCapture={handleRemoveFromBoard} + > + Remove from Board + + )} + } + onClickCapture={handleDelete} + > + {t('gallery.deleteImage')} + + + ) : ( + <> + } + onClickCapture={handleAddToBoard} + > + Move Selection to Board + + {/* } + onClickCapture={handleAddSelectionToBatch} + > + Add Selection to Batch + */} + } + onClickCapture={handleDelete} + > + Delete Selection + + + )} + + )} + > + {children} + + ); +}; + +export default memo(ImageContextMenu); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx index a22eb6d20f..33edb303e3 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx @@ -5,7 +5,7 @@ import { Flex, FlexProps, Grid, - Icon, + Skeleton, Text, VStack, forwardRef, @@ -18,12 +18,8 @@ import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox'; import IAIIconButton from 'common/components/IAIIconButton'; import IAIPopover from 'common/components/IAIPopover'; import IAISlider from 'common/components/IAISlider'; -import { gallerySelector } from 'features/gallery/store/gallerySelectors'; import { setGalleryImageMinimumWidth, - setGalleryImageObjectFit, - setShouldAutoSwitchToNewImages, - setShouldUseSingleGalleryColumn, setGalleryView, } from 'features/gallery/store/gallerySlice'; import { togglePinGalleryPanel } from 'features/ui/store/uiSlice'; @@ -42,77 +38,56 @@ import { import { useTranslation } from 'react-i18next'; import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs'; import { FaImage, FaServer, FaWrench } from 'react-icons/fa'; -import { MdPhotoLibrary } from 'react-icons/md'; -import HoverableImage from './HoverableImage'; +import GalleryImage from './GalleryImage'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { createSelector } from '@reduxjs/toolkit'; -import { RootState } from 'app/store/store'; -import { Virtuoso, VirtuosoGrid } from 'react-virtuoso'; +import { RootState, stateSelector } from 'app/store/store'; +import { VirtuosoGrid } from 'react-virtuoso'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { uiSelector } from 'features/ui/store/uiSelectors'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES, imageCategoriesChanged, - selectImagesAll, -} from '../store/imagesSlice'; + shouldAutoSwitchChanged, + selectFilteredImages, +} from 'features/gallery/store/gallerySlice'; import { receivedPageOfImages } from 'services/api/thunks/image'; import BoardsList from './Boards/BoardsList'; -import { boardsSelector } from '../store/boardSlice'; import { ChevronUpIcon } from '@chakra-ui/icons'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; import { mode } from 'theme/util/mode'; +import { ImageDTO } from 'services/api/types'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; -const itemSelector = createSelector( - [(state: RootState) => state], - (state) => { - const { categories, total: allImagesTotal, isLoading } = state.images; - const { selectedBoardId } = state.boards; +const LOADING_IMAGE_ARRAY = Array(20).fill('loading'); - const allImages = selectImagesAll(state); +const selector = createSelector( + [stateSelector, selectFilteredImages], + (state, filteredImages) => { + const { + categories, + total: allImagesTotal, + isLoading, + selectedBoardId, + galleryImageMinimumWidth, + galleryView, + shouldAutoSwitch, + } = state.gallery; + const { shouldPinGallery } = state.ui; - const images = allImages.filter((i) => { - const isInCategory = categories.includes(i.image_category); - const isInSelectedBoard = selectedBoardId - ? i.board_id === selectedBoardId - : true; - return isInCategory && isInSelectedBoard; - }); + const images = filteredImages as (ImageDTO | string)[]; return { - images, + images: isLoading ? images.concat(LOADING_IMAGE_ARRAY) : images, allImagesTotal, isLoading, categories, selectedBoardId, - }; - }, - defaultSelectorOptions -); - -const mainSelector = createSelector( - [gallerySelector, uiSelector, boardsSelector], - (gallery, ui, boards) => { - const { - galleryImageMinimumWidth, - galleryImageObjectFit, - shouldAutoSwitchToNewImages, - shouldUseSingleGalleryColumn, - selectedImage, - galleryView, - } = gallery; - - const { shouldPinGallery } = ui; - return { shouldPinGallery, galleryImageMinimumWidth, - galleryImageObjectFit, - shouldAutoSwitchToNewImages, - shouldUseSingleGalleryColumn, - selectedImage, + shouldAutoSwitch, galleryView, - selectedBoardId: boards.selectedBoardId, }; }, defaultSelectorOptions @@ -140,17 +115,16 @@ const ImageGalleryContent = () => { const { colorMode } = useColorMode(); const { + images, + isLoading, + allImagesTotal, + categories, + selectedBoardId, shouldPinGallery, galleryImageMinimumWidth, - galleryImageObjectFit, - shouldAutoSwitchToNewImages, - shouldUseSingleGalleryColumn, - selectedImage, + shouldAutoSwitch, galleryView, - } = useAppSelector(mainSelector); - - const { images, isLoading, allImagesTotal, categories, selectedBoardId } = - useAppSelector(itemSelector); + } = useAppSelector(selector); const { selectedBoard } = useListAllBoardsQuery(undefined, { selectFromResult: ({ data }) => ({ @@ -208,12 +182,6 @@ const ImageGalleryContent = () => { return () => osInstance()?.destroy(); }, [scroller, initialize, osInstance]); - const setScrollerRef = useCallback((ref: HTMLElement | Window | null) => { - if (ref instanceof HTMLElement) { - setScroller(ref); - } - }, []); - const handleClickImagesCategory = useCallback(() => { dispatch(imageCategoriesChanged(IMAGE_CATEGORIES)); dispatch(setGalleryView('images')); @@ -314,29 +282,11 @@ const ImageGalleryContent = () => { withReset handleReset={() => dispatch(setGalleryImageMinimumWidth(64))} /> - - dispatch( - setGalleryImageObjectFit( - galleryImageObjectFit === 'contain' ? 'cover' : 'contain' - ) - ) - } - /> ) => - dispatch(setShouldAutoSwitchToNewImages(e.target.checked)) - } - /> - ) => - dispatch(setShouldUseSingleGalleryColumn(e.target.checked)) + dispatch(shouldAutoSwitchChanged(e.target.checked)) } /> @@ -358,41 +308,28 @@ const ImageGalleryContent = () => { {images.length || areMoreAvailable ? ( <> - {shouldUseSingleGalleryColumn ? ( - setScrollerRef(ref)} - itemContent={(index, item) => ( - - - - )} - /> - ) : ( - ( - + typeof item === 'string' ? ( + - )} - /> - )} + ) : ( + + ) + } + /> { ) : ( - - - {t('gallery.noImagesInGallery')} - + )} @@ -436,7 +356,7 @@ const ImageGalleryContent = () => { type ItemContainerProps = PropsWithChildren & FlexProps; const ItemContainer = forwardRef((props: ItemContainerProps, ref) => ( - + {props.children} )); @@ -453,8 +373,7 @@ const ListContainer = forwardRef((props: ListContainerProps, ref) => { className="list-container" ref={ref} sx={{ - gap: 2, - gridTemplateColumns: `repeat(auto-fit, minmax(${galleryImageMinimumWidth}px, 1fr));`, + gridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr));`, }} > {props.children} diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx index b1f06ad433..69dc1b2b19 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx @@ -5,14 +5,13 @@ import { clamp, isEqual } from 'lodash-es'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FaAngleLeft, FaAngleRight } from 'react-icons/fa'; -import { gallerySelector } from '../store/gallerySelectors'; -import { RootState } from 'app/store/store'; -import { imageSelected } from '../store/gallerySlice'; -import { useHotkeys } from 'react-hotkeys-hook'; +import { stateSelector } from 'app/store/store'; import { - selectFilteredImagesAsObject, - selectFilteredImagesIds, -} from '../store/imagesSlice'; + imageSelected, + selectImagesById, +} from 'features/gallery/store/gallerySlice'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { selectFilteredImages } from 'features/gallery/store/gallerySlice'; const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = { height: '100%', @@ -25,45 +24,40 @@ const nextPrevButtonStyles: ChakraProps['sx'] = { }; export const nextPrevImageButtonsSelector = createSelector( - [ - (state: RootState) => state, - gallerySelector, - selectFilteredImagesAsObject, - selectFilteredImagesIds, - ], - (state, gallery, filteredImagesAsObject, filteredImageIds) => { - const { selectedImage } = gallery; + [stateSelector, selectFilteredImages], + (state, filteredImages) => { + const lastSelectedImage = + state.gallery.selection[state.gallery.selection.length - 1]; - if (!selectedImage) { + if (!lastSelectedImage || filteredImages.length === 0) { return { isOnFirstImage: true, isOnLastImage: true, }; } - const currentImageIndex = filteredImageIds.findIndex( - (i) => i === selectedImage + const currentImageIndex = filteredImages.findIndex( + (i) => i.image_name === lastSelectedImage ); - const nextImageIndex = clamp( currentImageIndex + 1, 0, - filteredImageIds.length - 1 + filteredImages.length - 1 ); const prevImageIndex = clamp( currentImageIndex - 1, 0, - filteredImageIds.length - 1 + filteredImages.length - 1 ); - const nextImageId = filteredImageIds[nextImageIndex]; - const prevImageId = filteredImageIds[prevImageIndex]; + const nextImageId = filteredImages[nextImageIndex].image_name; + const prevImageId = filteredImages[prevImageIndex].image_name; - const nextImage = filteredImagesAsObject[nextImageId]; - const prevImage = filteredImagesAsObject[prevImageId]; + const nextImage = selectImagesById(state, nextImageId); + const prevImage = selectImagesById(state, prevImageId); - const imagesLength = filteredImageIds.length; + const imagesLength = filteredImages.length; return { isOnFirstImage: currentImageIndex === 0, @@ -101,11 +95,11 @@ const NextPrevImageButtons = () => { }, []); const handlePrevImage = useCallback(() => { - dispatch(imageSelected(prevImageId)); + prevImageId && dispatch(imageSelected(prevImageId)); }, [dispatch, prevImageId]); const handleNextImage = useCallback(() => { - dispatch(imageSelected(nextImageId)); + nextImageId && dispatch(imageSelected(nextImageId)); }, [dispatch, nextImageId]); useHotkeys( diff --git a/invokeai/frontend/web/src/features/gallery/components/SelectedItemOverlay.tsx b/invokeai/frontend/web/src/features/gallery/components/SelectedItemOverlay.tsx deleted file mode 100644 index 3fabe706d6..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/SelectedItemOverlay.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useColorMode, useToken } from '@chakra-ui/react'; -import { motion } from 'framer-motion'; -import { mode } from 'theme/util/mode'; - -export const SelectedItemOverlay = () => { - const [accent400, accent500] = useToken('colors', [ - 'accent.400', - 'accent.500', - ]); - - const { colorMode } = useColorMode(); - - return ( - - ); -}; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGetImageByName.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGetImageByName.ts deleted file mode 100644 index 89709b322a..0000000000 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGetImageByName.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useAppSelector } from 'app/store/storeHooks'; -import { selectImagesEntities } from '../store/imagesSlice'; -import { useCallback } from 'react'; - -const useGetImageByName = () => { - const images = useAppSelector(selectImagesEntities); - return useCallback( - (name: string | undefined) => { - if (!name) { - return; - } - return images[name]; - }, - [images] - ); -}; - -export default useGetImageByName; diff --git a/invokeai/frontend/web/src/features/gallery/store/actions.ts b/invokeai/frontend/web/src/features/gallery/store/actions.ts index 4234778120..0e1b1ef2a0 100644 --- a/invokeai/frontend/web/src/features/gallery/store/actions.ts +++ b/invokeai/frontend/web/src/features/gallery/store/actions.ts @@ -1,15 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; -import { ImageUsage } from 'app/contexts/DeleteImageContext'; -import { ImageDTO, BoardDTO } from 'services/api/types'; - -export type RequestedImageDeletionArg = { - image: ImageDTO; - imageUsage: ImageUsage; -}; - -export const requestedImageDeletion = createAction( - 'gallery/requestedImageDeletion' -); +import { ImageUsage } from 'app/contexts/AddImageToBoardContext'; +import { BoardDTO } from 'services/api/types'; export type RequestedBoardImagesDeletionArg = { board: BoardDTO; diff --git a/invokeai/frontend/web/src/features/gallery/store/boardSlice.ts b/invokeai/frontend/web/src/features/gallery/store/boardSlice.ts index 7ec74dc4bf..e6b59eee9a 100644 --- a/invokeai/frontend/web/src/features/gallery/store/boardSlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/boardSlice.ts @@ -1,10 +1,8 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { RootState } from 'app/store/store'; -import { boardsApi } from 'services/api/endpoints/boards'; type BoardsState = { searchText: string; - selectedBoardId?: string; updateBoardModalOpen: boolean; }; @@ -17,9 +15,6 @@ const boardsSlice = createSlice({ name: 'boards', initialState: initialBoardsState, reducers: { - boardIdSelected: (state, action: PayloadAction) => { - state.selectedBoardId = action.payload; - }, setBoardSearchText: (state, action: PayloadAction) => { state.searchText = action.payload; }, @@ -27,19 +22,9 @@ const boardsSlice = createSlice({ state.updateBoardModalOpen = action.payload; }, }, - extraReducers: (builder) => { - builder.addMatcher( - boardsApi.endpoints.deleteBoard.matchFulfilled, - (state, action) => { - if (action.meta.arg.originalArgs === state.selectedBoardId) { - state.selectedBoardId = undefined; - } - } - ); - }, }); -export const { boardIdSelected, setBoardSearchText, setUpdateBoardModalOpen } = +export const { setBoardSearchText, setUpdateBoardModalOpen } = boardsSlice.actions; export const boardsSelector = (state: RootState) => state.boards; diff --git a/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts b/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts index 44e03f9f71..201cffa70e 100644 --- a/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts +++ b/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts @@ -1,8 +1,15 @@ -import { GalleryState } from './gallerySlice'; +import { initialGalleryState } from './gallerySlice'; /** * Gallery slice persist denylist */ -export const galleryPersistDenylist: (keyof GalleryState)[] = [ - 'shouldAutoSwitchToNewImages', +export const galleryPersistDenylist: (keyof typeof initialGalleryState)[] = [ + 'selection', + 'entities', + 'ids', + 'isLoading', + 'limit', + 'offset', + 'selectedBoardId', + 'total', ]; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index b7fc0809a6..f4d2babf38 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -1,87 +1,266 @@ -import type { PayloadAction } from '@reduxjs/toolkit'; -import { createSlice } from '@reduxjs/toolkit'; -import { imageUpserted } from './imagesSlice'; +import type { PayloadAction, Update } from '@reduxjs/toolkit'; +import { + createEntityAdapter, + createSelector, + createSlice, +} from '@reduxjs/toolkit'; +import { RootState } from 'app/store/store'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { dateComparator } from 'common/util/dateComparator'; +import { imageDeletionConfirmed } from 'features/imageDeletion/store/imageDeletionSlice'; +import { keyBy, uniq } from 'lodash-es'; +import { boardsApi } from 'services/api/endpoints/boards'; +import { + imageUrlsReceived, + receivedPageOfImages, +} from 'services/api/thunks/image'; +import { ImageCategory, ImageDTO } from 'services/api/types'; -type GalleryImageObjectFitType = 'contain' | 'cover'; +export const imagesAdapter = createEntityAdapter({ + selectId: (image) => image.image_name, + sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at), +}); -export interface GalleryState { - selectedImage?: string; +export const IMAGE_CATEGORIES: ImageCategory[] = ['general']; +export const ASSETS_CATEGORIES: ImageCategory[] = [ + 'control', + 'mask', + 'user', + 'other', +]; + +type AdditionaGalleryState = { + offset: number; + limit: number; + total: number; + isLoading: boolean; + categories: ImageCategory[]; + selectedBoardId?: string; + selection: string[]; + shouldAutoSwitch: boolean; galleryImageMinimumWidth: number; - galleryImageObjectFit: GalleryImageObjectFitType; - shouldAutoSwitchToNewImages: boolean; - shouldUseSingleGalleryColumn: boolean; galleryView: 'images' | 'assets' | 'boards'; -} - -export const initialGalleryState: GalleryState = { - galleryImageMinimumWidth: 64, - galleryImageObjectFit: 'cover', - shouldAutoSwitchToNewImages: true, - shouldUseSingleGalleryColumn: false, - galleryView: 'images', }; +export const initialGalleryState = + imagesAdapter.getInitialState({ + offset: 0, + limit: 0, + total: 0, + isLoading: true, + categories: IMAGE_CATEGORIES, + selection: [], + shouldAutoSwitch: true, + galleryImageMinimumWidth: 64, + galleryView: 'images', + }); + export const gallerySlice = createSlice({ name: 'gallery', initialState: initialGalleryState, reducers: { - imageSelected: (state, action: PayloadAction) => { - state.selectedImage = action.payload; - // TODO: if the user selects an image, disable the auto switch? - // state.shouldAutoSwitchToNewImages = false; + imageUpserted: (state, action: PayloadAction) => { + imagesAdapter.upsertOne(state, action.payload); + if ( + state.shouldAutoSwitch && + action.payload.image_category === 'general' + ) { + state.selection = [action.payload.image_name]; + } + }, + imageUpdatedOne: (state, action: PayloadAction>) => { + imagesAdapter.updateOne(state, action.payload); + }, + imageRemoved: (state, action: PayloadAction) => { + imagesAdapter.removeOne(state, action.payload); + }, + imagesRemoved: (state, action: PayloadAction) => { + imagesAdapter.removeMany(state, action.payload); + }, + imageCategoriesChanged: (state, action: PayloadAction) => { + state.categories = action.payload; + }, + imageRangeEndSelected: (state, action: PayloadAction) => { + const rangeEndImageName = action.payload; + const lastSelectedImage = state.selection[state.selection.length - 1]; + + const filteredImages = selectFilteredImagesLocal(state); + + const lastClickedIndex = filteredImages.findIndex( + (n) => n.image_name === lastSelectedImage + ); + + const currentClickedIndex = filteredImages.findIndex( + (n) => n.image_name === rangeEndImageName + ); + + if (lastClickedIndex > -1 && currentClickedIndex > -1) { + // We have a valid range! + const start = Math.min(lastClickedIndex, currentClickedIndex); + const end = Math.max(lastClickedIndex, currentClickedIndex); + + const imagesToSelect = filteredImages + .slice(start, end + 1) + .map((i) => i.image_name); + + state.selection = uniq(state.selection.concat(imagesToSelect)); + } + }, + imageSelectionToggled: (state, action: PayloadAction) => { + if ( + state.selection.includes(action.payload) && + state.selection.length > 1 + ) { + state.selection = state.selection.filter( + (imageName) => imageName !== action.payload + ); + } else { + state.selection = uniq(state.selection.concat(action.payload)); + } + }, + imageSelected: (state, action: PayloadAction) => { + state.selection = action.payload + ? [action.payload] + : [String(state.ids[0])]; + }, + shouldAutoSwitchChanged: (state, action: PayloadAction) => { + state.shouldAutoSwitch = action.payload; }, setGalleryImageMinimumWidth: (state, action: PayloadAction) => { state.galleryImageMinimumWidth = action.payload; }, - setGalleryImageObjectFit: ( - state, - action: PayloadAction - ) => { - state.galleryImageObjectFit = action.payload; - }, - setShouldAutoSwitchToNewImages: (state, action: PayloadAction) => { - state.shouldAutoSwitchToNewImages = action.payload; - }, - setShouldUseSingleGalleryColumn: ( - state, - action: PayloadAction - ) => { - state.shouldUseSingleGalleryColumn = action.payload; - }, setGalleryView: ( state, action: PayloadAction<'images' | 'assets' | 'boards'> ) => { state.galleryView = action.payload; }, + boardIdSelected: (state, action: PayloadAction) => { + state.selectedBoardId = action.payload; + }, }, extraReducers: (builder) => { - builder.addCase(imageUpserted, (state, action) => { - if ( - state.shouldAutoSwitchToNewImages && - action.payload.image_category === 'general' - ) { - state.selectedImage = action.payload.image_name; - } + builder.addCase(receivedPageOfImages.pending, (state) => { + state.isLoading = true; }); - // builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { - // const { image_name, image_url, thumbnail_url } = action.payload; + builder.addCase(receivedPageOfImages.rejected, (state) => { + state.isLoading = false; + }); + builder.addCase(receivedPageOfImages.fulfilled, (state, action) => { + state.isLoading = false; + const { board_id, categories, image_origin, is_intermediate } = + action.meta.arg; - // if (state.selectedImage?.image_name === image_name) { - // state.selectedImage.image_url = image_url; - // state.selectedImage.thumbnail_url = thumbnail_url; - // } - // }); + const { items, offset, limit, total } = action.payload; + + const transformedItems = items.map((item) => ({ + ...item, + isSelected: false, + })); + + imagesAdapter.upsertMany(state, transformedItems); + + if (state.selection.length === 0) { + state.selection = [items[0].image_name]; + } + + if (!categories?.includes('general') || board_id) { + // need to skip updating the total images count if the images recieved were for a specific board + // TODO: this doesn't work when on the Asset tab/category... + return; + } + + state.offset = offset; + state.limit = limit; + state.total = total; + }); + builder.addCase(imageDeletionConfirmed, (state, action) => { + // Image deleted + const { image_name } = action.payload.imageDTO; + imagesAdapter.removeOne(state, image_name); + }); + builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { + const { image_name, image_url, thumbnail_url } = action.payload; + + imagesAdapter.updateOne(state, { + id: image_name, + changes: { image_url, thumbnail_url }, + }); + }); + builder.addMatcher( + boardsApi.endpoints.deleteBoard.matchFulfilled, + (state, action) => { + if (action.meta.arg.originalArgs === state.selectedBoardId) { + state.selectedBoardId = undefined; + } + } + ); }, }); export const { + selectAll: selectImagesAll, + selectById: selectImagesById, + selectEntities: selectImagesEntities, + selectIds: selectImagesIds, + selectTotal: selectImagesTotal, +} = imagesAdapter.getSelectors((state) => state.gallery); + +export const { + imageUpserted, + imageUpdatedOne, + imageRemoved, + imagesRemoved, + imageCategoriesChanged, + imageRangeEndSelected, + imageSelectionToggled, imageSelected, + shouldAutoSwitchChanged, setGalleryImageMinimumWidth, - setGalleryImageObjectFit, - setShouldAutoSwitchToNewImages, - setShouldUseSingleGalleryColumn, setGalleryView, + boardIdSelected, } = gallerySlice.actions; export default gallerySlice.reducer; + +export const selectFilteredImagesLocal = createSelector( + (state: typeof initialGalleryState) => state, + (galleryState) => { + const allImages = imagesAdapter.getSelectors().selectAll(galleryState); + const { categories, selectedBoardId } = galleryState; + + const filteredImages = allImages.filter((i) => { + const isInCategory = categories.includes(i.image_category); + const isInSelectedBoard = selectedBoardId + ? i.board_id === selectedBoardId + : true; + return isInCategory && isInSelectedBoard; + }); + + return filteredImages; + } +); + +export const selectFilteredImages = createSelector( + (state: RootState) => state, + (state) => { + return selectFilteredImagesLocal(state.gallery); + }, + defaultSelectorOptions +); + +export const selectFilteredImagesAsObject = createSelector( + selectFilteredImages, + (filteredImages) => keyBy(filteredImages, 'image_name') +); + +export const selectFilteredImagesIds = createSelector( + selectFilteredImages, + (filteredImages) => filteredImages.map((i) => i.image_name) +); + +export const selectLastSelectedImage = createSelector( + (state: RootState) => state, + (state) => state.gallery.selection[state.gallery.selection.length - 1], + defaultSelectorOptions +); diff --git a/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts b/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts deleted file mode 100644 index 8041ffd5c5..0000000000 --- a/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { - PayloadAction, - Update, - createEntityAdapter, - createSelector, - createSlice, -} from '@reduxjs/toolkit'; -import { RootState } from 'app/store/store'; -import { ImageCategory, ImageDTO } from 'services/api/types'; -import { dateComparator } from 'common/util/dateComparator'; -import { keyBy } from 'lodash-es'; -import { - imageDeleted, - imageUrlsReceived, - receivedPageOfImages, -} from 'services/api/thunks/image'; - -export const imagesAdapter = createEntityAdapter({ - selectId: (image) => image.image_name, - sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at), -}); - -export const IMAGE_CATEGORIES: ImageCategory[] = ['general']; -export const ASSETS_CATEGORIES: ImageCategory[] = [ - 'control', - 'mask', - 'user', - 'other', -]; - -type AdditionaImagesState = { - offset: number; - limit: number; - total: number; - isLoading: boolean; - categories: ImageCategory[]; -}; - -export const initialImagesState = - imagesAdapter.getInitialState({ - offset: 0, - limit: 0, - total: 0, - isLoading: false, - categories: IMAGE_CATEGORIES, - }); - -export type ImagesState = typeof initialImagesState; - -const imagesSlice = createSlice({ - name: 'images', - initialState: initialImagesState, - reducers: { - imageUpserted: (state, action: PayloadAction) => { - imagesAdapter.upsertOne(state, action.payload); - }, - imageUpdatedOne: (state, action: PayloadAction>) => { - imagesAdapter.updateOne(state, action.payload); - }, - imageRemoved: (state, action: PayloadAction) => { - imagesAdapter.removeOne(state, action.payload); - }, - imagesRemoved: (state, action: PayloadAction) => { - imagesAdapter.removeMany(state, action.payload); - }, - imageCategoriesChanged: (state, action: PayloadAction) => { - state.categories = action.payload; - }, - }, - extraReducers: (builder) => { - builder.addCase(receivedPageOfImages.pending, (state) => { - state.isLoading = true; - }); - builder.addCase(receivedPageOfImages.rejected, (state) => { - state.isLoading = false; - }); - builder.addCase(receivedPageOfImages.fulfilled, (state, action) => { - state.isLoading = false; - const { board_id, categories, image_origin, is_intermediate } = - action.meta.arg; - - const { items, offset, limit, total } = action.payload; - imagesAdapter.upsertMany(state, items); - - if (!categories?.includes('general') || board_id) { - // need to skip updating the total images count if the images recieved were for a specific board - // TODO: this doesn't work when on the Asset tab/category... - return; - } - - state.offset = offset; - state.limit = limit; - state.total = total; - }); - builder.addCase(imageDeleted.pending, (state, action) => { - // Image deleted - const { image_name } = action.meta.arg; - imagesAdapter.removeOne(state, image_name); - }); - builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { - const { image_name, image_url, thumbnail_url } = action.payload; - - imagesAdapter.updateOne(state, { - id: image_name, - changes: { image_url, thumbnail_url }, - }); - }); - }, -}); - -export const { - selectAll: selectImagesAll, - selectById: selectImagesById, - selectEntities: selectImagesEntities, - selectIds: selectImagesIds, - selectTotal: selectImagesTotal, -} = imagesAdapter.getSelectors((state) => state.images); - -export const { - imageUpserted, - imageUpdatedOne, - imageRemoved, - imagesRemoved, - imageCategoriesChanged, -} = imagesSlice.actions; - -export default imagesSlice.reducer; - -export const selectFilteredImagesAsArray = createSelector( - (state: RootState) => state, - (state) => { - const { - images: { categories }, - } = state; - - return selectImagesAll(state).filter((i) => - categories.includes(i.image_category) - ); - } -); - -export const selectFilteredImagesAsObject = createSelector( - (state: RootState) => state, - (state) => { - const { - images: { categories }, - } = state; - - return keyBy( - selectImagesAll(state).filter((i) => - categories.includes(i.image_category) - ), - 'image_name' - ); - } -); - -export const selectFilteredImagesIds = createSelector( - (state: RootState) => state, - (state) => { - const { - images: { categories }, - } = state; - - return selectImagesAll(state) - .filter((i) => categories.includes(i.image_category)) - .map((i) => i.image_name); - } -); - -// export const selectImageById = createSelector( -// (state: RootState, imageId) => state, -// (state) => { -// const { -// images: { categories }, -// } = state; - -// return selectImagesAll(state) -// .filter((i) => categories.includes(i.image_category)) -// .map((i) => i.image_name); -// } -// ); diff --git a/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageButton.tsx b/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageButton.tsx new file mode 100644 index 0000000000..dde6d1a517 --- /dev/null +++ b/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageButton.tsx @@ -0,0 +1,37 @@ +import { IconButtonProps } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { useTranslation } from 'react-i18next'; +import { FaTrash } from 'react-icons/fa'; + +const deleteImageButtonsSelector = createSelector( + [stateSelector], + ({ system }) => { + const { isProcessing, isConnected } = system; + + return isConnected && !isProcessing; + } +); + +type DeleteImageButtonProps = Omit & { + onClick: () => void; +}; + +export const DeleteImageButton = (props: DeleteImageButtonProps) => { + const { onClick, isDisabled } = props; + const { t } = useTranslation(); + const canDeleteImage = useAppSelector(deleteImageButtonsSelector); + + return ( + } + tooltip={`${t('gallery.deleteImage')} (Del)`} + aria-label={`${t('gallery.deleteImage')} (Del)`} + isDisabled={isDisabled || !canDeleteImage} + colorScheme="error" + /> + ); +}; diff --git a/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageModal.tsx b/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageModal.tsx new file mode 100644 index 0000000000..cdc8257488 --- /dev/null +++ b/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageModal.tsx @@ -0,0 +1,122 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Divider, + Flex, + Text, +} from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIButton from 'common/components/IAIButton'; +import IAISwitch from 'common/components/IAISwitch'; +import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice'; + +import { ChangeEvent, memo, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import ImageUsageMessage from './ImageUsageMessage'; +import { stateSelector } from 'app/store/store'; +import { + imageDeletionConfirmed, + imageToDeleteCleared, + selectImageUsage, +} from '../store/imageDeletionSlice'; + +const selector = createSelector( + [stateSelector, selectImageUsage], + ({ system, config, imageDeletion }, imageUsage) => { + const { shouldConfirmOnDelete } = system; + const { canRestoreDeletedImagesFromBin } = config; + const { imageToDelete, isModalOpen } = imageDeletion; + return { + shouldConfirmOnDelete, + canRestoreDeletedImagesFromBin, + imageToDelete, + imageUsage, + isModalOpen, + }; + }, + defaultSelectorOptions +); + +const DeleteImageModal = () => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + const { + shouldConfirmOnDelete, + canRestoreDeletedImagesFromBin, + imageToDelete, + imageUsage, + isModalOpen, + } = useAppSelector(selector); + + const handleChangeShouldConfirmOnDelete = useCallback( + (e: ChangeEvent) => + dispatch(setShouldConfirmOnDelete(!e.target.checked)), + [dispatch] + ); + + const handleClose = useCallback(() => { + dispatch(imageToDeleteCleared()); + }, [dispatch]); + + const handleDelete = useCallback(() => { + if (!imageToDelete || !imageUsage) { + return; + } + dispatch(imageToDeleteCleared()); + dispatch(imageDeletionConfirmed({ imageDTO: imageToDelete, imageUsage })); + }, [dispatch, imageToDelete, imageUsage]); + + const cancelRef = useRef(null); + + return ( + + + + + {t('gallery.deleteImage')} + + + + + + + + {canRestoreDeletedImagesFromBin + ? t('gallery.deleteImageBin') + : t('gallery.deleteImagePermanent')} + + {t('common.areYouSure')} + + + + + + Cancel + + + Delete + + + + + + ); +}; + +export default memo(DeleteImageModal); diff --git a/invokeai/frontend/web/src/features/imageDeletion/components/ImageUsageMessage.tsx b/invokeai/frontend/web/src/features/imageDeletion/components/ImageUsageMessage.tsx new file mode 100644 index 0000000000..9bd4ca5198 --- /dev/null +++ b/invokeai/frontend/web/src/features/imageDeletion/components/ImageUsageMessage.tsx @@ -0,0 +1,33 @@ +import { some } from 'lodash-es'; +import { memo } from 'react'; +import { ImageUsage } from '../store/imageDeletionSlice'; +import { ListItem, Text, UnorderedList } from '@chakra-ui/react'; + +const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => { + const { imageUsage } = props; + + if (!imageUsage) { + return null; + } + + if (!some(imageUsage)) { + return null; + } + + return ( + <> + This image is currently in use in the following features: + + {imageUsage.isInitialImage && Image to Image} + {imageUsage.isCanvasImage && Unified Canvas} + {imageUsage.isControlNetImage && ControlNet} + {imageUsage.isNodesImage && Node Editor} + + + If you delete this image, those features will immediately be reset. + + + ); +}; + +export default memo(ImageUsageMessage); diff --git a/invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSlice.ts b/invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSlice.ts new file mode 100644 index 0000000000..0daffba0d7 --- /dev/null +++ b/invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSlice.ts @@ -0,0 +1,99 @@ +import { + PayloadAction, + createAction, + createSelector, + createSlice, +} from '@reduxjs/toolkit'; +import { RootState } from 'app/store/store'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { some } from 'lodash-es'; +import { ImageDTO } from 'services/api/types'; + +type DeleteImageState = { + imageToDelete: ImageDTO | null; + isModalOpen: boolean; +}; + +export const initialDeleteImageState: DeleteImageState = { + imageToDelete: null, + isModalOpen: false, +}; + +const imageDeletion = createSlice({ + name: 'imageDeletion', + initialState: initialDeleteImageState, + reducers: { + isModalOpenChanged: (state, action: PayloadAction) => { + state.isModalOpen = action.payload; + }, + imageToDeleteSelected: (state, action: PayloadAction) => { + state.imageToDelete = action.payload; + }, + imageToDeleteCleared: (state) => { + state.imageToDelete = null; + }, + }, +}); + +export const { + isModalOpenChanged, + imageToDeleteSelected, + imageToDeleteCleared, +} = imageDeletion.actions; + +export default imageDeletion.reducer; + +export type ImageUsage = { + isInitialImage: boolean; + isCanvasImage: boolean; + isNodesImage: boolean; + isControlNetImage: boolean; +}; + +export const selectImageUsage = createSelector( + [(state: RootState) => state], + ({ imageDeletion, generation, canvas, nodes, controlNet }) => { + const { imageToDelete } = imageDeletion; + + if (!imageToDelete) { + return; + } + + const { image_name } = imageToDelete; + + const isInitialImage = generation.initialImage?.imageName === image_name; + + const isCanvasImage = canvas.layerState.objects.some( + (obj) => obj.kind === 'image' && obj.imageName === image_name + ); + + const isNodesImage = nodes.nodes.some((node) => { + return some( + node.data.inputs, + (input) => + input.type === 'image' && input.value?.image_name === image_name + ); + }); + + const isControlNetImage = some( + controlNet.controlNets, + (c) => + c.controlImage === image_name || c.processedControlImage === image_name + ); + + const imageUsage: ImageUsage = { + isInitialImage, + isCanvasImage, + isNodesImage, + isControlNetImage, + }; + + return imageUsage; + }, + defaultSelectorOptions +); + +export const imageDeletionConfirmed = createAction<{ + imageDTO: ImageDTO; + imageUsage: ImageUsage; +}>('imageDeletion/imageDeletionConfirmed'); diff --git a/invokeai/frontend/web/src/features/nodes/components/InputFieldComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/InputFieldComponent.tsx index 65b7cfa560..b3b91ccf5e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/InputFieldComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/InputFieldComponent.tsx @@ -16,6 +16,7 @@ import NumberInputFieldComponent from './fields/NumberInputFieldComponent'; import StringInputFieldComponent from './fields/StringInputFieldComponent'; import ColorInputFieldComponent from './fields/ColorInputFieldComponent'; import ItemInputFieldComponent from './fields/ItemInputFieldComponent'; +import ImageCollectionInputFieldComponent from './fields/ImageCollectionInputFieldComponent'; type InputFieldComponentProps = { nodeId: string; @@ -191,6 +192,16 @@ const InputFieldComponent = (props: InputFieldComponentProps) => { ); } + if (type === 'image_collection' && template.type === 'image_collection') { + return ( + + ); + } + return Unknown field type: {type}; }; diff --git a/invokeai/frontend/web/src/features/nodes/components/InvocationComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/InvocationComponent.tsx index fc3a6377b2..3c3568a6b2 100644 --- a/invokeai/frontend/web/src/features/nodes/components/InvocationComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/InvocationComponent.tsx @@ -30,7 +30,7 @@ const InvocationComponentWrapper = (props: InvocationComponentWrapperProps) => { position: 'relative', borderRadius: 'md', minWidth: NODE_MIN_WIDTH, - boxShadow: props.selected + shadow: props.selected ? `${nodeSelectedOutline}, ${nodeShadow}` : `${nodeShadow}`, }} diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/ImageCollectionInputFieldComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/ImageCollectionInputFieldComponent.tsx new file mode 100644 index 0000000000..0ac1f7aa1c --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/fields/ImageCollectionInputFieldComponent.tsx @@ -0,0 +1,103 @@ +import { useAppDispatch } from 'app/store/storeHooks'; + +import { fieldValueChanged } from 'features/nodes/store/nodesSlice'; +import { + ImageCollectionInputFieldTemplate, + ImageCollectionInputFieldValue, +} from 'features/nodes/types/types'; +import { memo, useCallback } from 'react'; + +import { FieldComponentProps } from './types'; +import IAIDndImage from 'common/components/IAIDndImage'; +import { ImageDTO } from 'services/api/types'; +import { Flex } from '@chakra-ui/react'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { skipToken } from '@reduxjs/toolkit/dist/query'; +import { uniq, uniqBy } from 'lodash-es'; +import { + NodesMultiImageDropData, + isValidDrop, + useDroppable, +} from 'app/components/ImageDnd/typesafeDnd'; +import IAIDropOverlay from 'common/components/IAIDropOverlay'; + +const ImageCollectionInputFieldComponent = ( + props: FieldComponentProps< + ImageCollectionInputFieldValue, + ImageCollectionInputFieldTemplate + > +) => { + const { nodeId, field } = props; + + const dispatch = useAppDispatch(); + + const handleDrop = useCallback( + ({ image_name }: ImageDTO) => { + dispatch( + fieldValueChanged({ + nodeId, + fieldName: field.name, + value: uniqBy([...(field.value ?? []), { image_name }], 'image_name'), + }) + ); + }, + [dispatch, field.name, field.value, nodeId] + ); + + const droppableData: NodesMultiImageDropData = { + id: `node-${nodeId}-${field.name}`, + actionType: 'SET_MULTI_NODES_IMAGE', + context: { nodeId, fieldName: field.name }, + }; + + const { + isOver, + setNodeRef: setDroppableRef, + active, + over, + } = useDroppable({ + id: `node_${nodeId}`, + data: droppableData, + }); + + const handleReset = useCallback(() => { + dispatch( + fieldValueChanged({ + nodeId, + fieldName: field.name, + value: undefined, + }) + ); + }, [dispatch, field.name, nodeId]); + + return ( + + {field.value?.map(({ image_name }) => ( + + ))} + {isValidDrop(droppableData, active) && } + + ); +}; + +export default memo(ImageCollectionInputFieldComponent); + +type ImageSubFieldProps = { imageName: string }; + +const ImageSubField = (props: ImageSubFieldProps) => { + const { currentData: image } = useGetImageDTOQuery(props.imageName); + + return ( + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/ImageInputFieldComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/ImageInputFieldComponent.tsx index 8d83e8353f..499946e3af 100644 --- a/invokeai/frontend/web/src/features/nodes/components/fields/ImageInputFieldComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/fields/ImageInputFieldComponent.tsx @@ -5,7 +5,7 @@ import { ImageInputFieldTemplate, ImageInputFieldValue, } from 'features/nodes/types/types'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { FieldComponentProps } from './types'; import IAIDndImage from 'common/components/IAIDndImage'; @@ -13,6 +13,12 @@ import { ImageDTO } from 'services/api/types'; import { Flex } from '@chakra-ui/react'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { skipToken } from '@reduxjs/toolkit/dist/query'; +import { + NodesImageDropData, + TypesafeDraggableData, + TypesafeDroppableData, +} from 'app/components/ImageDnd/typesafeDnd'; +import { PostUploadAction } from 'services/api/thunks/image'; const ImageInputFieldComponent = ( props: FieldComponentProps @@ -22,7 +28,7 @@ const ImageInputFieldComponent = ( const dispatch = useAppDispatch(); const { - currentData: image, + currentData: imageDTO, isLoading, isError, isSuccess, @@ -55,6 +61,35 @@ const ImageInputFieldComponent = ( ); }, [dispatch, field.name, nodeId]); + const draggableData = useMemo(() => { + if (imageDTO) { + return { + id: `node-${nodeId}-${field.name}`, + payloadType: 'IMAGE_DTO', + payload: { imageDTO }, + }; + } + }, [field.name, imageDTO, nodeId]); + + const droppableData = useMemo(() => { + if (imageDTO) { + return { + id: `node-${nodeId}-${field.name}`, + actionType: 'SET_NODES_IMAGE', + context: { nodeId, fieldName: field.name }, + }; + } + }, [field.name, imageDTO, nodeId]); + + const postUploadAction = useMemo( + () => ({ + type: 'SET_NODES_IMAGE', + nodeId, + fieldName: field.name, + }), + [nodeId, field.name] + ); + return ( ); diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/NodeInvokeButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/NodeInvokeButton.tsx index be5e5a943e..740fecc2a4 100644 --- a/invokeai/frontend/web/src/features/nodes/components/ui/NodeInvokeButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/ui/NodeInvokeButton.tsx @@ -45,6 +45,7 @@ export default function NodeInvokeButton(props: InvokeButton) { {!isReady && ( @@ -71,6 +71,12 @@ export default function NodeInvokeButton(props: InvokeButton) { tooltipProps={{ placement: 'bottom' }} colorScheme="accent" id="invoke-button" + _disabled={{ + background: 'none', + _hover: { + background: 'none', + }, + }} {...rest} /> ) : ( @@ -84,6 +90,12 @@ export default function NodeInvokeButton(props: InvokeButton) { colorScheme="accent" id="invoke-button" fontWeight={700} + _disabled={{ + background: 'none', + _hover: { + background: 'none', + }, + }} {...rest} > Invoke diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index ba217fff5f..ffc93db2ba 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -16,6 +16,7 @@ import { receivedOpenAPISchema } from 'services/api/thunks/schema'; import { InvocationTemplate, InvocationValue } from '../types/types'; import { RgbaColor } from 'react-colorful'; import { RootState } from 'app/store/store'; +import { cloneDeep, isArray, uniq, uniqBy } from 'lodash-es'; export type NodesState = { nodes: Node[]; @@ -62,7 +63,14 @@ const nodesSlice = createSlice({ action: PayloadAction<{ nodeId: string; fieldName: string; - value: string | number | boolean | ImageField | RgbaColor | undefined; + value: + | string + | number + | boolean + | ImageField + | RgbaColor + | undefined + | ImageField[]; }> ) => { const { nodeId, fieldName, value } = action.payload; @@ -72,6 +80,35 @@ const nodesSlice = createSlice({ state.nodes[nodeIndex].data.inputs[fieldName].value = value; } }, + imageCollectionFieldValueChanged: ( + state, + action: PayloadAction<{ + nodeId: string; + fieldName: string; + value: ImageField[]; + }> + ) => { + const { nodeId, fieldName, value } = action.payload; + const nodeIndex = state.nodes.findIndex((n) => n.id === nodeId); + + if (nodeIndex === -1) { + return; + } + + const currentValue = cloneDeep( + state.nodes[nodeIndex].data.inputs[fieldName].value + ); + + if (!currentValue) { + state.nodes[nodeIndex].data.inputs[fieldName].value = value; + return; + } + + state.nodes[nodeIndex].data.inputs[fieldName].value = uniqBy( + (currentValue as ImageField[]).concat(value), + 'image_name' + ); + }, shouldShowGraphOverlayChanged: (state, action: PayloadAction) => { state.shouldShowGraphOverlay = action.payload; }, @@ -103,6 +140,7 @@ export const { shouldShowGraphOverlayChanged, nodeTemplatesBuilt, nodeEditorReset, + imageCollectionFieldValueChanged, } = nodesSlice.actions; export default nodesSlice.reducer; diff --git a/invokeai/frontend/web/src/features/nodes/types/constants.ts b/invokeai/frontend/web/src/features/nodes/types/constants.ts index 83fadb6bcb..9f6124c9d4 100644 --- a/invokeai/frontend/web/src/features/nodes/types/constants.ts +++ b/invokeai/frontend/web/src/features/nodes/types/constants.ts @@ -10,6 +10,7 @@ export const FIELD_TYPE_MAP: Record = { boolean: 'boolean', enum: 'enum', ImageField: 'image', + image_collection: 'image_collection', LatentsField: 'latents', ConditioningField: 'conditioning', UNetField: 'unet', @@ -30,9 +31,6 @@ const COLOR_TOKEN_VALUE = 500; const getColorTokenCssVariable = (color: string) => `var(--invokeai-colors-${color}-${COLOR_TOKEN_VALUE})`; -// @ts-ignore -// @ts-ignore -// @ts-ignore export const FIELDS: Record = { integer: { color: 'red', @@ -70,6 +68,12 @@ export const FIELDS: Record = { title: 'Image', description: 'Images may be passed between nodes.', }, + image_collection: { + color: 'purple', + colorCssVar: getColorTokenCssVariable('purple'), + title: 'Image Collection', + description: 'A collection of images.', + }, latents: { color: 'pink', colorCssVar: getColorTokenCssVariable('pink'), diff --git a/invokeai/frontend/web/src/features/nodes/types/types.ts b/invokeai/frontend/web/src/features/nodes/types/types.ts index 3faf2f9653..9498bbd5d5 100644 --- a/invokeai/frontend/web/src/features/nodes/types/types.ts +++ b/invokeai/frontend/web/src/features/nodes/types/types.ts @@ -66,7 +66,8 @@ export type FieldType = | 'model' | 'array' | 'item' - | 'color'; + | 'color' + | 'image_collection'; /** * An input field is persisted across reloads as part of the user's local state. @@ -92,7 +93,8 @@ export type InputFieldValue = | ModelInputFieldValue | ArrayInputFieldValue | ItemInputFieldValue - | ColorInputFieldValue; + | ColorInputFieldValue + | ImageCollectionInputFieldValue; /** * An input field template is generated on each page load from the OpenAPI schema. @@ -116,7 +118,8 @@ export type InputFieldTemplate = | ModelInputFieldTemplate | ArrayInputFieldTemplate | ItemInputFieldTemplate - | ColorInputFieldTemplate; + | ColorInputFieldTemplate + | ImageCollectionInputFieldTemplate; /** * An output field is persisted across as part of the user's local state. @@ -215,6 +218,11 @@ export type ImageInputFieldValue = FieldValueBase & { value?: ImageField; }; +export type ImageCollectionInputFieldValue = FieldValueBase & { + type: 'image_collection'; + value?: ImageField[]; +}; + export type ModelInputFieldValue = FieldValueBase & { type: 'model'; value?: string; @@ -282,6 +290,11 @@ export type ImageInputFieldTemplate = InputFieldTemplateBase & { type: 'image'; }; +export type ImageCollectionInputFieldTemplate = InputFieldTemplateBase & { + default: ImageField[]; + type: 'image_collection'; +}; + export type LatentsInputFieldTemplate = InputFieldTemplateBase & { default: string; type: 'latents'; diff --git a/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts b/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts index f1ad731d32..6f971dd60b 100644 --- a/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts +++ b/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts @@ -23,6 +23,7 @@ import { OutputFieldTemplate, TypeHints, FieldType, + ImageCollectionInputFieldTemplate, } from '../types/types'; export type BaseFieldProperties = 'name' | 'title' | 'description'; @@ -189,6 +190,21 @@ const buildImageInputFieldTemplate = ({ return template; }; +const buildImageCollectionInputFieldTemplate = ({ + schemaObject, + baseField, +}: BuildInputFieldArg): ImageCollectionInputFieldTemplate => { + const template: ImageCollectionInputFieldTemplate = { + ...baseField, + type: 'image_collection', + inputRequirement: 'always', + inputKind: 'any', + default: schemaObject.default ?? undefined, + }; + + return template; +}; + const buildLatentsInputFieldTemplate = ({ schemaObject, baseField, @@ -400,6 +416,10 @@ export const buildInputFieldTemplate = ( if (['image'].includes(fieldType)) { return buildImageInputFieldTemplate({ schemaObject, baseField }); } + + if (['image_collection'].includes(fieldType)) { + return buildImageCollectionInputFieldTemplate({ schemaObject, baseField }); + } if (['latents'].includes(fieldType)) { return buildLatentsInputFieldTemplate({ schemaObject, baseField }); } diff --git a/invokeai/frontend/web/src/features/nodes/util/fieldValueBuilders.ts b/invokeai/frontend/web/src/features/nodes/util/fieldValueBuilders.ts index 1703c45331..e05ef404c0 100644 --- a/invokeai/frontend/web/src/features/nodes/util/fieldValueBuilders.ts +++ b/invokeai/frontend/web/src/features/nodes/util/fieldValueBuilders.ts @@ -44,6 +44,10 @@ export const buildInputFieldValue = ( fieldValue.value = undefined; } + if (template.type === 'image_collection') { + fieldValue.value = []; + } + if (template.type === 'latents') { fieldValue.value = undefined; } diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearImageToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearImageToImageGraph.ts index 15d5a431a2..ca0a2e4dd9 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearImageToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearImageToImageGraph.ts @@ -1,7 +1,9 @@ import { RootState } from 'app/store/store'; import { + ImageCollectionInvocation, ImageResizeInvocation, ImageToLatentsInvocation, + IterateInvocation, } from 'services/api/types'; import { NonNullableGraph } from 'features/nodes/types/types'; import { log } from 'app/logging/useLogger'; @@ -15,6 +17,8 @@ import { IMAGE_TO_LATENTS, LATENTS_TO_LATENTS, RESIZE, + IMAGE_COLLECTION, + IMAGE_COLLECTION_ITERATE, } from './constants'; import { addControlNetToLinearGraph } from '../addControlNetToLinearGraph'; import { modelIdToPipelineModelField } from '../modelIdToPipelineModelField'; @@ -42,6 +46,15 @@ export const buildLinearImageToImageGraph = ( height, } = state.generation; + const { + isEnabled: isBatchEnabled, + imageNames: batchImageNames, + asInitialImage, + } = state.batch; + + const shouldBatch = + isBatchEnabled && batchImageNames.length > 0 && asInitialImage; + /** * The easiest way to build linear graphs is to do it in the node editor, then copy and paste the * full graph here as a template. Then use the parameters from app state and set friendlier node @@ -51,7 +64,7 @@ export const buildLinearImageToImageGraph = ( * the `fit` param. These are added to the graph at the end. */ - if (!initialImage) { + if (!initialImage && !shouldBatch) { moduleLog.error('No initial image found in state'); throw new Error('No initial image found in state'); } @@ -275,6 +288,41 @@ export const buildLinearImageToImageGraph = ( }); } + if (isBatchEnabled && asInitialImage && batchImageNames.length > 0) { + // we are going to connect an iterate up to the init image + delete (graph.nodes[IMAGE_TO_LATENTS] as ImageToLatentsInvocation).image; + + const imageCollection: ImageCollectionInvocation = { + id: IMAGE_COLLECTION, + type: 'image_collection', + images: batchImageNames.map((image_name) => ({ image_name })), + }; + + const imageCollectionIterate: IterateInvocation = { + id: IMAGE_COLLECTION_ITERATE, + type: 'iterate', + }; + + graph.nodes[IMAGE_COLLECTION] = imageCollection; + graph.nodes[IMAGE_COLLECTION_ITERATE] = imageCollectionIterate; + + graph.edges.push({ + source: { node_id: IMAGE_COLLECTION, field: 'collection' }, + destination: { + node_id: IMAGE_COLLECTION_ITERATE, + field: 'collection', + }, + }); + + graph.edges.push({ + source: { node_id: IMAGE_COLLECTION_ITERATE, field: 'item' }, + destination: { + node_id: IMAGE_TO_LATENTS, + field: 'image', + }, + }); + } + // add dynamic prompts, mutating `graph` addDynamicPromptsToGraph(graph, state); diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/constants.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/constants.ts index d6ab33a6ea..b0b1edde30 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/constants.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/constants.ts @@ -14,6 +14,8 @@ export const RESIZE = 'resize_image'; export const INPAINT = 'inpaint'; export const CONTROL_NET_COLLECT = 'control_net_collect'; export const DYNAMIC_PROMPT = 'dynamic_prompt'; +export const IMAGE_COLLECTION = 'image_collection'; +export const IMAGE_COLLECTION_ITERATE = 'image_collection_iterate'; // friendly graph ids export const TEXT_TO_IMAGE_GRAPH = 'text_to_image_graph'; diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImage.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImage.tsx new file mode 100644 index 0000000000..7951df31a7 --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImage.tsx @@ -0,0 +1,76 @@ +import { Flex, Icon, Text } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useMemo } from 'react'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIDndImage from 'common/components/IAIDndImage'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { skipToken } from '@reduxjs/toolkit/dist/query'; +import { FaImage } from 'react-icons/fa'; +import { stateSelector } from 'app/store/store'; +import { + TypesafeDraggableData, + TypesafeDroppableData, +} from 'app/components/ImageDnd/typesafeDnd'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; + +const selector = createSelector( + [stateSelector], + (state) => { + const { initialImage } = state.generation; + const { asInitialImage: useBatchAsInitialImage, imageNames } = state.batch; + return { + initialImage, + useBatchAsInitialImage, + isResetButtonDisabled: useBatchAsInitialImage + ? imageNames.length === 0 + : !initialImage, + }; + }, + defaultSelectorOptions +); + +const InitialImage = () => { + const { initialImage } = useAppSelector(selector); + + const { + currentData: imageDTO, + isLoading, + isError, + isSuccess, + } = useGetImageDTOQuery(initialImage?.imageName ?? skipToken); + + const draggableData = useMemo(() => { + if (imageDTO) { + return { + id: 'initial-image', + payloadType: 'IMAGE_DTO', + payload: { imageDTO }, + }; + } + }, [imageDTO]); + + const droppableData = useMemo( + () => ({ + id: 'initial-image', + actionType: 'SET_INITIAL_IMAGE', + }), + [] + ); + + return ( + + } + /> + ); +}; + +export default InitialImage; diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImageDisplay.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImageDisplay.tsx index 19eb45a0a9..c08f714488 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImageDisplay.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImageDisplay.tsx @@ -1,34 +1,154 @@ -import { Flex } from '@chakra-ui/react'; -import InitialImagePreview from './InitialImagePreview'; +import { Flex, Spacer, Text } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { clearInitialImage } from 'features/parameters/store/generationSlice'; +import { useCallback, useMemo } from 'react'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { skipToken } from '@reduxjs/toolkit/dist/query'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { FaLayerGroup, FaUndo, FaUpload } from 'react-icons/fa'; +import useImageUploader from 'common/hooks/useImageUploader'; +import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import IAIButton from 'common/components/IAIButton'; +import { stateSelector } from 'app/store/store'; +import { + asInitialImageToggled, + batchReset, +} from 'features/batch/store/batchSlice'; +import BatchImageContainer from 'features/batch/components/BatchImageContainer'; +import { PostUploadAction } from 'services/api/thunks/image'; +import InitialImage from './InitialImage'; + +const selector = createSelector( + [stateSelector], + (state) => { + const { initialImage } = state.generation; + const { asInitialImage: useBatchAsInitialImage, imageNames } = state.batch; + return { + initialImage, + useBatchAsInitialImage, + isResetButtonDisabled: useBatchAsInitialImage + ? imageNames.length === 0 + : !initialImage, + }; + }, + defaultSelectorOptions +); const InitialImageDisplay = () => { + const { initialImage, useBatchAsInitialImage, isResetButtonDisabled } = + useAppSelector(selector); + const dispatch = useAppDispatch(); + const { openUploader } = useImageUploader(); + + const { + currentData: imageDTO, + isLoading, + isError, + isSuccess, + } = useGetImageDTOQuery(initialImage?.imageName ?? skipToken); + + const postUploadAction = useMemo( + () => + useBatchAsInitialImage + ? { type: 'ADD_TO_BATCH' } + : { type: 'SET_INITIAL_IMAGE' }, + [useBatchAsInitialImage] + ); + + const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({ + postUploadAction, + }); + + const handleReset = useCallback(() => { + if (useBatchAsInitialImage) { + dispatch(batchReset()); + } else { + dispatch(clearInitialImage()); + } + }, [dispatch, useBatchAsInitialImage]); + + const handleUpload = useCallback(() => { + openUploader(); + }, [openUploader]); + + const handleClickUseBatch = useCallback(() => { + dispatch(asInitialImageToggled()); + }, [dispatch]); + return ( - + + Initial Image + + + {/* } + isChecked={useBatchAsInitialImage} + onClick={handleClickUseBatch} + > + {useBatchAsInitialImage ? 'Batch' : 'Single'} + */} + } + onClick={handleUpload} + {...getUploadButtonProps()} + /> + } + onClick={handleReset} + isDisabled={isResetButtonDisabled} + /> + + {/* {useBatchAsInitialImage ? : } */} + ); }; diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx deleted file mode 100644 index 2a05eee9b4..0000000000 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { Flex, Spacer, Text } from '@chakra-ui/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { - clearInitialImage, - initialImageChanged, -} from 'features/parameters/store/generationSlice'; -import { useCallback } from 'react'; -import { generationSelector } from 'features/parameters/store/generationSelectors'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import IAIDndImage from 'common/components/IAIDndImage'; -import { ImageDTO } from 'services/api/types'; -import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -import { skipToken } from '@reduxjs/toolkit/dist/query'; -import IAIIconButton from 'common/components/IAIIconButton'; -import { FaUndo, FaUpload } from 'react-icons/fa'; -import useImageUploader from 'common/hooks/useImageUploader'; -import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; - -const selector = createSelector( - [generationSelector], - (generation) => { - const { initialImage } = generation; - return { - initialImage, - }; - }, - defaultSelectorOptions -); - -const InitialImagePreview = () => { - const { initialImage } = useAppSelector(selector); - const dispatch = useAppDispatch(); - const { openUploader } = useImageUploader(); - - const { - currentData: image, - isLoading, - isError, - isSuccess, - } = useGetImageDTOQuery(initialImage?.imageName ?? skipToken); - - const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({ - postUploadAction: { type: 'SET_INITIAL_IMAGE' }, - }); - - const handleDrop = useCallback( - (droppedImage: ImageDTO) => { - if (droppedImage.image_name === initialImage?.imageName) { - return; - } - dispatch(initialImageChanged(droppedImage)); - }, - [dispatch, initialImage] - ); - - const handleReset = useCallback(() => { - dispatch(clearInitialImage()); - }, [dispatch]); - - const handleUpload = useCallback(() => { - openUploader(); - }, [openUploader]); - - return ( - - - - Initial Image - - - } - onClick={handleUpload} - {...getUploadButtonProps()} - /> - } - onClick={handleReset} - isDisabled={!initialImage} - /> - - } - isUploadDisabled={true} - fitContainer - /> - - - ); -}; - -export default InitialImagePreview; diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedRandomize.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedRandomize.tsx index 6b1dd46780..f30d9215e8 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedRandomize.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedRandomize.tsx @@ -1,10 +1,8 @@ import { ChangeEvent, memo } from 'react'; - import { RootState } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { setShouldRandomizeSeed } from 'features/parameters/store/generationSlice'; import { useTranslation } from 'react-i18next'; -import { FormControl, FormLabel, Switch, Tooltip } from '@chakra-ui/react'; import IAISwitch from 'common/components/IAISwitch'; const ParamSeedRandomize = () => { @@ -25,32 +23,6 @@ const ParamSeedRandomize = () => { onChange={handleChangeShouldRandomizeSeed} /> ); - - return ( - - - {t('parameters.randomizeSeed')} - - - - ); }; export default memo(ParamSeedRandomize); diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedShuffle.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedShuffle.tsx index 6442e34268..e71e2c36c0 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedShuffle.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedShuffle.tsx @@ -1,8 +1,6 @@ -import { Box } from '@chakra-ui/react'; import { NUMPY_RAND_MAX, NUMPY_RAND_MIN } from 'app/constants'; import { RootState } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIButton from 'common/components/IAIButton'; import IAIIconButton from 'common/components/IAIIconButton'; import randomInt from 'common/util/randomInt'; import { setSeed } from 'features/parameters/store/generationSlice'; @@ -29,16 +27,4 @@ export default function ParamSeedShuffle() { icon={} /> ); - - return ( - - {t('parameters.shuffle')} - - ); } diff --git a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx index 6f82562e48..e2338e2575 100644 --- a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx @@ -1,4 +1,4 @@ -import { Box } from '@chakra-ui/react'; +import { Box, ChakraProps } from '@chakra-ui/react'; import { userInvoked } from 'app/store/actions'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIButton, { IAIButtonProps } from 'common/components/IAIButton'; @@ -14,6 +14,16 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { FaPlay } from 'react-icons/fa'; +const IN_PROGRESS_STYLES: ChakraProps['sx'] = { + _disabled: { + bg: 'none', + cursor: 'not-allowed', + _hover: { + bg: 'none', + }, + }, +}; + interface InvokeButton extends Omit { iconButton?: boolean; @@ -24,6 +34,7 @@ export default function InvokeButton(props: InvokeButton) { const dispatch = useAppDispatch(); const isReady = useIsReadyToInvoke(); const activeTabName = useAppSelector(activeTabNameSelector); + const isProcessing = useAppSelector((state) => state.system.isProcessing); const handleInvoke = useCallback(() => { dispatch(clampSymmetrySteps()); @@ -48,6 +59,7 @@ export default function InvokeButton(props: InvokeButton) { {!isReady && ( @@ -68,13 +80,16 @@ export default function InvokeButton(props: InvokeButton) { icon={} isDisabled={!isReady} onClick={handleInvoke} - flexGrow={1} - w="100%" tooltip={t('parameters.invoke')} tooltipProps={{ placement: 'top' }} colorScheme="accent" id="invoke-button" {...rest} + sx={{ + w: 'full', + flexGrow: 1, + ...(isProcessing ? IN_PROGRESS_STYLES : {}), + }} /> ) : ( Invoke diff --git a/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx b/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx index f9eda624f2..8b098936b3 100644 --- a/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx +++ b/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx @@ -23,7 +23,7 @@ const ModelSelect = () => { (state: RootState) => state.generation.model ); - const { data: pipelineModels } = useListModelsQuery({ + const { data: pipelineModels, isLoading } = useListModelsQuery({ model_type: 'main', }); @@ -65,6 +65,8 @@ const ModelSelect = () => { ); useEffect(() => { + // If the selected model is not in the list of models, select the first one + // Handles first-run setting of models, and the user deleting the previously-selected model if (selectedModelId && pipelineModels?.ids.includes(selectedModelId)) { return; } @@ -78,13 +80,21 @@ const ModelSelect = () => { handleChangeModel(firstModel); }, [handleChangeModel, pipelineModels?.ids, selectedModelId]); - return ( + return isLoading ? ( + + ) : ( 0 ? 'Select a model' : 'No models detected!'} data={data} + error={data.length === 0} onChange={handleChangeModel} /> ); diff --git a/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx b/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx index 140a8b5978..34bd394214 100644 --- a/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx +++ b/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx @@ -35,6 +35,7 @@ const ProgressBar = () => { aria-label={t('accessibility.invokeProgressBar')} isIndeterminate={isProcessing && !currentStatusHasSteps} height="full" + colorScheme="accent" /> ); }; diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx index 6bbeedcaaa..1b2ae81072 100644 --- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx +++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx @@ -32,11 +32,12 @@ import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent import TextToImageTab from './tabs/TextToImage/TextToImageTab'; import UnifiedCanvasTab from './tabs/UnifiedCanvas/UnifiedCanvasTab'; import NodesTab from './tabs/Nodes/NodesTab'; -import { FaFont, FaImage } from 'react-icons/fa'; +import { FaFont, FaImage, FaLayerGroup } from 'react-icons/fa'; import ResizeHandle from './tabs/ResizeHandle'; import ImageTab from './tabs/ImageToImage/ImageToImageTab'; import AuxiliaryProgressIndicator from 'app/components/AuxiliaryProgressIndicator'; import { useMinimumPanelSize } from '../hooks/useMinimumPanelSize'; +import BatchTab from './tabs/Batch/BatchTab'; export interface InvokeTabInfo { id: InvokeTabName; @@ -65,6 +66,11 @@ const tabs: InvokeTabInfo[] = [ icon: , content: , }, + // { + // id: 'batch', + // icon: , + // content: , + // }, ]; const enabledTabsSelector = createSelector( diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx index b41017c2c9..0777463ec4 100644 --- a/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx +++ b/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx @@ -71,7 +71,15 @@ const ParametersDrawer = () => { onClose={handleClosePanel} > { - - {drawerContent} - + + {drawerContent} + ); diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPinnedWrapper.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPinnedWrapper.tsx index d47ca3e1ba..f327e10efc 100644 --- a/invokeai/frontend/web/src/features/ui/components/ParametersPinnedWrapper.tsx +++ b/invokeai/frontend/web/src/features/ui/components/ParametersPinnedWrapper.tsx @@ -42,18 +42,10 @@ const ParametersPinnedWrapper = (props: ParametersPinnedWrapperProps) => { h: 'full', w: 'full', position: 'absolute', + overflowY: 'auto', }} > - - - {props.children} - - + {props.children} { + const dispatch = useAppDispatch(); + const panelGroupRef = useRef(null); + + const handleDoubleClickHandle = useCallback(() => { + if (!panelGroupRef.current) { + return; + } + + panelGroupRef.current.setLayout([50, 50]); + }, []); + + return ( + + + + ); +}; + +export default memo(ImageToImageTab); diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasContent.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasContent.tsx index 77085bcb75..5474fe8358 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasContent.tsx @@ -14,8 +14,12 @@ import UnifiedCanvasToolbarBeta from './UnifiedCanvasBeta/UnifiedCanvasToolbarBe import UnifiedCanvasToolSettingsBeta from './UnifiedCanvasBeta/UnifiedCanvasToolSettingsBeta'; import { ImageDTO } from 'services/api/types'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; -import { useDroppable } from '@dnd-kit/core'; import IAIDropOverlay from 'common/components/IAIDropOverlay'; +import { + CanvasInitialImageDropData, + isValidDrop, + useDroppable, +} from 'app/components/ImageDnd/typesafeDnd'; const selector = createSelector( [canvasSelector, uiSelector], @@ -30,28 +34,24 @@ const selector = createSelector( defaultSelectorOptions ); +const droppableData: CanvasInitialImageDropData = { + id: 'canvas-intial-image', + actionType: 'SET_CANVAS_INITIAL_IMAGE', +}; + const UnifiedCanvasContent = () => { const dispatch = useAppDispatch(); const { doesCanvasNeedScaling, shouldUseCanvasBetaLayout } = useAppSelector(selector); - const onDrop = useCallback( - (droppedImage: ImageDTO) => { - dispatch(setInitialCanvasImage(droppedImage)); - }, - [dispatch] - ); - const { isOver, setNodeRef: setDroppableRef, active, } = useDroppable({ id: 'unifiedCanvas', - data: { - handleDrop: onDrop, - }, + data: droppableData, }); useLayoutEffect(() => { @@ -97,7 +97,12 @@ const UnifiedCanvasContent = () => { {doesCanvasNeedScaling ? : } - {active && } + {isValidDrop(droppableData, active) && ( + + )} @@ -139,7 +144,12 @@ const UnifiedCanvasContent = () => { > {doesCanvasNeedScaling ? : } - {active && } + {isValidDrop(droppableData, active) && ( + + )} diff --git a/invokeai/frontend/web/src/features/ui/store/tabMap.ts b/invokeai/frontend/web/src/features/ui/store/tabMap.ts index becf52886e..4f683c95cb 100644 --- a/invokeai/frontend/web/src/features/ui/store/tabMap.ts +++ b/invokeai/frontend/web/src/features/ui/store/tabMap.ts @@ -4,6 +4,7 @@ export const tabMap = [ // 'generate', 'unifiedCanvas', 'nodes', + 'batch', // 'postprocessing', // 'training', ] as const; diff --git a/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts b/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts index cef9ab7cae..a0db3f3dff 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts @@ -1,6 +1,7 @@ import { OffsetPaginatedResults_ImageDTO_ } from 'services/api/types'; import { api } from '..'; import { paths } from '../schema'; +import { imagesApi } from './images'; type ListBoardImagesArg = paths['/api/v1/board_images/{board_id}']['get']['parameters']['path'] & @@ -41,8 +42,22 @@ export const boardImagesApi = api.injectEndpoints({ }), invalidatesTags: (result, error, arg) => [ { type: 'Board', id: arg.board_id }, - { type: 'Image', id: arg.image_name }, ], + async onQueryStarted( + { image_name, ...patch }, + { dispatch, queryFulfilled } + ) { + const patchResult = dispatch( + imagesApi.util.updateQueryData('getImageDTO', image_name, (draft) => { + Object.assign(draft, patch); + }) + ); + try { + await queryFulfilled; + } catch { + patchResult.undo(); + } + }, }), removeImageFromBoard: build.mutation({ @@ -53,8 +68,22 @@ export const boardImagesApi = api.injectEndpoints({ }), invalidatesTags: (result, error, arg) => [ { type: 'Board', id: arg.board_id }, - { type: 'Image', id: arg.image_name }, ], + async onQueryStarted( + { image_name, ...patch }, + { dispatch, queryFulfilled } + ) { + const patchResult = dispatch( + imagesApi.util.updateQueryData('getImageDTO', image_name, (draft) => { + Object.assign(draft, { board_id: null }); + }) + ); + try { + await queryFulfilled; + } catch { + patchResult.undo(); + } + }, }), }), }); diff --git a/invokeai/frontend/web/src/services/api/schema.d.ts b/invokeai/frontend/web/src/services/api/schema.d.ts index 767fe7b2b3..e542cd4ba2 100644 --- a/invokeai/frontend/web/src/services/api/schema.d.ts +++ b/invokeai/frontend/web/src/services/api/schema.d.ts @@ -76,10 +76,10 @@ export type paths = { */ get: operations["list_models"]; /** - * Update Model + * Import Model * @description Add Model */ - post: operations["update_model"]; + post: operations["import_model"]; }; "/api/v1/models/{model_name}": { /** @@ -650,7 +650,7 @@ export type components = { end_step_percent: number; /** * Control Mode - * @description The contorl mode to use + * @description The control mode to use * @default balanced * @enum {string} */ @@ -1030,7 +1030,7 @@ export type components = { * @description The nodes in this graph */ nodes?: { - [key: string]: (components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]) | undefined; + [key: string]: (components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]) | undefined; }; /** * Edges @@ -1073,7 +1073,7 @@ export type components = { * @description The results of node executions */ results: { - [key: string]: (components["schemas"]["ImageOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["IntOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["IntCollectionOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["GraphInvocationOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["CollectInvocationOutput"]) | undefined; + [key: string]: (components["schemas"]["ImageOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["IntOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["IntCollectionOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["GraphInvocationOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["CollectInvocationOutput"]) | undefined; }; /** * Errors @@ -1276,6 +1276,53 @@ export type components = { */ channel?: "A" | "R" | "G" | "B"; }; + /** + * ImageCollectionInvocation + * @description Load a collection of images and provide it as output. + */ + ImageCollectionInvocation: { + /** + * Id + * @description The id of this node. Must be unique among all nodes. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this node is an intermediate node. + * @default false + */ + is_intermediate?: boolean; + /** + * Type + * @default image_collection + * @enum {string} + */ + type?: "image_collection"; + /** + * Images + * @description The image collection to load + * @default [] + */ + images?: (components["schemas"]["ImageField"])[]; + }; + /** + * ImageCollectionOutput + * @description A collection of images + */ + ImageCollectionOutput: { + /** + * Type + * @default image_collection + * @enum {string} + */ + type: "image_collection"; + /** + * Collection + * @description The output images + * @default [] + */ + collection: (components["schemas"]["ImageField"])[]; + }; /** * ImageConvertInvocation * @description Converts an image to a different mode. @@ -1928,6 +1975,20 @@ export type components = { */ thumbnail_url: string; }; + /** ImportModelRequest */ + ImportModelRequest: { + /** + * Name + * @description A model path, repo_id or URL to import + */ + name: string; + /** + * Prediction Type + * @description Prediction type for SDv2 checkpoint files + * @enum {string} + */ + prediction_type?: "epsilon" | "v_prediction" | "sample"; + }; /** * InfillColorInvocation * @description Infills transparent areas of an image with a solid color @@ -2440,6 +2501,64 @@ export type components = { */ strength?: number; }; + /** + * LeresImageProcessorInvocation + * @description Applies leres processing to image + */ + LeresImageProcessorInvocation: { + /** + * Id + * @description The id of this node. Must be unique among all nodes. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this node is an intermediate node. + * @default false + */ + is_intermediate?: boolean; + /** + * Type + * @default leres_image_processor + * @enum {string} + */ + type?: "leres_image_processor"; + /** + * Image + * @description The image to process + */ + image?: components["schemas"]["ImageField"]; + /** + * Thr A + * @description Leres parameter `thr_a` + * @default 0 + */ + thr_a?: number; + /** + * Thr B + * @description Leres parameter `thr_b` + * @default 0 + */ + thr_b?: number; + /** + * Boost + * @description Whether to use boost mode + * @default false + */ + boost?: boolean; + /** + * Detect Resolution + * @description The pixel resolution for detection + * @default 512 + */ + detect_resolution?: number; + /** + * Image Resolution + * @description The pixel resolution for the output image + * @default 512 + */ + image_resolution?: number; + }; /** * LineartAnimeImageProcessorInvocation * @description Applies line art anime processing to image @@ -2907,7 +3026,7 @@ export type components = { * @description An enumeration. * @enum {string} */ - ModelType: "pipeline" | "vae" | "lora" | "controlnet" | "embedding"; + ModelType: "main" | "vae" | "lora" | "controlnet" | "embedding"; /** * ModelVariantType * @description An enumeration. @@ -2993,12 +3112,6 @@ export type components = { * @default 512 */ height?: number; - /** - * Perlin - * @description The amount of perlin noise to add to the noise - * @default 0 - */ - perlin?: number; /** * Use Cpu * @description Use CPU for noise generation (for reproducible results across platforms) @@ -3697,11 +3810,33 @@ export type components = { antialias?: boolean; }; /** - * SchedulerPredictionType - * @description An enumeration. - * @enum {string} + * SegmentAnythingProcessorInvocation + * @description Applies segment anything processing to image */ - SchedulerPredictionType: "epsilon" | "v_prediction" | "sample"; + SegmentAnythingProcessorInvocation: { + /** + * Id + * @description The id of this node. Must be unique among all nodes. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this node is an intermediate node. + * @default false + */ + is_intermediate?: boolean; + /** + * Type + * @default segment_anything_processor + * @enum {string} + */ + type?: "segment_anything_processor"; + /** + * Image + * @description The image to process + */ + image?: components["schemas"]["ImageField"]; + }; /** * ShowImageInvocation * @description Displays a provided image, and passes it forward in the pipeline. @@ -3739,7 +3874,7 @@ export type components = { * Type * @enum {string} */ - type: "pipeline"; + type: "main"; /** Path */ path: string; /** Description */ @@ -3753,7 +3888,7 @@ export type components = { /** Vae */ vae?: string; /** Config */ - config?: string; + config: string; variant: components["schemas"]["ModelVariantType"]; }; /** StableDiffusion1ModelDiffusersConfig */ @@ -3765,7 +3900,7 @@ export type components = { * Type * @enum {string} */ - type: "pipeline"; + type: "main"; /** Path */ path: string; /** Description */ @@ -3789,7 +3924,7 @@ export type components = { * Type * @enum {string} */ - type: "pipeline"; + type: "main"; /** Path */ path: string; /** Description */ @@ -3803,11 +3938,8 @@ export type components = { /** Vae */ vae?: string; /** Config */ - config?: string; + config: string; variant: components["schemas"]["ModelVariantType"]; - prediction_type: components["schemas"]["SchedulerPredictionType"]; - /** Upcast Attention */ - upcast_attention: boolean; }; /** StableDiffusion2ModelDiffusersConfig */ StableDiffusion2ModelDiffusersConfig: { @@ -3818,7 +3950,7 @@ export type components = { * Type * @enum {string} */ - type: "pipeline"; + type: "main"; /** Path */ path: string; /** Description */ @@ -3832,9 +3964,6 @@ export type components = { /** Vae */ vae?: string; variant: components["schemas"]["ModelVariantType"]; - prediction_type: components["schemas"]["SchedulerPredictionType"]; - /** Upcast Attention */ - upcast_attention: boolean; }; /** * StepParamEasingInvocation @@ -4044,6 +4173,40 @@ export type components = { model_format: null; error?: components["schemas"]["ModelError"]; }; + /** + * TileResamplerProcessorInvocation + * @description Base class for invocations that preprocess images for ControlNet + */ + TileResamplerProcessorInvocation: { + /** + * Id + * @description The id of this node. Must be unique among all nodes. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this node is an intermediate node. + * @default false + */ + is_intermediate?: boolean; + /** + * Type + * @default tile_image_processor + * @enum {string} + */ + type?: "tile_image_processor"; + /** + * Image + * @description The image to process + */ + image?: components["schemas"]["ImageField"]; + /** + * Down Sampling Rate + * @description Down sampling rate + * @default 1 + */ + down_sampling_rate?: number; + }; /** UNetField */ UNetField: { /** @@ -4311,7 +4474,7 @@ export type operations = { }; requestBody: { content: { - "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]; + "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]; }; }; responses: { @@ -4348,7 +4511,7 @@ export type operations = { }; requestBody: { content: { - "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]; + "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]; }; }; responses: { @@ -4568,13 +4731,13 @@ export type operations = { }; }; /** - * Update Model + * Import Model * @description Add Model */ - update_model: { + import_model: { requestBody: { content: { - "application/json": components["schemas"]["CreateModelRequest"]; + "application/json": components["schemas"]["ImportModelRequest"]; }; }; responses: { @@ -4947,6 +5110,10 @@ export type operations = { */ delete_board: { parameters: { + query?: { + /** @description Permanently delete all images on the board */ + include_images?: boolean; + }; path: { /** @description The id of board to delete */ board_id: string; diff --git a/invokeai/frontend/web/src/services/api/thunks/image.ts b/invokeai/frontend/web/src/services/api/thunks/image.ts index a8b3dec5a7..d6e502bc54 100644 --- a/invokeai/frontend/web/src/services/api/thunks/image.ts +++ b/invokeai/frontend/web/src/services/api/thunks/image.ts @@ -1,6 +1,6 @@ import queryString from 'query-string'; import { createAppAsyncThunk } from 'app/store/storeUtils'; -import { selectImagesAll } from 'features/gallery/store/imagesSlice'; +import { selectImagesAll } from 'features/gallery/store/gallerySlice'; import { size } from 'lodash-es'; import { paths } from 'services/api/schema'; import { $client } from 'services/api/client'; @@ -112,6 +112,10 @@ type UploadedToastAction = { type: 'TOAST_UPLOADED'; }; +type AddToBatchAction = { + type: 'ADD_TO_BATCH'; +}; + export type PostUploadAction = | ControlNetAction | InitialImageAction @@ -119,12 +123,12 @@ export type PostUploadAction = | CanvasInitialImageAction | CanvasMergedAction | CanvasSavedToGalleryAction - | UploadedToastAction; + | UploadedToastAction + | AddToBatchAction; type UploadImageArg = paths['/api/v1/images/']['post']['parameters']['query'] & { file: File; - // file: paths['/api/v1/images/']['post']['requestBody']['content']['multipart/form-data']['file']; postUploadAction?: PostUploadAction; }; @@ -284,8 +288,7 @@ export const receivedPageOfImages = createAppAsyncThunk< const { get } = $client.get(); const state = getState(); - const { categories } = state.images; - const { selectedBoardId } = state.boards; + const { categories, selectedBoardId } = state.gallery; const images = selectImagesAll(state).filter((i) => { const isInCategory = categories.includes(i.image_category); diff --git a/invokeai/frontend/web/src/services/api/types.d.ts b/invokeai/frontend/web/src/services/api/types.d.ts index 2a2f90f434..12c072509b 100644 --- a/invokeai/frontend/web/src/services/api/types.d.ts +++ b/invokeai/frontend/web/src/services/api/types.d.ts @@ -58,6 +58,7 @@ export type LatentsToLatentsInvocation = N<'LatentsToLatentsInvocation'>; export type ImageToLatentsInvocation = N<'ImageToLatentsInvocation'>; export type LatentsToImageInvocation = N<'LatentsToImageInvocation'>; export type PipelineModelLoaderInvocation = N<'PipelineModelLoaderInvocation'>; +export type ImageCollectionInvocation = N<'ImageCollectionInvocation'>; // ControlNet Nodes export type ControlNetInvocation = N<'ControlNetInvocation'>; diff --git a/invokeai/frontend/web/src/theme/components/button.ts b/invokeai/frontend/web/src/theme/components/button.ts index 75662f7d42..7bb8a39a71 100644 --- a/invokeai/frontend/web/src/theme/components/button.ts +++ b/invokeai/frontend/web/src/theme/components/button.ts @@ -7,10 +7,10 @@ const invokeAI = defineStyle((props) => { if (c === 'base') { const _disabled = { - bg: mode('base.200', 'base.700')(props), - color: mode('base.500', 'base.150')(props), + bg: mode('base.150', 'base.700')(props), + color: mode('base.500', 'base.500')(props), svg: { - fill: mode('base.500', 'base.150')(props), + fill: mode('base.500', 'base.500')(props), }, opacity: 1, }; @@ -30,7 +30,6 @@ const invokeAI = defineStyle((props) => { 'drop-shadow(0px 0px 0.3rem var(--invokeai-colors-base-800))' )(props), }, - _disabled, _hover: { bg: mode('base.300', 'base.500')(props), color: mode('base.900', 'base.50')(props), @@ -39,34 +38,16 @@ const invokeAI = defineStyle((props) => { }, _disabled, }, - _checked: { - bg: mode('accent.400', 'accent.600')(props), - color: mode('base.50', 'base.100')(props), - svg: { - fill: mode(`${c}.50`, `${c}.100`)(props), - filter: mode( - `drop-shadow(0px 0px 0.3rem var(--invokeai-colors-${c}-600))`, - `drop-shadow(0px 0px 0.3rem var(--invokeai-colors-${c}-800))` - )(props), - }, - _disabled, - _hover: { - bg: mode('accent.500', 'accent.500')(props), - color: mode('white', 'base.50')(props), - svg: { - fill: mode('white', 'base.50')(props), - }, - _disabled, - }, - }, + _disabled, }; } const _disabled = { - bg: mode(`${c}.200`, `${c}.700`)(props), - color: mode(`${c}.100`, `${c}.150`)(props), + bg: mode(`${c}.250`, `${c}.700`)(props), + color: mode(`${c}.50`, `${c}.500`)(props), svg: { - fill: mode(`${c}.100`, `${c}.150`)(props), + fill: mode(`${c}.50`, `${c}.500`)(props), + filter: 'unset', }, opacity: 1, filter: mode(undefined, 'saturate(65%)')(props), @@ -78,7 +59,7 @@ const invokeAI = defineStyle((props) => { borderRadius: 'base', textShadow: mode( `0 0 0.3rem var(--invokeai-colors-${c}-600)`, - `0 0 0.3rem var(--invokeai-colors-${c}-900)` + `0 0 0.3rem var(--invokeai-colors-${c}-800)` )(props), svg: { fill: mode(`base.50`, `base.100`)(props), @@ -96,26 +77,6 @@ const invokeAI = defineStyle((props) => { }, _disabled, }, - _checked: { - bg: mode('accent.400', 'accent.600')(props), - color: mode('base.50', 'base.100')(props), - svg: { - fill: mode(`base.50`, `base.100`)(props), - filter: mode( - `drop-shadow(0px 0px 0.3rem var(--invokeai-colors-${c}-600))`, - `drop-shadow(0px 0px 0.3rem var(--invokeai-colors-${c}-800))` - )(props), - }, - _disabled, - _hover: { - bg: mode('accent.500', 'accent.500')(props), - color: mode('white', 'base.50')(props), - svg: { - fill: mode('white', 'base.50')(props), - }, - _disabled, - }, - }, }; }); diff --git a/invokeai/frontend/web/src/theme/components/menu.ts b/invokeai/frontend/web/src/theme/components/menu.ts index 02f75087ed..324720a040 100644 --- a/invokeai/frontend/web/src/theme/components/menu.ts +++ b/invokeai/frontend/web/src/theme/components/menu.ts @@ -22,6 +22,8 @@ const invokeAI = definePartsStyle((props) => ({ list: { zIndex: 9999, bg: mode('base.200', 'base.800')(props), + shadow: 'dark-lg', + border: 'none', }, item: { // this will style the MenuItem and MenuItemOption components diff --git a/invokeai/frontend/web/src/theme/components/progress.ts b/invokeai/frontend/web/src/theme/components/progress.ts index 87b6b7af01..71231869ce 100644 --- a/invokeai/frontend/web/src/theme/components/progress.ts +++ b/invokeai/frontend/web/src/theme/components/progress.ts @@ -3,24 +3,19 @@ import { createMultiStyleConfigHelpers, defineStyle, } from '@chakra-ui/styled-system'; +import { mode } from '@chakra-ui/theme-tools'; const { defineMultiStyleConfig, definePartsStyle } = createMultiStyleConfigHelpers(parts.keys); const invokeAIFilledTrack = defineStyle((_props) => ({ - bg: 'accent.600', - // TODO: the animation is nice but looks weird bc it is substantially longer than each step - // so we get to 100% long before it finishes - // transition: 'width 0.2s ease-in-out', - _indeterminate: { - bgGradient: - 'linear(to-r, transparent 0%, accent.600 50%, transparent 100%);', - }, + bg: 'accentAlpha.500', })); const invokeAITrack = defineStyle((_props) => { + const { colorScheme: c } = _props; return { - bg: 'none', + bg: mode(`${c}.200`, `${c}.700`)(_props), }; }); diff --git a/invokeai/frontend/web/src/theme/components/skeleton.ts b/invokeai/frontend/web/src/theme/components/skeleton.ts new file mode 100644 index 0000000000..8ee97e0fb8 --- /dev/null +++ b/invokeai/frontend/web/src/theme/components/skeleton.ts @@ -0,0 +1,25 @@ +import { defineStyle, defineStyleConfig, cssVar } from '@chakra-ui/react'; + +const $startColor = cssVar('skeleton-start-color'); +const $endColor = cssVar('skeleton-end-color'); + +const invokeAI = defineStyle({ + borderRadius: 'base', + maxW: 'full', + maxH: 'full', + _light: { + [$startColor.variable]: 'colors.base.250', + [$endColor.variable]: 'colors.base.450', + }, + _dark: { + [$startColor.variable]: 'colors.base.700', + [$endColor.variable]: 'colors.base.500', + }, +}); + +export const skeletonTheme = defineStyleConfig({ + variants: { invokeAI }, + defaultProps: { + variant: 'invokeAI', + }, +}); diff --git a/invokeai/frontend/web/src/theme/theme.ts b/invokeai/frontend/web/src/theme/theme.ts index 76b4aaaacc..03d1f640ac 100644 --- a/invokeai/frontend/web/src/theme/theme.ts +++ b/invokeai/frontend/web/src/theme/theme.ts @@ -19,6 +19,7 @@ import { tabsTheme } from './components/tabs'; import { textTheme } from './components/text'; import { textareaTheme } from './components/textarea'; import { tooltipTheme } from './components/tooltip'; +import { skeletonTheme } from './components/skeleton'; export const theme: ThemeOverride = { config: { @@ -68,6 +69,11 @@ export const theme: ThemeOverride = { working: `0 0 7px var(--invokeai-colors-working-400)`, error: `0 0 7px var(--invokeai-colors-error-400)`, }, + selected: { + light: + '0px 0px 0px 1px var(--invokeai-colors-base-150), 0px 0px 0px 4px var(--invokeai-colors-accent-400)', + dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 4px var(--invokeai-colors-accent-400)', + }, nodeSelectedOutline: `0 0 0 2px var(--invokeai-colors-base-500)`, }, colors: InvokeAIColors, @@ -82,6 +88,7 @@ export const theme: ThemeOverride = { Switch: switchTheme, NumberInput: numberInputTheme, Select: selectTheme, + Skeleton: skeletonTheme, Slider: sliderTheme, Popover: popoverTheme, Modal: modalTheme, diff --git a/invokeai/frontend/web/yarn.lock b/invokeai/frontend/web/yarn.lock index 2730f509e0..1f8be26bb4 100644 --- a/invokeai/frontend/web/yarn.lock +++ b/invokeai/frontend/web/yarn.lock @@ -5373,7 +5373,7 @@ open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" -openapi-fetch@^0.4.0: +openapi-fetch@0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/openapi-fetch/-/openapi-fetch-0.4.0.tgz#45c368321ba6c15bc2e168e7dc3fbf322e9cca6d" integrity sha512-4lzZtH5J1ZH9EXfmpcmKi0gOgjy0hc6BAcucAdCmLHY6jZopMeGP51vD3Cd4rE1nTFMfJzmYDc8ar0+364gBVw==