diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index 9c08013fef..cb867354a5 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -13,7 +13,6 @@ from invokeai.app.services.board_record_storage import SqliteBoardRecordStorage from invokeai.app.services.boards import BoardService, BoardServiceDependencies from invokeai.app.services.image_record_storage import SqliteImageRecordStorage from invokeai.app.services.images import ImageService, ImageServiceDependencies -from invokeai.app.services.metadata import CoreMetadataService from invokeai.app.services.resource_name import SimpleNameService from invokeai.app.services.urls import LocalUrlService from invokeai.backend.util.logging import InvokeAILogger @@ -75,7 +74,6 @@ class ApiDependencies: ) urls = LocalUrlService() - metadata = CoreMetadataService() image_record_storage = SqliteImageRecordStorage(db_location) image_file_storage = DiskImageFileStorage(f"{output_folder}/images") names = SimpleNameService() @@ -111,7 +109,6 @@ class ApiDependencies: board_image_record_storage=board_image_record_storage, image_record_storage=image_record_storage, image_file_storage=image_file_storage, - metadata=metadata, url=urls, logger=logger, names=names, diff --git a/invokeai/app/api/routers/app_info.py b/invokeai/app/api/routers/app_info.py index f6466d2b42..8e2955c9aa 100644 --- a/invokeai/app/api/routers/app_info.py +++ b/invokeai/app/api/routers/app_info.py @@ -1,18 +1,36 @@ from fastapi.routing import APIRouter -from pydantic import BaseModel +from pydantic import BaseModel, Field +from invokeai.backend.image_util.patchmatch import PatchMatch from invokeai.version import __version__ -app_router = APIRouter(prefix="/v1/app", tags=['app']) +app_router = APIRouter(prefix="/v1/app", tags=["app"]) class AppVersion(BaseModel): """App Version Response""" - version: str + + version: str = Field(description="App version") -@app_router.get('/version', operation_id="app_version", - status_code=200, - response_model=AppVersion) +class AppConfig(BaseModel): + """App Config Response""" + + infill_methods: list[str] = Field(description="List of available infill methods") + + +@app_router.get( + "/version", operation_id="app_version", status_code=200, response_model=AppVersion +) async def get_version() -> AppVersion: return AppVersion(version=__version__) + + +@app_router.get( + "/config", operation_id="get_config", status_code=200, response_model=AppConfig +) +async def get_config() -> AppConfig: + infill_methods = ['tile'] + if PatchMatch.patchmatch_available(): + infill_methods.append('patchmatch') + return AppConfig(infill_methods=infill_methods) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index a8c84b81b9..a0428e772e 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -1,20 +1,19 @@ import io from typing import Optional -from fastapi import Body, HTTPException, Path, Query, Request, Response, UploadFile -from fastapi.routing import APIRouter + +from fastapi import (Body, HTTPException, Path, Query, Request, Response, + UploadFile) from fastapi.responses import FileResponse +from fastapi.routing import APIRouter from PIL import Image -from invokeai.app.models.image import ( - ImageCategory, - ResourceOrigin, -) + +from invokeai.app.invocations.metadata import ImageMetadata +from invokeai.app.models.image import ImageCategory, ResourceOrigin from invokeai.app.services.image_record_storage import OffsetPaginatedResults -from invokeai.app.services.models.image_record import ( - ImageDTO, - ImageRecordChanges, - ImageUrlsDTO, -) from invokeai.app.services.item_storage import PaginatedResults +from invokeai.app.services.models.image_record import (ImageDTO, + ImageRecordChanges, + ImageUrlsDTO) from ..dependencies import ApiDependencies @@ -103,23 +102,38 @@ async def update_image( @images_router.get( - "/{image_name}/metadata", - operation_id="get_image_metadata", + "/{image_name}", + operation_id="get_image_dto", response_model=ImageDTO, ) -async def get_image_metadata( +async def get_image_dto( image_name: str = Path(description="The name of image to get"), ) -> ImageDTO: - """Gets an image's metadata""" + """Gets an image's DTO""" try: return ApiDependencies.invoker.services.images.get_dto(image_name) except Exception as e: raise HTTPException(status_code=404) +@images_router.get( + "/{image_name}/metadata", + operation_id="get_image_metadata", + response_model=ImageMetadata, +) +async def get_image_metadata( + image_name: str = Path(description="The name of image to get"), +) -> ImageMetadata: + """Gets an image's metadata""" + + try: + return ApiDependencies.invoker.services.images.get_metadata(image_name) + except Exception as e: + raise HTTPException(status_code=404) + @images_router.get( - "/{image_name}", + "/{image_name}/full", operation_id="get_image_full", response_class=Response, responses={ @@ -208,10 +222,10 @@ async def get_image_urls( @images_router.get( "/", - operation_id="list_images_with_metadata", + operation_id="list_image_dtos", response_model=OffsetPaginatedResults[ImageDTO], ) -async def list_images_with_metadata( +async def list_image_dtos( image_origin: Optional[ResourceOrigin] = Query( default=None, description="The origin of images to list" ), @@ -227,7 +241,7 @@ async def list_images_with_metadata( offset: int = Query(default=0, description="The page offset"), limit: int = Query(default=10, description="The number of images per page"), ) -> OffsetPaginatedResults[ImageDTO]: - """Gets a list of images""" + """Gets a list of image DTOs""" image_dtos = ApiDependencies.invoker.services.images.get_many( offset, diff --git a/invokeai/app/cli_app.py b/invokeai/app/cli_app.py index e3c6280ccb..888d36c4bf 100644 --- a/invokeai/app/cli_app.py +++ b/invokeai/app/cli_app.py @@ -34,7 +34,6 @@ from invokeai.app.services.board_record_storage import SqliteBoardRecordStorage from invokeai.app.services.boards import BoardService, BoardServiceDependencies from invokeai.app.services.image_record_storage import SqliteImageRecordStorage from invokeai.app.services.images import ImageService, ImageServiceDependencies -from invokeai.app.services.metadata import CoreMetadataService from invokeai.app.services.resource_name import SimpleNameService from invokeai.app.services.urls import LocalUrlService from .services.default_graphs import (default_text_to_image_graph_id, @@ -244,7 +243,6 @@ def invoke_cli(): ) urls = LocalUrlService() - metadata = CoreMetadataService() image_record_storage = SqliteImageRecordStorage(db_location) image_file_storage = DiskImageFileStorage(f"{output_folder}/images") names = SimpleNameService() @@ -277,7 +275,6 @@ def invoke_cli(): board_image_record_storage=board_image_record_storage, image_record_storage=image_record_storage, image_file_storage=image_file_storage, - metadata=metadata, url=urls, logger=logger, names=names, diff --git a/invokeai/app/invocations/generate.py b/invokeai/app/invocations/generate.py index 8accdb9851..6cdb83effc 100644 --- a/invokeai/app/invocations/generate.py +++ b/invokeai/app/invocations/generate.py @@ -154,40 +154,42 @@ class InpaintInvocation(BaseInvocation): @contextmanager def load_model_old_way(self, context, scheduler): + def _lora_loader(): + for lora in self.unet.loras: + lora_info = context.services.model_manager.get_model( + **lora.dict(exclude={"weight"})) + yield (lora_info.context.model, lora.weight) + del lora_info + return + unet_info = context.services.model_manager.get_model(**self.unet.unet.dict()) vae_info = context.services.model_manager.get_model(**self.vae.vae.dict()) - #unet = unet_info.context.model - #vae = vae_info.context.model + with vae_info as vae,\ + ModelPatcher.apply_lora_unet(unet_info.context.model, _lora_loader()),\ + unet_info as unet: - with ExitStack() as stack: - loras = [(stack.enter_context(context.services.model_manager.get_model(**lora.dict(exclude={"weight"}))), lora.weight) for lora in self.unet.loras] + device = context.services.model_manager.mgr.cache.execution_device + dtype = context.services.model_manager.mgr.cache.precision - with vae_info as vae,\ - unet_info as unet,\ - ModelPatcher.apply_lora_unet(unet, loras): + pipeline = StableDiffusionGeneratorPipeline( + vae=vae, + text_encoder=None, + tokenizer=None, + unet=unet, + scheduler=scheduler, + safety_checker=None, + feature_extractor=None, + requires_safety_checker=False, + precision="float16" if dtype == torch.float16 else "float32", + execution_device=device, + ) - device = context.services.model_manager.mgr.cache.execution_device - dtype = context.services.model_manager.mgr.cache.precision - - pipeline = StableDiffusionGeneratorPipeline( - vae=vae, - text_encoder=None, - tokenizer=None, - unet=unet, - scheduler=scheduler, - safety_checker=None, - feature_extractor=None, - requires_safety_checker=False, - precision="float16" if dtype == torch.float16 else "float32", - execution_device=device, - ) - - yield OldModelInfo( - name=self.unet.unet.model_name, - hash="", - model=pipeline, - ) + yield OldModelInfo( + name=self.unet.unet.model_name, + hash="", + model=pipeline, + ) def invoke(self, context: InvocationContext) -> ImageOutput: image = ( @@ -226,21 +228,21 @@ class InpaintInvocation(BaseInvocation): ), # Shorthand for passing all of the parameters above manually ) - # Outputs is an infinite iterator that will return a new InvokeAIGeneratorOutput object - # each time it is called. We only need the first one. - generator_output = next(outputs) + # Outputs is an infinite iterator that will return a new InvokeAIGeneratorOutput object + # each time it is called. We only need the first one. + generator_output = next(outputs) - image_dto = context.services.images.create( - image=generator_output.image, - image_origin=ResourceOrigin.INTERNAL, - image_category=ImageCategory.GENERAL, - session_id=context.graph_execution_state_id, - node_id=self.id, - is_intermediate=self.is_intermediate, - ) + image_dto = context.services.images.create( + image=generator_output.image, + image_origin=ResourceOrigin.INTERNAL, + image_category=ImageCategory.GENERAL, + session_id=context.graph_execution_state_id, + node_id=self.id, + is_intermediate=self.is_intermediate, + ) - return ImageOutput( - image=ImageField(image_name=image_dto.image_name), - width=image_dto.width, - height=image_dto.height, - ) + return ImageOutput( + image=ImageField(image_name=image_dto.image_name), + width=image_dto.width, + height=image_dto.height, + ) diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py index 5bdeaa5a9c..da99afc50f 100644 --- a/invokeai/app/invocations/latent.py +++ b/invokeai/app/invocations/latent.py @@ -9,9 +9,9 @@ from diffusers.image_processor import VaeImageProcessor from diffusers.schedulers import SchedulerMixin as Scheduler from pydantic import BaseModel, Field, validator +from invokeai.app.invocations.metadata import CoreMetadata from invokeai.app.util.step_callback import stable_diffusion_step_callback -from ..models.image import ImageCategory, ImageField, ResourceOrigin from ...backend.model_management.lora import ModelPatcher from ...backend.stable_diffusion import PipelineIntermediateState from ...backend.stable_diffusion.diffusers_pipeline import ( @@ -21,6 +21,7 @@ from ...backend.stable_diffusion.diffusion.shared_invokeai_diffusion import \ PostprocessingSettings from ...backend.stable_diffusion.schedulers import SCHEDULER_MAP from ...backend.util.devices import torch_dtype +from ..models.image import ImageCategory, ImageField, ResourceOrigin from .baseinvocation import (BaseInvocation, BaseInvocationOutput, InvocationConfig, InvocationContext) from .compel import ConditioningField @@ -449,6 +450,8 @@ class LatentsToImageInvocation(BaseInvocation): tiled: bool = Field( default=False, description="Decode latents by overlaping tiles(less memory consumption)") + metadata: Optional[CoreMetadata] = Field(default=None, description="Optional core metadata to be written to the image") + # Schema customisation class Config(InvocationConfig): @@ -493,7 +496,8 @@ class LatentsToImageInvocation(BaseInvocation): image_category=ImageCategory.GENERAL, node_id=self.id, session_id=context.graph_execution_state_id, - is_intermediate=self.is_intermediate + is_intermediate=self.is_intermediate, + metadata=self.metadata.dict() if self.metadata else None, ) return ImageOutput( diff --git a/invokeai/app/invocations/metadata.py b/invokeai/app/invocations/metadata.py new file mode 100644 index 0000000000..b7639c56c7 --- /dev/null +++ b/invokeai/app/invocations/metadata.py @@ -0,0 +1,124 @@ +from typing import Literal, Optional, Union + +from pydantic import BaseModel, Field + +from invokeai.app.invocations.baseinvocation import (BaseInvocation, + BaseInvocationOutput, + InvocationContext) +from invokeai.app.invocations.controlnet_image_processors import ControlField +from invokeai.app.invocations.model import (LoRAModelField, MainModelField, + VAEModelField) + + +class LoRAMetadataField(BaseModel): + """LoRA metadata for an image generated in InvokeAI.""" + lora: LoRAModelField = Field(description="The LoRA model") + weight: float = Field(description="The weight of the LoRA model") + + +class CoreMetadata(BaseModel): + """Core generation metadata for an image generated in InvokeAI.""" + + generation_mode: str = Field(description="The generation mode that output this image",) + positive_prompt: str = Field(description="The positive prompt parameter") + negative_prompt: str = Field(description="The negative prompt parameter") + width: int = Field(description="The width parameter") + height: int = Field(description="The height parameter") + seed: int = Field(description="The seed used for noise generation") + rand_device: str = Field(description="The device used for random number generation") + cfg_scale: float = Field(description="The classifier-free guidance scale parameter") + steps: int = Field(description="The number of steps used for inference") + scheduler: str = Field(description="The scheduler used for inference") + clip_skip: int = Field(description="The number of skipped CLIP layers",) + model: MainModelField = Field(description="The main model used for inference") + controlnets: list[ControlField]= Field(description="The ControlNets used for inference") + loras: list[LoRAMetadataField] = Field(description="The LoRAs used for inference") + strength: Union[float, None] = Field( + default=None, + description="The strength used for latents-to-latents", + ) + init_image: Union[str, None] = Field( + default=None, description="The name of the initial image" + ) + vae: Union[VAEModelField, None] = Field( + default=None, + description="The VAE used for decoding, if the main model's default was not used", + ) + + +class ImageMetadata(BaseModel): + """An image's generation metadata""" + + metadata: Optional[dict] = Field( + default=None, + description="The image's core metadata, if it was created in the Linear or Canvas UI", + ) + graph: Optional[dict] = Field( + default=None, description="The graph that created the image" + ) + + +class MetadataAccumulatorOutput(BaseInvocationOutput): + """The output of the MetadataAccumulator node""" + + type: Literal["metadata_accumulator_output"] = "metadata_accumulator_output" + + metadata: CoreMetadata = Field(description="The core metadata for the image") + + +class MetadataAccumulatorInvocation(BaseInvocation): + """Outputs a Core Metadata Object""" + + type: Literal["metadata_accumulator"] = "metadata_accumulator" + + generation_mode: str = Field(description="The generation mode that output this image",) + positive_prompt: str = Field(description="The positive prompt parameter") + negative_prompt: str = Field(description="The negative prompt parameter") + width: int = Field(description="The width parameter") + height: int = Field(description="The height parameter") + seed: int = Field(description="The seed used for noise generation") + rand_device: str = Field(description="The device used for random number generation") + cfg_scale: float = Field(description="The classifier-free guidance scale parameter") + steps: int = Field(description="The number of steps used for inference") + scheduler: str = Field(description="The scheduler used for inference") + clip_skip: int = Field(description="The number of skipped CLIP layers",) + model: MainModelField = Field(description="The main model used for inference") + controlnets: list[ControlField]= Field(description="The ControlNets used for inference") + loras: list[LoRAMetadataField] = Field(description="The LoRAs used for inference") + strength: Union[float, None] = Field( + default=None, + description="The strength used for latents-to-latents", + ) + init_image: Union[str, None] = Field( + default=None, description="The name of the initial image" + ) + vae: Union[VAEModelField, None] = Field( + default=None, + description="The VAE used for decoding, if the main model's default was not used", + ) + + + def invoke(self, context: InvocationContext) -> MetadataAccumulatorOutput: + """Collects and outputs a CoreMetadata object""" + + return MetadataAccumulatorOutput( + metadata=CoreMetadata( + generation_mode=self.generation_mode, + positive_prompt=self.positive_prompt, + negative_prompt=self.negative_prompt, + width=self.width, + height=self.height, + seed=self.seed, + rand_device=self.rand_device, + cfg_scale=self.cfg_scale, + steps=self.steps, + scheduler=self.scheduler, + model=self.model, + strength=self.strength, + init_image=self.init_image, + vae=self.vae, + controlnets=self.controlnets, + loras=self.loras, + clip_skip=self.clip_skip, + ) + ) diff --git a/invokeai/app/models/metadata.py b/invokeai/app/models/metadata.py deleted file mode 100644 index 8d90ca0bc8..0000000000 --- a/invokeai/app/models/metadata.py +++ /dev/null @@ -1,93 +0,0 @@ -from typing import Optional, Union, List -from pydantic import BaseModel, Extra, Field, StrictFloat, StrictInt, StrictStr - - -class ImageMetadata(BaseModel): - """ - Core generation metadata for an image/tensor generated in InvokeAI. - - Also includes any metadata from the image's PNG tEXt chunks. - - Generated by traversing the execution graph, collecting the parameters of the nearest ancestors - of a given node. - - Full metadata may be accessed by querying for the session in the `graph_executions` table. - """ - - class Config: - extra = Extra.allow - """ - This lets the ImageMetadata class accept arbitrary additional fields. The CoreMetadataService - won't add any fields that are not already defined, but other a different metadata service - implementation might. - """ - - type: Optional[StrictStr] = Field( - default=None, - description="The type of the ancestor node of the image output node.", - ) - """The type of the ancestor node of the image output node.""" - positive_conditioning: Optional[StrictStr] = Field( - default=None, description="The positive conditioning." - ) - """The positive conditioning""" - negative_conditioning: Optional[StrictStr] = Field( - default=None, description="The negative conditioning." - ) - """The negative conditioning""" - width: Optional[StrictInt] = Field( - default=None, description="Width of the image/latents in pixels." - ) - """Width of the image/latents in pixels""" - height: Optional[StrictInt] = Field( - default=None, description="Height of the image/latents in pixels." - ) - """Height of the image/latents in pixels""" - seed: Optional[StrictInt] = Field( - default=None, description="The seed used for noise generation." - ) - """The seed used for noise generation""" - # cfg_scale: Optional[StrictFloat] = Field( - # cfg_scale: Union[float, list[float]] = Field( - cfg_scale: Union[StrictFloat, List[StrictFloat]] = Field( - default=None, description="The classifier-free guidance scale." - ) - """The classifier-free guidance scale""" - steps: Optional[StrictInt] = Field( - default=None, description="The number of steps used for inference." - ) - """The number of steps used for inference""" - scheduler: Optional[StrictStr] = Field( - default=None, description="The scheduler used for inference." - ) - """The scheduler used for inference""" - model: Optional[StrictStr] = Field( - default=None, description="The model used for inference." - ) - """The model used for inference""" - strength: Optional[StrictFloat] = Field( - default=None, - description="The strength used for image-to-image/latents-to-latents.", - ) - """The strength used for image-to-image/latents-to-latents.""" - latents: Optional[StrictStr] = Field( - default=None, description="The ID of the initial latents." - ) - """The ID of the initial latents""" - vae: Optional[StrictStr] = Field( - default=None, description="The VAE used for decoding." - ) - """The VAE used for decoding""" - unet: Optional[StrictStr] = Field( - default=None, description="The UNet used dor inference." - ) - """The UNet used dor inference""" - clip: Optional[StrictStr] = Field( - default=None, description="The CLIP Encoder used for conditioning." - ) - """The CLIP Encoder used for conditioning""" - extra: Optional[StrictStr] = Field( - default=None, - description="Uploaded image metadata, extracted from the PNG tEXt chunk.", - ) - """Uploaded image metadata, extracted from the PNG tEXt chunk.""" diff --git a/invokeai/app/services/image_file_storage.py b/invokeai/app/services/image_file_storage.py index 136964afb5..60ae613748 100644 --- a/invokeai/app/services/image_file_storage.py +++ b/invokeai/app/services/image_file_storage.py @@ -1,14 +1,14 @@ # Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) and the InvokeAI Team +import json from abc import ABC, abstractmethod from pathlib import Path from queue import Queue from typing import Dict, Optional, Union -from PIL.Image import Image as PILImageType from PIL import Image, PngImagePlugin +from PIL.Image import Image as PILImageType from send2trash import send2trash -from invokeai.app.models.metadata import ImageMetadata from invokeai.app.util.thumbnails import get_thumbnail_name, make_thumbnail @@ -59,7 +59,8 @@ class ImageFileStorageBase(ABC): self, image: PILImageType, image_name: str, - metadata: Optional[ImageMetadata] = None, + metadata: Optional[dict] = None, + graph: Optional[dict] = None, thumbnail_size: int = 256, ) -> None: """Saves an image and a 256x256 WEBP thumbnail. Returns a tuple of the image name, thumbnail name, and created timestamp.""" @@ -110,20 +111,22 @@ class DiskImageFileStorage(ImageFileStorageBase): self, image: PILImageType, image_name: str, - metadata: Optional[ImageMetadata] = None, + metadata: Optional[dict] = None, + graph: Optional[dict] = None, thumbnail_size: int = 256, ) -> None: try: self.__validate_storage_folders() image_path = self.get_path(image_name) + pnginfo = PngImagePlugin.PngInfo() + if metadata is not None: - pnginfo = PngImagePlugin.PngInfo() - pnginfo.add_text("invokeai", metadata.json()) - image.save(image_path, "PNG", pnginfo=pnginfo) - else: - image.save(image_path, "PNG") + pnginfo.add_text("invokeai_metadata", json.dumps(metadata)) + if graph is not None: + pnginfo.add_text("invokeai_graph", json.dumps(graph)) + image.save(image_path, "PNG", pnginfo=pnginfo) thumbnail_name = get_thumbnail_name(image_name) thumbnail_path = self.get_path(thumbnail_name, thumbnail=True) thumbnail_image = make_thumbnail(image, thumbnail_size) diff --git a/invokeai/app/services/image_record_storage.py b/invokeai/app/services/image_record_storage.py index 014006eb7a..7b37307ce8 100644 --- a/invokeai/app/services/image_record_storage.py +++ b/invokeai/app/services/image_record_storage.py @@ -1,3 +1,4 @@ +import json import sqlite3 import threading from abc import ABC, abstractmethod @@ -8,7 +9,6 @@ from pydantic import BaseModel, Field from pydantic.generics import GenericModel from invokeai.app.models.image import ImageCategory, ResourceOrigin -from invokeai.app.models.metadata import ImageMetadata from invokeai.app.services.models.image_record import ( ImageRecord, ImageRecordChanges, deserialize_image_record) @@ -48,6 +48,28 @@ class ImageRecordDeleteException(Exception): super().__init__(message) +IMAGE_DTO_COLS = ", ".join( + list( + map( + lambda c: "images." + c, + [ + "image_name", + "image_origin", + "image_category", + "width", + "height", + "session_id", + "node_id", + "is_intermediate", + "created_at", + "updated_at", + "deleted_at", + ], + ) + ) +) + + class ImageRecordStorageBase(ABC): """Low-level service responsible for interfacing with the image record store.""" @@ -58,6 +80,11 @@ class ImageRecordStorageBase(ABC): """Gets an image record.""" pass + @abstractmethod + def get_metadata(self, image_name: str) -> Optional[dict]: + """Gets an image's metadata'.""" + pass + @abstractmethod def update( self, @@ -102,7 +129,7 @@ class ImageRecordStorageBase(ABC): height: int, session_id: Optional[str], node_id: Optional[str], - metadata: Optional[ImageMetadata], + metadata: Optional[dict], is_intermediate: bool = False, ) -> datetime: """Saves an image record.""" @@ -206,7 +233,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): self._cursor.execute( f"""--sql - SELECT * FROM images + SELECT {IMAGE_DTO_COLS} FROM images WHERE image_name = ?; """, (image_name,), @@ -224,6 +251,28 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): return deserialize_image_record(dict(result)) + def get_metadata(self, image_name: str) -> Optional[dict]: + try: + self._lock.acquire() + + self._cursor.execute( + f"""--sql + SELECT images.metadata FROM images + WHERE image_name = ?; + """, + (image_name,), + ) + + result = cast(Optional[sqlite3.Row], self._cursor.fetchone()) + if not result or not result[0]: + return None + return json.loads(result[0]) + except sqlite3.Error as e: + self._conn.rollback() + raise ImageRecordNotFoundException from e + finally: + self._lock.release() + def update( self, image_name: str, @@ -291,8 +340,8 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): WHERE 1=1 """ - images_query = """--sql - SELECT images.* + images_query = f"""--sql + SELECT {IMAGE_DTO_COLS} FROM images LEFT JOIN board_images ON board_images.image_name = images.image_name WHERE 1=1 @@ -410,12 +459,12 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): width: int, height: int, node_id: Optional[str], - metadata: Optional[ImageMetadata], + metadata: Optional[dict], is_intermediate: bool = False, ) -> datetime: try: metadata_json = ( - None if metadata is None else metadata.json(exclude_none=True) + None if metadata is None else json.dumps(metadata) ) self._lock.acquire() self._cursor.execute( @@ -465,9 +514,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): finally: self._lock.release() - def get_most_recent_image_for_board( - self, board_id: str - ) -> Optional[ImageRecord]: + def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]: try: self._lock.acquire() self._cursor.execute( diff --git a/invokeai/app/services/images.py b/invokeai/app/services/images.py index 4fbea1aa2d..a7d0b6ddee 100644 --- a/invokeai/app/services/images.py +++ b/invokeai/app/services/images.py @@ -1,39 +1,30 @@ +import json from abc import ABC, abstractmethod from logging import Logger -from typing import Optional, TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Optional + from PIL.Image import Image as PILImageType -from invokeai.app.models.image import ( - ImageCategory, - ResourceOrigin, - InvalidImageCategoryException, - InvalidOriginException, -) -from invokeai.app.models.metadata import ImageMetadata -from invokeai.app.services.board_image_record_storage import BoardImageRecordStorageBase -from invokeai.app.services.image_record_storage import ( - ImageRecordDeleteException, - ImageRecordNotFoundException, - ImageRecordSaveException, - ImageRecordStorageBase, - OffsetPaginatedResults, -) -from invokeai.app.services.models.image_record import ( - ImageRecord, - ImageDTO, - ImageRecordChanges, - image_record_to_dto, -) +from invokeai.app.invocations.metadata import ImageMetadata +from invokeai.app.models.image import (ImageCategory, + InvalidImageCategoryException, + InvalidOriginException, ResourceOrigin) +from invokeai.app.services.board_image_record_storage import \ + BoardImageRecordStorageBase +from invokeai.app.services.graph import Graph from invokeai.app.services.image_file_storage import ( - ImageFileDeleteException, - ImageFileNotFoundException, - ImageFileSaveException, - ImageFileStorageBase, -) -from invokeai.app.services.item_storage import ItemStorageABC, PaginatedResults -from invokeai.app.services.metadata import MetadataServiceBase + ImageFileDeleteException, ImageFileNotFoundException, + ImageFileSaveException, ImageFileStorageBase) +from invokeai.app.services.image_record_storage import ( + ImageRecordDeleteException, ImageRecordNotFoundException, + ImageRecordSaveException, ImageRecordStorageBase, OffsetPaginatedResults) +from invokeai.app.services.item_storage import ItemStorageABC +from invokeai.app.services.models.image_record import (ImageDTO, ImageRecord, + ImageRecordChanges, + image_record_to_dto) from invokeai.app.services.resource_name import NameServiceBase from invokeai.app.services.urls import UrlServiceBase +from invokeai.app.util.metadata import get_metadata_graph_from_raw_session if TYPE_CHECKING: from invokeai.app.services.graph import GraphExecutionState @@ -51,6 +42,7 @@ class ImageServiceABC(ABC): node_id: Optional[str] = None, session_id: Optional[str] = None, is_intermediate: bool = False, + metadata: Optional[dict] = None, ) -> ImageDTO: """Creates an image, storing the file and its metadata.""" pass @@ -79,6 +71,11 @@ class ImageServiceABC(ABC): """Gets an image DTO.""" pass + @abstractmethod + def get_metadata(self, image_name: str) -> ImageMetadata: + """Gets an image's metadata.""" + pass + @abstractmethod def get_path(self, image_name: str, thumbnail: bool = False) -> str: """Gets an image's path.""" @@ -124,7 +121,6 @@ class ImageServiceDependencies: image_records: ImageRecordStorageBase image_files: ImageFileStorageBase board_image_records: BoardImageRecordStorageBase - metadata: MetadataServiceBase urls: UrlServiceBase logger: Logger names: NameServiceBase @@ -135,7 +131,6 @@ class ImageServiceDependencies: image_record_storage: ImageRecordStorageBase, image_file_storage: ImageFileStorageBase, board_image_record_storage: BoardImageRecordStorageBase, - metadata: MetadataServiceBase, url: UrlServiceBase, logger: Logger, names: NameServiceBase, @@ -144,7 +139,6 @@ class ImageServiceDependencies: self.image_records = image_record_storage self.image_files = image_file_storage self.board_image_records = board_image_record_storage - self.metadata = metadata self.urls = url self.logger = logger self.names = names @@ -165,6 +159,7 @@ class ImageService(ImageServiceABC): node_id: Optional[str] = None, session_id: Optional[str] = None, is_intermediate: bool = False, + metadata: Optional[dict] = None, ) -> ImageDTO: if image_origin not in ResourceOrigin: raise InvalidOriginException @@ -174,7 +169,16 @@ class ImageService(ImageServiceABC): image_name = self._services.names.create_image_name() - metadata = self._get_metadata(session_id, node_id) + graph = None + + if session_id is not None: + session_raw = self._services.graph_execution_manager.get_raw(session_id) + if session_raw is not None: + try: + graph = get_metadata_graph_from_raw_session(session_raw) + except Exception as e: + self._services.logger.warn(f"Failed to parse session graph: {e}") + graph = None (width, height) = image.size @@ -191,14 +195,12 @@ class ImageService(ImageServiceABC): is_intermediate=is_intermediate, # Nullable fields node_id=node_id, - session_id=session_id, metadata=metadata, + session_id=session_id, ) self._services.image_files.save( - image_name=image_name, - image=image, - metadata=metadata, + image_name=image_name, image=image, metadata=metadata, graph=graph ) image_dto = self.get_dto(image_name) @@ -268,6 +270,34 @@ class ImageService(ImageServiceABC): self._services.logger.error("Problem getting image DTO") raise e + def get_metadata(self, image_name: str) -> Optional[ImageMetadata]: + try: + image_record = self._services.image_records.get(image_name) + + if not image_record.session_id: + return ImageMetadata() + + session_raw = self._services.graph_execution_manager.get_raw( + image_record.session_id + ) + graph = None + + if session_raw: + try: + graph = get_metadata_graph_from_raw_session(session_raw) + except Exception as e: + self._services.logger.warn(f"Failed to parse session graph: {e}") + graph = None + + metadata = self._services.image_records.get_metadata(image_name) + return ImageMetadata(graph=graph, metadata=metadata) + except ImageRecordNotFoundException: + self._services.logger.error("Image record not found") + raise + except Exception as e: + self._services.logger.error("Problem getting image DTO") + raise e + def get_path(self, image_name: str, thumbnail: bool = False) -> str: try: return self._services.image_files.get_path(image_name, thumbnail) @@ -367,15 +397,3 @@ class ImageService(ImageServiceABC): except Exception as e: self._services.logger.error("Problem deleting image records and files") raise e - - def _get_metadata( - self, session_id: Optional[str] = None, node_id: Optional[str] = None - ) -> Optional[ImageMetadata]: - """Get the metadata for a node.""" - metadata = None - - if node_id is not None and session_id is not None: - session = self._services.graph_execution_manager.get(session_id) - metadata = self._services.metadata.create_image_metadata(session, node_id) - - return metadata diff --git a/invokeai/app/services/item_storage.py b/invokeai/app/services/item_storage.py index 394f67797d..709d88bf97 100644 --- a/invokeai/app/services/item_storage.py +++ b/invokeai/app/services/item_storage.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Callable, Generic, TypeVar +from typing import Callable, Generic, Optional, TypeVar from pydantic import BaseModel, Field from pydantic.generics import GenericModel @@ -29,14 +29,22 @@ class ItemStorageABC(ABC, Generic[T]): @abstractmethod def get(self, item_id: str) -> T: + """Gets the item, parsing it into a Pydantic model""" + pass + + @abstractmethod + def get_raw(self, item_id: str) -> Optional[str]: + """Gets the raw item as a string, skipping Pydantic parsing""" pass @abstractmethod def set(self, item: T) -> None: + """Sets the item""" pass @abstractmethod def list(self, page: int = 0, per_page: int = 10) -> PaginatedResults[T]: + """Gets a paginated list of items""" pass @abstractmethod diff --git a/invokeai/app/services/metadata.py b/invokeai/app/services/metadata.py deleted file mode 100644 index cc169db3ce..0000000000 --- a/invokeai/app/services/metadata.py +++ /dev/null @@ -1,142 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Optional -import networkx as nx - -from invokeai.app.models.metadata import ImageMetadata -from invokeai.app.services.graph import Graph, GraphExecutionState - - -class MetadataServiceBase(ABC): - """Handles building metadata for nodes, images, and outputs.""" - - @abstractmethod - def create_image_metadata( - self, session: GraphExecutionState, node_id: str - ) -> ImageMetadata: - """Builds an ImageMetadata object for a node.""" - pass - - -class CoreMetadataService(MetadataServiceBase): - _ANCESTOR_TYPES = ["t2l", "l2l"] - """The ancestor types that contain the core metadata""" - - _ANCESTOR_PARAMS = ["type", "steps", "model", "cfg_scale", "scheduler", "strength"] - """The core metadata parameters in the ancestor types""" - - _NOISE_FIELDS = ["seed", "width", "height"] - """The core metadata parameters in the noise node""" - - def create_image_metadata( - self, session: GraphExecutionState, node_id: str - ) -> ImageMetadata: - metadata = self._build_metadata_from_graph(session, node_id) - - return metadata - - def _find_nearest_ancestor(self, G: nx.DiGraph, node_id: str) -> Optional[str]: - """ - Finds the id of the nearest ancestor (of a valid type) of a given node. - - Parameters: - G (nx.DiGraph): The execution graph, converted in to a networkx DiGraph. Its nodes must - have the same data as the execution graph. - node_id (str): The ID of the node. - - Returns: - str | None: The ID of the nearest ancestor, or None if there are no valid ancestors. - """ - - # Retrieve the node from the graph - node = G.nodes[node_id] - - # If the node type is one of the core metadata node types, return its id - if node.get("type") in self._ANCESTOR_TYPES: - return node.get("id") - - # Else, look for the ancestor in the predecessor nodes - for predecessor in G.predecessors(node_id): - result = self._find_nearest_ancestor(G, predecessor) - if result: - return result - - # If there are no valid ancestors, return None - return None - - def _get_additional_metadata( - self, graph: Graph, node_id: str - ) -> Optional[dict[str, Any]]: - """ - Returns additional metadata for a given node. - - Parameters: - graph (Graph): The execution graph. - node_id (str): The ID of the node. - - Returns: - dict[str, Any] | None: A dictionary of additional metadata. - """ - - metadata = {} - - # Iterate over all edges in the graph - for edge in graph.edges: - dest_node_id = edge.destination.node_id - dest_field = edge.destination.field - source_node_dict = graph.nodes[edge.source.node_id].dict() - - # If the destination node ID matches the given node ID, gather necessary metadata - if dest_node_id == node_id: - # Prompt - if dest_field == "positive_conditioning": - metadata["positive_conditioning"] = source_node_dict.get("prompt") - # Negative prompt - if dest_field == "negative_conditioning": - metadata["negative_conditioning"] = source_node_dict.get("prompt") - # Seed, width and height - if dest_field == "noise": - for field in self._NOISE_FIELDS: - metadata[field] = source_node_dict.get(field) - return metadata - - def _build_metadata_from_graph( - self, session: GraphExecutionState, node_id: str - ) -> ImageMetadata: - """ - Builds an ImageMetadata object for a node. - - Parameters: - session (GraphExecutionState): The session. - node_id (str): The ID of the node. - - Returns: - ImageMetadata: The metadata for the node. - """ - - # We need to do all the traversal on the execution graph - graph = session.execution_graph - - # Find the nearest `t2l`/`l2l` ancestor of the given node - ancestor_id = self._find_nearest_ancestor(graph.nx_graph_with_data(), node_id) - - # If no ancestor was found, return an empty ImageMetadata object - if ancestor_id is None: - return ImageMetadata() - - ancestor_node = graph.get_node(ancestor_id) - - # Grab all the core metadata from the ancestor node - ancestor_metadata = { - param: val - for param, val in ancestor_node.dict().items() - if param in self._ANCESTOR_PARAMS - } - - # Get this image's prompts and noise parameters - addl_metadata = self._get_additional_metadata(graph, ancestor_id) - - # If additional metadata was found, add it to the main metadata - if addl_metadata is not None: - ancestor_metadata.update(addl_metadata) - - return ImageMetadata(**ancestor_metadata) diff --git a/invokeai/app/services/models/image_record.py b/invokeai/app/services/models/image_record.py index c40d2138f8..cf10f6e8b2 100644 --- a/invokeai/app/services/models/image_record.py +++ b/invokeai/app/services/models/image_record.py @@ -1,13 +1,14 @@ import datetime from typing import Optional, Union + from pydantic import BaseModel, Extra, Field, StrictBool, StrictStr + from invokeai.app.models.image import ImageCategory, ResourceOrigin -from invokeai.app.models.metadata import ImageMetadata from invokeai.app.util.misc import get_iso_timestamp class ImageRecord(BaseModel): - """Deserialized image record.""" + """Deserialized image record without metadata.""" image_name: str = Field(description="The unique name of the image.") """The unique name of the image.""" @@ -43,11 +44,6 @@ class ImageRecord(BaseModel): description="The node ID that generated this image, if it is a generated image.", ) """The node ID that generated this image, if it is a generated image.""" - metadata: Optional[ImageMetadata] = Field( - default=None, - description="A limited subset of the image's generation metadata. Retrieve the image's session for full metadata.", - ) - """A limited subset of the image's generation metadata. Retrieve the image's session for full metadata.""" class ImageRecordChanges(BaseModel, extra=Extra.forbid): @@ -112,6 +108,7 @@ def deserialize_image_record(image_dict: dict) -> ImageRecord: # Retrieve all the values, setting "reasonable" defaults if they are not present. + # TODO: do we really need to handle default values here? ideally the data is the correct shape... image_name = image_dict.get("image_name", "unknown") image_origin = ResourceOrigin( image_dict.get("image_origin", ResourceOrigin.INTERNAL.value) @@ -128,13 +125,6 @@ def deserialize_image_record(image_dict: dict) -> ImageRecord: deleted_at = image_dict.get("deleted_at", get_iso_timestamp()) is_intermediate = image_dict.get("is_intermediate", False) - raw_metadata = image_dict.get("metadata") - - if raw_metadata is not None: - metadata = ImageMetadata.parse_raw(raw_metadata) - else: - metadata = None - return ImageRecord( image_name=image_name, image_origin=image_origin, @@ -143,7 +133,6 @@ def deserialize_image_record(image_dict: dict) -> ImageRecord: height=height, session_id=session_id, node_id=node_id, - metadata=metadata, created_at=created_at, updated_at=updated_at, deleted_at=deleted_at, diff --git a/invokeai/app/services/sqlite.py b/invokeai/app/services/sqlite.py index e71f039bcc..8902415096 100644 --- a/invokeai/app/services/sqlite.py +++ b/invokeai/app/services/sqlite.py @@ -1,6 +1,6 @@ import sqlite3 from threading import Lock -from typing import Generic, TypeVar, Optional, Union, get_args +from typing import Generic, Optional, TypeVar, get_args from pydantic import BaseModel, parse_raw_as @@ -78,6 +78,21 @@ class SqliteItemStorage(ItemStorageABC, Generic[T]): return self._parse_item(result[0]) + def get_raw(self, id: str) -> Optional[str]: + try: + self._lock.acquire() + self._cursor.execute( + f"""SELECT item FROM {self._table_name} WHERE id = ?;""", (str(id),) + ) + result = self._cursor.fetchone() + finally: + self._lock.release() + + if not result: + return None + + return result[0] + def delete(self, id: str): try: self._lock.acquire() diff --git a/invokeai/app/services/urls.py b/invokeai/app/services/urls.py index 5920e9e6c1..73d8ddadf4 100644 --- a/invokeai/app/services/urls.py +++ b/invokeai/app/services/urls.py @@ -22,4 +22,4 @@ class LocalUrlService(UrlServiceBase): if thumbnail: return f"{self._base_url}/images/{image_basename}/thumbnail" - return f"{self._base_url}/images/{image_basename}" + return f"{self._base_url}/images/{image_basename}/full" diff --git a/invokeai/app/util/metadata.py b/invokeai/app/util/metadata.py new file mode 100644 index 0000000000..5ca5f14e12 --- /dev/null +++ b/invokeai/app/util/metadata.py @@ -0,0 +1,55 @@ +import json +from typing import Optional + +from pydantic import ValidationError + +from invokeai.app.services.graph import Edge + + +def get_metadata_graph_from_raw_session(session_raw: str) -> Optional[dict]: + """ + Parses raw session string, returning a dict of the graph. + + Only the general graph shape is validated; none of the fields are validated. + + Any `metadata_accumulator` nodes and edges are removed. + + Any validation failure will return None. + """ + + graph = json.loads(session_raw).get("graph", None) + + # sanity check make sure the graph is at least reasonably shaped + if ( + type(graph) is not dict + or "nodes" not in graph + or type(graph["nodes"]) is not dict + or "edges" not in graph + or type(graph["edges"]) is not list + ): + # something has gone terribly awry, return an empty dict + return None + + try: + # delete the `metadata_accumulator` node + del graph["nodes"]["metadata_accumulator"] + except KeyError: + # no accumulator node, all good + pass + + # delete any edges to or from it + for i, edge in enumerate(graph["edges"]): + try: + # try to parse the edge + Edge(**edge) + except ValidationError: + # something has gone terribly awry, return an empty dict + return None + + if ( + edge["source"]["node_id"] == "metadata_accumulator" + or edge["destination"]["node_id"] == "metadata_accumulator" + ): + del graph["edges"][i] + + return graph diff --git a/invokeai/backend/install/model_install_backend.py b/invokeai/backend/install/model_install_backend.py index c5f15a3ce9..b6f6d62d97 100644 --- a/invokeai/backend/install/model_install_backend.py +++ b/invokeai/backend/install/model_install_backend.py @@ -121,8 +121,8 @@ class ModelInstall(object): installed_models = self.mgr.list_models() for md in installed_models: base = md['base_model'] - model_type = md['type'] - name = md['name'] + model_type = md['model_type'] + name = md['model_name'] key = ModelManager.create_key(name, base, model_type) if key in model_dict: model_dict[key].installed = True diff --git a/invokeai/backend/model_management/model_manager.py b/invokeai/backend/model_management/model_manager.py index 81beed0408..6b9d085885 100644 --- a/invokeai/backend/model_management/model_manager.py +++ b/invokeai/backend/model_management/model_manager.py @@ -538,9 +538,9 @@ class ModelManager(object): model_dict = dict( **model_config.dict(exclude_defaults=True), # OpenAPIModelInfoBase - name=cur_model_name, + model_name=cur_model_name, base_model=cur_base_model, - type=cur_model_type, + model_type=cur_model_type, ) models.append(model_dict) diff --git a/invokeai/backend/model_management/models/__init__.py b/invokeai/backend/model_management/models/__init__.py index b02d85471d..1c573b26b6 100644 --- a/invokeai/backend/model_management/models/__init__.py +++ b/invokeai/backend/model_management/models/__init__.py @@ -37,9 +37,9 @@ MODEL_CONFIGS = list() OPENAPI_MODEL_CONFIGS = list() class OpenAPIModelInfoBase(BaseModel): - name: str + model_name: str base_model: BaseModelType - type: ModelType + model_type: ModelType for base_model, models in MODEL_CLASSES.items(): @@ -56,7 +56,7 @@ for base_model, models in MODEL_CLASSES.items(): api_wrapper = type(openapi_cfg_name, (cfg, OpenAPIModelInfoBase), dict( __annotations__ = dict( - type=Literal[model_type.value], + model_type=Literal[model_type.value], ), )) diff --git a/invokeai/backend/stable_diffusion/diffusers_pipeline.py b/invokeai/backend/stable_diffusion/diffusers_pipeline.py index 25f1704c1d..1d701b21fd 100644 --- a/invokeai/backend/stable_diffusion/diffusers_pipeline.py +++ b/invokeai/backend/stable_diffusion/diffusers_pipeline.py @@ -127,7 +127,7 @@ class AddsMaskGuidance: def _t_for_field(self, field_name: str, t): if field_name == "pred_original_sample": - return torch.zeros_like(t, dtype=t.dtype) # it represents t=0 + return self.scheduler.timesteps[-1] return t def apply_mask(self, latents: torch.Tensor, t) -> torch.Tensor: @@ -631,7 +631,7 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): control_latent_input = torch.cat([unet_latent_input] * 2) if cfg_injection: # only applying ControlNet to conditional instead of in unconditioned - encoder_hidden_states = torch.cat([conditioning_data.unconditioned_embeddings]) + encoder_hidden_states = conditioning_data.text_embeddings else: encoder_hidden_states = torch.cat([conditioning_data.unconditioned_embeddings, conditioning_data.text_embeddings]) diff --git a/invokeai/frontend/web/.eslintrc.js b/invokeai/frontend/web/.eslintrc.js index 34db9d466b..c48e08d45e 100644 --- a/invokeai/frontend/web/.eslintrc.js +++ b/invokeai/frontend/web/.eslintrc.js @@ -36,6 +36,7 @@ module.exports = { ], 'prettier/prettier': ['error', { endOfLine: 'auto' }], '@typescript-eslint/ban-ts-comment': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-empty-interface': [ 'error', { diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 921b234aaf..81d6b0c7c7 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -108,6 +108,7 @@ "roarr": "^7.15.0", "serialize-error": "^11.0.0", "socket.io-client": "^4.7.0", + "use-debounce": "^9.0.4", "use-image": "^1.1.1", "uuid": "^9.0.0", "zod": "^3.21.4" diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index acf3afabf6..a841cb95f9 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -102,7 +102,8 @@ "openInNewTab": "Open in New Tab", "dontAskMeAgain": "Don't ask me again", "areYouSure": "Are you sure?", - "imagePrompt": "Image Prompt" + "imagePrompt": "Image Prompt", + "clearNodes": "Are you sure you want to clear all nodes?" }, "gallery": { "generations": "Generations", @@ -118,7 +119,7 @@ "pinGallery": "Pin Gallery", "allImagesLoaded": "All Images Loaded", "loadMore": "Load More", - "noImagesInGallery": "No Images In Gallery", + "noImagesInGallery": "No Images to Display", "deleteImage": "Delete Image", "deleteImageBin": "Deleted images will be sent to your operating system's Bin.", "deleteImagePermanent": "Deleted images cannot be restored.", @@ -600,7 +601,8 @@ "initialImageNotSetDesc": "Could not load initial image", "nodesSaved": "Nodes Saved", "nodesLoaded": "Nodes Loaded", - "nodesLoadedFailed": "Failed To Load Nodes" + "nodesLoadedFailed": "Failed To Load Nodes", + "nodesCleared": "Nodes Cleared" }, "tooltip": { "feature": { @@ -685,6 +687,7 @@ "nodes": { "reloadSchema": "Reload Schema", "saveNodes": "Save Nodes", - "loadNodes": "Load Nodes" + "loadNodes": "Load Nodes", + "clearNodes": "Clear Nodes" } } diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 8628360160..a05266d5f2 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -6,9 +6,7 @@ import { PartialAppConfig } from 'app/types/invokeai'; import ImageUploader from 'common/components/ImageUploader'; import GalleryDrawer from 'features/gallery/components/GalleryPanel'; import DeleteImageModal from 'features/imageDeletion/components/DeleteImageModal'; -import Lightbox from 'features/lightbox/components/Lightbox'; import SiteHeader from 'features/system/components/SiteHeader'; -import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { configChanged } from 'features/system/store/configSlice'; import { languageSelector } from 'features/system/store/systemSelectors'; import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton'; @@ -34,8 +32,6 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => { const log = useLogger(); - const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled; - const dispatch = useAppDispatch(); useEffect(() => { @@ -54,7 +50,6 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => { return ( <> - {isLightboxEnabled && } { - const gallerySelectionCount = state.gallery.selection.length; - const batchSelectionCount = state.batch.selection.length; - - return { - gallerySelectionCount, - batchSelectionCount, - }; - }, - defaultSelectorOptions -); - const DragPreview = (props: OverlayDragImageProps) => { - const { gallerySelectionCount, batchSelectionCount } = - useAppSelector(selector); - if (!props.dragData) { return; } @@ -82,7 +61,7 @@ const DragPreview = (props: OverlayDragImageProps) => { ); } - if (props.dragData.payloadType === 'BATCH_SELECTION') { + if (props.dragData.payloadType === 'IMAGE_NAMES') { return ( { ...STYLES, }} > - {batchSelectionCount} - Images - - ); - } - - if (props.dragData.payloadType === 'GALLERY_SELECTION') { - return ( - - {gallerySelectionCount} + {props.dragData.payload.image_names.length} Images ); diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx index 1b8687bf8e..6ce9b06bd9 100644 --- a/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx +++ b/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx @@ -6,18 +6,18 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; +import { snapCenterToCursor } from '@dnd-kit/modifiers'; +import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { AnimatePresence, motion } from 'framer-motion'; import { PropsWithChildren, memo, useCallback, useState } from 'react'; 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; @@ -42,18 +42,18 @@ const ImageDndContext = (props: ImageDndContextProps) => { if (!activeData || !overData) { return; } - dispatch(imageDropped({ overData, activeData })); + dispatch(dndDropped({ overData, activeData })); setActiveDragData(null); }, [dispatch] ); const mouseSensor = useSensor(MouseSensor, { - activationConstraint: { delay: 150, tolerance: 5 }, + activationConstraint: { distance: 10 }, }); const touchSensor = useSensor(TouchSensor, { - activationConstraint: { delay: 150, tolerance: 5 }, + activationConstraint: { distance: 10 }, }); // TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx index 1478ace748..003142390f 100644 --- a/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx +++ b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx @@ -77,18 +77,14 @@ export type ImageDraggableData = BaseDragData & { payload: { imageDTO: ImageDTO }; }; -export type GallerySelectionDraggableData = BaseDragData & { - payloadType: 'GALLERY_SELECTION'; -}; - -export type BatchSelectionDraggableData = BaseDragData & { - payloadType: 'BATCH_SELECTION'; +export type ImageNamesDraggableData = BaseDragData & { + payloadType: 'IMAGE_NAMES'; + payload: { image_names: string[] }; }; export type TypesafeDraggableData = | ImageDraggableData - | GallerySelectionDraggableData - | BatchSelectionDraggableData; + | ImageNamesDraggableData; interface UseDroppableTypesafeArguments extends Omit { @@ -159,13 +155,11 @@ export const isValidDrop = ( case 'SET_NODES_IMAGE': return payloadType === 'IMAGE_DTO'; case 'SET_MULTI_NODES_IMAGE': - return payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION'; + return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; case 'ADD_TO_BATCH': - return payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION'; + return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; case 'MOVE_BOARD': - return ( - payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION' || 'BATCH_SELECTION' - ); + return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; default: return false; } diff --git a/invokeai/frontend/web/src/app/socketio/actions.ts b/invokeai/frontend/web/src/app/socketio/actions.ts deleted file mode 100644 index bb2a0dd0cb..0000000000 --- a/invokeai/frontend/web/src/app/socketio/actions.ts +++ /dev/null @@ -1,67 +0,0 @@ -// import { createAction } from '@reduxjs/toolkit'; -// import * as InvokeAI from 'app/types/invokeai'; -// import { GalleryCategory } from 'features/gallery/store/gallerySlice'; -// import { InvokeTabName } from 'features/ui/store/tabMap'; - -// /** -// * We can't use redux-toolkit's createSlice() to make these actions, -// * because they have no associated reducer. They only exist to dispatch -// * requests to the server via socketio. These actions will be handled -// * by the middleware. -// */ - -// export const generateImage = createAction( -// 'socketio/generateImage' -// ); -// export const runESRGAN = createAction('socketio/runESRGAN'); -// export const runFacetool = createAction( -// 'socketio/runFacetool' -// ); -// export const deleteImage = createAction( -// 'socketio/deleteImage' -// ); -// export const requestImages = createAction( -// 'socketio/requestImages' -// ); -// export const requestNewImages = createAction( -// 'socketio/requestNewImages' -// ); -// export const cancelProcessing = createAction( -// 'socketio/cancelProcessing' -// ); - -// export const requestSystemConfig = createAction( -// 'socketio/requestSystemConfig' -// ); - -// export const searchForModels = createAction('socketio/searchForModels'); - -// export const addNewModel = createAction< -// InvokeAI.InvokeModelConfigProps | InvokeAI.InvokeDiffusersModelConfigProps -// >('socketio/addNewModel'); - -// export const deleteModel = createAction('socketio/deleteModel'); - -// export const convertToDiffusers = -// createAction( -// 'socketio/convertToDiffusers' -// ); - -// export const mergeDiffusersModels = -// createAction( -// 'socketio/mergeDiffusersModels' -// ); - -// export const requestModelChange = createAction( -// 'socketio/requestModelChange' -// ); - -// export const saveStagingAreaImageToGallery = createAction( -// 'socketio/saveStagingAreaImageToGallery' -// ); - -// export const emptyTempFolder = createAction( -// 'socketio/requestEmptyTempFolder' -// ); - -export default {}; diff --git a/invokeai/frontend/web/src/app/socketio/emitters.ts b/invokeai/frontend/web/src/app/socketio/emitters.ts deleted file mode 100644 index 8ed46cbc82..0000000000 --- a/invokeai/frontend/web/src/app/socketio/emitters.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { AnyAction, Dispatch, MiddlewareAPI } from '@reduxjs/toolkit'; -import * as InvokeAI from 'app/types/invokeai'; -import type { RootState } from 'app/store/store'; -import { - frontendToBackendParameters, - FrontendToBackendParametersConfig, -} from 'common/util/parameterTranslation'; -import dateFormat from 'dateformat'; -import { - GalleryCategory, - GalleryState, - removeImage, -} from 'features/gallery/store/gallerySlice'; -import { - generationRequested, - modelChangeRequested, - modelConvertRequested, - modelMergingRequested, - setIsProcessing, -} from 'features/system/store/systemSlice'; -import { InvokeTabName } from 'features/ui/store/tabMap'; -import { Socket } from 'socket.io-client'; - -/** - * Returns an object containing all functions which use `socketio.emit()`. - * i.e. those which make server requests. - */ -const makeSocketIOEmitters = ( - store: MiddlewareAPI, RootState>, - socketio: Socket -) => { - // We need to dispatch actions to redux and get pieces of state from the store. - const { dispatch, getState } = store; - - return { - emitGenerateImage: (generationMode: InvokeTabName) => { - dispatch(setIsProcessing(true)); - - const state: RootState = getState(); - - const { - generation: generationState, - postprocessing: postprocessingState, - system: systemState, - canvas: canvasState, - } = state; - - const frontendToBackendParametersConfig: FrontendToBackendParametersConfig = - { - generationMode, - generationState, - postprocessingState, - canvasState, - systemState, - }; - - dispatch(generationRequested()); - - const { generationParameters, esrganParameters, facetoolParameters } = - frontendToBackendParameters(frontendToBackendParametersConfig); - - socketio.emit( - 'generateImage', - generationParameters, - esrganParameters, - facetoolParameters - ); - - // we need to truncate the init_mask base64 else it takes up the whole log - // TODO: handle maintaining masks for reproducibility in future - if (generationParameters.init_mask) { - generationParameters.init_mask = generationParameters.init_mask - .substr(0, 64) - .concat('...'); - } - if (generationParameters.init_img) { - generationParameters.init_img = generationParameters.init_img - .substr(0, 64) - .concat('...'); - } - - dispatch( - addLogEntry({ - timestamp: dateFormat(new Date(), 'isoDateTime'), - message: `Image generation requested: ${JSON.stringify({ - ...generationParameters, - ...esrganParameters, - ...facetoolParameters, - })}`, - }) - ); - }, - emitRunESRGAN: (imageToProcess: InvokeAI._Image) => { - dispatch(setIsProcessing(true)); - - const { - postprocessing: { - upscalingLevel, - upscalingDenoising, - upscalingStrength, - }, - } = getState(); - - const esrganParameters = { - upscale: [upscalingLevel, upscalingDenoising, upscalingStrength], - }; - socketio.emit('runPostprocessing', imageToProcess, { - type: 'esrgan', - ...esrganParameters, - }); - dispatch( - addLogEntry({ - timestamp: dateFormat(new Date(), 'isoDateTime'), - message: `ESRGAN upscale requested: ${JSON.stringify({ - file: imageToProcess.url, - ...esrganParameters, - })}`, - }) - ); - }, - emitRunFacetool: (imageToProcess: InvokeAI._Image) => { - dispatch(setIsProcessing(true)); - - const { - postprocessing: { facetoolType, facetoolStrength, codeformerFidelity }, - } = getState(); - - const facetoolParameters: Record = { - facetool_strength: facetoolStrength, - }; - - if (facetoolType === 'codeformer') { - facetoolParameters.codeformer_fidelity = codeformerFidelity; - } - - socketio.emit('runPostprocessing', imageToProcess, { - type: facetoolType, - ...facetoolParameters, - }); - dispatch( - addLogEntry({ - timestamp: dateFormat(new Date(), 'isoDateTime'), - message: `Face restoration (${facetoolType}) requested: ${JSON.stringify( - { - file: imageToProcess.url, - ...facetoolParameters, - } - )}`, - }) - ); - }, - emitDeleteImage: (imageToDelete: InvokeAI._Image) => { - const { url, uuid, category, thumbnail } = imageToDelete; - dispatch(removeImage(imageToDelete)); - socketio.emit('deleteImage', url, thumbnail, uuid, category); - }, - emitRequestImages: (category: GalleryCategory) => { - const gallery: GalleryState = getState().gallery; - const { earliest_mtime } = gallery.categories[category]; - socketio.emit('requestImages', category, earliest_mtime); - }, - emitRequestNewImages: (category: GalleryCategory) => { - const gallery: GalleryState = getState().gallery; - const { latest_mtime } = gallery.categories[category]; - socketio.emit('requestLatestImages', category, latest_mtime); - }, - emitCancelProcessing: () => { - socketio.emit('cancel'); - }, - emitRequestSystemConfig: () => { - socketio.emit('requestSystemConfig'); - }, - emitSearchForModels: (modelFolder: string) => { - socketio.emit('searchForModels', modelFolder); - }, - emitAddNewModel: (modelConfig: InvokeAI.InvokeModelConfigProps) => { - socketio.emit('addNewModel', modelConfig); - }, - emitDeleteModel: (modelName: string) => { - socketio.emit('deleteModel', modelName); - }, - emitConvertToDiffusers: ( - modelToConvert: InvokeAI.InvokeModelConversionProps - ) => { - dispatch(modelConvertRequested()); - socketio.emit('convertToDiffusers', modelToConvert); - }, - emitMergeDiffusersModels: ( - modelMergeInfo: InvokeAI.InvokeModelMergingProps - ) => { - dispatch(modelMergingRequested()); - socketio.emit('mergeDiffusersModels', modelMergeInfo); - }, - emitRequestModelChange: (modelName: string) => { - dispatch(modelChangeRequested()); - socketio.emit('requestModelChange', modelName); - }, - emitSaveStagingAreaImageToGallery: (url: string) => { - socketio.emit('requestSaveStagingAreaImageToGallery', url); - }, - emitRequestEmptyTempFolder: () => { - socketio.emit('requestEmptyTempFolder'); - }, - }; -}; - -export default makeSocketIOEmitters; - -export default {}; diff --git a/invokeai/frontend/web/src/app/socketio/listeners.ts b/invokeai/frontend/web/src/app/socketio/listeners.ts deleted file mode 100644 index cb6db260fc..0000000000 --- a/invokeai/frontend/web/src/app/socketio/listeners.ts +++ /dev/null @@ -1,502 +0,0 @@ -// import { AnyAction, Dispatch, MiddlewareAPI } from '@reduxjs/toolkit'; -// import dateFormat from 'dateformat'; -// import i18n from 'i18n'; -// import { v4 as uuidv4 } from 'uuid'; - -// import * as InvokeAI from 'app/types/invokeai'; - -// import { -// addToast, -// errorOccurred, -// processingCanceled, -// setCurrentStatus, -// setFoundModels, -// setIsCancelable, -// setIsConnected, -// setIsProcessing, -// setModelList, -// setSearchFolder, -// setSystemConfig, -// setSystemStatus, -// } from 'features/system/store/systemSlice'; - -// import { -// addGalleryImages, -// addImage, -// clearIntermediateImage, -// GalleryState, -// removeImage, -// setIntermediateImage, -// } from 'features/gallery/store/gallerySlice'; - -// import type { RootState } from 'app/store/store'; -// import { addImageToStagingArea } from 'features/canvas/store/canvasSlice'; -// import { -// clearInitialImage, -// initialImageSelected, -// setInfillMethod, -// // setInitialImage, -// setMaskPath, -// } from 'features/parameters/store/generationSlice'; -// import { tabMap } from 'features/ui/store/tabMap'; -// import { -// requestImages, -// requestNewImages, -// requestSystemConfig, -// } from './actions'; - -// /** -// * Returns an object containing listener callbacks for socketio events. -// * TODO: This file is large, but simple. Should it be split up further? -// */ -// const makeSocketIOListeners = ( -// store: MiddlewareAPI, RootState> -// ) => { -// const { dispatch, getState } = store; - -// return { -// /** -// * Callback to run when we receive a 'connect' event. -// */ -// onConnect: () => { -// try { -// dispatch(setIsConnected(true)); -// dispatch(setCurrentStatus(i18n.t('common.statusConnected'))); -// dispatch(requestSystemConfig()); -// const gallery: GalleryState = getState().gallery; - -// if (gallery.categories.result.latest_mtime) { -// dispatch(requestNewImages('result')); -// } else { -// dispatch(requestImages('result')); -// } - -// if (gallery.categories.user.latest_mtime) { -// dispatch(requestNewImages('user')); -// } else { -// dispatch(requestImages('user')); -// } -// } catch (e) { -// console.error(e); -// } -// }, -// /** -// * Callback to run when we receive a 'disconnect' event. -// */ -// onDisconnect: () => { -// try { -// dispatch(setIsConnected(false)); -// dispatch(setCurrentStatus(i18n.t('common.statusDisconnected'))); - -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Disconnected from server`, -// level: 'warning', -// }) -// ); -// } catch (e) { -// console.error(e); -// } -// }, -// /** -// * Callback to run when we receive a 'generationResult' event. -// */ -// onGenerationResult: (data: InvokeAI.ImageResultResponse) => { -// try { -// const state = getState(); -// const { activeTab } = state.ui; -// const { shouldLoopback } = state.postprocessing; -// const { boundingBox: _, generationMode, ...rest } = data; - -// const newImage = { -// uuid: uuidv4(), -// ...rest, -// }; - -// if (['txt2img', 'img2img'].includes(generationMode)) { -// dispatch( -// addImage({ -// category: 'result', -// image: { ...newImage, category: 'result' }, -// }) -// ); -// } - -// if (generationMode === 'unifiedCanvas' && data.boundingBox) { -// const { boundingBox } = data; -// dispatch( -// addImageToStagingArea({ -// image: { ...newImage, category: 'temp' }, -// boundingBox, -// }) -// ); - -// if (state.canvas.shouldAutoSave) { -// dispatch( -// addImage({ -// image: { ...newImage, category: 'result' }, -// category: 'result', -// }) -// ); -// } -// } - -// // TODO: fix -// // if (shouldLoopback) { -// // const activeTabName = tabMap[activeTab]; -// // switch (activeTabName) { -// // case 'img2img': { -// // dispatch(initialImageSelected(newImage.uuid)); -// // // dispatch(setInitialImage(newImage)); -// // break; -// // } -// // } -// // } - -// dispatch(clearIntermediateImage()); - -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Image generated: ${data.url}`, -// }) -// ); -// } catch (e) { -// console.error(e); -// } -// }, -// /** -// * Callback to run when we receive a 'intermediateResult' event. -// */ -// onIntermediateResult: (data: InvokeAI.ImageResultResponse) => { -// try { -// dispatch( -// setIntermediateImage({ -// uuid: uuidv4(), -// ...data, -// category: 'result', -// }) -// ); -// if (!data.isBase64) { -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Intermediate image generated: ${data.url}`, -// }) -// ); -// } -// } catch (e) { -// console.error(e); -// } -// }, -// /** -// * Callback to run when we receive an 'esrganResult' event. -// */ -// onPostprocessingResult: (data: InvokeAI.ImageResultResponse) => { -// try { -// dispatch( -// addImage({ -// category: 'result', -// image: { -// uuid: uuidv4(), -// ...data, -// category: 'result', -// }, -// }) -// ); - -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Postprocessed: ${data.url}`, -// }) -// ); -// } catch (e) { -// console.error(e); -// } -// }, -// /** -// * Callback to run when we receive a 'progressUpdate' event. -// * TODO: Add additional progress phases -// */ -// onProgressUpdate: (data: InvokeAI.SystemStatus) => { -// try { -// dispatch(setIsProcessing(true)); -// dispatch(setSystemStatus(data)); -// } catch (e) { -// console.error(e); -// } -// }, -// /** -// * Callback to run when we receive a 'progressUpdate' event. -// */ -// onError: (data: InvokeAI.ErrorResponse) => { -// const { message, additionalData } = data; - -// if (additionalData) { -// // TODO: handle more data than short message -// } - -// try { -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Server error: ${message}`, -// level: 'error', -// }) -// ); -// dispatch(errorOccurred()); -// dispatch(clearIntermediateImage()); -// } catch (e) { -// console.error(e); -// } -// }, -// /** -// * Callback to run when we receive a 'galleryImages' event. -// */ -// onGalleryImages: (data: InvokeAI.GalleryImagesResponse) => { -// const { images, areMoreImagesAvailable, category } = data; - -// /** -// * the logic here ideally would be in the reducer but we have a side effect: -// * generating a uuid. so the logic needs to be here, outside redux. -// */ - -// // Generate a UUID for each image -// const preparedImages = images.map((image): InvokeAI._Image => { -// return { -// uuid: uuidv4(), -// ...image, -// }; -// }); - -// dispatch( -// addGalleryImages({ -// images: preparedImages, -// areMoreImagesAvailable, -// category, -// }) -// ); - -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Loaded ${images.length} images`, -// }) -// ); -// }, -// /** -// * Callback to run when we receive a 'processingCanceled' event. -// */ -// onProcessingCanceled: () => { -// dispatch(processingCanceled()); - -// const { intermediateImage } = getState().gallery; - -// if (intermediateImage) { -// if (!intermediateImage.isBase64) { -// dispatch( -// addImage({ -// category: 'result', -// image: intermediateImage, -// }) -// ); -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Intermediate image saved: ${intermediateImage.url}`, -// }) -// ); -// } -// dispatch(clearIntermediateImage()); -// } - -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Processing canceled`, -// level: 'warning', -// }) -// ); -// }, -// /** -// * Callback to run when we receive a 'imageDeleted' event. -// */ -// onImageDeleted: (data: InvokeAI.ImageDeletedResponse) => { -// const { url } = data; - -// // remove image from gallery -// dispatch(removeImage(data)); - -// // remove references to image in options -// const { -// generation: { initialImage, maskPath }, -// } = getState(); - -// if ( -// initialImage === url || -// (initialImage as InvokeAI._Image)?.url === url -// ) { -// dispatch(clearInitialImage()); -// } - -// if (maskPath === url) { -// dispatch(setMaskPath('')); -// } - -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Image deleted: ${url}`, -// }) -// ); -// }, -// onSystemConfig: (data: InvokeAI.SystemConfig) => { -// dispatch(setSystemConfig(data)); -// if (!data.infill_methods.includes('patchmatch')) { -// dispatch(setInfillMethod(data.infill_methods[0])); -// } -// }, -// onFoundModels: (data: InvokeAI.FoundModelResponse) => { -// const { search_folder, found_models } = data; -// dispatch(setSearchFolder(search_folder)); -// dispatch(setFoundModels(found_models)); -// }, -// onNewModelAdded: (data: InvokeAI.ModelAddedResponse) => { -// const { new_model_name, model_list, update } = data; -// dispatch(setModelList(model_list)); -// dispatch(setIsProcessing(false)); -// dispatch(setCurrentStatus(i18n.t('modelManager.modelAdded'))); -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Model Added: ${new_model_name}`, -// level: 'info', -// }) -// ); -// dispatch( -// addToast({ -// title: !update -// ? `${i18n.t('modelManager.modelAdded')}: ${new_model_name}` -// : `${i18n.t('modelManager.modelUpdated')}: ${new_model_name}`, -// status: 'success', -// duration: 2500, -// isClosable: true, -// }) -// ); -// }, -// onModelDeleted: (data: InvokeAI.ModelDeletedResponse) => { -// const { deleted_model_name, model_list } = data; -// dispatch(setModelList(model_list)); -// dispatch(setIsProcessing(false)); -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `${i18n.t( -// 'modelManager.modelAdded' -// )}: ${deleted_model_name}`, -// level: 'info', -// }) -// ); -// dispatch( -// addToast({ -// title: `${i18n.t( -// 'modelManager.modelEntryDeleted' -// )}: ${deleted_model_name}`, -// status: 'success', -// duration: 2500, -// isClosable: true, -// }) -// ); -// }, -// onModelConverted: (data: InvokeAI.ModelConvertedResponse) => { -// const { converted_model_name, model_list } = data; -// dispatch(setModelList(model_list)); -// dispatch(setCurrentStatus(i18n.t('common.statusModelConverted'))); -// dispatch(setIsProcessing(false)); -// dispatch(setIsCancelable(true)); -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Model converted: ${converted_model_name}`, -// level: 'info', -// }) -// ); -// dispatch( -// addToast({ -// title: `${i18n.t( -// 'modelManager.modelConverted' -// )}: ${converted_model_name}`, -// status: 'success', -// duration: 2500, -// isClosable: true, -// }) -// ); -// }, -// onModelsMerged: (data: InvokeAI.ModelsMergedResponse) => { -// const { merged_models, merged_model_name, model_list } = data; -// dispatch(setModelList(model_list)); -// dispatch(setCurrentStatus(i18n.t('common.statusMergedModels'))); -// dispatch(setIsProcessing(false)); -// dispatch(setIsCancelable(true)); -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Models merged: ${merged_models}`, -// level: 'info', -// }) -// ); -// dispatch( -// addToast({ -// title: `${i18n.t('modelManager.modelsMerged')}: ${merged_model_name}`, -// status: 'success', -// duration: 2500, -// isClosable: true, -// }) -// ); -// }, -// onModelChanged: (data: InvokeAI.ModelChangeResponse) => { -// const { model_name, model_list } = data; -// dispatch(setModelList(model_list)); -// dispatch(setCurrentStatus(i18n.t('common.statusModelChanged'))); -// dispatch(setIsProcessing(false)); -// dispatch(setIsCancelable(true)); -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Model changed: ${model_name}`, -// level: 'info', -// }) -// ); -// }, -// onModelChangeFailed: (data: InvokeAI.ModelChangeResponse) => { -// const { model_name, model_list } = data; -// dispatch(setModelList(model_list)); -// dispatch(setIsProcessing(false)); -// dispatch(setIsCancelable(true)); -// dispatch(errorOccurred()); -// dispatch( -// addLogEntry({ -// timestamp: dateFormat(new Date(), 'isoDateTime'), -// message: `Model change failed: ${model_name}`, -// level: 'error', -// }) -// ); -// }, -// onTempFolderEmptied: () => { -// dispatch( -// addToast({ -// title: i18n.t('toast.tempFoldersEmptied'), -// status: 'success', -// duration: 2500, -// isClosable: true, -// }) -// ); -// }, -// }; -// }; - -// export default makeSocketIOListeners; - -export default {}; diff --git a/invokeai/frontend/web/src/app/socketio/middleware.ts b/invokeai/frontend/web/src/app/socketio/middleware.ts deleted file mode 100644 index 88013ea222..0000000000 --- a/invokeai/frontend/web/src/app/socketio/middleware.ts +++ /dev/null @@ -1,248 +0,0 @@ -// import { Middleware } from '@reduxjs/toolkit'; -// import { io } from 'socket.io-client'; - -// import makeSocketIOEmitters from './emitters'; -// import makeSocketIOListeners from './listeners'; - -// import * as InvokeAI from 'app/types/invokeai'; - -// /** -// * Creates a socketio middleware to handle communication with server. -// * -// * Special `socketio/actionName` actions are created in actions.ts and -// * exported for use by the application, which treats them like any old -// * action, using `dispatch` to dispatch them. -// * -// * These actions are intercepted here, where `socketio.emit()` calls are -// * made on their behalf - see `emitters.ts`. The emitter functions -// * are the outbound communication to the server. -// * -// * Listeners are also established here - see `listeners.ts`. The listener -// * functions receive communication from the server and usually dispatch -// * some new action to handle whatever data was sent from the server. -// */ -// export const socketioMiddleware = () => { -// const { origin } = new URL(window.location.href); - -// const socketio = io(origin, { -// timeout: 60000, -// path: `${window.location.pathname}socket.io`, -// }); - -// socketio.disconnect(); - -// let areListenersSet = false; - -// const middleware: Middleware = (store) => (next) => (action) => { -// const { -// onConnect, -// onDisconnect, -// onError, -// onPostprocessingResult, -// onGenerationResult, -// onIntermediateResult, -// onProgressUpdate, -// onGalleryImages, -// onProcessingCanceled, -// onImageDeleted, -// onSystemConfig, -// onModelChanged, -// onFoundModels, -// onNewModelAdded, -// onModelDeleted, -// onModelConverted, -// onModelsMerged, -// onModelChangeFailed, -// onTempFolderEmptied, -// } = makeSocketIOListeners(store); - -// const { -// emitGenerateImage, -// emitRunESRGAN, -// emitRunFacetool, -// emitDeleteImage, -// emitRequestImages, -// emitRequestNewImages, -// emitCancelProcessing, -// emitRequestSystemConfig, -// emitSearchForModels, -// emitAddNewModel, -// emitDeleteModel, -// emitConvertToDiffusers, -// emitMergeDiffusersModels, -// emitRequestModelChange, -// emitSaveStagingAreaImageToGallery, -// emitRequestEmptyTempFolder, -// } = makeSocketIOEmitters(store, socketio); - -// /** -// * If this is the first time the middleware has been called (e.g. during store setup), -// * initialize all our socket.io listeners. -// */ -// if (!areListenersSet) { -// socketio.on('connect', () => onConnect()); - -// socketio.on('disconnect', () => onDisconnect()); - -// socketio.on('error', (data: InvokeAI.ErrorResponse) => onError(data)); - -// socketio.on('generationResult', (data: InvokeAI.ImageResultResponse) => -// onGenerationResult(data) -// ); - -// socketio.on( -// 'postprocessingResult', -// (data: InvokeAI.ImageResultResponse) => onPostprocessingResult(data) -// ); - -// socketio.on('intermediateResult', (data: InvokeAI.ImageResultResponse) => -// onIntermediateResult(data) -// ); - -// socketio.on('progressUpdate', (data: InvokeAI.SystemStatus) => -// onProgressUpdate(data) -// ); - -// socketio.on('galleryImages', (data: InvokeAI.GalleryImagesResponse) => -// onGalleryImages(data) -// ); - -// socketio.on('processingCanceled', () => { -// onProcessingCanceled(); -// }); - -// socketio.on('imageDeleted', (data: InvokeAI.ImageDeletedResponse) => { -// onImageDeleted(data); -// }); - -// socketio.on('systemConfig', (data: InvokeAI.SystemConfig) => { -// onSystemConfig(data); -// }); - -// socketio.on('foundModels', (data: InvokeAI.FoundModelResponse) => { -// onFoundModels(data); -// }); - -// socketio.on('newModelAdded', (data: InvokeAI.ModelAddedResponse) => { -// onNewModelAdded(data); -// }); - -// socketio.on('modelDeleted', (data: InvokeAI.ModelDeletedResponse) => { -// onModelDeleted(data); -// }); - -// socketio.on('modelConverted', (data: InvokeAI.ModelConvertedResponse) => { -// onModelConverted(data); -// }); - -// socketio.on('modelsMerged', (data: InvokeAI.ModelsMergedResponse) => { -// onModelsMerged(data); -// }); - -// socketio.on('modelChanged', (data: InvokeAI.ModelChangeResponse) => { -// onModelChanged(data); -// }); - -// socketio.on('modelChangeFailed', (data: InvokeAI.ModelChangeResponse) => { -// onModelChangeFailed(data); -// }); - -// socketio.on('tempFolderEmptied', () => { -// onTempFolderEmptied(); -// }); - -// areListenersSet = true; -// } - -// /** -// * Handle redux actions caught by middleware. -// */ -// switch (action.type) { -// case 'socketio/generateImage': { -// emitGenerateImage(action.payload); -// break; -// } - -// case 'socketio/runESRGAN': { -// emitRunESRGAN(action.payload); -// break; -// } - -// case 'socketio/runFacetool': { -// emitRunFacetool(action.payload); -// break; -// } - -// case 'socketio/deleteImage': { -// emitDeleteImage(action.payload); -// break; -// } - -// case 'socketio/requestImages': { -// emitRequestImages(action.payload); -// break; -// } - -// case 'socketio/requestNewImages': { -// emitRequestNewImages(action.payload); -// break; -// } - -// case 'socketio/cancelProcessing': { -// emitCancelProcessing(); -// break; -// } - -// case 'socketio/requestSystemConfig': { -// emitRequestSystemConfig(); -// break; -// } - -// case 'socketio/searchForModels': { -// emitSearchForModels(action.payload); -// break; -// } - -// case 'socketio/addNewModel': { -// emitAddNewModel(action.payload); -// break; -// } - -// case 'socketio/deleteModel': { -// emitDeleteModel(action.payload); -// break; -// } - -// case 'socketio/convertToDiffusers': { -// emitConvertToDiffusers(action.payload); -// break; -// } - -// case 'socketio/mergeDiffusersModels': { -// emitMergeDiffusersModels(action.payload); -// break; -// } - -// case 'socketio/requestModelChange': { -// emitRequestModelChange(action.payload); -// break; -// } - -// case 'socketio/saveStagingAreaImageToGallery': { -// emitSaveStagingAreaImageToGallery(action.payload); -// break; -// } - -// case 'socketio/requestEmptyTempFolder': { -// emitRequestEmptyTempFolder(); -// break; -// } -// } - -// next(action); -// }; - -// return middleware; -// }; - -export default {}; diff --git a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/serialize.ts b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/serialize.ts index ac1b9c5205..3407b3f7de 100644 --- a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/serialize.ts +++ b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/serialize.ts @@ -1,7 +1,6 @@ import { canvasPersistDenylist } from 'features/canvas/store/canvasPersistDenylist'; import { controlNetDenylist } from 'features/controlNet/store/controlNetDenylist'; import { galleryPersistDenylist } from 'features/gallery/store/galleryPersistDenylist'; -import { lightboxPersistDenylist } from 'features/lightbox/store/lightboxPersistDenylist'; import { nodesPersistDenylist } from 'features/nodes/store/nodesPersistDenylist'; import { generationPersistDenylist } from 'features/parameters/store/generationPersistDenylist'; import { postprocessingPersistDenylist } from 'features/parameters/store/postprocessingPersistDenylist'; @@ -16,7 +15,6 @@ const serializationDenylist: { canvas: canvasPersistDenylist, gallery: galleryPersistDenylist, generation: generationPersistDenylist, - lightbox: lightboxPersistDenylist, nodes: nodesPersistDenylist, postprocessing: postprocessingPersistDenylist, system: systemPersistDenylist, 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 23e6448987..5d94abd738 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 { initialLightboxState } from 'features/lightbox/store/lightboxSlice'; import { initialNodesState } from 'features/nodes/store/nodesSlice'; import { initialGenerationState } from 'features/parameters/store/generationSlice'; import { initialPostprocessingState } from 'features/parameters/store/postprocessingSlice'; @@ -18,7 +17,6 @@ const initialStates: { canvas: initialCanvasState, gallery: initialGalleryState, generation: initialGenerationState, - lightbox: initialLightboxState, nodes: initialNodesState, postprocessing: initialPostprocessingState, system: initialSystemState, diff --git a/invokeai/frontend/web/src/app/store/middleware/devtools/actionsDenylist.ts b/invokeai/frontend/web/src/app/store/middleware/devtools/actionsDenylist.ts index 8a6e112d27..6d41d488c8 100644 --- a/invokeai/frontend/web/src/app/store/middleware/devtools/actionsDenylist.ts +++ b/invokeai/frontend/web/src/app/store/middleware/devtools/actionsDenylist.ts @@ -1,4 +1,8 @@ +/** + * This is a list of actions that should be excluded in the Redux DevTools. + */ export const actionsDenylist = [ + // very spammy canvas actions 'canvas/setCursorPosition', 'canvas/setStageCoordinates', 'canvas/setStageScale', @@ -7,7 +11,11 @@ export const actionsDenylist = [ 'canvas/setBoundingBoxDimensions', 'canvas/setIsDrawing', 'canvas/addPointToCurrentLine', + // bazillions during generation 'socket/socketGeneratorProgress', 'socket/appSocketGeneratorProgress', + // every time user presses shift 'hotkeys/shiftKeyPressed', + // this happens after every state change + '@@REMEMBER_PERSISTED', ]; 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 bfaebb805a..edeb156439 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -8,6 +8,7 @@ import { import type { AppDispatch, RootState } from '../../store'; import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener'; +import { addAppConfigReceivedListener } from './listeners/appConfigReceived'; import { addAppStartedListener } from './listeners/appStarted'; import { addBoardIdSelectedListener } from './listeners/boardIdSelected'; import { addRequestedBoardImageDeletionListener } from './listeners/boardImagesDeleted'; @@ -51,12 +52,12 @@ import { } from './listeners/imageUrlsReceived'; import { addInitialImageSelectedListener } from './listeners/initialImageSelected'; import { addModelSelectedListener } from './listeners/modelSelected'; +import { addModelsLoadedListener } from './listeners/modelsLoaded'; import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema'; import { addReceivedPageOfImagesFulfilledListener, addReceivedPageOfImagesRejectedListener, } from './listeners/receivedPageOfImages'; -import { addSelectionAddedToBatchListener } from './listeners/selectionAddedToBatch'; import { addSessionCanceledFulfilledListener, addSessionCanceledPendingListener, @@ -213,9 +214,6 @@ addBoardIdSelectedListener(); // Node schemas addReceivedOpenAPISchemaListener(); -// Batches -addSelectionAddedToBatchListener(); - // DND addImageDroppedListener(); @@ -224,3 +222,5 @@ addModelSelectedListener(); // app startup addAppStartedListener(); +addModelsLoadedListener(); +addAppConfigReceivedListener(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appConfigReceived.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appConfigReceived.ts new file mode 100644 index 0000000000..68148a192f --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appConfigReceived.ts @@ -0,0 +1,17 @@ +import { setInfillMethod } from 'features/parameters/store/generationSlice'; +import { appInfoApi } from 'services/api/endpoints/appInfo'; +import { startAppListening } from '..'; + +export const addAppConfigReceivedListener = () => { + startAppListening({ + matcher: appInfoApi.endpoints.getAppConfig.matchFulfilled, + effect: async (action, { getState, dispatch }) => { + const { infill_methods } = action.payload; + const infillMethod = getState().generation.infillMethod; + + if (!infill_methods.includes(infillMethod)) { + dispatch(setInfillMethod(infill_methods[0])); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts index dc38ba911a..9f7085db6f 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts @@ -1,5 +1,7 @@ import { createAction } from '@reduxjs/toolkit'; import { + ASSETS_CATEGORIES, + IMAGE_CATEGORIES, INITIAL_IMAGE_LIMIT, isLoadingChanged, } from 'features/gallery/store/gallerySlice'; @@ -20,7 +22,7 @@ export const addAppStartedListener = () => { // fill up the gallery tab with images await dispatch( receivedPageOfImages({ - categories: ['general'], + categories: IMAGE_CATEGORIES, is_intermediate: false, offset: 0, limit: INITIAL_IMAGE_LIMIT, @@ -30,7 +32,7 @@ export const addAppStartedListener = () => { // fill up the assets tab with images await dispatch( receivedPageOfImages({ - categories: ['control', 'mask', 'user', 'other'], + categories: ASSETS_CATEGORIES, is_intermediate: false, offset: 0, limit: INITIAL_IMAGE_LIMIT, 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 6ce6665cc5..9ce17e3099 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,15 +1,18 @@ import { log } from 'app/logging/useLogger'; -import { startAppListening } from '..'; +import { selectFilteredImages } from 'features/gallery/store/gallerySelectors'; import { + ASSETS_CATEGORIES, + IMAGE_CATEGORIES, + boardIdSelected, imageSelected, selectImagesAll, - boardIdSelected, } from 'features/gallery/store/gallerySlice'; +import { boardsApi } from 'services/api/endpoints/boards'; import { IMAGES_PER_PAGE, receivedPageOfImages, } from 'services/api/thunks/image'; -import { boardsApi } from 'services/api/endpoints/boards'; +import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'boards' }); @@ -24,19 +27,24 @@ export const addBoardIdSelectedListener = () => { const state = getState(); const allImages = selectImagesAll(state); - if (!board_id) { - // a board was unselected - dispatch(imageSelected(allImages[0]?.image_name)); + if (board_id === 'all') { + // Selected all images + dispatch(imageSelected(allImages[0]?.image_name ?? null)); return; } - const { categories } = state.gallery; + if (board_id === 'batch') { + // Selected the batch + dispatch(imageSelected(state.gallery.batchImageNames[0] ?? null)); + return; + } - const filteredImages = allImages.filter((i) => { - const isInCategory = categories.includes(i.image_category); - const isInSelectedBoard = board_id ? i.board_id === board_id : true; - return isInCategory && isInSelectedBoard; - }); + const filteredImages = selectFilteredImages(state); + + const categories = + state.gallery.galleryView === 'images' + ? IMAGE_CATEGORIES + : ASSETS_CATEGORIES; // get the board from the cache const { data: boards } = @@ -45,7 +53,7 @@ export const addBoardIdSelectedListener = () => { if (!board) { // can't find the board in cache... - dispatch(imageSelected(allImages[0]?.image_name)); + dispatch(boardIdSelected('all')); return; } @@ -63,48 +71,3 @@ export const addBoardIdSelectedListener = () => { }, }); }; - -export const addBoardIdSelected_changeSelectedImage_listener = () => { - startAppListening({ - actionCreator: boardIdSelected, - effect: (action, { getState, dispatch }) => { - const board_id = action.payload; - - const state = getState(); - - // we need to check if we need to fetch more images - - if (!board_id) { - // a board was unselected - we don't need to do anything - return; - } - - const { categories } = state.gallery; - - const filteredImages = selectImagesAll(state).filter((i) => { - const isInCategory = categories.includes(i.image_category); - const isInSelectedBoard = board_id ? i.board_id === board_id : true; - return isInCategory && isInSelectedBoard; - }); - - // get the board from the cache - const { data: boards } = - boardsApi.endpoints.listAllBoards.select()(state); - const board = boards?.find((b) => b.board_id === board_id); - if (!board) { - // can't find the board in cache... - return; - } - - // if we haven't loaded one full page of images from this board, load more - if ( - filteredImages.length < board.image_count && - filteredImages.length < IMAGES_PER_PAGE - ) { - dispatch( - receivedPageOfImages({ categories, board_id, is_intermediate: false }) - ); - } - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts index 3e11a5f98b..42387b8078 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts @@ -1,13 +1,13 @@ -import { startAppListening } from '..'; -import { imageMetadataReceived } from 'services/api/thunks/image'; import { log } from 'app/logging/useLogger'; import { controlNetImageProcessed } from 'features/controlNet/store/actions'; -import { Graph } from 'services/api/types'; -import { sessionCreated } from 'services/api/thunks/session'; -import { sessionReadyToInvoke } from 'features/system/store/actions'; -import { socketInvocationComplete } from 'services/events/actions'; -import { isImageOutput } from 'services/api/guards'; import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice'; +import { sessionReadyToInvoke } from 'features/system/store/actions'; +import { isImageOutput } from 'services/api/guards'; +import { imageDTOReceived } from 'services/api/thunks/image'; +import { sessionCreated } from 'services/api/thunks/session'; +import { Graph } from 'services/api/types'; +import { socketInvocationComplete } from 'services/events/actions'; +import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'controlNet' }); @@ -63,10 +63,8 @@ export const addControlNetImageProcessedListener = () => { // Wait for the ImageDTO to be received const [imageMetadataReceivedAction] = await take( - ( - action - ): action is ReturnType => - imageMetadataReceived.fulfilled.match(action) && + (action): action is ReturnType => + imageDTOReceived.fulfilled.match(action) && action.payload.image_name === image_name ); const processedControlImage = imageMetadataReceivedAction.payload; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts index 082dfc0efb..c92eeac0db 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts @@ -1,7 +1,6 @@ import { log } from 'app/logging/useLogger'; -import { startAppListening } from '..'; -import { imageMetadataReceived } from 'services/api/thunks/image'; import { boardImagesApi } from 'services/api/endpoints/boardImages'; +import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'boards' }); @@ -15,12 +14,6 @@ export const addImageAddedToBoardFulfilledListener = () => { { data: { board_id, image_name } }, 'Image added to board' ); - - dispatch( - imageMetadataReceived({ - image_name, - }) - ); }, }); }; 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 f083a716a4..c90c08d94a 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,10 +1,10 @@ import { log } from 'app/logging/useLogger'; import { resetCanvas } from 'features/canvas/store/canvasSlice'; import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; +import { selectNextImageToSelect } from 'features/gallery/store/gallerySelectors'; import { imageRemoved, imageSelected, - selectFilteredImages, } from 'features/gallery/store/gallerySlice'; import { imageDeletionConfirmed, @@ -12,7 +12,6 @@ import { } from 'features/imageDeletion/store/imageDeletionSlice'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; import { clearInitialImage } from 'features/parameters/store/generationSlice'; -import { clamp } from 'lodash-es'; import { api } from 'services/api'; import { imageDeleted } from 'services/api/thunks/image'; import { startAppListening } from '..'; @@ -37,26 +36,10 @@ export const addRequestedImageDeletionListener = () => { state.gallery.selection[state.gallery.selection.length - 1]; if (lastSelectedImage === image_name) { - const filteredImages = selectFilteredImages(state); - - const ids = filteredImages.map((i) => i.image_name); - - const deletedImageIndex = ids.findIndex( - (result) => result.toString() === image_name - ); - - const filteredIds = ids.filter((id) => id.toString() !== image_name); - - const newSelectedImageIndex = clamp( - deletedImageIndex, - 0, - filteredIds.length - 1 - ); - - const newSelectedImageId = filteredIds[newSelectedImageIndex]; + const newSelectedImageId = selectNextImageToSelect(state, image_name); if (newSelectedImageId) { - dispatch(imageSelected(newSelectedImageId as string)); + dispatch(imageSelected(newSelectedImageId)); } else { dispatch(imageSelected(null)); } diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts index 24a5bffec7..51894d50de 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts @@ -4,13 +4,12 @@ import { TypesafeDroppableData, } from 'app/components/ImageDnd/typesafeDnd'; import { log } from 'app/logging/useLogger'; -import { - imageAddedToBatch, - imagesAddedToBatch, -} from 'features/batch/store/batchSlice'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { + imageSelected, + imagesAddedToBatch, +} from 'features/gallery/store/gallerySlice'; import { fieldValueChanged, imageCollectionFieldValueChanged, @@ -21,57 +20,66 @@ import { startAppListening } from '../'; const moduleLog = log.child({ namespace: 'dnd' }); -export const imageDropped = createAction<{ +export const dndDropped = createAction<{ overData: TypesafeDroppableData; activeData: TypesafeDraggableData; -}>('dnd/imageDropped'); +}>('dnd/dndDropped'); export const addImageDroppedListener = () => { startAppListening({ - actionCreator: imageDropped, - effect: (action, { dispatch, getState }) => { + actionCreator: dndDropped, + effect: async (action, { dispatch, getState, take }) => { const { activeData, overData } = action.payload; - const { actionType } = overData; const state = getState(); + moduleLog.debug( + { data: { activeData, overData } }, + 'Image or selection dropped' + ); + // set current image if ( - actionType === 'SET_CURRENT_IMAGE' && + overData.actionType === 'SET_CURRENT_IMAGE' && activeData.payloadType === 'IMAGE_DTO' && activeData.payload.imageDTO ) { dispatch(imageSelected(activeData.payload.imageDTO.image_name)); + return; } // set initial image if ( - actionType === 'SET_INITIAL_IMAGE' && + overData.actionType === 'SET_INITIAL_IMAGE' && activeData.payloadType === 'IMAGE_DTO' && activeData.payload.imageDTO ) { dispatch(initialImageChanged(activeData.payload.imageDTO)); + return; } // add image to batch if ( - actionType === 'ADD_TO_BATCH' && + overData.actionType === 'ADD_TO_BATCH' && activeData.payloadType === 'IMAGE_DTO' && activeData.payload.imageDTO ) { - dispatch(imageAddedToBatch(activeData.payload.imageDTO.image_name)); + dispatch(imagesAddedToBatch([activeData.payload.imageDTO.image_name])); + return; } // add multiple images to batch if ( - actionType === 'ADD_TO_BATCH' && - activeData.payloadType === 'GALLERY_SELECTION' + overData.actionType === 'ADD_TO_BATCH' && + activeData.payloadType === 'IMAGE_NAMES' ) { - dispatch(imagesAddedToBatch(state.gallery.selection)); + dispatch(imagesAddedToBatch(activeData.payload.image_names)); + + return; } // set control image if ( - actionType === 'SET_CONTROLNET_IMAGE' && + overData.actionType === 'SET_CONTROLNET_IMAGE' && activeData.payloadType === 'IMAGE_DTO' && activeData.payload.imageDTO ) { @@ -82,20 +90,22 @@ export const addImageDroppedListener = () => { controlNetId, }) ); + return; } // set canvas image if ( - actionType === 'SET_CANVAS_INITIAL_IMAGE' && + overData.actionType === 'SET_CANVAS_INITIAL_IMAGE' && activeData.payloadType === 'IMAGE_DTO' && activeData.payload.imageDTO ) { dispatch(setInitialCanvasImage(activeData.payload.imageDTO)); + return; } // set nodes image if ( - actionType === 'SET_NODES_IMAGE' && + overData.actionType === 'SET_NODES_IMAGE' && activeData.payloadType === 'IMAGE_DTO' && activeData.payload.imageDTO ) { @@ -107,11 +117,12 @@ export const addImageDroppedListener = () => { value: activeData.payload.imageDTO, }) ); + return; } // set multiple nodes images (single image handler) if ( - actionType === 'SET_MULTI_NODES_IMAGE' && + overData.actionType === 'SET_MULTI_NODES_IMAGE' && activeData.payloadType === 'IMAGE_DTO' && activeData.payload.imageDTO ) { @@ -123,43 +134,30 @@ export const addImageDroppedListener = () => { value: [activeData.payload.imageDTO], }) ); + return; } // set multiple nodes images (multiple images handler) if ( - actionType === 'SET_MULTI_NODES_IMAGE' && - activeData.payloadType === 'GALLERY_SELECTION' + overData.actionType === 'SET_MULTI_NODES_IMAGE' && + activeData.payloadType === 'IMAGE_NAMES' ) { const { fieldName, nodeId } = overData.context; dispatch( imageCollectionFieldValueChanged({ nodeId, fieldName, - value: state.gallery.selection.map((image_name) => ({ + value: activeData.payload.image_names.map((image_name) => ({ image_name, })), }) ); + return; } - // 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' && + overData.actionType === 'MOVE_BOARD' && activeData.payloadType === 'IMAGE_DTO' && activeData.payload.imageDTO && overData.context.boardId @@ -172,17 +170,89 @@ export const addImageDroppedListener = () => { board_id: boardId, }) ); + return; } - // 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({})); - // } + // remove image from board + if ( + overData.actionType === 'MOVE_BOARD' && + activeData.payloadType === 'IMAGE_DTO' && + activeData.payload.imageDTO && + overData.context.boardId === null + ) { + const { image_name, board_id } = activeData.payload.imageDTO; + if (board_id) { + dispatch( + boardImagesApi.endpoints.removeImageFromBoard.initiate({ + image_name, + board_id, + }) + ); + } + return; + } + + // add gallery selection to board + if ( + overData.actionType === 'MOVE_BOARD' && + activeData.payloadType === 'IMAGE_NAMES' && + overData.context.boardId + ) { + console.log('adding gallery selection to board'); + const board_id = overData.context.boardId; + dispatch( + boardImagesApi.endpoints.addManyBoardImages.initiate({ + board_id, + image_names: activeData.payload.image_names, + }) + ); + return; + } + + // remove gallery selection from board + if ( + overData.actionType === 'MOVE_BOARD' && + activeData.payloadType === 'IMAGE_NAMES' && + overData.context.boardId === null + ) { + console.log('removing gallery selection to board'); + dispatch( + boardImagesApi.endpoints.deleteManyBoardImages.initiate({ + image_names: activeData.payload.image_names, + }) + ); + return; + } + + // add batch selection to board + if ( + overData.actionType === 'MOVE_BOARD' && + activeData.payloadType === 'IMAGE_NAMES' && + overData.context.boardId + ) { + const board_id = overData.context.boardId; + dispatch( + boardImagesApi.endpoints.addManyBoardImages.initiate({ + board_id, + image_names: activeData.payload.image_names, + }) + ); + return; + } + + // remove batch selection from board + if ( + overData.actionType === 'MOVE_BOARD' && + activeData.payloadType === 'IMAGE_NAMES' && + overData.context.boardId === null + ) { + dispatch( + boardImagesApi.endpoints.deleteManyBoardImages.initiate({ + image_names: activeData.payload.image_names, + }) + ); + return; + } }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts index 19af5b24c3..8a6d069ab0 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,13 +1,13 @@ import { log } from 'app/logging/useLogger'; -import { startAppListening } from '..'; -import { imageMetadataReceived, imageUpdated } from 'services/api/thunks/image'; import { imageUpserted } from 'features/gallery/store/gallerySlice'; +import { imageDTOReceived, imageUpdated } from 'services/api/thunks/image'; +import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'image' }); export const addImageMetadataReceivedFulfilledListener = () => { startAppListening({ - actionCreator: imageMetadataReceived.fulfilled, + actionCreator: imageDTOReceived.fulfilled, effect: (action, { getState, dispatch }) => { const image = action.payload; @@ -40,7 +40,7 @@ export const addImageMetadataReceivedFulfilledListener = () => { export const addImageMetadataReceivedRejectedListener = () => { startAppListening({ - actionCreator: imageMetadataReceived.rejected, + actionCreator: imageDTOReceived.rejected, effect: (action, { getState, dispatch }) => { moduleLog.debug( { data: { image: action.meta.arg } }, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard.ts index 5c056474e3..3c6731bb31 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard.ts @@ -1,7 +1,6 @@ import { log } from 'app/logging/useLogger'; -import { startAppListening } from '..'; -import { imageMetadataReceived } from 'services/api/thunks/image'; import { boardImagesApi } from 'services/api/endpoints/boardImages'; +import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'boards' }); @@ -15,12 +14,6 @@ export const addImageRemovedFromBoardFulfilledListener = () => { { data: { board_id, image_name } }, 'Image added to board' ); - - dispatch( - imageMetadataReceived({ - image_name, - }) - ); }, }); }; 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 0cd852c3de..cca01354b5 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts @@ -1,13 +1,15 @@ -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/gallerySlice'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice'; -import { initialImageChanged } from 'features/parameters/store/generationSlice'; +import { + imageUpserted, + imagesAddedToBatch, +} from 'features/gallery/store/gallerySlice'; import { fieldValueChanged } from 'features/nodes/store/nodesSlice'; -import { imageAddedToBatch } from 'features/batch/store/batchSlice'; +import { initialImageChanged } from 'features/parameters/store/generationSlice'; +import { addToast } from 'features/system/store/systemSlice'; +import { imageUploaded } from 'services/api/thunks/image'; +import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'image' }); @@ -73,7 +75,7 @@ export const addImageUploadedFulfilledListener = () => { } if (postUploadAction?.type === 'ADD_TO_BATCH') { - dispatch(imageAddedToBatch(image.image_name)); + dispatch(imagesAddedToBatch([image.image_name])); return; } }, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts index 934581d02a..5ab30570d9 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts @@ -14,7 +14,7 @@ export const addModelSelectedListener = () => { actionCreator: modelSelected, effect: (action, { getState, dispatch }) => { const state = getState(); - const [base_model, type, name] = action.payload.split('/'); + const { base_model, model_name } = action.payload; if (state.generation.model?.base_model !== base_model) { dispatch( @@ -30,11 +30,7 @@ export const addModelSelectedListener = () => { // TODO: controlnet cleared } - const newModel = zMainModel.parse({ - id: action.payload, - base_model, - name, - }); + const newModel = zMainModel.parse(action.payload); dispatch(modelChanged(newModel)); }, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts new file mode 100644 index 0000000000..ee02028848 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts @@ -0,0 +1,42 @@ +import { modelChanged } from 'features/parameters/store/generationSlice'; +import { some } from 'lodash-es'; +import { modelsApi } from 'services/api/endpoints/models'; +import { startAppListening } from '..'; + +export const addModelsLoadedListener = () => { + startAppListening({ + matcher: modelsApi.endpoints.getMainModels.matchFulfilled, + effect: async (action, { getState, dispatch }) => { + // models loaded, we need to ensure the selected model is available and if not, select the first one + + const currentModel = getState().generation.model; + + const isCurrentModelAvailable = some( + action.payload.entities, + (m) => + m?.model_name === currentModel?.model_name && + m?.base_model === currentModel?.base_model + ); + + if (isCurrentModelAvailable) { + return; + } + + const firstModelId = action.payload.ids[0]; + const firstModel = action.payload.entities[firstModelId]; + + if (!firstModel) { + // No models loaded at all + dispatch(modelChanged(null)); + return; + } + + dispatch( + modelChanged({ + base_model: firstModel.base_model, + model_name: firstModel.model_name, + }) + ); + }, + }); +}; 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 deleted file mode 100644 index dae72d92e7..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/selectionAddedToBatch.ts +++ /dev/null @@ -1,19 +0,0 @@ -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/sessionCreated.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/sessionCreated.ts index c502b4e38c..a55bd07c4d 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/sessionCreated.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/sessionCreated.ts @@ -30,6 +30,7 @@ export const addSessionCreatedRejectedListener = () => { effect: (action, { getState, dispatch }) => { if (action.payload) { const { arg, error } = action.payload; + const stringifiedError = JSON.stringify(error); moduleLog.error( { data: { @@ -37,7 +38,7 @@ export const addSessionCreatedRejectedListener = () => { error: serializeError(error), }, }, - `Problem creating session` + `Problem creating session: ${stringifiedError}` ); } }, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/sessionInvoked.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/sessionInvoked.ts index 6aff246cbe..7fc2e07dcd 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/sessionInvoked.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/sessionInvoked.ts @@ -33,6 +33,7 @@ export const addSessionInvokedRejectedListener = () => { effect: (action, { getState, dispatch }) => { if (action.payload) { const { arg, error } = action.payload; + const stringifiedError = JSON.stringify(error); moduleLog.error( { data: { @@ -40,7 +41,7 @@ export const addSessionInvokedRejectedListener = () => { error: serializeError(error), }, }, - `Problem invoking session` + `Problem invoking session: ${stringifiedError}` ); } }, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts index 3686816d5c..2d091af0b6 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts @@ -1,15 +1,15 @@ -import { addImageToStagingArea } from 'features/canvas/store/canvasSlice'; -import { startAppListening } from '../..'; import { log } from 'app/logging/useLogger'; +import { addImageToStagingArea } from 'features/canvas/store/canvasSlice'; +import { progressImageSet } from 'features/system/store/systemSlice'; +import { boardImagesApi } from 'services/api/endpoints/boardImages'; +import { isImageOutput } from 'services/api/guards'; +import { imageDTOReceived } from 'services/api/thunks/image'; +import { sessionCanceled } from 'services/api/thunks/session'; import { appSocketInvocationComplete, socketInvocationComplete, } from 'services/events/actions'; -import { imageMetadataReceived } from 'services/api/thunks/image'; -import { sessionCanceled } from 'services/api/thunks/session'; -import { isImageOutput } from 'services/api/guards'; -import { progressImageSet } from 'features/system/store/systemSlice'; -import { boardImagesApi } from 'services/api/endpoints/boardImages'; +import { startAppListening } from '../..'; const moduleLog = log.child({ namespace: 'socketio' }); const nodeDenylist = ['dataURL_image']; @@ -42,13 +42,13 @@ export const addInvocationCompleteEventListener = () => { // Get its metadata dispatch( - imageMetadataReceived({ + imageDTOReceived({ image_name, }) ); const [{ payload: imageDTO }] = await take( - imageMetadataReceived.fulfilled.match + imageDTOReceived.fulfilled.match ); // Handle canvas image diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationError.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationError.ts index 51480bbad4..f0dd656097 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationError.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationError.ts @@ -13,7 +13,7 @@ export const addInvocationErrorEventListener = () => { effect: (action, { dispatch, getState }) => { moduleLog.error( action.payload, - `Invocation error (${action.payload.data.node.type})` + `Invocation error (${action.payload.data.node.type}): ${action.payload.data.error}` ); dispatch(appSocketInvocationError(action.payload)); }, diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 5208933e7b..da09b496d7 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -1,6 +1,7 @@ import { AnyAction, ThunkDispatch, + autoBatchEnhancer, combineReducers, configureStore, } from '@reduxjs/toolkit'; @@ -8,14 +9,12 @@ import { import dynamicMiddlewares from 'redux-dynamic-middlewares'; import { rememberEnhancer, rememberReducer } from 'redux-remember'; -import batchReducer from 'features/batch/store/batchSlice'; import canvasReducer from 'features/canvas/store/canvasSlice'; import controlNetReducer from 'features/controlNet/store/controlNetSlice'; import dynamicPromptsReducer from 'features/dynamicPrompts/store/slice'; import boardsReducer from 'features/gallery/store/boardSlice'; import galleryReducer from 'features/gallery/store/gallerySlice'; import imageDeletionReducer from 'features/imageDeletion/store/imageDeletionSlice'; -import lightboxReducer from 'features/lightbox/store/lightboxSlice'; import loraReducer from 'features/lora/store/loraSlice'; import nodesReducer from 'features/nodes/store/nodesSlice'; import generationReducer from 'features/parameters/store/generationSlice'; @@ -39,7 +38,6 @@ const allReducers = { canvas: canvasReducer, gallery: galleryReducer, generation: generationReducer, - lightbox: lightboxReducer, nodes: nodesReducer, postprocessing: postprocessingReducer, system: systemReducer, @@ -49,7 +47,6 @@ const allReducers = { controlNet: controlNetReducer, boards: boardsReducer, dynamicPrompts: dynamicPromptsReducer, - batch: batchReducer, imageDeletion: imageDeletionReducer, lora: loraReducer, [api.reducerPath]: api.reducer, @@ -63,30 +60,29 @@ const rememberedKeys: (keyof typeof allReducers)[] = [ 'canvas', 'gallery', 'generation', - 'lightbox', 'nodes', 'postprocessing', 'system', 'ui', 'controlNet', 'dynamicPrompts', - 'batch', 'lora', - // 'boards', - // 'hotkeys', - // 'config', ]; export const store = configureStore({ reducer: rememberedRootReducer, - enhancers: [ - rememberEnhancer(window.localStorage, rememberedKeys, { - persistDebounce: 300, - serialize, - unserialize, - prefix: LOCALSTORAGE_PREFIX, - }), - ], + enhancers: (existingEnhancers) => { + return existingEnhancers + .concat( + rememberEnhancer(window.localStorage, rememberedKeys, { + persistDebounce: 300, + serialize, + unserialize, + prefix: LOCALSTORAGE_PREFIX, + }) + ) + .concat(autoBatchEnhancer()); + }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ immutableCheck: false, @@ -96,10 +92,26 @@ export const store = configureStore({ .concat(dynamicMiddlewares) .prepend(listenerMiddleware.middleware), devTools: { - actionsDenylist, actionSanitizer, stateSanitizer, trace: true, + predicate: (state, action) => { + // TODO: hook up to the log level param in system slice + // manually type state, cannot type the arg + // const typedState = state as ReturnType; + + if (action.type.startsWith('api/')) { + // don't log api actions, with manual cache updates they are extremely noisy + return false; + } + + if (actionsDenylist.includes(action.type)) { + // don't log other noisy actions + return false; + } + + return true; + }, }, }); diff --git a/invokeai/frontend/web/src/app/types/invokeai.ts b/invokeai/frontend/web/src/app/types/invokeai.ts index 229761dabb..40b8c1c73a 100644 --- a/invokeai/frontend/web/src/app/types/invokeai.ts +++ b/invokeai/frontend/web/src/app/types/invokeai.ts @@ -94,7 +94,8 @@ export type AppFeature = | 'bugLink' | 'localization' | 'consoleLogging' - | 'dynamicPrompting'; + | 'dynamicPrompting' + | 'batches'; /** * A disable-able Stable Diffusion feature diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx index 959a70bc29..59a1d281fe 100644 --- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx @@ -6,30 +6,21 @@ import { useColorMode, useColorModeValue, } from '@chakra-ui/react'; -import { useCombinedRefs } from '@dnd-kit/utilities'; -import IAIIconButton from 'common/components/IAIIconButton'; -import { - IAILoadingImageFallback, - IAINoContentFallback, -} from 'common/components/IAIImageFallback'; -import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; -import { AnimatePresence } from 'framer-motion'; -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'; -import { v4 as uuidv4 } from 'uuid'; -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'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; +import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react'; +import { FaImage, FaUndo, FaUpload } from 'react-icons/fa'; +import { PostUploadAction } from 'services/api/thunks/image'; +import { ImageDTO } from 'services/api/types'; +import { mode } from 'theme/util/mode'; +import IAIDraggable from './IAIDraggable'; +import IAIDroppable from './IAIDroppable'; type IAIDndImageProps = { imageDTO: ImageDTO | undefined; @@ -83,28 +74,6 @@ const IAIDndImage = (props: IAIDndImageProps) => { const { colorMode } = useColorMode(); - const dndId = useRef(uuidv4()); - - const { - attributes, - listeners, - setNodeRef: setDraggableRef, - isDragging, - active, - } = useDraggable({ - id: dndId.current, - 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, @@ -139,9 +108,6 @@ const IAIDndImage = (props: IAIDndImageProps) => { userSelect: 'none', cursor: isDragDisabled || !imageDTO ? 'default' : 'pointer', }} - {...attributes} - {...listeners} - ref={setDndRef} > {imageDTO && ( { }} > } + // If we fall back to thumbnail, it feels much snappier than the skeleton... + fallbackSrc={imageDTO.thumbnail_url} + // fallback={} + width={imageDTO.width} + height={imageDTO.height} onError={onError} draggable={false} sx={{ @@ -171,30 +140,6 @@ const IAIDndImage = (props: IAIDndImageProps) => { }} /> {withMetadataOverlay && } - {onClickReset && withResetIcon && ( - - )} )} {!imageDTO && !isUploadDisabled && ( @@ -225,11 +170,42 @@ const IAIDndImage = (props: IAIDndImageProps) => { )} {!imageDTO && isUploadDisabled && noContentFallback} - - {isValidDrop(droppableData, active) && !isDragging && ( - - )} - + + {imageDTO && ( + + )} + {onClickReset && withResetIcon && imageDTO && ( + + )} ); }; diff --git a/invokeai/frontend/web/src/common/components/IAIDraggable.tsx b/invokeai/frontend/web/src/common/components/IAIDraggable.tsx new file mode 100644 index 0000000000..482a8ac604 --- /dev/null +++ b/invokeai/frontend/web/src/common/components/IAIDraggable.tsx @@ -0,0 +1,40 @@ +import { Box } from '@chakra-ui/react'; +import { + TypesafeDraggableData, + useDraggable, +} from 'app/components/ImageDnd/typesafeDnd'; +import { MouseEvent, memo, useRef } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +type IAIDraggableProps = { + disabled?: boolean; + data?: TypesafeDraggableData; + onClick?: (event: MouseEvent) => void; +}; + +const IAIDraggable = (props: IAIDraggableProps) => { + const { data, disabled, onClick } = props; + const dndId = useRef(uuidv4()); + + const { attributes, listeners, setNodeRef } = useDraggable({ + id: dndId.current, + disabled, + data, + }); + + return ( + + ); +}; + +export default memo(IAIDraggable); diff --git a/invokeai/frontend/web/src/common/components/IAIDroppable.tsx b/invokeai/frontend/web/src/common/components/IAIDroppable.tsx new file mode 100644 index 0000000000..98093d04e4 --- /dev/null +++ b/invokeai/frontend/web/src/common/components/IAIDroppable.tsx @@ -0,0 +1,47 @@ +import { Box } from '@chakra-ui/react'; +import { + TypesafeDroppableData, + isValidDrop, + useDroppable, +} from 'app/components/ImageDnd/typesafeDnd'; +import { AnimatePresence } from 'framer-motion'; +import { memo, useRef } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import IAIDropOverlay from './IAIDropOverlay'; + +type IAIDroppableProps = { + dropLabel?: string; + disabled?: boolean; + data?: TypesafeDroppableData; +}; + +const IAIDroppable = (props: IAIDroppableProps) => { + const { dropLabel, data, disabled } = props; + const dndId = useRef(uuidv4()); + + const { isOver, setNodeRef, active } = useDroppable({ + id: dndId.current, + disabled, + data, + }); + + return ( + + + {isValidDrop(data, active) && ( + + )} + + + ); +}; + +export default memo(IAIDroppable); diff --git a/invokeai/frontend/web/src/common/components/IAIErrorLoadingImageFallback.tsx b/invokeai/frontend/web/src/common/components/IAIErrorLoadingImageFallback.tsx new file mode 100644 index 0000000000..2136acc3c3 --- /dev/null +++ b/invokeai/frontend/web/src/common/components/IAIErrorLoadingImageFallback.tsx @@ -0,0 +1,42 @@ +import { Box, Flex, Icon } from '@chakra-ui/react'; +import { FaExclamation } from 'react-icons/fa'; + +const IAIErrorLoadingImageFallback = () => { + return ( + + + + + + ); +}; + +export default IAIErrorLoadingImageFallback; diff --git a/invokeai/frontend/web/src/common/components/IAIFillSkeleton.tsx b/invokeai/frontend/web/src/common/components/IAIFillSkeleton.tsx new file mode 100644 index 0000000000..a3c83cb734 --- /dev/null +++ b/invokeai/frontend/web/src/common/components/IAIFillSkeleton.tsx @@ -0,0 +1,30 @@ +import { Box, Skeleton } from '@chakra-ui/react'; + +const IAIFillSkeleton = () => { + return ( + + + + ); +}; + +export default IAIFillSkeleton; diff --git a/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts b/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts index 605aa8b162..3b1476fb1f 100644 --- a/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts +++ b/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts @@ -3,36 +3,22 @@ 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'; -import { generationSelector } from 'features/parameters/store/generationSelectors'; -import { systemSelector } from 'features/system/store/systemSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; -import { - modelsApi, - useGetMainModelsQuery, -} from '../../services/api/endpoints/models'; +import { modelsApi } from '../../services/api/endpoints/models'; const readinessSelector = createSelector( [stateSelector, activeTabNameSelector], (state, activeTabName) => { - const { generation, system, batch } = state; + const { generation, system } = state; 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 && - !(asInitialImage && batchImageNames.length > 1) - ) { + if (activeTabName === 'img2img' && !initialImage) { 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 deleted file mode 100644 index 4231c84bec..0000000000 --- a/invokeai/frontend/web/src/features/batch/components/BatchControlNet.tsx +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 4a6250f93a..0000000000 --- a/invokeai/frontend/web/src/features/batch/components/BatchImage.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { Box, Icon, Skeleton } from '@chakra-ui/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; -import { stateSelector } from 'app/store/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import IAIDndImage from 'common/components/IAIDndImage'; -import { - batchImageRangeEndSelected, - batchImageSelected, - batchImageSelectionToggled, - imageRemovedFromBatch, -} from 'features/batch/store/batchSlice'; -import { MouseEvent, memo, useCallback, useMemo } from 'react'; -import { FaExclamationCircle } from 'react-icons/fa'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; - -const makeSelector = (image_name: string) => - createSelector( - [stateSelector], - (state) => ({ - selectionCount: state.batch.selection.length, - isSelected: state.batch.selection.includes(image_name), - }), - defaultSelectorOptions - ); - -type BatchImageProps = { - imageName: string; -}; - -const BatchImage = (props: BatchImageProps) => { - const { - currentData: imageDTO, - isFetching, - isError, - isSuccess, - } = useGetImageDTOQuery(props.imageName); - const dispatch = useAppDispatch(); - - const selector = useMemo( - () => makeSelector(props.imageName), - [props.imageName] - ); - - const { isSelected, selectionCount } = useAppSelector(selector); - - 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 (selectionCount > 1) { - return { - id: 'batch', - payloadType: 'BATCH_SELECTION', - }; - } - - if (imageDTO) { - return { - id: 'batch', - payloadType: 'IMAGE_DTO', - payload: { imageDTO }, - }; - } - }, [imageDTO, selectionCount]); - - 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 deleted file mode 100644 index 09e6b8afd7..0000000000 --- a/invokeai/frontend/web/src/features/batch/components/BatchImageContainer.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index f61d27d4cf..0000000000 --- a/invokeai/frontend/web/src/features/batch/components/BatchImageGrid.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index d7855dd4e2..0000000000 --- a/invokeai/frontend/web/src/features/batch/components/BatchManager.tsx +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 6a96361d3f..0000000000 --- a/invokeai/frontend/web/src/features/batch/store/batchSlice.ts +++ /dev/null @@ -1,142 +0,0 @@ -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/embedding/components/ParamEmbeddingPopover.tsx b/invokeai/frontend/web/src/features/embedding/components/ParamEmbeddingPopover.tsx index b7de3a8d99..32822125d2 100644 --- a/invokeai/frontend/web/src/features/embedding/components/ParamEmbeddingPopover.tsx +++ b/invokeai/frontend/web/src/features/embedding/components/ParamEmbeddingPopover.tsx @@ -47,8 +47,8 @@ const ParamEmbeddingPopover = (props: Props) => { const disabled = currentMainModel?.base_model !== embedding.base_model; data.push({ - value: embedding.name, - label: embedding.name, + value: embedding.model_name, + label: embedding.model_name, group: MODEL_TYPE_MAP[embedding.base_model], disabled, tooltip: disabled diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/AllImagesBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/AllImagesBoard.tsx deleted file mode 100644 index 918e9390f9..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/AllImagesBoard.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Flex, useColorMode } from '@chakra-ui/react'; -import { FaImages } from 'react-icons/fa'; -import { boardIdSelected } from 'features/gallery/store/gallerySlice'; -import { useDispatch } from 'react-redux'; -import { IAINoContentFallback } from 'common/components/IAIImageFallback'; -import { AnimatePresence } from 'framer-motion'; -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(); - const { colorMode } = useColorMode(); - - const handleAllImagesBoardClick = () => { - dispatch(boardIdSelected()); - }; - - const droppableData: MoveBoardDropData = { - id: 'all-images-board', - actionType: 'MOVE_BOARD', - context: { boardId: null }, - }; - - const { isOver, setNodeRef, active } = useDroppable({ - id: `board_droppable_all_images`, - data: droppableData, - }); - - return ( - - - - - {isValidDrop(droppableData, active) && ( - - )} - - - - All Images - - - ); -}; - -export default AllImagesBoard; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/AddBoardButton.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx similarity index 100% rename from invokeai/frontend/web/src/features/gallery/components/Boards/AddBoardButton.tsx rename to invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllImagesBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllImagesBoard.tsx new file mode 100644 index 0000000000..c14ae24483 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllImagesBoard.tsx @@ -0,0 +1,31 @@ +import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; +import { boardIdSelected } from 'features/gallery/store/gallerySlice'; +import { FaImages } from 'react-icons/fa'; +import { useDispatch } from 'react-redux'; +import GenericBoard from './GenericBoard'; + +const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => { + const dispatch = useDispatch(); + + const handleAllImagesBoardClick = () => { + dispatch(boardIdSelected('all')); + }; + + const droppableData: MoveBoardDropData = { + id: 'all-images-board', + actionType: 'MOVE_BOARD', + context: { boardId: null }, + }; + + return ( + + ); +}; + +export default AllImagesBoard; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BatchBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BatchBoard.tsx new file mode 100644 index 0000000000..816fba33c1 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BatchBoard.tsx @@ -0,0 +1,42 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { AddToBatchDropData } from 'app/components/ImageDnd/typesafeDnd'; +import { stateSelector } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import { boardIdSelected } from 'features/gallery/store/gallerySlice'; +import { useCallback } from 'react'; +import { FaLayerGroup } from 'react-icons/fa'; +import { useDispatch } from 'react-redux'; +import GenericBoard from './GenericBoard'; + +const selector = createSelector(stateSelector, (state) => { + return { + count: state.gallery.batchImageNames.length, + }; +}); + +const BatchBoard = ({ isSelected }: { isSelected: boolean }) => { + const dispatch = useDispatch(); + const { count } = useAppSelector(selector); + + const handleBatchBoardClick = useCallback(() => { + dispatch(boardIdSelected('batch')); + }, [dispatch]); + + const droppableData: AddToBatchDropData = { + id: 'batch-board', + actionType: 'ADD_TO_BATCH', + }; + + return ( + + ); +}; + +export default BatchBoard; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx similarity index 85% rename from invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx rename to invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx index 5618c5c5c2..b479c46fd9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx @@ -1,3 +1,4 @@ +import { CloseIcon } from '@chakra-ui/icons'; import { Collapse, Flex, @@ -9,17 +10,18 @@ import { InputRightElement, } 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 { setBoardSearchText } from 'features/gallery/store/boardSlice'; -import { memo, useState } from 'react'; -import HoverableBoard from './HoverableBoard'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import { memo, useState } from 'react'; +import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; 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'; +import BatchBoard from './BatchBoard'; +import GalleryBoard from './GalleryBoard'; +import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus'; const selector = createSelector( [stateSelector], @@ -42,6 +44,8 @@ const BoardsList = (props: Props) => { const { data: boards } = useListAllBoardsQuery(); + const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled; + const filteredBoards = searchText ? boards?.filter((board) => board.board_name.toLowerCase().includes(searchText.toLowerCase()) @@ -115,14 +119,21 @@ const BoardsList = (props: Props) => { }} > {!searchMode && ( - - - + <> + + + + {isBatchEnabled && ( + + + + )} + )} {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/BoardsList/GalleryBoard.tsx similarity index 76% rename from invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx rename to invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 035ee77f18..c01113d38a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -12,35 +12,31 @@ import { } from '@chakra-ui/react'; 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 } from 'services/api/types'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { boardIdSelected } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback, useContext, useMemo } from 'react'; +import { FaFolder, FaImages, FaTrash } from 'react-icons/fa'; import { useDeleteBoardMutation, useUpdateBoardMutation, } from 'services/api/endpoints/boards'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { BoardDTO } from 'services/api/types'; import { skipToken } from '@reduxjs/toolkit/dist/query'; -import { AnimatePresence } from 'framer-motion'; -import IAIDropOverlay from 'common/components/IAIDropOverlay'; -import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext'; +import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; +// import { boardAddedToBatch } from 'app/store/middleware/listenerMiddleware/listeners/addBoardToBatch'; +import IAIDroppable from 'common/components/IAIDroppable'; import { mode } from 'theme/util/mode'; -import { - MoveBoardDropData, - isValidDrop, - useDroppable, -} from 'app/components/ImageDnd/typesafeDnd'; +import { DeleteBoardImagesContext } from '../../../../../app/contexts/DeleteBoardImagesContext'; -interface HoverableBoardProps { +interface GalleryBoardProps { board: BoardDTO; isSelected: boolean; } -const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { +const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => { const dispatch = useAppDispatch(); const { currentData: coverImage } = useGetImageDTOQuery( @@ -71,21 +67,22 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { deleteBoard(board_id); }, [board_id, deleteBoard]); + const handleAddBoardToBatch = useCallback(() => { + // dispatch(boardAddedToBatch({ board_id })); + }, []); + const handleDeleteBoardAndImages = useCallback(() => { - console.log({ board }); onClickDeleteBoardImages(board); }, [board, onClickDeleteBoardImages]); - const droppableData: MoveBoardDropData = { - id: board_id, - actionType: 'MOVE_BOARD', - context: { boardId: board_id }, - }; - - const { isOver, setNodeRef, active } = useDroppable({ - id: `board_droppable_${board_id}`, - data: droppableData, - }); + const droppableData: MoveBoardDropData = useMemo( + () => ({ + id: board_id, + actionType: 'MOVE_BOARD', + context: { boardId: board_id }, + }), + [board_id] + ); return ( @@ -94,16 +91,25 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { renderMenu={() => ( {board.image_count > 0 && ( - } - onClickCapture={handleDeleteBoardAndImages} - > - Delete Board and Images - + <> + } + onClickCapture={handleAddBoardToBatch} + > + Add Board to Batch + + } + onClickCapture={handleDeleteBoardAndImages} + > + Delete Board and Images + + )} } onClickCapture={handleDeleteBoard} > @@ -127,7 +133,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { }} > { > {board.image_count} - - {isValidDrop(droppableData, active) && ( - - )} - + { ); }); -HoverableBoard.displayName = 'HoverableBoard'; +GalleryBoard.displayName = 'HoverableBoard'; -export default HoverableBoard; +export default GalleryBoard; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx new file mode 100644 index 0000000000..a300c1b18c --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx @@ -0,0 +1,83 @@ +import { As, Badge, Flex } from '@chakra-ui/react'; +import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd'; +import IAIDroppable from 'common/components/IAIDroppable'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; + +type GenericBoardProps = { + droppableData: TypesafeDroppableData; + onClick: () => void; + isSelected: boolean; + icon: As; + label: string; + badgeCount?: number; +}; + +const GenericBoard = (props: GenericBoardProps) => { + const { droppableData, onClick, isSelected, icon, label, badgeCount } = props; + + return ( + + + + + {badgeCount !== undefined && ( + {badgeCount} + )} + + + + + {label} + + + ); +}; + +export default GenericBoard; diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx similarity index 84% rename from invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx rename to invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx index efece3ee2f..c01a00fafe 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx @@ -8,8 +8,6 @@ import IAIButton from 'common/components/IAIButton'; import IAIIconButton from 'common/components/IAIIconButton'; import IAIPopover from 'common/components/IAIPopover'; -import { setIsLightboxOpen } from 'features/lightbox/store/lightboxSlice'; - import { skipToken } from '@reduxjs/toolkit/dist/query'; import { useAppToaster } from 'app/components/Toaster'; import { stateSelector } from 'app/store/store'; @@ -36,7 +34,6 @@ import { FaCode, FaCopy, FaDownload, - FaExpand, FaExpandArrowsAlt, FaGrinStars, FaHourglassHalf, @@ -45,12 +42,16 @@ import { FaShare, FaShareAlt, } from 'react-icons/fa'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions'; +import { + useGetImageDTOQuery, + useGetImageMetadataQuery, +} from 'services/api/endpoints/images'; +import { useDebounce } from 'use-debounce'; +import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions'; const currentImageButtonsSelector = createSelector( [stateSelector, activeTabNameSelector], - ({ gallery, system, postprocessing, ui, lightbox }, activeTabName) => { + ({ gallery, system, postprocessing, ui }, activeTabName) => { const { isProcessing, isConnected, @@ -62,8 +63,6 @@ const currentImageButtonsSelector = createSelector( const { upscalingLevel, facetoolStrength } = postprocessing; - const { isLightboxOpen } = lightbox; - const { shouldShowImageDetails, shouldHidePreview, @@ -84,7 +83,6 @@ const currentImageButtonsSelector = createSelector( shouldDisableToolbarButtons: Boolean(progressImage) || !lastSelectedImage, shouldShowImageDetails, activeTabName, - isLightboxOpen, shouldHidePreview, shouldShowProgressInViewer, lastSelectedImage, @@ -110,14 +108,11 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { facetoolStrength, shouldDisableToolbarButtons, shouldShowImageDetails, - isLightboxOpen, activeTabName, - shouldHidePreview, lastSelectedImage, shouldShowProgressInViewer, } = useAppSelector(currentImageButtonsSelector); - const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled; const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled; const isUpscalingEnabled = useFeatureStatus('upscaling').isFeatureEnabled; const isFaceRestoreEnabled = useFeatureStatus('faceRestore').isFeatureEnabled; @@ -128,33 +123,22 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { const { recallBothPrompts, recallSeed, recallAllParameters } = useRecallParameters(); - const { currentData: image } = useGetImageDTOQuery( + const [debouncedMetadataQueryArg, debounceState] = useDebounce( + lastSelectedImage, + 500 + ); + + const { currentData: image, isFetching } = useGetImageDTOQuery( lastSelectedImage ?? skipToken ); - // const handleCopyImage = useCallback(async () => { - // if (!image?.url) { - // return; - // } + const { currentData: metadataData } = useGetImageMetadataQuery( + debounceState.isPending() + ? skipToken + : debouncedMetadataQueryArg ?? skipToken + ); - // const url = getUrl(image.url); - - // if (!url) { - // return; - // } - - // const blob = await fetch(url).then((res) => res.blob()); - // const data = [new ClipboardItem({ [blob.type]: blob })]; - - // await navigator.clipboard.write(data); - - // toast({ - // title: t('toast.imageCopied'), - // status: 'success', - // duration: 2500, - // isClosable: true, - // }); - // }, [getUrl, t, image?.url, toast]); + const metadata = metadataData?.metadata; const handleCopyImageLink = useCallback(() => { const getImageUrl = () => { @@ -193,29 +177,26 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { }, [toaster, t, image]); const handleClickUseAllParameters = useCallback(() => { - recallAllParameters(image); - }, [image, recallAllParameters]); + recallAllParameters(metadata); + }, [metadata, recallAllParameters]); useHotkeys( 'a', () => { handleClickUseAllParameters; }, - [image, recallAllParameters] + [metadata, recallAllParameters] ); const handleUseSeed = useCallback(() => { - recallSeed(image?.metadata?.seed); - }, [image, recallSeed]); + recallSeed(metadata?.seed); + }, [metadata?.seed, recallSeed]); useHotkeys('s', handleUseSeed, [image]); const handleUsePrompt = useCallback(() => { - recallBothPrompts( - image?.metadata?.positive_conditioning, - image?.metadata?.negative_conditioning - ); - }, [image, recallBothPrompts]); + recallBothPrompts(metadata?.positive_prompt, metadata?.negative_prompt); + }, [metadata?.negative_prompt, metadata?.positive_prompt, recallBothPrompts]); useHotkeys('p', handleUsePrompt, [image]); @@ -304,7 +285,6 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { const handleSendToCanvas = useCallback(() => { if (!image) return; dispatch(sentImageToCanvas()); - if (isLightboxOpen) dispatch(setIsLightboxOpen(false)); dispatch(setInitialCanvasImage(image)); dispatch(requestCanvasRescale()); @@ -319,7 +299,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { duration: 2500, isClosable: true, }); - }, [image, isLightboxOpen, dispatch, activeTabName, toaster, t]); + }, [image, dispatch, activeTabName, toaster, t]); useHotkeys( 'i', @@ -342,10 +322,6 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { dispatch(setShouldShowProgressInViewer(!shouldShowProgressInViewer)); }, [dispatch, shouldShowProgressInViewer]); - const handleLightBox = useCallback(() => { - dispatch(setIsLightboxOpen(!isLightboxOpen)); - }, [dispatch, isLightboxOpen]); - return ( <> { - {isLightboxEnabled && ( - } - tooltip={ - !isLightboxOpen - ? `${t('parameters.openInViewer')} (Z)` - : `${t('parameters.closeViewer')} (Z)` - } - aria-label={ - !isLightboxOpen - ? `${t('parameters.openInViewer')} (Z)` - : `${t('parameters.closeViewer')} (Z)` - } - isChecked={isLightboxOpen} - onClick={handleLightBox} - isDisabled={shouldDisableToolbarButtons} - /> - )} @@ -440,7 +398,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { icon={} tooltip={`${t('parameters.usePrompt')} (P)`} aria-label={`${t('parameters.usePrompt')} (P)`} - isDisabled={!image?.metadata?.positive_conditioning} + isDisabled={!metadata?.positive_prompt} onClick={handleUsePrompt} /> @@ -448,7 +406,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { icon={} tooltip={`${t('parameters.useSeed')} (S)`} aria-label={`${t('parameters.useSeed')} (S)`} - isDisabled={!image?.metadata?.seed} + isDisabled={!metadata?.seed} onClick={handleUseSeed} /> @@ -456,10 +414,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { icon={} tooltip={`${t('parameters.useAll')} (A)`} aria-label={`${t('parameters.useAll')} (A)`} - isDisabled={ - // not sure what this list should be - !['t2l', 'l2l', 'inpaint'].includes(String(image?.metadata?.type)) - } + isDisabled={!metadata} onClick={handleClickUseAllParameters} /> diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageDisplay.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageDisplay.tsx similarity index 100% rename from invokeai/frontend/web/src/features/gallery/components/CurrentImageDisplay.tsx rename to invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageDisplay.tsx diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageHidden.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageHidden.tsx similarity index 100% rename from invokeai/frontend/web/src/features/gallery/components/CurrentImageHidden.tsx rename to invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageHidden.tsx diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx similarity index 58% rename from invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx rename to invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx index 8018beea9a..e143a87fc9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx @@ -8,12 +8,15 @@ import { import { stateSelector } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; import IAIDndImage from 'common/components/IAIDndImage'; -import { selectLastSelectedImage } from 'features/gallery/store/gallerySlice'; +import { useNextPrevImage } from 'features/gallery/hooks/useNextPrevImage'; +import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { AnimatePresence, motion } from 'framer-motion'; import { isEqual } from 'lodash-es'; -import { memo, useMemo } from 'react'; +import { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer'; -import NextPrevImageButtons from './NextPrevImageButtons'; +import ImageMetadataViewer from '../ImageMetadataViewer/ImageMetadataViewer'; +import NextPrevImageButtons from '../NextPrevImageButtons'; export const imagesSelector = createSelector( [stateSelector, selectLastSelectedImage], @@ -49,6 +52,45 @@ const CurrentImagePreview = () => { shouldAntialiasProgressImage, } = useAppSelector(imagesSelector); + const { + handlePrevImage, + handleNextImage, + prevImageId, + nextImageId, + isOnLastImage, + handleLoadMoreImages, + areMoreImagesAvailable, + isFetching, + } = useNextPrevImage(); + + useHotkeys( + 'left', + () => { + handlePrevImage(); + }, + [prevImageId] + ); + + useHotkeys( + 'right', + () => { + if (isOnLastImage && areMoreImagesAvailable && !isFetching) { + handleLoadMoreImages(); + return; + } + if (!isOnLastImage) { + handleNextImage(); + } + }, + [ + nextImageId, + isOnLastImage, + areMoreImagesAvailable, + handleLoadMoreImages, + isFetching, + ] + ); + const { currentData: imageDTO, isLoading, @@ -74,8 +116,27 @@ const CurrentImagePreview = () => { [] ); + // Show and hide the next/prev buttons on mouse move + const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = + useState(false); + + const timeoutId = useRef(0); + + const handleMouseOver = useCallback(() => { + setShouldShowNextPrevButtons(true); + window.clearTimeout(timeoutId.current); + }, []); + + const handleMouseOut = useCallback(() => { + timeoutId.current = window.setTimeout(() => { + setShouldShowNextPrevButtons(false); + }, 500); + }, []); + return ( { width: 'full', height: 'full', borderRadius: 'base', - overflow: 'scroll', }} > )} - {!shouldShowImageDetails && imageDTO && ( - - - - )} + + {!shouldShowImageDetails && imageDTO && shouldShowNextPrevButtons && ( + + + + )} + ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx index 9ab8ccb5c9..2aa44e50a1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx @@ -5,32 +5,23 @@ import { setGalleryImageMinimumWidth } from 'features/gallery/store/gallerySlice import { clamp, isEqual } from 'lodash-es'; import { useHotkeys } from 'react-hotkeys-hook'; -import './ImageGallery.css'; -import ImageGalleryContent from './ImageGalleryContent'; -import ResizableDrawer from 'features/ui/components/common/ResizableDrawer/ResizableDrawer'; -import { setShouldShowGallery } from 'features/ui/store/uiSlice'; import { createSelector } from '@reduxjs/toolkit'; +import { isStagingSelector } from 'features/canvas/store/canvasSelectors'; +import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; +import ResizableDrawer from 'features/ui/components/common/ResizableDrawer/ResizableDrawer'; import { activeTabNameSelector, uiSelector, } from 'features/ui/store/uiSelectors'; -import { isStagingSelector } from 'features/canvas/store/canvasSelectors'; -import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; -import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors'; +import { setShouldShowGallery } from 'features/ui/store/uiSlice'; import { memo } from 'react'; +import ImageGalleryContent from './ImageGalleryContent'; const selector = createSelector( - [ - activeTabNameSelector, - uiSelector, - gallerySelector, - isStagingSelector, - lightboxSelector, - ], - (activeTabName, ui, gallery, isStaging, lightbox) => { + [activeTabNameSelector, uiSelector, gallerySelector, isStagingSelector], + (activeTabName, ui, gallery, isStaging) => { const { shouldPinGallery, shouldShowGallery } = ui; const { galleryImageMinimumWidth } = gallery; - const { isLightboxOpen } = lightbox; return { activeTabName, @@ -39,7 +30,6 @@ const selector = createSelector( shouldShowGallery, galleryImageMinimumWidth, isResizable: activeTabName !== 'unifiedCanvas', - isLightboxOpen, }; }, { @@ -58,7 +48,6 @@ const GalleryDrawer = () => { // activeTabName, // isStaging, // isResizable, - // isLightboxOpen, } = useAppSelector(selector); const handleCloseGallery = () => { diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu.tsx deleted file mode 100644 index 64b1d349d8..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import { ExternalLinkIcon } from '@chakra-ui/icons'; -import { MenuItem, MenuList } from '@chakra-ui/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { useAppToaster } from 'app/components/Toaster'; -import { stateSelector } from 'app/store/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; -import { - imagesAddedToBatch, - selectionAddedToBatch, -} from 'features/batch/store/batchSlice'; -import { - resizeAndScaleCanvas, - setInitialCanvasImage, -} from 'features/canvas/store/canvasSlice'; -import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; -import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; -import { initialImageSelected } from 'features/parameters/store/actions'; -import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; -import { setActiveTab } from 'features/ui/store/uiSlice'; -import { memo, useCallback, useContext, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { FaExpand, FaFolder, FaShare, FaTrash } from 'react-icons/fa'; -import { IoArrowUndoCircleOutline } from 'react-icons/io5'; -import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages'; -import { ImageDTO } from 'services/api/types'; -import { AddImageToBoardContext } from '../../../app/contexts/AddImageToBoardContext'; -import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions'; - -type Props = { - image: ImageDTO; - children: ContextMenuProps['children']; -}; - -const ImageContextMenu = ({ image, children }: Props) => { - const selector = useMemo( - () => - createSelector( - [stateSelector], - ({ gallery, batch }) => { - const selectionCount = gallery.selection.length; - const isInBatch = batch.imageNames.includes(image.image_name); - - return { selectionCount, isInBatch }; - }, - defaultSelectorOptions - ), - [image.image_name] - ); - const { selectionCount, isInBatch } = useAppSelector(selector); - 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/ImageContextMenu/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx new file mode 100644 index 0000000000..44fa964596 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx @@ -0,0 +1,52 @@ +import { MenuList } 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 { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; +import { memo, useMemo } from 'react'; +import { ImageDTO } from 'services/api/types'; +import MultipleSelectionMenuItems from './MultipleSelectionMenuItems'; +import SingleSelectionMenuItems from './SingleSelectionMenuItems'; + +type Props = { + imageDTO: ImageDTO; + children: ContextMenuProps['children']; +}; + +const ImageContextMenu = ({ imageDTO, children }: Props) => { + const selector = useMemo( + () => + createSelector( + [stateSelector], + ({ gallery }) => { + const selectionCount = gallery.selection.length; + + return { selectionCount }; + }, + defaultSelectorOptions + ), + [] + ); + + const { selectionCount } = useAppSelector(selector); + + return ( + + menuProps={{ size: 'sm', isLazy: true }} + renderMenu={() => ( + + {selectionCount === 1 ? ( + + ) : ( + + )} + + )} + > + {children} + + ); +}; + +export default memo(ImageContextMenu); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx new file mode 100644 index 0000000000..62d2cb06f4 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx @@ -0,0 +1,40 @@ +import { MenuItem } from '@chakra-ui/react'; +import { useCallback } from 'react'; +import { FaFolder, FaFolderPlus, FaTrash } from 'react-icons/fa'; + +const MultipleSelectionMenuItems = () => { + const handleAddSelectionToBoard = useCallback(() => { + // TODO: add selection to board + }, []); + + const handleDeleteSelection = useCallback(() => { + // TODO: delete all selected images + }, []); + + const handleAddSelectionToBatch = useCallback(() => { + // TODO: add selection to batch + }, []); + + return ( + <> + } onClickCapture={handleAddSelectionToBoard}> + Move Selection to Board + + } + onClickCapture={handleAddSelectionToBatch} + > + Add Selection to Batch + + } + onClickCapture={handleDeleteSelection} + > + Delete Selection + + + ); +}; + +export default MultipleSelectionMenuItems; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx new file mode 100644 index 0000000000..fda984e2c3 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx @@ -0,0 +1,207 @@ +import { ExternalLinkIcon } from '@chakra-ui/icons'; +import { MenuItem } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppToaster } from 'app/components/Toaster'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { + resizeAndScaleCanvas, + setInitialCanvasImage, +} from 'features/canvas/store/canvasSlice'; +import { imagesAddedToBatch } from 'features/gallery/store/gallerySlice'; +import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; +import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; +import { initialImageSelected } from 'features/parameters/store/actions'; +import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; +import { setActiveTab } from 'features/ui/store/uiSlice'; +import { memo, useCallback, useContext, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaFolder, FaShare, FaTrash } from 'react-icons/fa'; +import { IoArrowUndoCircleOutline } from 'react-icons/io5'; +import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages'; +import { useGetImageMetadataQuery } from 'services/api/endpoints/images'; +import { ImageDTO } from 'services/api/types'; +import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext'; +import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions'; + +type SingleSelectionMenuItemsProps = { + imageDTO: ImageDTO; +}; + +const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { + const { imageDTO } = props; + + const selector = useMemo( + () => + createSelector( + [stateSelector], + ({ gallery }) => { + const isInBatch = gallery.batchImageNames.includes( + imageDTO.image_name + ); + + return { isInBatch }; + }, + defaultSelectorOptions + ), + [imageDTO.image_name] + ); + + const { isInBatch } = useAppSelector(selector); + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + const toaster = useAppToaster(); + + const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled; + const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled; + + const { onClickAddToBoard } = useContext(AddImageToBoardContext); + + const { currentData } = useGetImageMetadataQuery(imageDTO.image_name); + + const metadata = currentData?.metadata; + + const handleDelete = useCallback(() => { + if (!imageDTO) { + return; + } + dispatch(imageToDeleteSelected(imageDTO)); + }, [dispatch, imageDTO]); + + const { recallBothPrompts, recallSeed, recallAllParameters } = + useRecallParameters(); + + const [removeFromBoard] = useRemoveImageFromBoardMutation(); + + // Recall parameters handlers + const handleRecallPrompt = useCallback(() => { + recallBothPrompts(metadata?.positive_prompt, metadata?.negative_prompt); + }, [metadata?.negative_prompt, metadata?.positive_prompt, recallBothPrompts]); + + const handleRecallSeed = useCallback(() => { + recallSeed(metadata?.seed); + }, [metadata?.seed, recallSeed]); + + const handleSendToImageToImage = useCallback(() => { + dispatch(sentImageToImg2Img()); + dispatch(initialImageSelected(imageDTO)); + }, [dispatch, imageDTO]); + + const handleSendToCanvas = useCallback(() => { + dispatch(sentImageToCanvas()); + dispatch(setInitialCanvasImage(imageDTO)); + dispatch(resizeAndScaleCanvas()); + dispatch(setActiveTab('unifiedCanvas')); + + toaster({ + title: t('toast.sentToUnifiedCanvas'), + status: 'success', + duration: 2500, + isClosable: true, + }); + }, [dispatch, imageDTO, t, toaster]); + + const handleUseAllParameters = useCallback(() => { + console.log(metadata); + recallAllParameters(metadata); + }, [metadata, recallAllParameters]); + + const handleAddToBoard = useCallback(() => { + onClickAddToBoard(imageDTO); + }, [imageDTO, onClickAddToBoard]); + + const handleRemoveFromBoard = useCallback(() => { + if (!imageDTO.board_id) { + return; + } + removeFromBoard({ + board_id: imageDTO.board_id, + image_name: imageDTO.image_name, + }); + }, [imageDTO.board_id, imageDTO.image_name, removeFromBoard]); + + const handleOpenInNewTab = useCallback(() => { + window.open(imageDTO.image_url, '_blank'); + }, [imageDTO.image_url]); + + const handleAddToBatch = useCallback(() => { + dispatch(imagesAddedToBatch([imageDTO.image_name])); + }, [dispatch, imageDTO.image_name]); + + return ( + <> + } onClickCapture={handleOpenInNewTab}> + {t('common.openInNewTab')} + + } + onClickCapture={handleRecallPrompt} + isDisabled={ + metadata?.positive_prompt === undefined && + metadata?.negative_prompt === undefined + } + > + {t('parameters.usePrompt')} + + + } + onClickCapture={handleRecallSeed} + isDisabled={metadata?.seed === undefined} + > + {t('parameters.useSeed')} + + } + onClickCapture={handleUseAllParameters} + isDisabled={!metadata} + > + {t('parameters.useAll')} + + } + onClickCapture={handleSendToImageToImage} + id="send-to-img2img" + > + {t('parameters.sendToImg2Img')} + + {isCanvasEnabled && ( + } + onClickCapture={handleSendToCanvas} + id="send-to-canvas" + > + {t('parameters.sendToUnifiedCanvas')} + + )} + {isBatchEnabled && ( + } + isDisabled={isInBatch} + onClickCapture={handleAddToBatch} + > + Add to Batch + + )} + } onClickCapture={handleAddToBoard}> + {imageDTO.board_id ? 'Change Board' : 'Add to Board'} + + {imageDTO.board_id && ( + } onClickCapture={handleRemoveFromBoard}> + Remove from Board + + )} + } + onClickCapture={handleDelete} + > + {t('gallery.deleteImage')} + + + ); +}; + +export default memo(SingleSelectionMenuItems); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGallery.css b/invokeai/frontend/web/src/features/gallery/components/ImageGallery.css deleted file mode 100644 index 559248dd0f..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGallery.css +++ /dev/null @@ -1,35 +0,0 @@ -.ltr-image-gallery-css-transition-enter { - transform: translateX(150%); -} - -.ltr-image-gallery-css-transition-enter-active { - transform: translateX(0); - transition: all 120ms ease-out; -} - -.ltr-image-gallery-css-transition-exit { - transform: translateX(0); -} - -.ltr-image-gallery-css-transition-exit-active { - transform: translateX(150%); - transition: all 120ms ease-out; -} - -.rtl-image-gallery-css-transition-enter { - transform: translateX(-150%); -} - -.rtl-image-gallery-css-transition-enter-active { - transform: translateX(0); - transition: all 120ms ease-out; -} - -.rtl-image-gallery-css-transition-exit { - transform: translateX(0); -} - -.rtl-image-gallery-css-transition-exit-active { - transform: translateX(-150%); - transition: all 120ms ease-out; -} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx index 19d48ea910..8badad942e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx @@ -19,7 +19,7 @@ import { } from 'features/gallery/store/gallerySlice'; import { togglePinGalleryPanel } from 'features/ui/store/uiSlice'; -import { ChangeEvent, memo, useCallback, useRef } from 'react'; +import { ChangeEvent, memo, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs'; import { FaImage, FaServer, FaWrench } from 'react-icons/fa'; @@ -29,16 +29,12 @@ import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; -import { - ASSETS_CATEGORIES, - IMAGE_CATEGORIES, - imageCategoriesChanged, - shouldAutoSwitchChanged, -} from 'features/gallery/store/gallerySlice'; +import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; import { mode } from 'theme/util/mode'; -import BoardsList from './Boards/BoardsList'; -import ImageGalleryGrid from './ImageGalleryGrid'; +import BoardsList from './Boards/BoardsList/BoardsList'; +import BatchImageGrid from './ImageGrid/BatchImageGrid'; +import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; const selector = createSelector( [stateSelector], @@ -66,6 +62,7 @@ const ImageGalleryContent = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); const resizeObserverRef = useRef(null); + const galleryGridRef = useRef(null); const { colorMode } = useColorMode(); @@ -83,6 +80,16 @@ const ImageGalleryContent = () => { }), }); + const boardTitle = useMemo(() => { + if (selectedBoardId === 'batch') { + return 'Batch'; + } + if (selectedBoard) { + return selectedBoard.board_name; + } + return 'All Images'; + }, [selectedBoard, selectedBoardId]); + const { isOpen: isBoardListOpen, onToggle } = useDisclosure(); const handleChangeGalleryImageMinimumWidth = (v: number) => { @@ -95,12 +102,10 @@ const ImageGalleryContent = () => { }; const handleClickImagesCategory = useCallback(() => { - dispatch(imageCategoriesChanged(IMAGE_CATEGORIES)); dispatch(setGalleryView('images')); }, [dispatch]); const handleClickAssetsCategory = useCallback(() => { - dispatch(imageCategoriesChanged(ASSETS_CATEGORIES)); dispatch(setGalleryView('assets')); }, [dispatch]); @@ -163,7 +168,7 @@ const ImageGalleryContent = () => { fontWeight: 600, }} > - {selectedBoard ? selectedBoard.board_name : 'All Images'} + {boardTitle} { - - + + {selectedBoardId === 'batch' ? ( + + ) : ( + + )} ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryGrid.tsx deleted file mode 100644 index c7d4e5f0f8..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryGrid.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { - Box, - Flex, - FlexProps, - Grid, - Skeleton, - Spinner, - forwardRef, -} from '@chakra-ui/react'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIButton from 'common/components/IAIButton'; -import { IMAGE_LIMIT } from 'features/gallery/store/gallerySlice'; -import { useOverlayScrollbars } from 'overlayscrollbars-react'; - -import { - PropsWithChildren, - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useTranslation } from 'react-i18next'; -import { FaImage } from 'react-icons/fa'; -import GalleryImage from './GalleryImage'; - -import { createSelector } from '@reduxjs/toolkit'; -import { RootState, stateSelector } from 'app/store/store'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { IAINoContentFallback } from 'common/components/IAIImageFallback'; -import { selectFilteredImages } from 'features/gallery/store/gallerySlice'; -import { VirtuosoGrid } from 'react-virtuoso'; -import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; -import { receivedPageOfImages } from 'services/api/thunks/image'; -import { ImageDTO } from 'services/api/types'; - -const selector = createSelector( - [stateSelector, selectFilteredImages], - (state, filteredImages) => { - const { - categories, - total: allImagesTotal, - isLoading, - isFetching, - selectedBoardId, - } = state.gallery; - - let images = filteredImages as (ImageDTO | 'loading')[]; - - if (!isLoading && isFetching) { - // loading, not not the initial load - images = images.concat(Array(IMAGE_LIMIT).fill('loading')); - } - - return { - images, - allImagesTotal, - isLoading, - isFetching, - categories, - selectedBoardId, - }; - }, - defaultSelectorOptions -); - -const ImageGalleryGrid = () => { - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - const rootRef = useRef(null); - const [scroller, setScroller] = useState(null); - const [initialize, osInstance] = useOverlayScrollbars({ - defer: true, - options: { - scrollbars: { - visibility: 'auto', - autoHide: 'leave', - autoHideDelay: 1300, - theme: 'os-theme-dark', - }, - overflow: { x: 'hidden' }, - }, - }); - - const { - images, - isLoading, - isFetching, - allImagesTotal, - categories, - selectedBoardId, - } = useAppSelector(selector); - - const { selectedBoard } = useListAllBoardsQuery(undefined, { - selectFromResult: ({ data }) => ({ - selectedBoard: data?.find((b) => b.board_id === selectedBoardId), - }), - }); - - const filteredImagesTotal = useMemo( - () => selectedBoard?.image_count ?? allImagesTotal, - [allImagesTotal, selectedBoard?.image_count] - ); - - const areMoreAvailable = useMemo(() => { - return images.length < filteredImagesTotal; - }, [images.length, filteredImagesTotal]); - - const handleLoadMoreImages = useCallback(() => { - dispatch( - receivedPageOfImages({ - categories, - board_id: selectedBoardId, - is_intermediate: false, - }) - ); - }, [categories, dispatch, selectedBoardId]); - - const handleEndReached = useMemo(() => { - if (areMoreAvailable && !isLoading) { - return handleLoadMoreImages; - } - return undefined; - }, [areMoreAvailable, handleLoadMoreImages, isLoading]); - - useEffect(() => { - const { current: root } = rootRef; - if (scroller && root) { - initialize({ - target: root, - elements: { - viewport: scroller, - }, - }); - } - return () => osInstance()?.destroy(); - }, [scroller, initialize, osInstance]); - - if (isLoading) { - return ( - - - - ); - } - - if (images.length) { - return ( - <> - - - typeof item === 'string' ? ( - - ) : ( - - ) - } - /> - - - {areMoreAvailable - ? t('gallery.loadMore') - : t('gallery.allImagesLoaded')} - - - ); - } - - return ( - - ); -}; - -type ItemContainerProps = PropsWithChildren & FlexProps; -const ItemContainer = forwardRef((props: ItemContainerProps, ref) => ( - - {props.children} - -)); - -type ListContainerProps = PropsWithChildren & FlexProps; -const ListContainer = forwardRef((props: ListContainerProps, ref) => { - const galleryImageMinimumWidth = useAppSelector( - (state: RootState) => state.gallery.galleryImageMinimumWidth - ); - - return ( - - {props.children} - - ); -}); - -export default memo(ImageGalleryGrid); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImage.tsx new file mode 100644 index 0000000000..a918682ccd --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImage.tsx @@ -0,0 +1,128 @@ +import { Box } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIDndImage from 'common/components/IAIDndImage'; +import IAIErrorLoadingImageFallback from 'common/components/IAIErrorLoadingImageFallback'; +import IAIFillSkeleton from 'common/components/IAIFillSkeleton'; +import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; +import { + imageRangeEndSelected, + imageSelected, + imageSelectionToggled, + imagesRemovedFromBatch, +} from 'features/gallery/store/gallerySlice'; +import { MouseEvent, memo, useCallback, useMemo } from 'react'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; + +const makeSelector = (image_name: string) => + createSelector( + [stateSelector], + (state) => ({ + selectionCount: state.gallery.selection.length, + selection: state.gallery.selection, + isSelected: state.gallery.selection.includes(image_name), + }), + defaultSelectorOptions + ); + +type BatchImageProps = { + imageName: string; +}; + +const BatchImage = (props: BatchImageProps) => { + const dispatch = useAppDispatch(); + const { imageName } = props; + const { + currentData: imageDTO, + isLoading, + isError, + isSuccess, + } = useGetImageDTOQuery(imageName); + const selector = useMemo(() => makeSelector(imageName), [imageName]); + + const { isSelected, selectionCount, selection } = useAppSelector(selector); + + const handleClickRemove = useCallback(() => { + dispatch(imagesRemovedFromBatch([imageName])); + }, [dispatch, imageName]); + + const handleClick = useCallback( + (e: MouseEvent) => { + if (e.shiftKey) { + dispatch(imageRangeEndSelected(imageName)); + } else if (e.ctrlKey || e.metaKey) { + dispatch(imageSelectionToggled(imageName)); + } else { + dispatch(imageSelected(imageName)); + } + }, + [dispatch, imageName] + ); + + const draggableData = useMemo(() => { + if (selectionCount > 1) { + return { + id: 'batch', + payloadType: 'IMAGE_NAMES', + payload: { image_names: selection }, + }; + } + + if (imageDTO) { + return { + id: 'batch', + payloadType: 'IMAGE_DTO', + payload: { imageDTO }, + }; + } + }, [imageDTO, selection, selectionCount]); + + if (isLoading) { + return ; + } + + if (isError || !imageDTO) { + return ; + } + + return ( + + + {(ref) => ( + + + + )} + + + ); +}; + +export default memo(BatchImage); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImageGrid.tsx new file mode 100644 index 0000000000..feaa47403d --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImageGrid.tsx @@ -0,0 +1,87 @@ +import { Box } from '@chakra-ui/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; + +import { memo, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaImage } from 'react-icons/fa'; + +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { VirtuosoGrid } from 'react-virtuoso'; +import BatchImage from './BatchImage'; +import ItemContainer from './ImageGridItemContainer'; +import ListContainer from './ImageGridListContainer'; + +const selector = createSelector( + [stateSelector], + (state) => { + return { + imageNames: state.gallery.batchImageNames, + }; + }, + defaultSelectorOptions +); + +const BatchImageGrid = () => { + const { t } = useTranslation(); + const rootRef = useRef(null); + const [scroller, setScroller] = useState(null); + const [initialize, osInstance] = useOverlayScrollbars({ + defer: true, + options: { + scrollbars: { + visibility: 'auto', + autoHide: 'leave', + autoHideDelay: 1300, + theme: 'os-theme-dark', + }, + overflow: { x: 'hidden' }, + }, + }); + + const { imageNames } = useAppSelector(selector); + + useEffect(() => { + const { current: root } = rootRef; + if (scroller && root) { + initialize({ + target: root, + elements: { + viewport: scroller, + }, + }); + } + return () => osInstance()?.destroy(); + }, [scroller, initialize, osInstance]); + + if (imageNames.length) { + return ( + + ( + + )} + /> + + ); + } + + return ( + + ); +}; + +export default memo(BatchImageGrid); diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx similarity index 64% rename from invokeai/frontend/web/src/features/gallery/components/GalleryImage.tsx rename to invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 468db558b3..eb7428bb69 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -1,69 +1,57 @@ -import { Box } from '@chakra-ui/react'; +import { Box, Spinner } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIDndImage from 'common/components/IAIDndImage'; -import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; -import { MouseEvent, memo, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { FaTrash } from 'react-icons/fa'; -import { ImageDTO } from 'services/api/types'; +import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { imageRangeEndSelected, imageSelected, imageSelectionToggled, -} from '../store/gallerySlice'; -import ImageContextMenu from './ImageContextMenu'; +} from 'features/gallery/store/gallerySlice'; +import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; +import { MouseEvent, memo, useCallback, useMemo } from 'react'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; export const makeSelector = (image_name: string) => createSelector( [stateSelector], - ({ gallery }) => { - const isSelected = gallery.selection.includes(image_name); - const selectionCount = gallery.selection.length; - - return { - isSelected, - selectionCount, - }; - }, + ({ gallery }) => ({ + isSelected: gallery.selection.includes(image_name), + selectionCount: gallery.selection.length, + selection: gallery.selection, + }), defaultSelectorOptions ); interface HoverableImageProps { - imageDTO: ImageDTO; + imageName: string; } -/** - * Gallery image component with delete/use all/use seed buttons on hover. - */ const GalleryImage = (props: HoverableImageProps) => { - const { imageDTO } = props; - const { image_url, thumbnail_url, image_name } = imageDTO; - - const localSelector = useMemo(() => makeSelector(image_name), [image_name]); - - const { isSelected, selectionCount } = useAppSelector(localSelector); - const dispatch = useAppDispatch(); + const { imageName } = props; + const { currentData: imageDTO } = useGetImageDTOQuery(imageName); + const localSelector = useMemo(() => makeSelector(imageName), [imageName]); - const { t } = useTranslation(); + const { isSelected, selectionCount, selection } = + useAppSelector(localSelector); const handleClick = useCallback( (e: MouseEvent) => { - // multiselect disabled for now + // disable multiselect for now // if (e.shiftKey) { - // dispatch(imageRangeEndSelected(props.imageDTO.image_name)); + // dispatch(imageRangeEndSelected(imageName)); // } else if (e.ctrlKey || e.metaKey) { - // dispatch(imageSelectionToggled(props.imageDTO.image_name)); + // dispatch(imageSelectionToggled(imageName)); // } else { - // dispatch(imageSelected(props.imageDTO.image_name)); + // dispatch(imageSelected(imageName)); // } - dispatch(imageSelected(props.imageDTO.image_name)); + dispatch(imageSelected(imageName)); }, - [dispatch, props.imageDTO.image_name] + [dispatch, imageName] ); const handleDelete = useCallback( @@ -81,7 +69,8 @@ const GalleryImage = (props: HoverableImageProps) => { if (selectionCount > 1) { return { id: 'gallery-image', - payloadType: 'GALLERY_SELECTION', + payloadType: 'IMAGE_NAMES', + payload: { image_names: selection }, }; } @@ -92,15 +81,19 @@ const GalleryImage = (props: HoverableImageProps) => { payload: { imageDTO }, }; } - }, [imageDTO, selectionCount]); + }, [imageDTO, selection, selectionCount]); + + if (!imageDTO) { + return ; + } return ( - + {(ref) => ( { isSelected={isSelected} minSize={0} onClickReset={handleDelete} - resetIcon={} - resetTooltip="Delete image" imageSx={{ w: 'full', h: 'full' }} - // withResetIcon // removed bc it's too easy to accidentally delete images isDropDisabled={true} isUploadDisabled={true} thumbnail={true} + // resetIcon={} + // resetTooltip="Delete image" + // withResetIcon // removed bc it's too easy to accidentally delete images /> )} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx new file mode 100644 index 0000000000..858eeedaa3 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx @@ -0,0 +1,204 @@ +import { Box } from '@chakra-ui/react'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import IAIButton from 'common/components/IAIButton'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; + +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaImage } from 'react-icons/fa'; +import GalleryImage from './GalleryImage'; + +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { + ASSETS_CATEGORIES, + IMAGE_CATEGORIES, + IMAGE_LIMIT, + selectImagesAll, +} from 'features/gallery//store/gallerySlice'; +import { selectFilteredImages } from 'features/gallery/store/gallerySelectors'; +import { VirtuosoGrid } from 'react-virtuoso'; +import { receivedPageOfImages } from 'services/api/thunks/image'; +import ImageGridItemContainer from './ImageGridItemContainer'; +import ImageGridListContainer from './ImageGridListContainer'; +import { useListBoardImagesQuery } from '../../../../services/api/endpoints/boardImages'; + +const selector = createSelector( + [stateSelector, selectFilteredImages], + (state, filteredImages) => { + const { + galleryImageMinimumWidth, + selectedBoardId, + galleryView, + total, + isLoading, + } = state.gallery; + + return { + imageNames: filteredImages.map((i) => i.image_name), + total, + selectedBoardId, + galleryView, + galleryImageMinimumWidth, + isLoading, + }; + }, + defaultSelectorOptions +); + +const GalleryImageGrid = () => { + const { t } = useTranslation(); + const rootRef = useRef(null); + const emptyGalleryRef = useRef(null); + const [scroller, setScroller] = useState(null); + const [initialize, osInstance] = useOverlayScrollbars({ + defer: true, + options: { + scrollbars: { + visibility: 'auto', + autoHide: 'leave', + autoHideDelay: 1300, + theme: 'os-theme-dark', + }, + overflow: { x: 'hidden' }, + }, + }); + + const [didInitialFetch, setDidInitialFetch] = useState(false); + + const dispatch = useAppDispatch(); + + const { + galleryImageMinimumWidth, + imageNames: imageNamesAll, //all images names loaded on main tab, + total: totalAll, + selectedBoardId, + galleryView, + isLoading: isLoadingAll, + } = useAppSelector(selector); + + const { data: imagesForBoard, isLoading: isLoadingImagesForBoard } = + useListBoardImagesQuery( + { board_id: selectedBoardId }, + { skip: selectedBoardId === 'all' } + ); + + const imageNames = useMemo(() => { + if (selectedBoardId === 'all') { + return imageNamesAll; // already sorted by images/uploads in gallery selector + } else { + const categories = + galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; + const imageList = (imagesForBoard?.items || []).filter((img) => + categories.includes(img.image_category) + ); + return imageList.map((img) => img.image_name); + } + }, [selectedBoardId, galleryView, imagesForBoard, imageNamesAll]); + + const areMoreAvailable = useMemo(() => { + return selectedBoardId === 'all' ? totalAll > imageNamesAll.length : false; + }, [selectedBoardId, imageNamesAll.length, totalAll]); + + const isLoading = useMemo(() => { + return selectedBoardId === 'all' ? isLoadingAll : isLoadingImagesForBoard; + }, [selectedBoardId, isLoadingAll, isLoadingImagesForBoard]); + + const handleLoadMoreImages = useCallback(() => { + dispatch( + receivedPageOfImages({ + categories: + galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES, + is_intermediate: false, + offset: imageNames.length, + limit: IMAGE_LIMIT, + }) + ); + }, [dispatch, imageNames.length, galleryView]); + + const handleEndReached = useMemo(() => { + if (areMoreAvailable) { + return handleLoadMoreImages; + } + return undefined; + }, [areMoreAvailable, handleLoadMoreImages]); + + // useEffect(() => { + // if (!didInitialFetch) { + // return; + // } + // // rough, conservative calculation of how many images fit in the gallery + // // TODO: this gets an incorrect value on first load... + // const galleryHeight = rootRef.current?.clientHeight ?? 0; + // const galleryWidth = rootRef.current?.clientHeight ?? 0; + + // const rows = galleryHeight / galleryImageMinimumWidth; + // const columns = galleryWidth / galleryImageMinimumWidth; + + // const imagesToLoad = Math.ceil(rows * columns); + + // setDidInitialFetch(true); + + // // load up that many images + // dispatch( + // receivedPageOfImages({ + // offset: 0, + // limit: 10, + // }) + // ); + // }, [ + // didInitialFetch, + // dispatch, + // galleryImageMinimumWidth, + // galleryView, + // selectedBoardId, + // ]); + + if (!isLoading && imageNames.length === 0) { + return ( + + + + ); + } + console.log({ selectedBoardId }); + + if (status !== 'rejected') { + return ( + <> + + ( + + )} + /> + + + {areMoreAvailable + ? t('gallery.loadMore') + : t('gallery.allImagesLoaded')} + + + ); + } +}; + +export default memo(GalleryImageGrid); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridItemContainer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridItemContainer.tsx new file mode 100644 index 0000000000..a09455ef2c --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridItemContainer.tsx @@ -0,0 +1,11 @@ +import { Box, FlexProps, forwardRef } from '@chakra-ui/react'; +import { PropsWithChildren } from 'react'; + +type ItemContainerProps = PropsWithChildren & FlexProps; +const ItemContainer = forwardRef((props: ItemContainerProps, ref) => ( + + {props.children} + +)); + +export default ItemContainer; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridListContainer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridListContainer.tsx new file mode 100644 index 0000000000..fbbca2b2cf --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridListContainer.tsx @@ -0,0 +1,26 @@ +import { FlexProps, Grid, forwardRef } from '@chakra-ui/react'; +import { RootState } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import { PropsWithChildren } from 'react'; + +type ListContainerProps = PropsWithChildren & FlexProps; +const ListContainer = forwardRef((props: ListContainerProps, ref) => { + const galleryImageMinimumWidth = useAppSelector( + (state: RootState) => state.gallery.galleryImageMinimumWidth + ); + + return ( + + {props.children} + + ); +}); + +export default ListContainer; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx deleted file mode 100644 index e150cea883..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import { ExternalLinkIcon } from '@chakra-ui/icons'; -import { - Box, - Center, - Flex, - IconButton, - Link, - Text, - Tooltip, -} from '@chakra-ui/react'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; -import { setShouldShowImageDetails } from 'features/ui/store/uiSlice'; -import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; -import { memo } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; -import { useTranslation } from 'react-i18next'; -import { FaCopy } from 'react-icons/fa'; -import { IoArrowUndoCircleOutline } from 'react-icons/io5'; -import { ImageDTO } from 'services/api/types'; - -type MetadataItemProps = { - isLink?: boolean; - label: string; - onClick?: () => void; - value: number | string | boolean; - labelPosition?: string; - withCopy?: boolean; -}; - -/** - * Component to display an individual metadata item or parameter. - */ -const MetadataItem = ({ - label, - value, - onClick, - isLink, - labelPosition, - withCopy = false, -}: MetadataItemProps) => { - const { t } = useTranslation(); - - if (!value) { - return null; - } - - return ( - - {onClick && ( - - } - size="xs" - variant="ghost" - fontSize={20} - onClick={onClick} - /> - - )} - {withCopy && ( - - } - size="xs" - variant="ghost" - fontSize={14} - onClick={() => navigator.clipboard.writeText(value.toString())} - /> - - )} - - - {label}: - - {isLink ? ( - - {value.toString()} - - ) : ( - - {value.toString()} - - )} - - - ); -}; - -type ImageMetadataViewerProps = { - image: ImageDTO; -}; - -/** - * Image metadata viewer overlays currently selected image and provides - * access to any of its metadata for use in processing. - */ -const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => { - const dispatch = useAppDispatch(); - const { - recallBothPrompts, - recallPositivePrompt, - recallNegativePrompt, - recallSeed, - recallInitialImage, - recallCfgScale, - recallModel, - recallScheduler, - recallSteps, - recallWidth, - recallHeight, - recallStrength, - recallAllParameters, - } = useRecallParameters(); - - useHotkeys('esc', () => { - dispatch(setShouldShowImageDetails(false)); - }); - - const sessionId = image?.session_id; - - const metadata = image?.metadata; - - const { t } = useTranslation(); - - const metadataJSON = JSON.stringify(image, null, 2); - - return ( - - - File: - - {image.image_name} - - - - {metadata && Object.keys(metadata).length > 0 ? ( - <> - {metadata.type && ( - - )} - {sessionId && } - {metadata.positive_conditioning && ( - - recallPositivePrompt(metadata.positive_conditioning) - } - /> - )} - {metadata.negative_conditioning && ( - - recallNegativePrompt(metadata.negative_conditioning) - } - /> - )} - {metadata.seed !== undefined && ( - recallSeed(metadata.seed)} - /> - )} - {metadata.model !== undefined && ( - recallModel(metadata.model)} - /> - )} - {metadata.width && ( - recallWidth(metadata.width)} - /> - )} - {metadata.height && ( - recallHeight(metadata.height)} - /> - )} - {/* {metadata.threshold !== undefined && ( - dispatch(setThreshold(Number(metadata.threshold)))} - /> - )} - {metadata.perlin !== undefined && ( - dispatch(setPerlin(Number(metadata.perlin)))} - /> - )} */} - {metadata.scheduler && ( - recallScheduler(metadata.scheduler)} - /> - )} - {metadata.steps && ( - recallSteps(metadata.steps)} - /> - )} - {metadata.cfg_scale !== undefined && ( - recallCfgScale(metadata.cfg_scale)} - /> - )} - {/* {metadata.variations && metadata.variations.length > 0 && ( - - dispatch( - setSeedWeights(seedWeightsToString(metadata.variations)) - ) - } - /> - )} - {metadata.seamless && ( - dispatch(setSeamless(metadata.seamless))} - /> - )} - {metadata.hires_fix && ( - dispatch(setHiresFix(metadata.hires_fix))} - /> - )} */} - - {/* {init_image_path && ( - dispatch(setInitialImage(init_image_path))} - /> - )} */} - {metadata.strength && ( - recallStrength(metadata.strength)} - /> - )} - {/* {metadata.fit && ( - dispatch(setShouldFitToWidthHeight(metadata.fit))} - /> - )} */} - - ) : ( -
- - No metadata available - -
- )} - - - - } - size="xs" - variant="ghost" - fontSize={14} - onClick={() => navigator.clipboard.writeText(metadataJSON)} - /> - - Metadata JSON: - - - -
{metadataJSON}
-
-
-
-
- ); -}; - -export default memo(ImageMetadataViewer); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx new file mode 100644 index 0000000000..89cd0a5005 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx @@ -0,0 +1,212 @@ +import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; +import { useCallback } from 'react'; +import { UnsafeImageMetadata } from 'services/api/endpoints/images'; +import ImageMetadataItem from './ImageMetadataItem'; + +type Props = { + metadata?: UnsafeImageMetadata['metadata']; +}; + +const ImageMetadataActions = (props: Props) => { + const { metadata } = props; + + const { + recallBothPrompts, + recallPositivePrompt, + recallNegativePrompt, + recallSeed, + recallInitialImage, + recallCfgScale, + recallModel, + recallScheduler, + recallSteps, + recallWidth, + recallHeight, + recallStrength, + recallAllParameters, + } = useRecallParameters(); + + const handleRecallPositivePrompt = useCallback(() => { + recallPositivePrompt(metadata?.positive_prompt); + }, [metadata?.positive_prompt, recallPositivePrompt]); + + const handleRecallNegativePrompt = useCallback(() => { + recallNegativePrompt(metadata?.negative_prompt); + }, [metadata?.negative_prompt, recallNegativePrompt]); + + const handleRecallSeed = useCallback(() => { + recallSeed(metadata?.seed); + }, [metadata?.seed, recallSeed]); + + const handleRecallModel = useCallback(() => { + recallModel(metadata?.model); + }, [metadata?.model, recallModel]); + + const handleRecallWidth = useCallback(() => { + recallWidth(metadata?.width); + }, [metadata?.width, recallWidth]); + + const handleRecallHeight = useCallback(() => { + recallHeight(metadata?.height); + }, [metadata?.height, recallHeight]); + + const handleRecallScheduler = useCallback(() => { + recallScheduler(metadata?.scheduler); + }, [metadata?.scheduler, recallScheduler]); + + const handleRecallSteps = useCallback(() => { + recallSteps(metadata?.steps); + }, [metadata?.steps, recallSteps]); + + const handleRecallCfgScale = useCallback(() => { + recallCfgScale(metadata?.cfg_scale); + }, [metadata?.cfg_scale, recallCfgScale]); + + const handleRecallStrength = useCallback(() => { + recallStrength(metadata?.strength); + }, [metadata?.strength, recallStrength]); + + if (!metadata || Object.keys(metadata).length === 0) { + return null; + } + + return ( + <> + {metadata.generation_mode && ( + + )} + {metadata.positive_prompt && ( + + )} + {metadata.negative_prompt && ( + + )} + {metadata.seed !== undefined && ( + + )} + {metadata.model !== undefined && ( + + )} + {metadata.width && ( + + )} + {metadata.height && ( + + )} + {/* {metadata.threshold !== undefined && ( + dispatch(setThreshold(Number(metadata.threshold)))} + /> + )} + {metadata.perlin !== undefined && ( + dispatch(setPerlin(Number(metadata.perlin)))} + /> + )} */} + {metadata.scheduler && ( + + )} + {metadata.steps && ( + + )} + {metadata.cfg_scale !== undefined && ( + + )} + {/* {metadata.variations && metadata.variations.length > 0 && ( + + dispatch( + setSeedWeights(seedWeightsToString(metadata.variations)) + ) + } + /> + )} + {metadata.seamless && ( + dispatch(setSeamless(metadata.seamless))} + /> + )} + {metadata.hires_fix && ( + dispatch(setHiresFix(metadata.hires_fix))} + /> + )} */} + + {/* {init_image_path && ( + dispatch(setInitialImage(init_image_path))} + /> + )} */} + {metadata.strength && ( + + )} + {/* {metadata.fit && ( + dispatch(setShouldFitToWidthHeight(metadata.fit))} + /> + )} */} + + ); +}; + +export default ImageMetadataActions; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataItem.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataItem.tsx new file mode 100644 index 0000000000..d72561351f --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataItem.tsx @@ -0,0 +1,77 @@ +import { ExternalLinkIcon } from '@chakra-ui/icons'; +import { Flex, IconButton, Link, Text, Tooltip } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import { FaCopy } from 'react-icons/fa'; +import { IoArrowUndoCircleOutline } from 'react-icons/io5'; + +type MetadataItemProps = { + isLink?: boolean; + label: string; + onClick?: () => void; + value: number | string | boolean; + labelPosition?: string; + withCopy?: boolean; +}; + +/** + * Component to display an individual metadata item or parameter. + */ +const ImageMetadataItem = ({ + label, + value, + onClick, + isLink, + labelPosition, + withCopy = false, +}: MetadataItemProps) => { + const { t } = useTranslation(); + + if (!value) { + return null; + } + + return ( + + {onClick && ( + + } + size="xs" + variant="ghost" + fontSize={20} + onClick={onClick} + /> + + )} + {withCopy && ( + + } + size="xs" + variant="ghost" + fontSize={14} + onClick={() => navigator.clipboard.writeText(value.toString())} + /> + + )} + + + {label}: + + {isLink ? ( + + {value.toString()} + + ) : ( + + {value.toString()} + + )} + + + ); +}; + +export default ImageMetadataItem; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataJSON.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataJSON.tsx new file mode 100644 index 0000000000..590d40438b --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataJSON.tsx @@ -0,0 +1,70 @@ +import { Box, Flex, IconButton, Tooltip } from '@chakra-ui/react'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import { useMemo } from 'react'; +import { FaCopy } from 'react-icons/fa'; + +type Props = { + copyTooltip: string; + jsonObject: object; +}; + +const ImageMetadataJSON = (props: Props) => { + const { copyTooltip, jsonObject } = props; + const jsonString = useMemo( + () => JSON.stringify(jsonObject, null, 2), + [jsonObject] + ); + + return ( + + + +
{jsonString}
+
+
+ + + } + variant="ghost" + onClick={() => navigator.clipboard.writeText(jsonString)} + /> + + +
+ ); +}; + +export default ImageMetadataJSON; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataViewer.tsx new file mode 100644 index 0000000000..e1f2a9e46a --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataViewer.tsx @@ -0,0 +1,138 @@ +import { ExternalLinkIcon } from '@chakra-ui/icons'; +import { + Flex, + Link, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, +} from '@chakra-ui/react'; +import { skipToken } from '@reduxjs/toolkit/dist/query'; +import { memo, useMemo } from 'react'; +import { useGetImageMetadataQuery } from 'services/api/endpoints/images'; +import { ImageDTO } from 'services/api/types'; +import { useDebounce } from 'use-debounce'; +import ImageMetadataActions from './ImageMetadataActions'; +import ImageMetadataJSON from './ImageMetadataJSON'; + +type ImageMetadataViewerProps = { + image: ImageDTO; +}; + +const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => { + // TODO: fix hotkeys + // const dispatch = useAppDispatch(); + // useHotkeys('esc', () => { + // dispatch(setShouldShowImageDetails(false)); + // }); + + const [debouncedMetadataQueryArg, debounceState] = useDebounce( + image.image_name, + 500 + ); + + const { currentData } = useGetImageMetadataQuery( + debounceState.isPending() + ? skipToken + : debouncedMetadataQueryArg ?? skipToken + ); + const metadata = currentData?.metadata; + const graph = currentData?.graph; + + const tabData = useMemo(() => { + const _tabData: { label: string; data: object; copyTooltip: string }[] = []; + + if (metadata) { + _tabData.push({ + label: 'Core Metadata', + data: metadata, + copyTooltip: 'Copy Core Metadata JSON', + }); + } + + if (image) { + _tabData.push({ + label: 'Image Details', + data: image, + copyTooltip: 'Copy Image Details JSON', + }); + } + + if (graph) { + _tabData.push({ + label: 'Graph', + data: graph, + copyTooltip: 'Copy Graph JSON', + }); + } + return _tabData; + }, [metadata, graph, image]); + + return ( + + + File: + + {image.image_name} + + + + + + + + + {tabData.map((tab) => ( + + + {tab.label} + + + ))} + + + + {tabData.map((tab) => ( + + + + ))} + + + + ); +}; + +export default memo(ImageMetadataViewer); diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx index 3fcdd54cc9..06e2a22cbd 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx @@ -1,171 +1,44 @@ -import { ChakraProps, Flex, Grid, IconButton, Spinner } from '@chakra-ui/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { stateSelector } from 'app/store/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { - imageSelected, - selectFilteredImages, - selectImagesById, -} from 'features/gallery/store/gallerySlice'; -import { clamp, isEqual } from 'lodash-es'; -import { memo, useCallback, useState } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; +import { Box, ChakraProps, Flex, IconButton, Spinner } from '@chakra-ui/react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { FaAngleDoubleRight, FaAngleLeft, FaAngleRight } from 'react-icons/fa'; -import { receivedPageOfImages } from 'services/api/thunks/image'; +import { useNextPrevImage } from '../hooks/useNextPrevImage'; -const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = { - height: '100%', - width: '15%', - alignItems: 'center', - pointerEvents: 'auto', -}; const nextPrevButtonStyles: ChakraProps['sx'] = { color: 'base.100', + pointerEvents: 'auto', }; -export const nextPrevImageButtonsSelector = createSelector( - [stateSelector, selectFilteredImages], - (state, filteredImages) => { - const { total, isFetching } = state.gallery; - const lastSelectedImage = - state.gallery.selection[state.gallery.selection.length - 1]; - - if (!lastSelectedImage || filteredImages.length === 0) { - return { - isOnFirstImage: true, - isOnLastImage: true, - }; - } - - const currentImageIndex = filteredImages.findIndex( - (i) => i.image_name === lastSelectedImage - ); - const nextImageIndex = clamp( - currentImageIndex + 1, - 0, - filteredImages.length - 1 - ); - - const prevImageIndex = clamp( - currentImageIndex - 1, - 0, - filteredImages.length - 1 - ); - - const nextImageId = filteredImages[nextImageIndex].image_name; - const prevImageId = filteredImages[prevImageIndex].image_name; - - const nextImage = selectImagesById(state, nextImageId); - const prevImage = selectImagesById(state, prevImageId); - - const imagesLength = filteredImages.length; - - return { - isOnFirstImage: currentImageIndex === 0, - isOnLastImage: - !isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1, - areMoreImagesAvailable: total > imagesLength, - isFetching, - nextImage, - prevImage, - nextImageId, - prevImageId, - }; - }, - { - memoizeOptions: { - resultEqualityCheck: isEqual, - }, - } -); - const NextPrevImageButtons = () => { - const dispatch = useAppDispatch(); const { t } = useTranslation(); const { + handlePrevImage, + handleNextImage, isOnFirstImage, isOnLastImage, - nextImageId, - prevImageId, + handleLoadMoreImages, areMoreImagesAvailable, isFetching, - } = useAppSelector(nextPrevImageButtonsSelector); - - const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = - useState(false); - - const handleCurrentImagePreviewMouseOver = useCallback(() => { - setShouldShowNextPrevButtons(true); - }, []); - - const handleCurrentImagePreviewMouseOut = useCallback(() => { - setShouldShowNextPrevButtons(false); - }, []); - - const handlePrevImage = useCallback(() => { - prevImageId && dispatch(imageSelected(prevImageId)); - }, [dispatch, prevImageId]); - - const handleNextImage = useCallback(() => { - nextImageId && dispatch(imageSelected(nextImageId)); - }, [dispatch, nextImageId]); - - const handleLoadMoreImages = useCallback(() => { - dispatch( - receivedPageOfImages({ - is_intermediate: false, - }) - ); - }, [dispatch]); - - useHotkeys( - 'left', - () => { - handlePrevImage(); - }, - [prevImageId] - ); - - useHotkeys( - 'right', - () => { - if (isOnLastImage && areMoreImagesAvailable && !isFetching) { - handleLoadMoreImages(); - return; - } - if (!isOnLastImage) { - handleNextImage(); - } - }, - [ - nextImageId, - isOnLastImage, - areMoreImagesAvailable, - handleLoadMoreImages, - isFetching, - ] - ); + } = useNextPrevImage(); return ( - - - {shouldShowNextPrevButtons && !isOnFirstImage && ( + {!isOnFirstImage && ( } @@ -175,16 +48,16 @@ const NextPrevImageButtons = () => { sx={nextPrevButtonStyles} /> )} - - + - {shouldShowNextPrevButtons && !isOnLastImage && ( + {!isOnLastImage && ( } @@ -194,36 +67,30 @@ const NextPrevImageButtons = () => { sx={nextPrevButtonStyles} /> )} - {shouldShowNextPrevButtons && - isOnLastImage && - areMoreImagesAvailable && - !isFetching && ( - } - variant="unstyled" - onClick={handleLoadMoreImages} - boxSize={16} - sx={nextPrevButtonStyles} - /> - )} - {shouldShowNextPrevButtons && - isOnLastImage && - areMoreImagesAvailable && - isFetching && ( - - - - )} - - + {isOnLastImage && areMoreImagesAvailable && !isFetching && ( + } + variant="unstyled" + onClick={handleLoadMoreImages} + boxSize={16} + sx={nextPrevButtonStyles} + /> + )} + {isOnLastImage && areMoreImagesAvailable && isFetching && ( + + + + )} +
+ ); }; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts b/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts new file mode 100644 index 0000000000..44473bea83 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts @@ -0,0 +1,108 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { + imageSelected, + selectImagesById, +} from 'features/gallery/store/gallerySlice'; +import { clamp, isEqual } from 'lodash-es'; +import { useCallback } from 'react'; +import { receivedPageOfImages } from 'services/api/thunks/image'; +import { selectFilteredImages } from '../store/gallerySelectors'; + +export const nextPrevImageButtonsSelector = createSelector( + [stateSelector, selectFilteredImages], + (state, filteredImages) => { + const { total, isFetching } = state.gallery; + const lastSelectedImage = + state.gallery.selection[state.gallery.selection.length - 1]; + + if (!lastSelectedImage || filteredImages.length === 0) { + return { + isOnFirstImage: true, + isOnLastImage: true, + }; + } + + const currentImageIndex = filteredImages.findIndex( + (i) => i.image_name === lastSelectedImage + ); + const nextImageIndex = clamp( + currentImageIndex + 1, + 0, + filteredImages.length - 1 + ); + + const prevImageIndex = clamp( + currentImageIndex - 1, + 0, + filteredImages.length - 1 + ); + + const nextImageId = filteredImages[nextImageIndex].image_name; + const prevImageId = filteredImages[prevImageIndex].image_name; + + const nextImage = selectImagesById(state, nextImageId); + const prevImage = selectImagesById(state, prevImageId); + + const imagesLength = filteredImages.length; + + return { + isOnFirstImage: currentImageIndex === 0, + isOnLastImage: + !isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1, + areMoreImagesAvailable: total > imagesLength, + isFetching, + nextImage, + prevImage, + nextImageId, + prevImageId, + }; + }, + { + memoizeOptions: { + resultEqualityCheck: isEqual, + }, + } +); + +export const useNextPrevImage = () => { + const dispatch = useAppDispatch(); + + const { + isOnFirstImage, + isOnLastImage, + nextImageId, + prevImageId, + areMoreImagesAvailable, + isFetching, + } = useAppSelector(nextPrevImageButtonsSelector); + + const handlePrevImage = useCallback(() => { + prevImageId && dispatch(imageSelected(prevImageId)); + }, [dispatch, prevImageId]); + + const handleNextImage = useCallback(() => { + nextImageId && dispatch(imageSelected(nextImageId)); + }, [dispatch, nextImageId]); + + const handleLoadMoreImages = useCallback(() => { + dispatch( + receivedPageOfImages({ + is_intermediate: false, + }) + ); + }, [dispatch]); + + return { + handlePrevImage, + handleNextImage, + isOnFirstImage, + isOnLastImage, + nextImageId, + prevImageId, + areMoreImagesAvailable, + handleLoadMoreImages, + isFetching, + }; +}; diff --git a/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts b/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts index 5b4a439e38..acef7d6fc1 100644 --- a/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts +++ b/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts @@ -11,7 +11,6 @@ export const galleryPersistDenylist: (keyof typeof initialGalleryState)[] = [ 'limit', 'offset', 'selectedBoardId', - 'categories', 'galleryView', 'total', 'isInitialized', diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index 3c7e366bf7..045fb68737 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -1,3 +1,136 @@ +import { createSelector } from '@reduxjs/toolkit'; import { RootState } from 'app/store/store'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { clamp, keyBy } from 'lodash-es'; +import { ImageDTO } from 'services/api/types'; +import { + ASSETS_CATEGORIES, + BoardId, + IMAGE_CATEGORIES, + imagesAdapter, + initialGalleryState, +} from './gallerySlice'; export const gallerySelector = (state: RootState) => state.gallery; + +const isInSelectedBoard = ( + selectedBoardId: BoardId, + imageDTO: ImageDTO, + batchImageNames: string[] +) => { + if (selectedBoardId === 'all') { + // all images are in the "All Images" board + return true; + } + + if (selectedBoardId === 'none' && !imageDTO.board_id) { + // Only images without a board are in the "No Board" board + return true; + } + + if ( + selectedBoardId === 'batch' && + batchImageNames.includes(imageDTO.image_name) + ) { + // Only images with is_batch are in the "Batch" board + return true; + } + + return selectedBoardId === imageDTO.board_id; +}; + +export const selectFilteredImagesLocal = createSelector( + [(state: typeof initialGalleryState) => state], + (galleryState) => { + const allImages = imagesAdapter.getSelectors().selectAll(galleryState); + const { galleryView, selectedBoardId } = galleryState; + + const categories = + galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; + + const filteredImages = allImages.filter((i) => { + const isInCategory = categories.includes(i.image_category); + + const isInBoard = isInSelectedBoard( + selectedBoardId, + i, + galleryState.batchImageNames + ); + return isInCategory && isInBoard; + }); + + 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 +); + +export const selectSelectedImages = createSelector( + (state: RootState) => state, + (state) => + imagesAdapter + .getSelectors() + .selectAll(state.gallery) + .filter((i) => state.gallery.selection.includes(i.image_name)), + defaultSelectorOptions +); + +export const selectNextImageToSelectLocal = createSelector( + [ + (state: typeof initialGalleryState) => state, + (state: typeof initialGalleryState, image_name: string) => image_name, + ], + (state, image_name) => { + const filteredImages = selectFilteredImagesLocal(state); + const ids = filteredImages.map((i) => i.image_name); + + const deletedImageIndex = ids.findIndex( + (result) => result.toString() === image_name + ); + + const filteredIds = ids.filter((id) => id.toString() !== image_name); + + const newSelectedImageIndex = clamp( + deletedImageIndex, + 0, + filteredIds.length - 1 + ); + + const newSelectedImageId = filteredIds[newSelectedImageIndex]; + + return newSelectedImageId; + } +); + +export const selectNextImageToSelect = createSelector( + [ + (state: RootState) => state, + (state: RootState, image_name: string) => image_name, + ], + (state, image_name) => { + return selectNextImageToSelectLocal(state.gallery, image_name); + }, + defaultSelectorOptions +); diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 63fd9625a0..fa1f6a6f1a 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -1,19 +1,15 @@ import type { PayloadAction, Update } from '@reduxjs/toolkit'; -import { - createEntityAdapter, - createSelector, - createSlice, -} from '@reduxjs/toolkit'; +import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; import { RootState } from 'app/store/store'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { dateComparator } from 'common/util/dateComparator'; -import { keyBy, uniq } from 'lodash-es'; +import { 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'; +import { selectFilteredImagesLocal } from './gallerySelectors'; export const imagesAdapter = createEntityAdapter({ selectId: (image) => image.image_name, @@ -27,23 +23,30 @@ export const ASSETS_CATEGORIES: ImageCategory[] = [ 'user', 'other', ]; - export const INITIAL_IMAGE_LIMIT = 100; export const IMAGE_LIMIT = 20; +export type GalleryView = 'images' | 'assets'; +export type BoardId = + | 'all' + | 'none' + | 'batch' + | (string & Record); + type AdditionaGalleryState = { offset: number; limit: number; total: number; isLoading: boolean; isFetching: boolean; - categories: ImageCategory[]; - selectedBoardId?: string; selection: string[]; shouldAutoSwitch: boolean; galleryImageMinimumWidth: number; - galleryView: 'images' | 'assets'; + galleryView: GalleryView; + selectedBoardId: BoardId; isInitialized: boolean; + batchImageNames: string[]; + isBatchEnabled: boolean; }; export const initialGalleryState = @@ -53,12 +56,14 @@ export const initialGalleryState = total: 0, isLoading: true, isFetching: true, - categories: IMAGE_CATEGORIES, selection: [], shouldAutoSwitch: true, galleryImageMinimumWidth: 96, galleryView: 'images', + selectedBoardId: 'all', isInitialized: false, + batchImageNames: [], + isBatchEnabled: false, }); export const gallerySlice = createSlice({ @@ -73,7 +78,7 @@ export const gallerySlice = createSlice({ ) { state.selection = [action.payload.image_name]; state.galleryView = 'images'; - state.categories = IMAGE_CATEGORIES; + state.selectedBoardId = 'all'; } }, imageUpdatedOne: (state, action: PayloadAction>) => { @@ -81,12 +86,15 @@ export const gallerySlice = createSlice({ }, imageRemoved: (state, action: PayloadAction) => { imagesAdapter.removeOne(state, action.payload); + state.batchImageNames = state.batchImageNames.filter( + (name) => name !== action.payload + ); }, imagesRemoved: (state, action: PayloadAction) => { imagesAdapter.removeMany(state, action.payload); - }, - imageCategoriesChanged: (state, action: PayloadAction) => { - state.categories = action.payload; + state.batchImageNames = state.batchImageNames.filter( + (name) => !action.payload.includes(name) + ); }, imageRangeEndSelected: (state, action: PayloadAction) => { const rangeEndImageName = action.payload; @@ -127,9 +135,7 @@ export const gallerySlice = createSlice({ } }, imageSelected: (state, action: PayloadAction) => { - state.selection = action.payload - ? [action.payload] - : [String(state.ids[0])]; + state.selection = action.payload ? [action.payload] : []; }, shouldAutoSwitchChanged: (state, action: PayloadAction) => { state.shouldAutoSwitch = action.payload; @@ -137,15 +143,43 @@ export const gallerySlice = createSlice({ setGalleryImageMinimumWidth: (state, action: PayloadAction) => { state.galleryImageMinimumWidth = action.payload; }, - setGalleryView: (state, action: PayloadAction<'images' | 'assets'>) => { + setGalleryView: (state, action: PayloadAction) => { state.galleryView = action.payload; }, - boardIdSelected: (state, action: PayloadAction) => { + boardIdSelected: (state, action: PayloadAction) => { state.selectedBoardId = action.payload; }, isLoadingChanged: (state, action: PayloadAction) => { state.isLoading = action.payload; }, + isBatchEnabledChanged: (state, action: PayloadAction) => { + state.isBatchEnabled = action.payload; + }, + imagesAddedToBatch: (state, action: PayloadAction) => { + state.batchImageNames = uniq( + state.batchImageNames.concat(action.payload) + ); + }, + imagesRemovedFromBatch: (state, action: PayloadAction) => { + state.batchImageNames = state.batchImageNames.filter( + (imageName) => !action.payload.includes(imageName) + ); + + const newSelection = state.selection.filter( + (imageName) => !action.payload.includes(imageName) + ); + + if (newSelection.length) { + state.selection = newSelection; + return; + } + + state.selection = [state.batchImageNames[0]] ?? []; + }, + batchReset: (state) => { + state.batchImageNames = []; + state.selection = []; + }, }, extraReducers: (builder) => { builder.addCase(receivedPageOfImages.pending, (state) => { @@ -188,7 +222,7 @@ export const gallerySlice = createSlice({ boardsApi.endpoints.deleteBoard.matchFulfilled, (state, action) => { if (action.meta.arg.originalArgs === state.selectedBoardId) { - state.selectedBoardId = undefined; + state.selectedBoardId = 'all'; } } ); @@ -208,7 +242,6 @@ export const { imageUpdatedOne, imageRemoved, imagesRemoved, - imageCategoriesChanged, imageRangeEndSelected, imageSelectionToggled, imageSelected, @@ -217,48 +250,9 @@ export const { setGalleryView, boardIdSelected, isLoadingChanged, + isBatchEnabledChanged, + imagesAddedToBatch, + imagesRemovedFromBatch, } = 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/lightbox/components/Lightbox.tsx b/invokeai/frontend/web/src/features/lightbox/components/Lightbox.tsx deleted file mode 100644 index cd0ce55b1e..0000000000 --- a/invokeai/frontend/web/src/features/lightbox/components/Lightbox.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { Box, Flex } from '@chakra-ui/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { RootState } from 'app/store/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIIconButton from 'common/components/IAIIconButton'; -import CurrentImageButtons from 'features/gallery/components/CurrentImageButtons'; -import ImageMetadataViewer from 'features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer'; -import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; -import { gallerySelector } from 'features/gallery/store/gallerySelectors'; -import { setIsLightboxOpen } from 'features/lightbox/store/lightboxSlice'; -import { uiSelector } from 'features/ui/store/uiSelectors'; -import { AnimatePresence, motion } from 'framer-motion'; -import { isEqual } from 'lodash-es'; -import { useHotkeys } from 'react-hotkeys-hook'; -import { BiExit } from 'react-icons/bi'; -import { TransformWrapper } from 'react-zoom-pan-pinch'; -import { PROGRESS_BAR_THICKNESS } from 'theme/util/constants'; -import useImageTransform from '../hooks/useImageTransform'; -import ReactPanZoomButtons from './ReactPanZoomButtons'; -import ReactPanZoomImage from './ReactPanZoomImage'; - -export const lightboxSelector = createSelector( - [gallerySelector, uiSelector], - (gallery, ui) => { - const { currentImage } = gallery; - const { shouldShowImageDetails } = ui; - - return { - viewerImageToDisplay: currentImage, - shouldShowImageDetails, - }; - }, - { - memoizeOptions: { - resultEqualityCheck: isEqual, - }, - } -); - -export default function Lightbox() { - const dispatch = useAppDispatch(); - const isLightBoxOpen = useAppSelector( - (state: RootState) => state.lightbox.isLightboxOpen - ); - - const { - rotation, - scaleX, - scaleY, - flipHorizontally, - flipVertically, - rotateCounterClockwise, - rotateClockwise, - reset, - } = useImageTransform(); - - const { viewerImageToDisplay, shouldShowImageDetails } = - useAppSelector(lightboxSelector); - - useHotkeys( - 'Esc', - () => { - if (isLightBoxOpen) dispatch(setIsLightboxOpen(false)); - }, - [isLightBoxOpen] - ); - - return ( - - {isLightBoxOpen && ( - - - - } - aria-label="Exit Viewer" - className="lightbox-close-btn" - onClick={() => { - dispatch(setIsLightboxOpen(false)); - }} - fontSize={20} - /> - - - - - - - {viewerImageToDisplay && ( - <> - - {shouldShowImageDetails && ( - - )} - - {!shouldShowImageDetails && ( - - - - )} - - )} - - - )} - - ); -} diff --git a/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomButtons.tsx b/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomButtons.tsx deleted file mode 100644 index 2e592e83d7..0000000000 --- a/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomButtons.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { ButtonGroup } from '@chakra-ui/react'; -import IAIIconButton from 'common/components/IAIIconButton'; -import { useTranslation } from 'react-i18next'; -import { - BiReset, - BiRotateLeft, - BiRotateRight, - BiZoomIn, - BiZoomOut, -} from 'react-icons/bi'; -import { MdFlip } from 'react-icons/md'; -import { useTransformContext } from 'react-zoom-pan-pinch'; - -type ReactPanZoomButtonsProps = { - flipHorizontally: () => void; - flipVertically: () => void; - rotateCounterClockwise: () => void; - rotateClockwise: () => void; - reset: () => void; -}; - -const ReactPanZoomButtons = ({ - flipHorizontally, - flipVertically, - rotateCounterClockwise, - rotateClockwise, - reset, -}: ReactPanZoomButtonsProps) => { - const { zoomIn, zoomOut, resetTransform } = useTransformContext(); - const { t } = useTranslation(); - - return ( - - } - aria-label={t('accessibility.zoomIn')} - tooltip={t('accessibility.zoomIn')} - onClick={() => zoomIn()} - fontSize={20} - /> - - } - aria-label={t('accessibility.zoomOut')} - tooltip={t('accessibility.zoomOut')} - onClick={() => zoomOut()} - fontSize={20} - /> - - } - aria-label={t('accessibility.rotateCounterClockwise')} - tooltip={t('accessibility.rotateCounterClockwise')} - onClick={rotateCounterClockwise} - fontSize={20} - /> - - } - aria-label={t('accessibility.rotateClockwise')} - tooltip={t('accessibility.rotateClockwise')} - onClick={rotateClockwise} - fontSize={20} - /> - - } - aria-label={t('accessibility.flipHorizontally')} - tooltip={t('accessibility.flipHorizontally')} - onClick={flipHorizontally} - fontSize={20} - /> - - } - aria-label={t('accessibility.flipVertically')} - tooltip={t('accessibility.flipVertically')} - onClick={flipVertically} - fontSize={20} - /> - - } - aria-label={t('accessibility.reset')} - tooltip={t('accessibility.reset')} - onClick={() => { - resetTransform(); - reset(); - }} - fontSize={20} - /> - - ); -}; - -export default ReactPanZoomButtons; diff --git a/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomImage.tsx b/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomImage.tsx deleted file mode 100644 index 73e7144163..0000000000 --- a/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomImage.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from 'react'; -import { TransformComponent, useTransformContext } from 'react-zoom-pan-pinch'; -import { ImageDTO } from 'services/api/types'; - -type ReactPanZoomProps = { - image: ImageDTO; - styleClass?: string; - alt?: string; - ref?: React.Ref; - rotation: number; - scaleX: number; - scaleY: number; -}; - -export default function ReactPanZoomImage({ - image, - alt, - ref, - styleClass, - rotation, - scaleX, - scaleY, -}: ReactPanZoomProps) { - const { centerView } = useTransformContext(); - - return ( - - {alt} centerView(1, 0, 'easeOut')} - /> - - ); -} diff --git a/invokeai/frontend/web/src/features/lightbox/hooks/useImageTransform.ts b/invokeai/frontend/web/src/features/lightbox/hooks/useImageTransform.ts deleted file mode 100644 index c191d7d1d7..0000000000 --- a/invokeai/frontend/web/src/features/lightbox/hooks/useImageTransform.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useState } from 'react'; - -const useImageTransform = () => { - const [rotation, setRotation] = useState(0); - const [scaleX, setScaleX] = useState(1); - const [scaleY, setScaleY] = useState(1); - - const rotateCounterClockwise = () => { - if (rotation === -270) { - setRotation(0); - } else { - setRotation(rotation - 90); - } - }; - - const rotateClockwise = () => { - if (rotation === 270) { - setRotation(0); - } else { - setRotation(rotation + 90); - } - }; - - const flipHorizontally = () => { - setScaleX(scaleX * -1); - }; - - const flipVertically = () => { - setScaleY(scaleY * -1); - }; - - const reset = () => { - setRotation(0); - setScaleX(1); - setScaleY(1); - }; - - return { - rotation, - scaleX, - scaleY, - flipHorizontally, - flipVertically, - rotateCounterClockwise, - rotateClockwise, - reset, - }; -}; - -export default useImageTransform; diff --git a/invokeai/frontend/web/src/features/lightbox/store/lightboxPersistDenylist.ts b/invokeai/frontend/web/src/features/lightbox/store/lightboxPersistDenylist.ts deleted file mode 100644 index b8a1d12f44..0000000000 --- a/invokeai/frontend/web/src/features/lightbox/store/lightboxPersistDenylist.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { LightboxState } from './lightboxSlice'; - -/** - * Lightbox slice persist denylist - */ -export const lightboxPersistDenylist: (keyof LightboxState)[] = [ - 'isLightboxOpen', -]; diff --git a/invokeai/frontend/web/src/features/lightbox/store/lightboxSelectors.ts b/invokeai/frontend/web/src/features/lightbox/store/lightboxSelectors.ts deleted file mode 100644 index f7d7e0129a..0000000000 --- a/invokeai/frontend/web/src/features/lightbox/store/lightboxSelectors.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { RootState } from 'app/store/store'; -import { isEqual } from 'lodash-es'; - -export const lightboxSelector = createSelector( - (state: RootState) => state.lightbox, - (lightbox) => lightbox, - { - memoizeOptions: { - equalityCheck: isEqual, - }, - } -); diff --git a/invokeai/frontend/web/src/features/lightbox/store/lightboxSlice.ts b/invokeai/frontend/web/src/features/lightbox/store/lightboxSlice.ts deleted file mode 100644 index ea73e5bb13..0000000000 --- a/invokeai/frontend/web/src/features/lightbox/store/lightboxSlice.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { PayloadAction } from '@reduxjs/toolkit'; -import { createSlice } from '@reduxjs/toolkit'; - -export interface LightboxState { - isLightboxOpen: boolean; -} - -export const initialLightboxState: LightboxState = { - isLightboxOpen: false, -}; - -const initialState: LightboxState = initialLightboxState; - -export const lightboxSlice = createSlice({ - name: 'lightbox', - initialState, - reducers: { - setIsLightboxOpen: (state, action: PayloadAction) => { - state.isLightboxOpen = action.payload; - }, - }, -}); - -export const { setIsLightboxOpen } = lightboxSlice.actions; - -export default lightboxSlice.reducer; diff --git a/invokeai/frontend/web/src/features/lora/components/ParamLora.tsx b/invokeai/frontend/web/src/features/lora/components/ParamLora.tsx index 4ca9700a8c..7dba2aa6ed 100644 --- a/invokeai/frontend/web/src/features/lora/components/ParamLora.tsx +++ b/invokeai/frontend/web/src/features/lora/components/ParamLora.tsx @@ -37,7 +37,7 @@ const ParamLora = (props: Props) => { return ( { const { loras } = useAppSelector(selector); - return map(loras, (lora) => ); + return map(loras, (lora) => ); }; export default ParamLoraList; diff --git a/invokeai/frontend/web/src/features/lora/components/ParamLoraSelect.tsx b/invokeai/frontend/web/src/features/lora/components/ParamLoraSelect.tsx index 7b5aa5946b..ebceeb34db 100644 --- a/invokeai/frontend/web/src/features/lora/components/ParamLoraSelect.tsx +++ b/invokeai/frontend/web/src/features/lora/components/ParamLoraSelect.tsx @@ -45,7 +45,7 @@ const ParamLoraSelect = () => { data.push({ value: id, - label: lora.name, + label: lora.model_name, disabled, group: MODEL_TYPE_MAP[lora.base_model], tooltip: disabled diff --git a/invokeai/frontend/web/src/features/lora/store/loraSlice.ts b/invokeai/frontend/web/src/features/lora/store/loraSlice.ts index 6fe6109c4d..a97a0887a5 100644 --- a/invokeai/frontend/web/src/features/lora/store/loraSlice.ts +++ b/invokeai/frontend/web/src/features/lora/store/loraSlice.ts @@ -1,12 +1,8 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { LoRAModelParam } from 'features/parameters/store/parameterZodSchemas'; import { LoRAModelConfigEntity } from 'services/api/endpoints/models'; -import { BaseModelType } from 'services/api/types'; -export type Lora = { - id: string; - base_model: BaseModelType; - name: string; +export type Lora = LoRAModelParam & { weight: number; }; @@ -27,8 +23,8 @@ export const loraSlice = createSlice({ initialState: intialLoraState, reducers: { loraAdded: (state, action: PayloadAction) => { - const { name, id, base_model } = action.payload; - state.loras[id] = { id, name, base_model, ...defaultLoRAConfig }; + const { model_name, id, base_model } = action.payload; + state.loras[id] = { id, model_name, base_model, ...defaultLoRAConfig }; }, loraRemoved: (state, action: PayloadAction) => { const id = action.payload; diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/LoRAModelInputFieldComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/LoRAModelInputFieldComponent.tsx index 02cdfd454d..271408b817 100644 --- a/invokeai/frontend/web/src/features/nodes/components/fields/LoRAModelInputFieldComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/fields/LoRAModelInputFieldComponent.tsx @@ -45,7 +45,7 @@ const LoRAModelInputFieldComponent = ( data.push({ value: id, - label: model.name, + label: model.model_name, group: BASE_MODEL_NAME_MAP[model.base_model], }); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/ModelInputFieldComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/ModelInputFieldComponent.tsx index ee739e1002..95e73e0fd2 100644 --- a/invokeai/frontend/web/src/features/nodes/components/fields/ModelInputFieldComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/fields/ModelInputFieldComponent.tsx @@ -38,7 +38,7 @@ const ModelInputFieldComponent = ( data.push({ value: id, - label: model.name, + label: model.model_name, group: BASE_MODEL_NAME_MAP[model.base_model], }); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/VaeModelInputFieldComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/VaeModelInputFieldComponent.tsx index b4408e41b2..0aefec0511 100644 --- a/invokeai/frontend/web/src/features/nodes/components/fields/VaeModelInputFieldComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/fields/VaeModelInputFieldComponent.tsx @@ -45,7 +45,7 @@ const VaeModelInputFieldComponent = ( data.push({ value: id, - label: model.name, + label: model.model_name, group: BASE_MODEL_NAME_MAP[model.base_model], }); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx index 10e59f2af9..90f8039285 100644 --- a/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx @@ -6,6 +6,7 @@ import LoadNodesButton from '../ui/LoadNodesButton'; import NodeInvokeButton from '../ui/NodeInvokeButton'; import ReloadSchemaButton from '../ui/ReloadSchemaButton'; import SaveNodesButton from '../ui/SaveNodesButton'; +import ClearNodesButton from '../ui/ClearNodesButton'; const TopCenterPanel = () => { return ( @@ -16,6 +17,7 @@ const TopCenterPanel = () => { + ); diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/ClearNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/ClearNodesButton.tsx new file mode 100644 index 0000000000..c5de45a826 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/ui/ClearNodesButton.tsx @@ -0,0 +1,86 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Button, + Text, + useDisclosure, +} from '@chakra-ui/react'; +import { makeToast } from 'app/components/Toaster'; +import { RootState } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; +import { addToast } from 'features/system/store/systemSlice'; +import { memo, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaTrash } from 'react-icons/fa'; + +const ClearNodesButton = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const cancelRef = useRef(null); + + const nodes = useAppSelector((state: RootState) => state.nodes.nodes); + + const handleConfirmClear = useCallback(() => { + dispatch(nodeEditorReset()); + + dispatch( + addToast( + makeToast({ + title: t('toast.nodesCleared'), + status: 'success', + }) + ) + ); + + onClose(); + }, [dispatch, t, onClose]); + + return ( + <> + } + tooltip={t('nodes.clearNodes')} + aria-label={t('nodes.clearNodes')} + onClick={onOpen} + isDisabled={nodes.length === 0} + /> + + + + + + + {t('nodes.clearNodes')} + + + + {t('common.clearNodes')} + + + + + + + + + + ); +}; + +export default memo(ClearNodesButton); diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx index 14bf0a1ce8..5833182456 100644 --- a/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx @@ -12,6 +12,8 @@ const SaveNodesButton = () => { (state: RootState) => state.nodes.editorInstance ); + const nodes = useAppSelector((state: RootState) => state.nodes.nodes); + const saveEditorToJSON = useCallback(() => { if (editorInstance) { const editorState = editorInstance.toObject(); @@ -38,6 +40,7 @@ const SaveNodesButton = () => { tooltip={t('nodes.saveNodes')} aria-label={t('nodes.saveNodes')} onClick={saveEditorToJSON} + isDisabled={nodes.length === 0} /> ); }; diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 094a43b944..91b6f685e6 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -121,8 +121,9 @@ const nodesSlice = createSlice({ ) => { state.invocationTemplates = action.payload; }, - nodeEditorReset: () => { - return { ...initialNodesState }; + nodeEditorReset: (state) => { + state.nodes = []; + state.edges = []; }, setEditorInstance: (state, action) => { state.editorInstance = action.payload; diff --git a/invokeai/frontend/web/src/features/nodes/util/addControlNetToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/addControlNetToLinearGraph.ts deleted file mode 100644 index 5c4d67ebd3..0000000000 --- a/invokeai/frontend/web/src/features/nodes/util/addControlNetToLinearGraph.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { RootState } from 'app/store/store'; -import { getValidControlNets } from 'features/controlNet/util/getValidControlNets'; -import { CollectInvocation, ControlNetInvocation } from 'services/api/types'; -import { NonNullableGraph } from '../types/types'; -import { CONTROL_NET_COLLECT } from './graphBuilders/constants'; - -export const addControlNetToLinearGraph = ( - graph: NonNullableGraph, - baseNodeId: string, - state: RootState -): void => { - const { isEnabled: isControlNetEnabled, controlNets } = state.controlNet; - - const validControlNets = getValidControlNets(controlNets); - - if (isControlNetEnabled && Boolean(validControlNets.length)) { - if (validControlNets.length > 1) { - // We have multiple controlnets, add ControlNet collector - const controlNetIterateNode: CollectInvocation = { - id: CONTROL_NET_COLLECT, - type: 'collect', - }; - graph.nodes[controlNetIterateNode.id] = controlNetIterateNode; - graph.edges.push({ - source: { node_id: controlNetIterateNode.id, field: 'collection' }, - destination: { - node_id: baseNodeId, - field: 'control', - }, - }); - } - - validControlNets.forEach((controlNet) => { - const { - controlNetId, - controlImage, - processedControlImage, - beginStepPct, - endStepPct, - controlMode, - model, - processorType, - weight, - } = controlNet; - - const controlNetNode: ControlNetInvocation = { - id: `control_net_${controlNetId}`, - type: 'controlnet', - begin_step_percent: beginStepPct, - end_step_percent: endStepPct, - control_mode: controlMode, - control_model: model as ControlNetInvocation['control_model'], - control_weight: weight, - }; - - if (processedControlImage && processorType !== 'none') { - // We've already processed the image in the app, so we can just use the processed image - controlNetNode.image = { - image_name: processedControlImage, - }; - } else if (controlImage) { - // The control image is preprocessed - controlNetNode.image = { - image_name: controlImage, - }; - } else { - // Skip ControlNets without an unprocessed image - should never happen if everything is working correctly - return; - } - - graph.nodes[controlNetNode.id] = controlNetNode; - - if (validControlNets.length > 1) { - // if we have multiple controlnets, link to the collector - graph.edges.push({ - source: { node_id: controlNetNode.id, field: 'control' }, - destination: { - node_id: CONTROL_NET_COLLECT, - field: 'item', - }, - }); - } else { - // otherwise, link directly to the base node - graph.edges.push({ - source: { node_id: controlNetNode.id, field: 'control' }, - destination: { - node_id: baseNodeId, - field: 'control', - }, - }); - } - }); - } -}; diff --git a/invokeai/frontend/web/src/features/nodes/util/edgeBuilders/buildEdges.ts b/invokeai/frontend/web/src/features/nodes/util/edgeBuilders/buildEdges.ts deleted file mode 100644 index 1c3d2c909e..0000000000 --- a/invokeai/frontend/web/src/features/nodes/util/edgeBuilders/buildEdges.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - Edge, - ImageToImageInvocation, - InpaintInvocation, - IterateInvocation, - RandomRangeInvocation, - RangeInvocation, - TextToImageInvocation, -} from 'services/api/types'; - -export const buildEdges = ( - baseNode: TextToImageInvocation | ImageToImageInvocation | InpaintInvocation, - rangeNode: RangeInvocation | RandomRangeInvocation, - iterateNode: IterateInvocation -): Edge[] => { - const edges: Edge[] = [ - { - source: { - node_id: rangeNode.id, - field: 'collection', - }, - destination: { - node_id: iterateNode.id, - field: 'collection', - }, - }, - { - source: { - node_id: iterateNode.id, - field: 'item', - }, - destination: { - node_id: baseNode.id, - field: 'seed', - }, - }, - ]; - - return edges; -}; diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addControlNetToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addControlNetToLinearGraph.ts new file mode 100644 index 0000000000..2b7ccccda2 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addControlNetToLinearGraph.ts @@ -0,0 +1,102 @@ +import { RootState } from 'app/store/store'; +import { getValidControlNets } from 'features/controlNet/util/getValidControlNets'; +import { omit } from 'lodash-es'; +import { + CollectInvocation, + ControlField, + ControlNetInvocation, + MetadataAccumulatorInvocation, +} from 'services/api/types'; +import { NonNullableGraph } from '../../types/types'; +import { CONTROL_NET_COLLECT, METADATA_ACCUMULATOR } from './constants'; + +export const addControlNetToLinearGraph = ( + state: RootState, + graph: NonNullableGraph, + baseNodeId: string +): void => { + const { isEnabled: isControlNetEnabled, controlNets } = state.controlNet; + + const validControlNets = getValidControlNets(controlNets); + + const metadataAccumulator = graph.nodes[METADATA_ACCUMULATOR] as + | MetadataAccumulatorInvocation + | undefined; + + if (isControlNetEnabled && Boolean(validControlNets.length)) { + if (validControlNets.length) { + // We have multiple controlnets, add ControlNet collector + const controlNetIterateNode: CollectInvocation = { + id: CONTROL_NET_COLLECT, + type: 'collect', + }; + graph.nodes[CONTROL_NET_COLLECT] = controlNetIterateNode; + graph.edges.push({ + source: { node_id: CONTROL_NET_COLLECT, field: 'collection' }, + destination: { + node_id: baseNodeId, + field: 'control', + }, + }); + + validControlNets.forEach((controlNet) => { + const { + controlNetId, + controlImage, + processedControlImage, + beginStepPct, + endStepPct, + controlMode, + model, + processorType, + weight, + } = controlNet; + + const controlNetNode: ControlNetInvocation = { + id: `control_net_${controlNetId}`, + type: 'controlnet', + begin_step_percent: beginStepPct, + end_step_percent: endStepPct, + control_mode: controlMode, + control_model: model as ControlNetInvocation['control_model'], + control_weight: weight, + }; + + if (processedControlImage && processorType !== 'none') { + // We've already processed the image in the app, so we can just use the processed image + controlNetNode.image = { + image_name: processedControlImage, + }; + } else if (controlImage) { + // The control image is preprocessed + controlNetNode.image = { + image_name: controlImage, + }; + } else { + // Skip ControlNets without an unprocessed image - should never happen if everything is working correctly + return; + } + + graph.nodes[controlNetNode.id] = controlNetNode; + + if (metadataAccumulator) { + // metadata accumulator only needs a control field - not the whole node + // extract what we need and add to the accumulator + const controlField = omit(controlNetNode, [ + 'id', + 'type', + ]) as ControlField; + metadataAccumulator.controlnets.push(controlField); + } + + graph.edges.push({ + source: { node_id: controlNetNode.id, field: 'control' }, + destination: { + node_id: CONTROL_NET_COLLECT, + field: 'item', + }, + }); + }); + } + } +}; diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addDynamicPromptsToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addDynamicPromptsToGraph.ts index 264dac2e14..d4289433eb 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addDynamicPromptsToGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addDynamicPromptsToGraph.ts @@ -1,8 +1,10 @@ import { RootState } from 'app/store/store'; import { NonNullableGraph } from 'features/nodes/types/types'; +import { unset } from 'lodash-es'; import { DynamicPromptInvocation, IterateInvocation, + MetadataAccumulatorInvocation, NoiseInvocation, RandomIntInvocation, RangeOfSizeInvocation, @@ -10,16 +12,16 @@ import { import { DYNAMIC_PROMPT, ITERATE, + METADATA_ACCUMULATOR, NOISE, POSITIVE_CONDITIONING, RANDOM_INT, RANGE_OF_SIZE, } from './constants'; -import { unset } from 'lodash-es'; export const addDynamicPromptsToGraph = ( - graph: NonNullableGraph, - state: RootState + state: RootState, + graph: NonNullableGraph ): void => { const { positivePrompt, iterations, seed, shouldRandomizeSeed } = state.generation; @@ -30,6 +32,10 @@ export const addDynamicPromptsToGraph = ( maxPrompts, } = state.dynamicPrompts; + const metadataAccumulator = graph.nodes[METADATA_ACCUMULATOR] as + | MetadataAccumulatorInvocation + | undefined; + if (isDynamicPromptsEnabled) { // iteration is handled via dynamic prompts unset(graph.nodes[POSITIVE_CONDITIONING], 'prompt'); @@ -74,6 +80,18 @@ export const addDynamicPromptsToGraph = ( } ); + // hook up positive prompt to metadata + graph.edges.push({ + source: { + node_id: ITERATE, + field: 'item', + }, + destination: { + node_id: METADATA_ACCUMULATOR, + field: 'positive_prompt', + }, + }); + if (shouldRandomizeSeed) { // Random int node to generate the starting seed const randomIntNode: RandomIntInvocation = { @@ -88,11 +106,26 @@ export const addDynamicPromptsToGraph = ( source: { node_id: RANDOM_INT, field: 'a' }, destination: { node_id: NOISE, field: 'seed' }, }); + + graph.edges.push({ + source: { node_id: RANDOM_INT, field: 'a' }, + destination: { node_id: METADATA_ACCUMULATOR, field: 'seed' }, + }); } else { // User specified seed, so set the start of the range of size to the seed (graph.nodes[NOISE] as NoiseInvocation).seed = seed; + + // hook up seed to metadata + if (metadataAccumulator) { + metadataAccumulator.seed = seed; + } } } else { + // no dynamic prompt - hook up positive prompt + if (metadataAccumulator) { + metadataAccumulator.positive_prompt = positivePrompt; + } + const rangeOfSizeNode: RangeOfSizeInvocation = { id: RANGE_OF_SIZE, type: 'range_of_size', @@ -130,6 +163,18 @@ export const addDynamicPromptsToGraph = ( }, }); + // hook up seed to metadata + graph.edges.push({ + source: { + node_id: ITERATE, + field: 'item', + }, + destination: { + node_id: METADATA_ACCUMULATOR, + field: 'seed', + }, + }); + // handle seed if (shouldRandomizeSeed) { // Random int node to generate the starting seed diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addLoRAsToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addLoRAsToGraph.ts index 74cc8b1f57..3614c26976 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addLoRAsToGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addLoRAsToGraph.ts @@ -1,19 +1,23 @@ import { RootState } from 'app/store/store'; import { NonNullableGraph } from 'features/nodes/types/types'; import { forEach, size } from 'lodash-es'; -import { LoraLoaderInvocation } from 'services/api/types'; +import { + LoraLoaderInvocation, + MetadataAccumulatorInvocation, +} from 'services/api/types'; import { modelIdToLoRAModelField } from '../modelIdToLoRAName'; import { CLIP_SKIP, LORA_LOADER, MAIN_MODEL_LOADER, + METADATA_ACCUMULATOR, NEGATIVE_CONDITIONING, POSITIVE_CONDITIONING, } from './constants'; export const addLoRAsToGraph = ( - graph: NonNullableGraph, state: RootState, + graph: NonNullableGraph, baseNodeId: string ): void => { /** @@ -26,6 +30,9 @@ export const addLoRAsToGraph = ( const { loras } = state.lora; const loraCount = size(loras); + const metadataAccumulator = graph.nodes[METADATA_ACCUMULATOR] as + | MetadataAccumulatorInvocation + | undefined; if (loraCount > 0) { // Remove MAIN_MODEL_LOADER unet connection to feed it to LoRAs @@ -62,6 +69,12 @@ export const addLoRAsToGraph = ( weight, }; + // add the lora to the metadata accumulator + if (metadataAccumulator) { + metadataAccumulator.loras.push({ lora: loraField, weight }); + } + + // add to graph graph.nodes[currentLoraNodeId] = loraLoaderNode; if (currentLoraIndex === 0) { diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addVAEToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addVAEToGraph.ts index 9de8f6e99d..d76fec093c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addVAEToGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addVAEToGraph.ts @@ -1,5 +1,6 @@ import { RootState } from 'app/store/store'; import { NonNullableGraph } from 'features/nodes/types/types'; +import { MetadataAccumulatorInvocation } from 'services/api/types'; import { modelIdToVAEModelField } from '../modelIdToVAEModelField'; import { IMAGE_TO_IMAGE_GRAPH, @@ -8,18 +9,22 @@ import { INPAINT_GRAPH, LATENTS_TO_IMAGE, MAIN_MODEL_LOADER, + METADATA_ACCUMULATOR, TEXT_TO_IMAGE_GRAPH, VAE_LOADER, } from './constants'; export const addVAEToGraph = ( - graph: NonNullableGraph, - state: RootState + state: RootState, + graph: NonNullableGraph ): void => { const { vae } = state.generation; const vae_model = modelIdToVAEModelField(vae?.id || ''); const isAutoVae = !vae; + const metadataAccumulator = graph.nodes[METADATA_ACCUMULATOR] as + | MetadataAccumulatorInvocation + | undefined; if (!isAutoVae) { graph.nodes[VAE_LOADER] = { @@ -67,4 +72,8 @@ export const addVAEToGraph = ( }, }); } + + if (vae && metadataAccumulator) { + metadataAccumulator.vae = vae_model; + } }; diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasImageToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasImageToImageGraph.ts index 197e62aa2d..821df8fe6e 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasImageToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasImageToImageGraph.ts @@ -7,8 +7,7 @@ import { ImageResizeInvocation, ImageToLatentsInvocation, } from 'services/api/types'; -import { addControlNetToLinearGraph } from '../addControlNetToLinearGraph'; -import { modelIdToMainModelField } from '../modelIdToMainModelField'; +import { addControlNetToLinearGraph } from './addControlNetToLinearGraph'; import { addDynamicPromptsToGraph } from './addDynamicPromptsToGraph'; import { addLoRAsToGraph } from './addLoRAsToGraph'; import { addVAEToGraph } from './addVAEToGraph'; @@ -19,6 +18,7 @@ import { LATENTS_TO_IMAGE, LATENTS_TO_LATENTS, MAIN_MODEL_LOADER, + METADATA_ACCUMULATOR, NEGATIVE_CONDITIONING, NOISE, POSITIVE_CONDITIONING, @@ -37,7 +37,7 @@ export const buildCanvasImageToImageGraph = ( const { positivePrompt, negativePrompt, - model: currentModel, + model, cfgScale: cfg_scale, scheduler, steps, @@ -50,7 +50,10 @@ export const buildCanvasImageToImageGraph = ( // The bounding box determines width and height, not the width and height params const { width, height } = state.canvas.boundingBoxDimensions; - const model = modelIdToMainModelField(currentModel?.id || ''); + if (!model) { + moduleLog.error('No model found in state'); + throw new Error('No model found in state'); + } const use_cpu = shouldUseNoiseSettings ? shouldUseCpuNoise @@ -275,16 +278,51 @@ export const buildCanvasImageToImageGraph = ( }); } - addLoRAsToGraph(graph, state, LATENTS_TO_LATENTS); + // add metadata accumulator, which is only mostly populated - some fields are added later + graph.nodes[METADATA_ACCUMULATOR] = { + id: METADATA_ACCUMULATOR, + type: 'metadata_accumulator', + generation_mode: 'img2img', + cfg_scale, + height, + width, + positive_prompt: '', // set in addDynamicPromptsToGraph + negative_prompt: negativePrompt, + model, + seed: 0, // set in addDynamicPromptsToGraph + steps, + rand_device: use_cpu ? 'cpu' : 'cuda', + scheduler, + vae: undefined, // option; set in addVAEToGraph + controlnets: [], // populated in addControlNetToLinearGraph + loras: [], // populated in addLoRAsToGraph + clip_skip: clipSkip, + strength, + init_image: initialImage.image_name, + }; - // Add VAE - addVAEToGraph(graph, state); + graph.edges.push({ + source: { + node_id: METADATA_ACCUMULATOR, + field: 'metadata', + }, + destination: { + node_id: LATENTS_TO_IMAGE, + field: 'metadata', + }, + }); - // add dynamic prompts, mutating `graph` - addDynamicPromptsToGraph(graph, state); + // add LoRA support + addLoRAsToGraph(state, graph, LATENTS_TO_LATENTS); + + // optionally add custom VAE + addVAEToGraph(state, graph); + + // add dynamic prompts - also sets up core iteration and seed + addDynamicPromptsToGraph(state, graph); // add controlnet, mutating `graph` - addControlNetToLinearGraph(graph, LATENTS_TO_LATENTS, state); + addControlNetToLinearGraph(state, graph, LATENTS_TO_LATENTS); return graph; }; diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasInpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasInpaintGraph.ts index 2bac864015..9ad976b01f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasInpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasInpaintGraph.ts @@ -7,7 +7,6 @@ import { RandomIntInvocation, RangeOfSizeInvocation, } from 'services/api/types'; -import { modelIdToMainModelField } from '../modelIdToMainModelField'; import { addLoRAsToGraph } from './addLoRAsToGraph'; import { addVAEToGraph } from './addVAEToGraph'; import { @@ -35,7 +34,7 @@ export const buildCanvasInpaintGraph = ( const { positivePrompt, negativePrompt, - model: currentModel, + model, cfgScale: cfg_scale, scheduler, steps, @@ -53,14 +52,17 @@ export const buildCanvasInpaintGraph = ( clipSkip, } = state.generation; + if (!model) { + moduleLog.error('No model found in state'); + throw new Error('No model found in state'); + } + // The bounding box determines width and height, not the width and height params const { width, height } = state.canvas.boundingBoxDimensions; // We may need to set the inpaint width and height to scale the image const { scaledBoundingBoxDimensions, boundingBoxScaleMethod } = state.canvas; - const model = modelIdToMainModelField(currentModel?.id || ''); - const graph: NonNullableGraph = { id: INPAINT_GRAPH, nodes: { @@ -212,10 +214,10 @@ export const buildCanvasInpaintGraph = ( ], }; - addLoRAsToGraph(graph, state, INPAINT); + addLoRAsToGraph(state, graph, INPAINT); // Add VAE - addVAEToGraph(graph, state); + addVAEToGraph(state, graph); // handle seed if (shouldRandomizeSeed) { diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasTextToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasTextToImageGraph.ts index 918f18a34a..f6fd43b0a5 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasTextToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildCanvasTextToImageGraph.ts @@ -1,8 +1,8 @@ +import { log } from 'app/logging/useLogger'; import { RootState } from 'app/store/store'; import { NonNullableGraph } from 'features/nodes/types/types'; import { initialGenerationState } from 'features/parameters/store/generationSlice'; -import { addControlNetToLinearGraph } from '../addControlNetToLinearGraph'; -import { modelIdToMainModelField } from '../modelIdToMainModelField'; +import { addControlNetToLinearGraph } from './addControlNetToLinearGraph'; import { addDynamicPromptsToGraph } from './addDynamicPromptsToGraph'; import { addLoRAsToGraph } from './addLoRAsToGraph'; import { addVAEToGraph } from './addVAEToGraph'; @@ -10,6 +10,7 @@ import { CLIP_SKIP, LATENTS_TO_IMAGE, MAIN_MODEL_LOADER, + METADATA_ACCUMULATOR, NEGATIVE_CONDITIONING, NOISE, POSITIVE_CONDITIONING, @@ -17,6 +18,8 @@ import { TEXT_TO_LATENTS, } from './constants'; +const moduleLog = log.child({ namespace: 'nodes' }); + /** * Builds the Canvas tab's Text to Image graph. */ @@ -26,7 +29,7 @@ export const buildCanvasTextToImageGraph = ( const { positivePrompt, negativePrompt, - model: currentModel, + model, cfgScale: cfg_scale, scheduler, steps, @@ -38,7 +41,10 @@ export const buildCanvasTextToImageGraph = ( // The bounding box determines width and height, not the width and height params const { width, height } = state.canvas.boundingBoxDimensions; - const model = modelIdToMainModelField(currentModel?.id || ''); + if (!model) { + moduleLog.error('No model found in state'); + throw new Error('No model found in state'); + } const use_cpu = shouldUseNoiseSettings ? shouldUseCpuNoise @@ -180,16 +186,49 @@ export const buildCanvasTextToImageGraph = ( ], }; - addLoRAsToGraph(graph, state, TEXT_TO_LATENTS); + // add metadata accumulator, which is only mostly populated - some fields are added later + graph.nodes[METADATA_ACCUMULATOR] = { + id: METADATA_ACCUMULATOR, + type: 'metadata_accumulator', + generation_mode: 'txt2img', + cfg_scale, + height, + width, + positive_prompt: '', // set in addDynamicPromptsToGraph + negative_prompt: negativePrompt, + model, + seed: 0, // set in addDynamicPromptsToGraph + steps, + rand_device: use_cpu ? 'cpu' : 'cuda', + scheduler, + vae: undefined, // option; set in addVAEToGraph + controlnets: [], // populated in addControlNetToLinearGraph + loras: [], // populated in addLoRAsToGraph + clip_skip: clipSkip, + }; - // Add VAE - addVAEToGraph(graph, state); + graph.edges.push({ + source: { + node_id: METADATA_ACCUMULATOR, + field: 'metadata', + }, + destination: { + node_id: LATENTS_TO_IMAGE, + field: 'metadata', + }, + }); - // add dynamic prompts, mutating `graph` - addDynamicPromptsToGraph(graph, state); + // add LoRA support + addLoRAsToGraph(state, graph, TEXT_TO_LATENTS); + + // optionally add custom VAE + addVAEToGraph(state, graph); + + // add dynamic prompts - also sets up core iteration and seed + addDynamicPromptsToGraph(state, graph); // add controlnet, mutating `graph` - addControlNetToLinearGraph(graph, TEXT_TO_LATENTS, state); + addControlNetToLinearGraph(state, graph, TEXT_TO_LATENTS); return graph; }; 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 f8b95b4d77..9d200c4574 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearImageToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearImageToImageGraph.ts @@ -3,25 +3,21 @@ import { RootState } from 'app/store/store'; import { NonNullableGraph } from 'features/nodes/types/types'; import { initialGenerationState } from 'features/parameters/store/generationSlice'; import { - ImageCollectionInvocation, ImageResizeInvocation, ImageToLatentsInvocation, - IterateInvocation, } from 'services/api/types'; -import { addControlNetToLinearGraph } from '../addControlNetToLinearGraph'; -import { modelIdToMainModelField } from '../modelIdToMainModelField'; +import { addControlNetToLinearGraph } from './addControlNetToLinearGraph'; import { addDynamicPromptsToGraph } from './addDynamicPromptsToGraph'; import { addLoRAsToGraph } from './addLoRAsToGraph'; import { addVAEToGraph } from './addVAEToGraph'; import { CLIP_SKIP, - IMAGE_COLLECTION, - IMAGE_COLLECTION_ITERATE, IMAGE_TO_IMAGE_GRAPH, IMAGE_TO_LATENTS, LATENTS_TO_IMAGE, LATENTS_TO_LATENTS, MAIN_MODEL_LOADER, + METADATA_ACCUMULATOR, NEGATIVE_CONDITIONING, NOISE, POSITIVE_CONDITIONING, @@ -39,7 +35,7 @@ export const buildLinearImageToImageGraph = ( const { positivePrompt, negativePrompt, - model: currentModel, + model, cfgScale: cfg_scale, scheduler, steps, @@ -53,14 +49,15 @@ export const buildLinearImageToImageGraph = ( shouldUseNoiseSettings, } = state.generation; - const { - isEnabled: isBatchEnabled, - imageNames: batchImageNames, - asInitialImage, - } = state.batch; + // TODO: add batch functionality + // const { + // isEnabled: isBatchEnabled, + // imageNames: batchImageNames, + // asInitialImage, + // } = state.batch; - const shouldBatch = - isBatchEnabled && batchImageNames.length > 0 && asInitialImage; + // 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 @@ -71,12 +68,15 @@ export const buildLinearImageToImageGraph = ( * the `fit` param. These are added to the graph at the end. */ - if (!initialImage && !shouldBatch) { + if (!initialImage) { moduleLog.error('No initial image found in state'); throw new Error('No initial image found in state'); } - const model = modelIdToMainModelField(currentModel?.id || ''); + if (!model) { + moduleLog.error('No model found in state'); + throw new Error('No model found in state'); + } const use_cpu = shouldUseNoiseSettings ? shouldUseCpuNoise @@ -295,51 +295,87 @@ 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; + // TODO: add batch functionality + // 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 imageCollection: ImageCollectionInvocation = { + // id: IMAGE_COLLECTION, + // type: 'image_collection', + // images: batchImageNames.map((image_name) => ({ image_name })), + // }; - const imageCollectionIterate: IterateInvocation = { - id: IMAGE_COLLECTION_ITERATE, - type: 'iterate', - }; + // const imageCollectionIterate: IterateInvocation = { + // id: IMAGE_COLLECTION_ITERATE, + // type: 'iterate', + // }; - graph.nodes[IMAGE_COLLECTION] = imageCollection; - graph.nodes[IMAGE_COLLECTION_ITERATE] = imageCollectionIterate; + // 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, 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', - }, - }); - } + // graph.edges.push({ + // source: { node_id: IMAGE_COLLECTION_ITERATE, field: 'item' }, + // destination: { + // node_id: IMAGE_TO_LATENTS, + // field: 'image', + // }, + // }); + // } - addLoRAsToGraph(graph, state, LATENTS_TO_LATENTS); + // add metadata accumulator, which is only mostly populated - some fields are added later + graph.nodes[METADATA_ACCUMULATOR] = { + id: METADATA_ACCUMULATOR, + type: 'metadata_accumulator', + generation_mode: 'img2img', + cfg_scale, + height, + width, + positive_prompt: '', // set in addDynamicPromptsToGraph + negative_prompt: negativePrompt, + model, + seed: 0, // set in addDynamicPromptsToGraph + steps, + rand_device: use_cpu ? 'cpu' : 'cuda', + scheduler, + vae: undefined, // option; set in addVAEToGraph + controlnets: [], // populated in addControlNetToLinearGraph + loras: [], // populated in addLoRAsToGraph + clip_skip: clipSkip, + strength, + init_image: initialImage.imageName, + }; - // Add VAE - addVAEToGraph(graph, state); + graph.edges.push({ + source: { + node_id: METADATA_ACCUMULATOR, + field: 'metadata', + }, + destination: { + node_id: LATENTS_TO_IMAGE, + field: 'metadata', + }, + }); - // add dynamic prompts, mutating `graph` - addDynamicPromptsToGraph(graph, state); + // add LoRA support + addLoRAsToGraph(state, graph, LATENTS_TO_LATENTS); + + // optionally add custom VAE + addVAEToGraph(state, graph); + + // add dynamic prompts - also sets up core iteration and seed + addDynamicPromptsToGraph(state, graph); // add controlnet, mutating `graph` - addControlNetToLinearGraph(graph, LATENTS_TO_LATENTS, state); + addControlNetToLinearGraph(state, graph, LATENTS_TO_LATENTS); return graph; }; diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearTextToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearTextToImageGraph.ts index 90ddf09343..98af2a0a2f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearTextToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearTextToImageGraph.ts @@ -1,8 +1,8 @@ +import { log } from 'app/logging/useLogger'; import { RootState } from 'app/store/store'; import { NonNullableGraph } from 'features/nodes/types/types'; import { initialGenerationState } from 'features/parameters/store/generationSlice'; -import { addControlNetToLinearGraph } from '../addControlNetToLinearGraph'; -import { modelIdToMainModelField } from '../modelIdToMainModelField'; +import { addControlNetToLinearGraph } from './addControlNetToLinearGraph'; import { addDynamicPromptsToGraph } from './addDynamicPromptsToGraph'; import { addLoRAsToGraph } from './addLoRAsToGraph'; import { addVAEToGraph } from './addVAEToGraph'; @@ -10,6 +10,7 @@ import { CLIP_SKIP, LATENTS_TO_IMAGE, MAIN_MODEL_LOADER, + METADATA_ACCUMULATOR, NEGATIVE_CONDITIONING, NOISE, POSITIVE_CONDITIONING, @@ -17,13 +18,15 @@ import { TEXT_TO_LATENTS, } from './constants'; +const moduleLog = log.child({ namespace: 'nodes' }); + export const buildLinearTextToImageGraph = ( state: RootState ): NonNullableGraph => { const { positivePrompt, negativePrompt, - model: currentModel, + model, cfgScale: cfg_scale, scheduler, steps, @@ -34,12 +37,15 @@ export const buildLinearTextToImageGraph = ( shouldUseNoiseSettings, } = state.generation; - const model = modelIdToMainModelField(currentModel?.id || ''); - const use_cpu = shouldUseNoiseSettings ? shouldUseCpuNoise : initialGenerationState.shouldUseCpuNoise; + if (!model) { + moduleLog.error('No model found in state'); + throw new Error('No model found in state'); + } + /** * 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 @@ -176,16 +182,49 @@ export const buildLinearTextToImageGraph = ( ], }; - addLoRAsToGraph(graph, state, TEXT_TO_LATENTS); + // add metadata accumulator, which is only mostly populated - some fields are added later + graph.nodes[METADATA_ACCUMULATOR] = { + id: METADATA_ACCUMULATOR, + type: 'metadata_accumulator', + generation_mode: 'txt2img', + cfg_scale, + height, + width, + positive_prompt: '', // set in addDynamicPromptsToGraph + negative_prompt: negativePrompt, + model, + seed: 0, // set in addDynamicPromptsToGraph + steps, + rand_device: use_cpu ? 'cpu' : 'cuda', + scheduler, + vae: undefined, // option; set in addVAEToGraph + controlnets: [], // populated in addControlNetToLinearGraph + loras: [], // populated in addLoRAsToGraph + clip_skip: clipSkip, + }; - // Add Custom VAE Support - addVAEToGraph(graph, state); + graph.edges.push({ + source: { + node_id: METADATA_ACCUMULATOR, + field: 'metadata', + }, + destination: { + node_id: LATENTS_TO_IMAGE, + field: 'metadata', + }, + }); - // add dynamic prompts, mutating `graph` - addDynamicPromptsToGraph(graph, state); + // add LoRA support + addLoRAsToGraph(state, graph, TEXT_TO_LATENTS); + + // optionally add custom VAE + addVAEToGraph(state, graph); + + // add dynamic prompts - also sets up core iteration and seed + addDynamicPromptsToGraph(state, graph); // add controlnet, mutating `graph` - addControlNetToLinearGraph(graph, TEXT_TO_LATENTS, state); + addControlNetToLinearGraph(state, graph, TEXT_TO_LATENTS); return graph; }; 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 256a623bba..92ce7715ba 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/constants.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/constants.ts @@ -19,6 +19,7 @@ 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'; +export const METADATA_ACCUMULATOR = 'metadata_accumulator'; // friendly graph ids export const TEXT_TO_IMAGE_GRAPH = 'text_to_image_graph'; diff --git a/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts b/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts index c77fdeca5e..ee751f0b16 100644 --- a/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts +++ b/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts @@ -5,17 +5,21 @@ import { InputFieldTemplate, InvocationSchemaObject, InvocationTemplate, - isInvocationSchemaObject, OutputFieldTemplate, + isInvocationSchemaObject, } from '../types/types'; import { buildInputFieldTemplate, buildOutputFieldTemplates, } from './fieldTemplateBuilders'; -const RESERVED_FIELD_NAMES = ['id', 'type', 'is_intermediate']; +const RESERVED_FIELD_NAMES = ['id', 'type', 'is_intermediate', 'metadata']; -const invocationDenylist = ['Graph', 'InvocationMeta']; +const invocationDenylist = [ + 'Graph', + 'InvocationMeta', + 'MetadataAccumulatorInvocation', +]; export const parseSchema = (openAPI: OpenAPIV3.Document) => { // filter out non-invocation schemas, plus some tricky invocations for now diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/InfillAndScaling/ParamInfillMethod.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/InfillAndScaling/ParamInfillMethod.tsx index d0f8545e14..9ac0e3588f 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/InfillAndScaling/ParamInfillMethod.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/InfillAndScaling/ParamInfillMethod.tsx @@ -1,23 +1,21 @@ 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 IAIMantineSelect from 'common/components/IAIMantineSelect'; -import { generationSelector } from 'features/parameters/store/generationSelectors'; import { setInfillMethod } from 'features/parameters/store/generationSlice'; -import { systemSelector } from 'features/system/store/systemSelectors'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useGetAppConfigQuery } from 'services/api/endpoints/appInfo'; const selector = createSelector( - [generationSelector, systemSelector], - (parameters, system) => { - const { infillMethod } = parameters; - const { infillMethods } = system; + [stateSelector], + ({ generation }) => { + const { infillMethod } = generation; return { infillMethod, - infillMethods, }; }, defaultSelectorOptions @@ -25,7 +23,11 @@ const selector = createSelector( const ParamInfillMethod = () => { const dispatch = useAppDispatch(); - const { infillMethod, infillMethods } = useAppSelector(selector); + const { infillMethod } = useAppSelector(selector); + + const { data: appConfigData, isLoading } = useGetAppConfigQuery(); + + const infill_methods = appConfigData?.infill_methods; const { t } = useTranslation(); @@ -38,9 +40,11 @@ const ParamInfillMethod = () => { return ( ); 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 index 7951df31a7..c95149393e 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImage.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImage.tsx @@ -1,30 +1,24 @@ -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 { stateSelector } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIDndImage from 'common/components/IAIDndImage'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { useMemo } from 'react'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; const selector = createSelector( [stateSelector], (state) => { const { initialImage } = state.generation; - const { asInitialImage: useBatchAsInitialImage, imageNames } = state.batch; return { initialImage, - useBatchAsInitialImage, - isResetButtonDisabled: useBatchAsInitialImage - ? imageNames.length === 0 - : !initialImage, + isResetButtonDisabled: !initialImage, }; }, defaultSelectorOptions 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 c08f714488..2422e6f542 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,22 +1,14 @@ 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 { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import useImageUploader from 'common/hooks/useImageUploader'; +import { clearInitialImage } from 'features/parameters/store/generationSlice'; +import { useCallback } from 'react'; +import { FaUndo, FaUpload } from 'react-icons/fa'; import { PostUploadAction } from 'services/api/thunks/image'; import InitialImage from './InitialImage'; @@ -24,59 +16,34 @@ const selector = createSelector( [stateSelector], (state) => { const { initialImage } = state.generation; - const { asInitialImage: useBatchAsInitialImage, imageNames } = state.batch; return { - initialImage, - useBatchAsInitialImage, - isResetButtonDisabled: useBatchAsInitialImage - ? imageNames.length === 0 - : !initialImage, + isResetButtonDisabled: !initialImage, }; }, defaultSelectorOptions ); +const postUploadAction: PostUploadAction = { + type: 'SET_INITIAL_IMAGE', +}; + const InitialImageDisplay = () => { - const { initialImage, useBatchAsInitialImage, isResetButtonDisabled } = - useAppSelector(selector); + const { 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]); + dispatch(clearInitialImage()); + }, [dispatch]); 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/hooks/useRecallParameters.ts b/invokeai/frontend/web/src/features/parameters/hooks/useRecallParameters.ts index 721b44d329..9e4f5aeff0 100644 --- a/invokeai/frontend/web/src/features/parameters/hooks/useRecallParameters.ts +++ b/invokeai/frontend/web/src/features/parameters/hooks/useRecallParameters.ts @@ -2,6 +2,7 @@ import { useAppToaster } from 'app/components/Toaster'; import { useAppDispatch } from 'app/store/storeHooks'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { UnsafeImageMetadata } from 'services/api/endpoints/images'; import { isImageField } from 'services/api/guards'; import { ImageDTO } from 'services/api/types'; import { initialImageSelected, modelSelected } from '../store/actions'; @@ -162,7 +163,7 @@ export const useRecallParameters = () => { parameterNotSetToast(); return; } - dispatch(modelSelected(model?.id || '')); + dispatch(modelSelected(model)); parameterSetToast(); }, [dispatch, parameterSetToast, parameterNotSetToast] @@ -269,28 +270,24 @@ export const useRecallParameters = () => { ); const recallAllParameters = useCallback( - (image: ImageDTO | undefined) => { - if (!image || !image.metadata) { + (metadata: UnsafeImageMetadata['metadata'] | undefined) => { + if (!metadata) { allParameterNotSetToast(); return; } + const { cfg_scale, height, model, - positive_conditioning, - negative_conditioning, + positive_prompt, + negative_prompt, scheduler, seed, steps, width, strength, - clip, - extra, - latents, - unet, - vae, - } = image.metadata; + } = metadata; if (isValidCfgScale(cfg_scale)) { dispatch(setCfgScale(cfg_scale)); @@ -298,11 +295,11 @@ export const useRecallParameters = () => { if (isValidMainModel(model)) { dispatch(modelSelected(model)); } - if (isValidPositivePrompt(positive_conditioning)) { - dispatch(setPositivePrompt(positive_conditioning)); + if (isValidPositivePrompt(positive_prompt)) { + dispatch(setPositivePrompt(positive_prompt)); } - if (isValidNegativePrompt(negative_conditioning)) { - dispatch(setNegativePrompt(negative_conditioning)); + if (isValidNegativePrompt(negative_prompt)) { + dispatch(setNegativePrompt(negative_prompt)); } if (isValidScheduler(scheduler)) { dispatch(setScheduler(scheduler)); diff --git a/invokeai/frontend/web/src/features/parameters/store/actions.ts b/invokeai/frontend/web/src/features/parameters/store/actions.ts index a74a2f633d..7a4f86d681 100644 --- a/invokeai/frontend/web/src/features/parameters/store/actions.ts +++ b/invokeai/frontend/web/src/features/parameters/store/actions.ts @@ -1,8 +1,10 @@ import { createAction } from '@reduxjs/toolkit'; -import { ImageDTO } from 'services/api/types'; +import { ImageDTO, MainModelField } from 'services/api/types'; export const initialImageSelected = createAction( 'generation/initialImageSelected' ); -export const modelSelected = createAction('generation/modelSelected'); +export const modelSelected = createAction( + 'generation/modelSelected' +); diff --git a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts index 56728f216f..dff277ae7e 100644 --- a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts @@ -8,12 +8,11 @@ import { setShouldShowAdvancedOptions, } from 'features/ui/store/uiSlice'; import { clamp } from 'lodash-es'; -import { ImageDTO } from 'services/api/types'; +import { ImageDTO, MainModelField } from 'services/api/types'; import { clipSkipMap } from '../components/Parameters/Advanced/ParamClipSkip'; import { CfgScaleParam, HeightParam, - MainModelParam, NegativePromptParam, PositivePromptParam, SchedulerParam, @@ -54,7 +53,7 @@ export interface GenerationState { shouldUseSymmetry: boolean; horizontalSymmetrySteps: number; verticalSymmetrySteps: number; - model: MainModelParam | null; + model: MainModelField | null; vae: VaeModelParam | null; seamlessXAxis: boolean; seamlessYAxis: boolean; @@ -227,23 +226,17 @@ export const generationSlice = createSlice({ const { image_name, width, height } = action.payload; state.initialImage = { imageName: image_name, width, height }; }, - modelSelected: (state, action: PayloadAction) => { - const [base_model, type, name] = action.payload.split('/'); + modelChanged: (state, action: PayloadAction) => { + if (!action.payload) { + state.model = null; + } - state.model = zMainModel.parse({ - id: action.payload, - base_model, - name, - type, - }); + state.model = zMainModel.parse(action.payload); // Clamp ClipSkip Based On Selected Model const { maxClip } = clipSkipMap[state.model.base_model]; state.clipSkip = clamp(state.clipSkip, 0, maxClip); }, - modelChanged: (state, action: PayloadAction) => { - state.model = action.payload; - }, vaeSelected: (state, action: PayloadAction) => { state.vae = action.payload; }, diff --git a/invokeai/frontend/web/src/features/parameters/store/parameterZodSchemas.ts b/invokeai/frontend/web/src/features/parameters/store/parameterZodSchemas.ts index 074162e5ab..16fbf0e155 100644 --- a/invokeai/frontend/web/src/features/parameters/store/parameterZodSchemas.ts +++ b/invokeai/frontend/web/src/features/parameters/store/parameterZodSchemas.ts @@ -135,8 +135,7 @@ export type BaseModelParam = z.infer; * TODO: Make this a dynamically generated enum? */ export const zMainModel = z.object({ - id: z.string(), - name: z.string(), + model_name: z.string(), base_model: zBaseModel, }); @@ -171,7 +170,7 @@ export const isValidVaeModel = (val: unknown): val is VaeModelParam => */ export const zLoRAModel = z.object({ id: z.string(), - name: z.string(), + model_name: z.string(), base_model: zBaseModel, }); /** diff --git a/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx b/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx index 6b5aa830d9..bc3da20b06 100644 --- a/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx +++ b/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx @@ -1,13 +1,16 @@ -import { memo, useCallback, useEffect, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIMantineSelect from 'common/components/IAIMantineSelect'; import { SelectItem } from '@mantine/core'; -import { RootState } from 'app/store/store'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { modelIdToMainModelField } from 'features/nodes/util/modelIdToMainModelField'; import { modelSelected } from 'features/parameters/store/actions'; -import { forEach, isString } from 'lodash-es'; +import { forEach } from 'lodash-es'; import { useGetMainModelsQuery } from 'services/api/endpoints/models'; export const MODEL_TYPE_MAP = { @@ -15,13 +18,17 @@ export const MODEL_TYPE_MAP = { 'sd-2': 'Stable Diffusion 2.x', }; +const selector = createSelector( + stateSelector, + (state) => ({ currentModel: state.generation.model }), + defaultSelectorOptions +); + const ModelSelect = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); - const currentModel = useAppSelector( - (state: RootState) => state.generation.model - ); + const { currentModel } = useAppSelector(selector); const { data: mainModels, isLoading } = useGetMainModelsQuery(); @@ -39,7 +46,7 @@ const ModelSelect = () => { data.push({ value: id, - label: model.name, + label: model.model_name, group: MODEL_TYPE_MAP[model.base_model], }); }); @@ -48,7 +55,10 @@ const ModelSelect = () => { }, [mainModels]); const selectedModel = useMemo( - () => mainModels?.entities[currentModel?.id || ''], + () => + mainModels?.entities[ + `${currentModel?.base_model}/main/${currentModel?.model_name}` + ], [mainModels?.entities, currentModel] ); @@ -57,31 +67,13 @@ const ModelSelect = () => { if (!v) { return; } - dispatch(modelSelected(v)); + + const modelField = modelIdToMainModelField(v); + dispatch(modelSelected(modelField)); }, [dispatch] ); - useEffect(() => { - if (isLoading) { - // return early here to avoid resetting model selection before we've loaded the available models - return; - } - - if (selectedModel && mainModels?.ids.includes(selectedModel?.id)) { - // the selected model is an available model, no need to change it - return; - } - - const firstModel = mainModels?.ids[0]; - - if (!isString(firstModel)) { - return; - } - - handleChangeModel(firstModel); - }, [handleChangeModel, isLoading, mainModels?.ids, selectedModel]); - return isLoading ? ( { tooltip={selectedModel?.description} label={t('modelManager.model')} value={selectedModel?.id} - placeholder={data.length > 0 ? 'Select a model' : 'No models detected!'} + placeholder={data.length > 0 ? 'Select a model' : 'No models available'} data={data} error={data.length === 0} + disabled={data.length === 0} onChange={handleChangeModel} /> ); diff --git a/invokeai/frontend/web/src/features/system/components/VAESelect.tsx b/invokeai/frontend/web/src/features/system/components/VAESelect.tsx index 50e82b0699..bed1b72123 100644 --- a/invokeai/frontend/web/src/features/system/components/VAESelect.tsx +++ b/invokeai/frontend/web/src/features/system/components/VAESelect.tsx @@ -50,7 +50,7 @@ const VAESelect = () => { data.push({ value: id, - label: model.name, + label: model.model_name, group: MODEL_TYPE_MAP[model.base_model], disabled, tooltip: disabled diff --git a/invokeai/frontend/web/src/features/system/store/configSlice.ts b/invokeai/frontend/web/src/features/system/store/configSlice.ts index c69d596b78..6cff92a136 100644 --- a/invokeai/frontend/web/src/features/system/store/configSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/configSlice.ts @@ -6,7 +6,7 @@ import { merge } from 'lodash-es'; export const initialConfigState: AppConfig = { shouldUpdateImagesOnConnect: false, disabledTabs: [], - disabledFeatures: ['lightbox', 'faceRestore'], + disabledFeatures: ['lightbox', 'faceRestore', 'batches'], disabledSDFeatures: [ 'variation', 'seamless', diff --git a/invokeai/frontend/web/src/features/system/store/systemSlice.ts b/invokeai/frontend/web/src/features/system/store/systemSlice.ts index 2de0f75963..01c1344263 100644 --- a/invokeai/frontend/web/src/features/system/store/systemSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/systemSlice.ts @@ -4,8 +4,14 @@ import * as InvokeAI from 'app/types/invokeai'; import { InvokeLogLevel } from 'app/logging/useLogger'; import { userInvoked } from 'app/store/actions'; +import { nodeTemplatesBuilt } from 'features/nodes/store/nodesSlice'; import { TFuncKey, t } from 'i18next'; import { LogLevelName } from 'roarr'; +import { imageUploaded } from 'services/api/thunks/image'; +import { + isAnySessionRejected, + sessionCanceled, +} from 'services/api/thunks/session'; import { appSocketConnected, appSocketDisconnected, @@ -18,19 +24,11 @@ import { appSocketUnsubscribed, } from 'services/events/actions'; import { ProgressImage } from 'services/events/types'; -import { imageUploaded } from 'services/api/thunks/image'; -import { - isAnySessionRejected, - sessionCanceled, -} from 'services/api/thunks/session'; import { makeToast } from '../../../app/components/Toaster'; import { LANGUAGES } from '../components/LanguagePicker'; -import { nodeTemplatesBuilt } from 'features/nodes/store/nodesSlice'; export type CancelStrategy = 'immediate' | 'scheduled'; -export type InfillMethod = 'tile' | 'patchmatch'; - export interface SystemState { isGFPGANAvailable: boolean; isESRGANAvailable: boolean; @@ -87,10 +85,6 @@ export interface SystemState { * When a session is canceled, its ID is stored here until a new session is created. */ canceledSession: string; - /** - * TODO: get this from backend - */ - infillMethods: InfillMethod[]; isPersisted: boolean; shouldAntialiasProgressImage: boolean; language: keyof typeof LANGUAGES; @@ -128,7 +122,6 @@ export const initialSystemState: SystemState = { shouldLogToConsole: true, statusTranslationKey: 'common.statusDisconnected', canceledSession: '', - infillMethods: ['tile', 'patchmatch'], isPersisted: false, language: 'en', isUploading: false, diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx index ef168285ee..a4e0773695 100644 --- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx +++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx @@ -15,7 +15,6 @@ import { RootState } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent'; -import { setIsLightboxOpen } from 'features/lightbox/store/lightboxSlice'; import { configSelector } from 'features/system/store/configSelectors'; import { InvokeTabName } from 'features/ui/store/tabMap'; import { setActiveTab, togglePanels } from 'features/ui/store/uiSlice'; @@ -38,7 +37,6 @@ import NodesTab from './tabs/Nodes/NodesTab'; import ResizeHandle from './tabs/ResizeHandle'; import TextToImageTab from './tabs/TextToImage/TextToImageTab'; import UnifiedCanvasTab from './tabs/UnifiedCanvas/UnifiedCanvasTab'; -import { useFeatureStatus } from '../../system/hooks/useFeatureStatus'; export interface InvokeTabInfo { id: InvokeTabName; @@ -105,10 +103,6 @@ const InvokeTabs = () => { const activeTab = useAppSelector(activeTabIndexSelector); const activeTabName = useAppSelector(activeTabNameSelector); const enabledTabs = useAppSelector(enabledTabsSelector); - const isLightBoxOpen = useAppSelector( - (state: RootState) => state.lightbox.isLightboxOpen - ); - const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled; const { shouldPinGallery, shouldPinParametersPanel, shouldShowGallery } = useAppSelector((state: RootState) => state.ui); @@ -117,17 +111,6 @@ const InvokeTabs = () => { const dispatch = useAppDispatch(); - // Lightbox Hotkey - useHotkeys( - 'z', - () => { - if (isLightboxEnabled) { - dispatch(setIsLightboxOpen(!isLightBoxOpen)); - } - }, - [isLightBoxOpen] - ); - useHotkeys( 'f', () => { diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx index 0777463ec4..ee9cc4bbb8 100644 --- a/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx +++ b/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx @@ -2,7 +2,6 @@ import { Flex } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors'; import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent'; import { activeTabNameSelector, @@ -12,19 +11,16 @@ import { setShouldShowParametersPanel } from 'features/ui/store/uiSlice'; import { memo, useMemo } from 'react'; import { PARAMETERS_PANEL_WIDTH } from 'theme/util/constants'; import PinParametersPanelButton from './PinParametersPanelButton'; -import OverlayScrollable from './common/OverlayScrollable'; import ResizableDrawer from './common/ResizableDrawer/ResizableDrawer'; import ImageToImageTabParameters from './tabs/ImageToImage/ImageToImageTabParameters'; import TextToImageTabParameters from './tabs/TextToImage/TextToImageTabParameters'; import UnifiedCanvasParameters from './tabs/UnifiedCanvas/UnifiedCanvasParameters'; const selector = createSelector( - [uiSelector, activeTabNameSelector, lightboxSelector], - (ui, activeTabName, lightbox) => { + [uiSelector, activeTabNameSelector], + (ui, activeTabName) => { const { shouldPinParametersPanel, shouldShowParametersPanel } = ui; - const { isLightboxOpen } = lightbox; - return { activeTabName, shouldPinParametersPanel, diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/CheckpointModelEdit.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/CheckpointModelEdit.tsx index 8f70a4f322..30ec4cec32 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/CheckpointModelEdit.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/CheckpointModelEdit.tsx @@ -52,9 +52,9 @@ export default function CheckpointModelEdit(props: CheckpointModelEditProps) { const checkpointEditForm = useForm({ initialValues: { - name: retrievedModel.name ? retrievedModel.name : '', + model_name: retrievedModel.model_name ? retrievedModel.model_name : '', base_model: retrievedModel.base_model, - type: 'main', + model_type: 'main', path: retrievedModel.path ? retrievedModel.path : '', description: retrievedModel.description ? retrievedModel.description : '', model_format: 'checkpoint', @@ -102,7 +102,7 @@ export default function CheckpointModelEdit(props: CheckpointModelEditProps) { - {retrievedModel.name} + {retrievedModel.model_name} {MODEL_TYPE_MAP[retrievedModel.base_model]} Model diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/DiffusersModelEdit.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/DiffusersModelEdit.tsx index 776290bf08..343fa729fe 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/DiffusersModelEdit.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/DiffusersModelEdit.tsx @@ -49,9 +49,9 @@ export default function DiffusersModelEdit(props: DiffusersModelEditProps) { const diffusersEditForm = useForm({ initialValues: { - name: retrievedModel.name ? retrievedModel.name : '', + model_name: retrievedModel.model_name ? retrievedModel.model_name : '', base_model: retrievedModel.base_model, - type: 'main', + model_type: 'main', path: retrievedModel.path ? retrievedModel.path : '', description: retrievedModel.description ? retrievedModel.description : '', model_format: 'diffusers', @@ -97,7 +97,7 @@ export default function DiffusersModelEdit(props: DiffusersModelEditProps) { - {retrievedModel.name} + {retrievedModel.model_name} {MODEL_TYPE_MAP[retrievedModel.base_model]} Model diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelList.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelList.tsx index 803821f0e9..655b8c4de3 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelList.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelList.tsx @@ -71,12 +71,14 @@ const ModelList = () => { // If no model info found for a model, ignore it if (!modelInfo) return; - if (modelInfo.name.toLowerCase().includes(searchText.toLowerCase())) { + if ( + modelInfo.model_name.toLowerCase().includes(searchText.toLowerCase()) + ) { filteredModelListItemsToRender.push( ); @@ -85,7 +87,7 @@ const ModelList = () => { ); @@ -97,7 +99,7 @@ const ModelList = () => { ); @@ -106,7 +108,7 @@ const ModelList = () => { ); diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/TextToImage/TextToImageTabMain.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/TextToImage/TextToImageTabMain.tsx index de21cb14eb..1864e3d043 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/TextToImage/TextToImageTabMain.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/TextToImage/TextToImageTabMain.tsx @@ -1,5 +1,5 @@ import { Box, Flex } from '@chakra-ui/react'; -import CurrentImageDisplay from 'features/gallery/components/CurrentImageDisplay'; +import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay'; const TextToImageTabMain = () => { return ( diff --git a/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts b/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts index 999f7a1035..f76b56761c 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts @@ -1,5 +1,5 @@ import { api } from '..'; -import { AppVersion } from '../types'; +import { AppConfig, AppVersion } from '../types'; export const appInfoApi = api.injectEndpoints({ endpoints: (build) => ({ @@ -8,8 +8,16 @@ export const appInfoApi = api.injectEndpoints({ url: `app/version`, method: 'GET', }), + keepUnusedDataFor: 86400000, // 1 day + }), + getAppConfig: build.query({ + query: () => ({ + url: `app/config`, + method: 'GET', + }), + keepUnusedDataFor: 86400000, // 1 day }), }), }); -export const { useGetAppVersionQuery } = appInfoApi; +export const { useGetAppVersionQuery, useGetAppConfigQuery } = appInfoApi; diff --git a/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts b/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts index a0db3f3dff..f7a486e8fc 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts @@ -1,11 +1,10 @@ import { OffsetPaginatedResults_ImageDTO_ } from 'services/api/types'; -import { api } from '..'; +import { ApiFullTagDescription, LIST_TAG, api } from '..'; import { paths } from '../schema'; -import { imagesApi } from './images'; type ListBoardImagesArg = paths['/api/v1/board_images/{board_id}']['get']['parameters']['path'] & - paths['/api/v1/board_images/{board_id}']['get']['parameters']['query']; + paths['/api/v1/board_images/{board_id}']['get']['parameters']['query']; type AddImageToBoardArg = paths['/api/v1/board_images/']['post']['requestBody']['content']['application/json']; @@ -25,9 +24,25 @@ export const boardImagesApi = api.injectEndpoints({ >({ query: ({ board_id, offset, limit }) => ({ url: `board_images/${board_id}`, - method: 'DELETE', - body: { offset, limit }, + method: 'GET', + }), + providesTags: (result, error, arg) => { + // any list of boardimages + const tags: ApiFullTagDescription[] = [{ id: 'BoardImage', type: `${arg.board_id}_${LIST_TAG}` }]; + + if (result) { + // and individual tags for each boardimage + tags.push( + ...result.items.map(({ board_id, image_name }) => ({ + type: 'BoardImage' as const, + id: `${board_id}_${image_name}`, + })) + ); + } + + return tags; + }, }), /** @@ -41,23 +56,9 @@ export const boardImagesApi = api.injectEndpoints({ body: { board_id, image_name }, }), invalidatesTags: (result, error, arg) => [ - { type: 'Board', id: arg.board_id }, + { type: 'BoardImage' }, + { type: 'Board', id: arg.board_id } ], - 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({ @@ -67,23 +68,9 @@ export const boardImagesApi = api.injectEndpoints({ body: { board_id, image_name }, }), invalidatesTags: (result, error, arg) => [ - { type: 'Board', id: arg.board_id }, + { type: 'BoardImage' }, + { type: 'Board', id: arg.board_id } ], - 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/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 5090fc4fc1..d49ab5f131 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -1,13 +1,22 @@ import { ApiFullTagDescription, api } from '..'; +import { components } from '../schema'; import { ImageDTO } from '../types'; +/** + * This is an unsafe type; the object inside is not guaranteed to be valid. + */ +export type UnsafeImageMetadata = { + metadata: components['schemas']['CoreMetadata']; + graph: NonNullable; +}; + export const imagesApi = api.injectEndpoints({ endpoints: (build) => ({ /** * Image Queries */ getImageDTO: build.query({ - query: (image_name) => ({ url: `images/${image_name}/metadata` }), + query: (image_name) => ({ url: `images/${image_name}` }), providesTags: (result, error, arg) => { const tags: ApiFullTagDescription[] = [{ type: 'Image', id: arg }]; if (result?.board_id) { @@ -17,7 +26,17 @@ export const imagesApi = api.injectEndpoints({ }, keepUnusedDataFor: 86400, // 24 hours }), + getImageMetadata: build.query({ + query: (image_name) => ({ url: `images/${image_name}/metadata` }), + providesTags: (result, error, arg) => { + const tags: ApiFullTagDescription[] = [ + { type: 'ImageMetadata', id: arg }, + ]; + return tags; + }, + keepUnusedDataFor: 86400, // 24 hours + }), }), }); -export const { useGetImageDTOQuery } = imagesApi; +export const { useGetImageDTOQuery, useGetImageMetadataQuery } = imagesApi; diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index cea586a3ff..95b2146448 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -56,25 +56,28 @@ type MergeMainModelQuery = { }; const mainModelsAdapter = createEntityAdapter({ - sortComparer: (a, b) => a.name.localeCompare(b.name), + sortComparer: (a, b) => a.model_name.localeCompare(b.model_name), }); const loraModelsAdapter = createEntityAdapter({ - sortComparer: (a, b) => a.name.localeCompare(b.name), + sortComparer: (a, b) => a.model_name.localeCompare(b.model_name), }); const controlNetModelsAdapter = createEntityAdapter({ - sortComparer: (a, b) => a.name.localeCompare(b.name), + sortComparer: (a, b) => a.model_name.localeCompare(b.model_name), }); const textualInversionModelsAdapter = createEntityAdapter({ - sortComparer: (a, b) => a.name.localeCompare(b.name), + sortComparer: (a, b) => a.model_name.localeCompare(b.model_name), }); const vaeModelsAdapter = createEntityAdapter({ - sortComparer: (a, b) => a.name.localeCompare(b.name), + sortComparer: (a, b) => a.model_name.localeCompare(b.model_name), }); -export const getModelId = ({ base_model, type, name }: AnyModelConfig) => - `${base_model}/${type}/${name}`; +export const getModelId = ({ + base_model, + model_type, + model_name, +}: AnyModelConfig) => `${base_model}/${model_type}/${model_name}`; const createModelEntities = ( models: AnyModelConfig[] diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index f91c3b90fd..33eb7c35c6 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -1,3 +1,4 @@ +import { FullTagDescription } from '@reduxjs/toolkit/dist/query/endpointDefinitions'; import { BaseQueryFn, FetchArgs, @@ -5,10 +6,9 @@ import { createApi, fetchBaseQuery, } from '@reduxjs/toolkit/query/react'; -import { FullTagDescription } from '@reduxjs/toolkit/dist/query/endpointDefinitions'; import { $authToken, $baseUrl } from 'services/api/client'; -export const tagTypes = ['Board', 'Image', 'Model']; +export const tagTypes = ['Board', 'Image', 'ImageMetadata', 'Model']; export type ApiFullTagDescription = FullTagDescription< (typeof tagTypes)[number] >; diff --git a/invokeai/frontend/web/src/services/api/schema.d.ts b/invokeai/frontend/web/src/services/api/schema.d.ts index 69aaa90e21..3da7a0bf8d 100644 --- a/invokeai/frontend/web/src/services/api/schema.d.ts +++ b/invokeai/frontend/web/src/services/api/schema.d.ts @@ -109,10 +109,10 @@ export type paths = { }; "/api/v1/images/": { /** - * List Images With Metadata - * @description Gets a list of images + * List Image Dtos + * @description Gets a list of image DTOs */ - get: operations["list_images_with_metadata"]; + get: operations["list_image_dtos"]; /** * Upload Image * @description Uploads an image @@ -121,10 +121,10 @@ export type paths = { }; "/api/v1/images/{image_name}": { /** - * Get Image Full - * @description Gets a full-resolution image file + * Get Image Dto + * @description Gets an image's DTO */ - get: operations["get_image_full"]; + get: operations["get_image_dto"]; /** * Delete Image * @description Deletes an image @@ -143,6 +143,13 @@ export type paths = { */ get: operations["get_image_metadata"]; }; + "/api/v1/images/{image_name}/full": { + /** + * Get Image Full + * @description Gets a full-resolution image file + */ + get: operations["get_image_full"]; + }; "/api/v1/images/{image_name}/thumbnail": { /** * Get Image Thumbnail @@ -209,6 +216,10 @@ export type paths = { /** Get Version */ get: operations["app_version"]; }; + "/api/v1/app/config": { + /** Get Config */ + get: operations["get_config"]; + }; }; export type webhooks = Record; @@ -250,12 +261,26 @@ export type components = { */ b?: number; }; + /** + * AppConfig + * @description App Config Response + */ + AppConfig: { + /** + * Infill Methods + * @description List of available infill methods + */ + infill_methods: (string)[]; + }; /** * AppVersion * @description App Version Response */ AppVersion: { - /** Version */ + /** + * Version + * @description App version + */ version: string; }; /** @@ -798,14 +823,14 @@ export type components = { }; /** ControlNetModelConfig */ ControlNetModelConfig: { - /** Name */ - name: string; + /** Model Name */ + model_name: string; base_model: components["schemas"]["BaseModelType"]; /** - * Type + * Model Type * @enum {string} */ - type: "controlnet"; + model_type: "controlnet"; /** Path */ path: string; /** Description */ @@ -836,6 +861,97 @@ export type components = { */ control?: components["schemas"]["ControlField"]; }; + /** + * CoreMetadata + * @description Core generation metadata for an image generated in InvokeAI. + */ + CoreMetadata: { + /** + * Generation Mode + * @description The generation mode that output this image + */ + generation_mode: string; + /** + * Positive Prompt + * @description The positive prompt parameter + */ + positive_prompt: string; + /** + * Negative Prompt + * @description The negative prompt parameter + */ + negative_prompt: string; + /** + * Width + * @description The width parameter + */ + width: number; + /** + * Height + * @description The height parameter + */ + height: number; + /** + * Seed + * @description The seed used for noise generation + */ + seed: number; + /** + * Rand Device + * @description The device used for random number generation + */ + rand_device: string; + /** + * Cfg Scale + * @description The classifier-free guidance scale parameter + */ + cfg_scale: number; + /** + * Steps + * @description The number of steps used for inference + */ + steps: number; + /** + * Scheduler + * @description The scheduler used for inference + */ + scheduler: string; + /** + * Clip Skip + * @description The number of skipped CLIP layers + */ + clip_skip: number; + /** + * Model + * @description The main model used for inference + */ + model: components["schemas"]["MainModelField"]; + /** + * Controlnets + * @description The ControlNets used for inference + */ + controlnets: (components["schemas"]["ControlField"])[]; + /** + * Loras + * @description The LoRAs used for inference + */ + loras: (components["schemas"]["LoRAMetadataField"])[]; + /** + * Strength + * @description The strength used for latents-to-latents + */ + strength?: number; + /** + * Init Image + * @description The name of the initial image + */ + init_image?: string; + /** + * Vae + * @description The VAE used for decoding, if the main model's default was not used + */ + vae?: components["schemas"]["VAEModelField"]; + }; /** * CvInpaintInvocation * @description Simple inpaint using opencv. @@ -1058,7 +1174,7 @@ export type components = { * @description The nodes in this graph */ nodes?: { - [key: string]: (components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | 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"]["CvInpaintInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["UpscaleInvocation"] | 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; + [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"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["MetadataAccumulatorInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | 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 @@ -1101,7 +1217,7 @@ export type components = { * @description The results of node executions */ results: { - [key: string]: (components["schemas"]["IntCollectionOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["VaeLoaderOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["ClipSkipInvocationOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["IntOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | 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"]["VaeLoaderOutput"] | components["schemas"]["MetadataAccumulatorOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["ClipSkipInvocationOutput"] | 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 @@ -1502,11 +1618,6 @@ export type components = { * @description The node ID that generated this image, if it is a generated image. */ node_id?: string; - /** - * Metadata - * @description A limited subset of the image's generation metadata. Retrieve the image's session for full metadata. - */ - metadata?: components["schemas"]["ImageMetadata"]; /** * Board Id * @description The id of the board the image belongs to, if one exists. @@ -1606,96 +1717,19 @@ export type components = { }; /** * ImageMetadata - * @description Core generation metadata for an image/tensor generated in InvokeAI. - * - * Also includes any metadata from the image's PNG tEXt chunks. - * - * Generated by traversing the execution graph, collecting the parameters of the nearest ancestors - * of a given node. - * - * Full metadata may be accessed by querying for the session in the `graph_executions` table. + * @description An image's generation metadata */ ImageMetadata: { /** - * Type - * @description The type of the ancestor node of the image output node. + * Metadata + * @description The image's core metadata, if it was created in the Linear or Canvas UI */ - type?: string; + metadata?: Record; /** - * Positive Conditioning - * @description The positive conditioning. + * Graph + * @description The graph that created the image */ - positive_conditioning?: string; - /** - * Negative Conditioning - * @description The negative conditioning. - */ - negative_conditioning?: string; - /** - * Width - * @description Width of the image/latents in pixels. - */ - width?: number; - /** - * Height - * @description Height of the image/latents in pixels. - */ - height?: number; - /** - * Seed - * @description The seed used for noise generation. - */ - seed?: number; - /** - * Cfg Scale - * @description The classifier-free guidance scale. - */ - cfg_scale?: number | (number)[]; - /** - * Steps - * @description The number of steps used for inference. - */ - steps?: number; - /** - * Scheduler - * @description The scheduler used for inference. - */ - scheduler?: string; - /** - * Model - * @description The model used for inference. - */ - model?: string; - /** - * Strength - * @description The strength used for image-to-image/latents-to-latents. - */ - strength?: number; - /** - * Latents - * @description The ID of the initial latents. - */ - latents?: string; - /** - * Vae - * @description The VAE used for decoding. - */ - vae?: string; - /** - * Unet - * @description The UNet used dor inference. - */ - unet?: string; - /** - * Clip - * @description The CLIP Encoder used for conditioning. - */ - clip?: string; - /** - * Extra - * @description Uploaded image metadata, extracted from the PNG tEXt chunk. - */ - extra?: string; + graph?: Record; }; /** * ImageMultiplyInvocation @@ -2436,6 +2470,11 @@ export type components = { * @default false */ tiled?: boolean; + /** + * Metadata + * @description Optional core metadata to be written to the image + */ + metadata?: components["schemas"]["CoreMetadata"]; }; /** * LatentsToLatentsInvocation @@ -2659,16 +2698,32 @@ export type components = { */ coarse?: boolean; }; + /** + * LoRAMetadataField + * @description LoRA metadata for an image generated in InvokeAI. + */ + LoRAMetadataField: { + /** + * Lora + * @description The LoRA model + */ + lora: components["schemas"]["LoRAModelField"]; + /** + * Weight + * @description The weight of the LoRA model + */ + weight: number; + }; /** LoRAModelConfig */ LoRAModelConfig: { - /** Name */ - name: string; + /** Model Name */ + model_name: string; base_model: components["schemas"]["BaseModelType"]; /** - * Type + * Model Type * @enum {string} */ - type: "lora"; + model_type: "lora"; /** Path */ path: string; /** Description */ @@ -2956,6 +3011,131 @@ export type components = { * @enum {string} */ MergeInterpolationMethod: "weighted_sum" | "sigmoid" | "inv_sigmoid" | "add_difference"; + /** + * MetadataAccumulatorInvocation + * @description Outputs a Core Metadata Object + */ + MetadataAccumulatorInvocation: { + /** + * 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 metadata_accumulator + * @enum {string} + */ + type?: "metadata_accumulator"; + /** + * Generation Mode + * @description The generation mode that output this image + */ + generation_mode: string; + /** + * Positive Prompt + * @description The positive prompt parameter + */ + positive_prompt: string; + /** + * Negative Prompt + * @description The negative prompt parameter + */ + negative_prompt: string; + /** + * Width + * @description The width parameter + */ + width: number; + /** + * Height + * @description The height parameter + */ + height: number; + /** + * Seed + * @description The seed used for noise generation + */ + seed: number; + /** + * Rand Device + * @description The device used for random number generation + */ + rand_device: string; + /** + * Cfg Scale + * @description The classifier-free guidance scale parameter + */ + cfg_scale: number; + /** + * Steps + * @description The number of steps used for inference + */ + steps: number; + /** + * Scheduler + * @description The scheduler used for inference + */ + scheduler: string; + /** + * Clip Skip + * @description The number of skipped CLIP layers + */ + clip_skip: number; + /** + * Model + * @description The main model used for inference + */ + model: components["schemas"]["MainModelField"]; + /** + * Controlnets + * @description The ControlNets used for inference + */ + controlnets: (components["schemas"]["ControlField"])[]; + /** + * Loras + * @description The LoRAs used for inference + */ + loras: (components["schemas"]["LoRAMetadataField"])[]; + /** + * Strength + * @description The strength used for latents-to-latents + */ + strength?: number; + /** + * Init Image + * @description The name of the initial image + */ + init_image?: string; + /** + * Vae + * @description The VAE used for decoding, if the main model's default was not used + */ + vae?: components["schemas"]["VAEModelField"]; + }; + /** + * MetadataAccumulatorOutput + * @description The output of the MetadataAccumulator node + */ + MetadataAccumulatorOutput: { + /** + * Type + * @default metadata_accumulator_output + * @enum {string} + */ + type?: "metadata_accumulator_output"; + /** + * Metadata + * @description The core metadata for the image + */ + metadata: components["schemas"]["CoreMetadata"]; + }; /** * MidasDepthImageProcessorInvocation * @description Applies Midas depth processing to image @@ -3110,7 +3290,7 @@ export type components = { /** ModelsList */ ModelsList: { /** Models */ - models: (components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"])[]; + models: (components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"])[]; }; /** * MultiplyInvocation @@ -3900,14 +4080,14 @@ export type components = { }; /** StableDiffusion1ModelCheckpointConfig */ StableDiffusion1ModelCheckpointConfig: { - /** Name */ - name: string; + /** Model Name */ + model_name: string; base_model: components["schemas"]["BaseModelType"]; /** - * Type + * Model Type * @enum {string} */ - type: "main"; + model_type: "main"; /** Path */ path: string; /** Description */ @@ -3926,14 +4106,14 @@ export type components = { }; /** StableDiffusion1ModelDiffusersConfig */ StableDiffusion1ModelDiffusersConfig: { - /** Name */ - name: string; + /** Model Name */ + model_name: string; base_model: components["schemas"]["BaseModelType"]; /** - * Type + * Model Type * @enum {string} */ - type: "main"; + model_type: "main"; /** Path */ path: string; /** Description */ @@ -3950,14 +4130,14 @@ export type components = { }; /** StableDiffusion2ModelCheckpointConfig */ StableDiffusion2ModelCheckpointConfig: { - /** Name */ - name: string; + /** Model Name */ + model_name: string; base_model: components["schemas"]["BaseModelType"]; /** - * Type + * Model Type * @enum {string} */ - type: "main"; + model_type: "main"; /** Path */ path: string; /** Description */ @@ -3976,14 +4156,14 @@ export type components = { }; /** StableDiffusion2ModelDiffusersConfig */ StableDiffusion2ModelDiffusersConfig: { - /** Name */ - name: string; + /** Model Name */ + model_name: string; base_model: components["schemas"]["BaseModelType"]; /** - * Type + * Model Type * @enum {string} */ - type: "main"; + model_type: "main"; /** Path */ path: string; /** Description */ @@ -4190,14 +4370,14 @@ export type components = { }; /** TextualInversionModelConfig */ TextualInversionModelConfig: { - /** Name */ - name: string; + /** Model Name */ + model_name: string; base_model: components["schemas"]["BaseModelType"]; /** - * Type + * Model Type * @enum {string} */ - type: "embedding"; + model_type: "embedding"; /** Path */ path: string; /** Description */ @@ -4367,14 +4547,14 @@ export type components = { }; /** VaeModelConfig */ VaeModelConfig: { - /** Name */ - name: string; + /** Model Name */ + model_name: string; base_model: components["schemas"]["BaseModelType"]; /** - * Type + * Model Type * @enum {string} */ - type: "vae"; + model_type: "vae"; /** Path */ path: string; /** Description */ @@ -4425,18 +4605,18 @@ export type components = { */ image?: components["schemas"]["ImageField"]; }; - /** - * StableDiffusion2ModelFormat - * @description An enumeration. - * @enum {string} - */ - StableDiffusion2ModelFormat: "checkpoint" | "diffusers"; /** * StableDiffusion1ModelFormat * @description An enumeration. * @enum {string} */ StableDiffusion1ModelFormat: "checkpoint" | "diffusers"; + /** + * StableDiffusion2ModelFormat + * @description An enumeration. + * @enum {string} + */ + StableDiffusion2ModelFormat: "checkpoint" | "diffusers"; }; responses: never; parameters: never; @@ -4547,7 +4727,7 @@ export type operations = { }; requestBody: { content: { - "application/json": components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | 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"]["CvInpaintInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["UpscaleInvocation"] | 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"]; + "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"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["MetadataAccumulatorInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | 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: { @@ -4584,7 +4764,7 @@ export type operations = { }; requestBody: { content: { - "application/json": components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | 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"]["CvInpaintInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["UpscaleInvocation"] | 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"]; + "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"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["MetadataAccumulatorInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | 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: { @@ -4817,7 +4997,7 @@ export type operations = { /** @description The model imported successfully */ 201: { content: { - "application/json": components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"]; + "application/json": components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"]; }; }; /** @description The model could not be found */ @@ -4885,14 +5065,14 @@ export type operations = { }; requestBody: { content: { - "application/json": components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"]; + "application/json": components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"]; }; }; responses: { /** @description The model was updated successfully */ 200: { content: { - "application/json": components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"]; + "application/json": components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"]; }; }; /** @description Bad request */ @@ -4926,7 +5106,7 @@ export type operations = { /** @description Model converted successfully */ 200: { content: { - "application/json": components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"]; + "application/json": components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"]; }; }; /** @description Bad request */ @@ -4961,7 +5141,7 @@ export type operations = { /** @description Model converted successfully */ 200: { content: { - "application/json": components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"]; + "application/json": components["schemas"]["StableDiffusion1ModelDiffusersConfig"] | components["schemas"]["StableDiffusion1ModelCheckpointConfig"] | components["schemas"]["VaeModelConfig"] | components["schemas"]["LoRAModelConfig"] | components["schemas"]["ControlNetModelConfig"] | components["schemas"]["TextualInversionModelConfig"] | components["schemas"]["StableDiffusion2ModelCheckpointConfig"] | components["schemas"]["StableDiffusion2ModelDiffusersConfig"]; }; }; /** @description Incompatible models */ @@ -4977,10 +5157,10 @@ export type operations = { }; }; /** - * List Images With Metadata - * @description Gets a list of images + * List Image Dtos + * @description Gets a list of image DTOs */ - list_images_with_metadata: { + list_image_dtos: { parameters: { query?: { /** @description The origin of images to list */ @@ -5050,25 +5230,23 @@ export type operations = { }; }; /** - * Get Image Full - * @description Gets a full-resolution image file + * Get Image Dto + * @description Gets an image's DTO */ - get_image_full: { + get_image_dto: { parameters: { path: { - /** @description The name of full-resolution image file to get */ + /** @description The name of image to get */ image_name: string; }; }; responses: { - /** @description Return the full-resolution image */ + /** @description Successful Response */ 200: { content: { - "image/png": unknown; + "application/json": components["schemas"]["ImageDTO"]; }; }; - /** @description Image not found */ - 404: never; /** @description Validation Error */ 422: { content: { @@ -5149,7 +5327,7 @@ export type operations = { /** @description Successful Response */ 200: { content: { - "application/json": components["schemas"]["ImageDTO"]; + "application/json": components["schemas"]["ImageMetadata"]; }; }; /** @description Validation Error */ @@ -5160,6 +5338,34 @@ export type operations = { }; }; }; + /** + * Get Image Full + * @description Gets a full-resolution image file + */ + get_image_full: { + parameters: { + path: { + /** @description The name of full-resolution image file to get */ + image_name: string; + }; + }; + responses: { + /** @description Return the full-resolution image */ + 200: { + content: { + "image/png": unknown; + }; + }; + /** @description Image not found */ + 404: never; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; /** * Get Image Thumbnail * @description Gets a thumbnail image file @@ -5450,4 +5656,15 @@ export type operations = { }; }; }; + /** Get Config */ + get_config: { + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["AppConfig"]; + }; + }; + }; + }; }; diff --git a/invokeai/frontend/web/src/services/api/thunks/image.ts b/invokeai/frontend/web/src/services/api/thunks/image.ts index 71eedb0327..09271c3625 100644 --- a/invokeai/frontend/web/src/services/api/thunks/image.ts +++ b/invokeai/frontend/web/src/services/api/thunks/image.ts @@ -1,9 +1,13 @@ -import queryString from 'query-string'; import { createAppAsyncThunk } from 'app/store/storeUtils'; -import { selectImagesAll } from 'features/gallery/store/gallerySlice'; +import { selectFilteredImages } from 'features/gallery/store/gallerySelectors'; +import { + ASSETS_CATEGORIES, + IMAGE_CATEGORIES, +} from 'features/gallery/store/gallerySlice'; import { size } from 'lodash-es'; -import { paths } from 'services/api/schema'; +import queryString from 'query-string'; import { $client } from 'services/api/client'; +import { paths } from 'services/api/schema'; type GetImageUrlsArg = paths['/api/v1/images/{image_name}/urls']['get']['parameters']['path']; @@ -24,7 +28,7 @@ export const imageUrlsReceived = createAppAsyncThunk< GetImageUrlsResponse, GetImageUrlsArg, GetImageUrlsThunkConfig ->('api/imageUrlsReceived', async (arg, { rejectWithValue }) => { +>('thunkApi/imageUrlsReceived', async (arg, { rejectWithValue }) => { const { image_name } = arg; const { get } = $client.get(); const { data, error, response } = await get( @@ -46,10 +50,10 @@ export const imageUrlsReceived = createAppAsyncThunk< }); type GetImageMetadataArg = - paths['/api/v1/images/{image_name}/metadata']['get']['parameters']['path']; + paths['/api/v1/images/{image_name}']['get']['parameters']['path']; type GetImageMetadataResponse = - paths['/api/v1/images/{image_name}/metadata']['get']['responses']['200']['content']['application/json']; + paths['/api/v1/images/{image_name}']['get']['responses']['200']['content']['application/json']; type GetImageMetadataThunkConfig = { rejectValue: { @@ -58,21 +62,18 @@ type GetImageMetadataThunkConfig = { }; }; -export const imageMetadataReceived = createAppAsyncThunk< +export const imageDTOReceived = createAppAsyncThunk< GetImageMetadataResponse, GetImageMetadataArg, GetImageMetadataThunkConfig ->('api/imageMetadataReceived', async (arg, { rejectWithValue }) => { +>('thunkApi/imageMetadataReceived', async (arg, { rejectWithValue }) => { const { image_name } = arg; const { get } = $client.get(); - const { data, error, response } = await get( - '/api/v1/images/{image_name}/metadata', - { - params: { - path: { image_name }, - }, - } - ); + const { data, error, response } = await get('/api/v1/images/{image_name}', { + params: { + path: { image_name }, + }, + }); if (error) { return rejectWithValue({ arg, error }); @@ -148,7 +149,7 @@ export const imageUploaded = createAppAsyncThunk< UploadImageResponse, UploadImageArg, UploadImageThunkConfig ->('api/imageUploaded', async (arg, { rejectWithValue }) => { +>('thunkApi/imageUploaded', async (arg, { rejectWithValue }) => { const { postUploadAction, file, @@ -199,7 +200,7 @@ export const imageDeleted = createAppAsyncThunk< DeleteImageResponse, DeleteImageArg, DeleteImageThunkConfig ->('api/imageDeleted', async (arg, { rejectWithValue }) => { +>('thunkApi/imageDeleted', async (arg, { rejectWithValue }) => { const { image_name } = arg; const { del } = $client.get(); const { data, error, response } = await del('/api/v1/images/{image_name}', { @@ -235,7 +236,7 @@ export const imageUpdated = createAppAsyncThunk< UpdateImageResponse, UpdateImageArg, UpdateImageThunkConfig ->('api/imageUpdated', async (arg, { rejectWithValue }) => { +>('thunkApi/imageUpdated', async (arg, { rejectWithValue }) => { const { image_name, image_category, is_intermediate, session_id } = arg; const { patch } = $client.get(); const { data, error, response } = await patch('/api/v1/images/{image_name}', { @@ -284,46 +285,46 @@ export const receivedPageOfImages = createAppAsyncThunk< ListImagesResponse, ListImagesArg, ListImagesThunkConfig ->('api/receivedPageOfImages', async (arg, { getState, rejectWithValue }) => { - const { get } = $client.get(); +>( + 'thunkApi/receivedPageOfImages', + async (arg, { getState, rejectWithValue }) => { + const { get } = $client.get(); - const state = getState(); - const { categories, selectedBoardId } = state.gallery; + const state = getState(); - const images = selectImagesAll(state).filter((i) => { - const isInCategory = categories.includes(i.image_category); - const isInSelectedBoard = selectedBoardId - ? i.board_id === selectedBoardId - : true; - return isInCategory && isInSelectedBoard; - }); + const images = selectFilteredImages(state); + const categories = + state.gallery.galleryView === 'images' + ? IMAGE_CATEGORIES + : ASSETS_CATEGORIES; - let query: ListImagesArg = {}; + let query: ListImagesArg = {}; - if (size(arg)) { - query = { - ...DEFAULT_IMAGES_LISTED_ARG, - offset: images.length, - ...arg, - }; - } else { - query = { - ...DEFAULT_IMAGES_LISTED_ARG, - categories, - offset: images.length, - }; + if (size(arg)) { + query = { + ...DEFAULT_IMAGES_LISTED_ARG, + offset: images.length, + ...arg, + }; + } else { + query = { + ...DEFAULT_IMAGES_LISTED_ARG, + categories, + offset: images.length, + }; + } + + const { data, error, response } = await get('/api/v1/images/', { + params: { + query, + }, + querySerializer: (q) => queryString.stringify(q, { arrayFormat: 'none' }), + }); + + if (error) { + return rejectWithValue({ arg, error }); + } + + return data; } - - const { data, error, response } = await get('/api/v1/images/', { - params: { - query, - }, - querySerializer: (q) => queryString.stringify(q, { arrayFormat: 'none' }), - }); - - if (error) { - return rejectWithValue({ arg, error }); - } - - return data; -}); +); diff --git a/invokeai/frontend/web/src/services/api/types.d.ts b/invokeai/frontend/web/src/services/api/types.d.ts index 9c154cbc46..c2657701e7 100644 --- a/invokeai/frontend/web/src/services/api/types.d.ts +++ b/invokeai/frontend/web/src/services/api/types.d.ts @@ -10,6 +10,7 @@ type TypeReq = O.Required; // App Info export type AppVersion = components['schemas']['AppVersion']; +export type AppConfig = components['schemas']['AppConfig']; // Images export type ImageDTO = components['schemas']['ImageDTO']; @@ -19,6 +20,7 @@ export type ImageChanges = components['schemas']['ImageRecordChanges']; export type ImageCategory = components['schemas']['ImageCategory']; export type ResourceOrigin = components['schemas']['ResourceOrigin']; export type ImageField = components['schemas']['ImageField']; +export type ImageMetadata = components['schemas']['ImageMetadata']; export type OffsetPaginatedResults_BoardDTO_ = components['schemas']['OffsetPaginatedResults_BoardDTO_']; export type OffsetPaginatedResults_ImageDTO_ = @@ -31,6 +33,7 @@ export type MainModelField = components['schemas']['MainModelField']; export type VAEModelField = components['schemas']['VAEModelField']; export type LoRAModelField = components['schemas']['LoRAModelField']; export type ModelsList = components['schemas']['ModelsList']; +export type ControlField = components['schemas']['ControlField']; // Model Configs export type LoRAModelConfig = components['schemas']['LoRAModelConfig']; @@ -108,6 +111,9 @@ export type MainModelLoaderInvocation = TypeReq< export type LoraLoaderInvocation = TypeReq< components['schemas']['LoraLoaderInvocation'] >; +export type MetadataAccumulatorInvocation = TypeReq< + components['schemas']['MetadataAccumulatorInvocation'] +>; // ControlNet Nodes export type ControlNetInvocation = TypeReq< diff --git a/invokeai/frontend/web/yarn.lock b/invokeai/frontend/web/yarn.lock index c1ac8e9c7a..2db168a8ce 100644 --- a/invokeai/frontend/web/yarn.lock +++ b/invokeai/frontend/web/yarn.lock @@ -6409,6 +6409,11 @@ use-composed-ref@^1.3.0: resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda" integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ== +use-debounce@^9.0.4: + version "9.0.4" + resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-9.0.4.tgz#51d25d856fbdfeb537553972ce3943b897f1ac85" + integrity sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ== + use-image@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/use-image/-/use-image-1.1.1.tgz#bdd3f2e1718393ffc0e56136f993467103d9d2df"