Merge branch 'main' into lstein/feat/simple-mm2-api

This commit is contained in:
Lincoln Stein 2024-05-17 22:54:03 -04:00 committed by GitHub
commit 987ee704a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
241 changed files with 10422 additions and 7910 deletions

View File

@ -98,7 +98,7 @@ Updating is exactly the same as installing - download the latest installer, choo
If you have installation issues, please review the [FAQ]. You can also [create an issue] or ask for help on [discord]. If you have installation issues, please review the [FAQ]. You can also [create an issue] or ask for help on [discord].
[installation requirements]: INSTALLATION.md#installation-requirements [installation requirements]: INSTALL_REQUIREMENTS.md
[FAQ]: ../help/FAQ.md [FAQ]: ../help/FAQ.md
[install some models]: 050_INSTALLING_MODELS.md [install some models]: 050_INSTALLING_MODELS.md
[configuration docs]: ../features/CONFIGURATION.md [configuration docs]: ../features/CONFIGURATION.md

View File

@ -37,13 +37,13 @@ Invoke runs best with a dedicated GPU, but will fall back to running on CPU, alb
=== "Nvidia" === "Nvidia"
``` ```
Any GPU with at least 8GB VRAM. Linux only. Any GPU with at least 8GB VRAM.
``` ```
=== "AMD" === "AMD"
``` ```
Any GPU with at least 16GB VRAM. Any GPU with at least 16GB VRAM. Linux only.
``` ```
=== "Mac" === "Mac"

View File

@ -13,7 +13,6 @@ from pydantic import BaseModel, Field
from invokeai.app.invocations.upscale import ESRGAN_MODELS from invokeai.app.invocations.upscale import ESRGAN_MODELS
from invokeai.app.services.invocation_cache.invocation_cache_common import InvocationCacheStatus from invokeai.app.services.invocation_cache.invocation_cache_common import InvocationCacheStatus
from invokeai.backend.image_util.infill_methods.patchmatch import PatchMatch from invokeai.backend.image_util.infill_methods.patchmatch import PatchMatch
from invokeai.backend.image_util.safety_checker import SafetyChecker
from invokeai.backend.util.logging import logging from invokeai.backend.util.logging import logging
from invokeai.version import __version__ from invokeai.version import __version__
@ -109,9 +108,7 @@ async def get_config() -> AppConfig:
upscaling_models.append(str(Path(model).stem)) upscaling_models.append(str(Path(model).stem))
upscaler = Upscaler(upscaling_method="esrgan", upscaling_models=upscaling_models) upscaler = Upscaler(upscaling_method="esrgan", upscaling_models=upscaling_models)
nsfw_methods = [] nsfw_methods = ["nsfw_checker"]
if SafetyChecker.safety_checker_available():
nsfw_methods.append("nsfw_checker")
watermarking_methods = ["invisible_watermark"] watermarking_methods = ["invisible_watermark"]

View File

@ -6,13 +6,12 @@ from fastapi import BackgroundTasks, Body, HTTPException, Path, Query, Request,
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from PIL import Image from PIL import Image
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field
from invokeai.app.invocations.fields import MetadataField, MetadataFieldValidator from invokeai.app.invocations.fields import MetadataField
from invokeai.app.services.image_records.image_records_common import ImageCategory, ImageRecordChanges, ResourceOrigin from invokeai.app.services.image_records.image_records_common import ImageCategory, ImageRecordChanges, ResourceOrigin
from invokeai.app.services.images.images_common import ImageDTO, ImageUrlsDTO from invokeai.app.services.images.images_common import ImageDTO, ImageUrlsDTO
from invokeai.app.services.shared.pagination import OffsetPaginatedResults from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutID, WorkflowWithoutIDValidator
from ..dependencies import ApiDependencies from ..dependencies import ApiDependencies
@ -49,6 +48,7 @@ async def upload_image(
metadata = None metadata = None
workflow = None workflow = None
graph = None
contents = await file.read() contents = await file.read()
try: try:
@ -63,21 +63,27 @@ async def upload_image(
# TODO: retain non-invokeai metadata on upload? # TODO: retain non-invokeai metadata on upload?
# attempt to parse metadata from image # attempt to parse metadata from image
metadata_raw = pil_image.info.get("invokeai_metadata", None) metadata_raw = pil_image.info.get("invokeai_metadata", None)
if metadata_raw: if isinstance(metadata_raw, str):
try: metadata = metadata_raw
metadata = MetadataFieldValidator.validate_json(metadata_raw) else:
except ValidationError: ApiDependencies.invoker.services.logger.warn("Failed to parse metadata for uploaded image")
ApiDependencies.invoker.services.logger.warn("Failed to parse metadata for uploaded image") pass
pass
# attempt to parse workflow from image # attempt to parse workflow from image
workflow_raw = pil_image.info.get("invokeai_workflow", None) workflow_raw = pil_image.info.get("invokeai_workflow", None)
if workflow_raw is not None: if isinstance(workflow_raw, str):
try: workflow = workflow_raw
workflow = WorkflowWithoutIDValidator.validate_json(workflow_raw) else:
except ValidationError: ApiDependencies.invoker.services.logger.warn("Failed to parse workflow for uploaded image")
ApiDependencies.invoker.services.logger.warn("Failed to parse metadata for uploaded image") pass
pass
# attempt to extract graph from image
graph_raw = pil_image.info.get("invokeai_graph", None)
if isinstance(graph_raw, str):
graph = graph_raw
else:
ApiDependencies.invoker.services.logger.warn("Failed to parse graph for uploaded image")
pass
try: try:
image_dto = ApiDependencies.invoker.services.images.create( image_dto = ApiDependencies.invoker.services.images.create(
@ -88,6 +94,7 @@ async def upload_image(
board_id=board_id, board_id=board_id,
metadata=metadata, metadata=metadata,
workflow=workflow, workflow=workflow,
graph=graph,
is_intermediate=is_intermediate, is_intermediate=is_intermediate,
) )
@ -185,14 +192,21 @@ async def get_image_metadata(
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
class WorkflowAndGraphResponse(BaseModel):
workflow: Optional[str] = Field(description="The workflow used to generate the image, as stringified JSON")
graph: Optional[str] = Field(description="The graph used to generate the image, as stringified JSON")
@images_router.get( @images_router.get(
"/i/{image_name}/workflow", operation_id="get_image_workflow", response_model=Optional[WorkflowWithoutID] "/i/{image_name}/workflow", operation_id="get_image_workflow", response_model=WorkflowAndGraphResponse
) )
async def get_image_workflow( async def get_image_workflow(
image_name: str = Path(description="The name of image whose workflow to get"), image_name: str = Path(description="The name of image whose workflow to get"),
) -> Optional[WorkflowWithoutID]: ) -> WorkflowAndGraphResponse:
try: try:
return ApiDependencies.invoker.services.images.get_workflow(image_name) workflow = ApiDependencies.invoker.services.images.get_workflow(image_name)
graph = ApiDependencies.invoker.services.images.get_graph(image_name)
return WorkflowAndGraphResponse(workflow=workflow, graph=graph)
except Exception: except Exception:
raise HTTPException(status_code=404) raise HTTPException(status_code=404)

View File

@ -6,7 +6,7 @@ import pathlib
import shutil import shutil
import traceback import traceback
from copy import deepcopy from copy import deepcopy
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Type
from fastapi import Body, Path, Query, Response, UploadFile from fastapi import Body, Path, Query, Response, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@ -16,6 +16,7 @@ from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from typing_extensions import Annotated from typing_extensions import Annotated
from invokeai.app.services.model_images.model_images_common import ModelImageFileNotFoundException
from invokeai.app.services.model_install import ModelInstallJob from invokeai.app.services.model_install import ModelInstallJob
from invokeai.app.services.model_records import ( from invokeai.app.services.model_records import (
DuplicateModelException, DuplicateModelException,
@ -52,6 +53,13 @@ class ModelsList(BaseModel):
model_config = ConfigDict(use_enum_values=True) model_config = ConfigDict(use_enum_values=True)
def add_cover_image_to_model_config(config: AnyModelConfig, dependencies: Type[ApiDependencies]) -> AnyModelConfig:
"""Add a cover image URL to a model configuration."""
cover_image = dependencies.invoker.services.model_images.get_url(config.key)
config.cover_image = cover_image
return config
############################################################################## ##############################################################################
# These are example inputs and outputs that are used in places where Swagger # These are example inputs and outputs that are used in places where Swagger
# is unable to generate a correct example. # is unable to generate a correct example.
@ -118,8 +126,7 @@ async def list_model_records(
record_store.search_by_attr(model_type=model_type, model_name=model_name, model_format=model_format) record_store.search_by_attr(model_type=model_type, model_name=model_name, model_format=model_format)
) )
for model in found_models: for model in found_models:
cover_image = ApiDependencies.invoker.services.model_images.get_url(model.key) model = add_cover_image_to_model_config(model, ApiDependencies)
model.cover_image = cover_image
return ModelsList(models=found_models) return ModelsList(models=found_models)
@ -160,12 +167,9 @@ async def get_model_record(
key: str = Path(description="Key of the model record to fetch."), key: str = Path(description="Key of the model record to fetch."),
) -> AnyModelConfig: ) -> AnyModelConfig:
"""Get a model record""" """Get a model record"""
record_store = ApiDependencies.invoker.services.model_manager.store
try: try:
config: AnyModelConfig = record_store.get_model(key) config = ApiDependencies.invoker.services.model_manager.store.get_model(key)
cover_image = ApiDependencies.invoker.services.model_images.get_url(key) return add_cover_image_to_model_config(config, ApiDependencies)
config.cover_image = cover_image
return config
except UnknownModelException as e: except UnknownModelException as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@ -294,14 +298,15 @@ async def update_model_record(
installer = ApiDependencies.invoker.services.model_manager.install installer = ApiDependencies.invoker.services.model_manager.install
try: try:
record_store.update_model(key, changes=changes) record_store.update_model(key, changes=changes)
model_response: AnyModelConfig = installer.sync_model_path(key) config = installer.sync_model_path(key)
config = add_cover_image_to_model_config(config, ApiDependencies)
logger.info(f"Updated model: {key}") logger.info(f"Updated model: {key}")
except UnknownModelException as e: except UnknownModelException as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
logger.error(str(e)) logger.error(str(e))
raise HTTPException(status_code=409, detail=str(e)) raise HTTPException(status_code=409, detail=str(e))
return model_response return config
@model_manager_router.get( @model_manager_router.get(
@ -648,6 +653,14 @@ async def convert_model(
logger.error(str(e)) logger.error(str(e))
raise HTTPException(status_code=409, detail=str(e)) raise HTTPException(status_code=409, detail=str(e))
# Update the model image if the model had one
try:
model_image = ApiDependencies.invoker.services.model_images.get(key)
ApiDependencies.invoker.services.model_images.save(model_image, new_key)
ApiDependencies.invoker.services.model_images.delete(key)
except ModelImageFileNotFoundException:
pass
# delete the original safetensors file # delete the original safetensors file
installer.delete(key) installer.delete(key)
@ -655,7 +668,8 @@ async def convert_model(
shutil.rmtree(cache_path) shutil.rmtree(cache_path)
# return the config record for the new diffusers directory # return the config record for the new diffusers directory
new_config: AnyModelConfig = store.get_model(new_key) new_config = store.get_model(new_key)
new_config = add_cover_image_to_model_config(new_config, ApiDependencies)
return new_config return new_config

View File

@ -164,6 +164,12 @@ def custom_openapi() -> dict[str, Any]:
for schema_key, schema_json in additional_schemas[1]["$defs"].items(): for schema_key, schema_json in additional_schemas[1]["$defs"].items():
openapi_schema["components"]["schemas"][schema_key] = schema_json openapi_schema["components"]["schemas"][schema_key] = schema_json
openapi_schema["components"]["schemas"]["InvocationOutputMap"] = {
"type": "object",
"properties": {},
"required": [],
}
# Add a reference to the output type to additionalProperties of the invoker schema # Add a reference to the output type to additionalProperties of the invoker schema
for invoker in all_invocations: for invoker in all_invocations:
invoker_name = invoker.__name__ # type: ignore [attr-defined] # this is a valid attribute invoker_name = invoker.__name__ # type: ignore [attr-defined] # this is a valid attribute
@ -172,6 +178,8 @@ def custom_openapi() -> dict[str, Any]:
invoker_schema = openapi_schema["components"]["schemas"][f"{invoker_name}"] invoker_schema = openapi_schema["components"]["schemas"][f"{invoker_name}"]
outputs_ref = {"$ref": f"#/components/schemas/{output_type_title}"} outputs_ref = {"$ref": f"#/components/schemas/{output_type_title}"}
invoker_schema["output"] = outputs_ref invoker_schema["output"] = outputs_ref
openapi_schema["components"]["schemas"]["InvocationOutputMap"]["properties"][invoker.get_type()] = outputs_ref
openapi_schema["components"]["schemas"]["InvocationOutputMap"]["required"].append(invoker.get_type())
invoker_schema["class"] = "invocation" invoker_schema["class"] = "invocation"
# This code no longer seems to be necessary? # This code no longer seems to be necessary?

View File

@ -1,6 +1,5 @@
# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) # Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654)
from pathlib import Path
from typing import Literal, Optional from typing import Literal, Optional
import cv2 import cv2
@ -504,7 +503,7 @@ class ImageInverseLerpInvocation(BaseInvocation, WithMetadata, WithBoard):
title="Blur NSFW Image", title="Blur NSFW Image",
tags=["image", "nsfw"], tags=["image", "nsfw"],
category="image", category="image",
version="1.2.2", version="1.2.3",
) )
class ImageNSFWBlurInvocation(BaseInvocation, WithMetadata, WithBoard): class ImageNSFWBlurInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Add blur to NSFW-flagged images""" """Add blur to NSFW-flagged images"""
@ -516,23 +515,12 @@ class ImageNSFWBlurInvocation(BaseInvocation, WithMetadata, WithBoard):
logger = context.logger logger = context.logger
logger.debug("Running NSFW checker") logger.debug("Running NSFW checker")
if SafetyChecker.has_nsfw_concept(image): image = SafetyChecker.blur_if_nsfw(image)
logger.info("A potentially NSFW image has been detected. Image will be blurred.")
blurry_image = image.filter(filter=ImageFilter.GaussianBlur(radius=32))
caution = self._get_caution_img()
blurry_image.paste(caution, (0, 0), caution)
image = blurry_image
image_dto = context.images.save(image=image) image_dto = context.images.save(image=image)
return ImageOutput.build(image_dto) return ImageOutput.build(image_dto)
def _get_caution_img(self) -> Image.Image:
import invokeai.app.assets.images as image_assets
caution = Image.open(Path(image_assets.__path__[0]) / "caution.png")
return caution.resize((caution.width // 2, caution.height // 2))
@invocation( @invocation(
"img_watermark", "img_watermark",

View File

@ -586,13 +586,6 @@ class DenoiseLatentsInvocation(BaseInvocation):
unet: UNet2DConditionModel, unet: UNet2DConditionModel,
scheduler: Scheduler, scheduler: Scheduler,
) -> StableDiffusionGeneratorPipeline: ) -> StableDiffusionGeneratorPipeline:
# TODO:
# configure_model_padding(
# unet,
# self.seamless,
# self.seamless_axes,
# )
class FakeVae: class FakeVae:
class FakeVaeConfig: class FakeVaeConfig:
def __init__(self) -> None: def __init__(self) -> None:

View File

@ -190,6 +190,75 @@ class LoRALoaderInvocation(BaseInvocation):
return output return output
@invocation_output("lora_selector_output")
class LoRASelectorOutput(BaseInvocationOutput):
"""Model loader output"""
lora: LoRAField = OutputField(description="LoRA model and weight", title="LoRA")
@invocation("lora_selector", title="LoRA Selector", tags=["model"], category="model", version="1.0.0")
class LoRASelectorInvocation(BaseInvocation):
"""Selects a LoRA model and weight."""
lora: ModelIdentifierField = InputField(
description=FieldDescriptions.lora_model, input=Input.Direct, title="LoRA", ui_type=UIType.LoRAModel
)
weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight)
def invoke(self, context: InvocationContext) -> LoRASelectorOutput:
return LoRASelectorOutput(lora=LoRAField(lora=self.lora, weight=self.weight))
@invocation("lora_collection_loader", title="LoRA Collection Loader", tags=["model"], category="model", version="1.0.0")
class LoRACollectionLoader(BaseInvocation):
"""Applies a collection of LoRAs to the provided UNet and CLIP models."""
loras: LoRAField | list[LoRAField] = InputField(
description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
)
unet: Optional[UNetField] = InputField(
default=None,
description=FieldDescriptions.unet,
input=Input.Connection,
title="UNet",
)
clip: Optional[CLIPField] = InputField(
default=None,
description=FieldDescriptions.clip,
input=Input.Connection,
title="CLIP",
)
def invoke(self, context: InvocationContext) -> LoRALoaderOutput:
output = LoRALoaderOutput()
loras = self.loras if isinstance(self.loras, list) else [self.loras]
added_loras: list[str] = []
for lora in loras:
if lora.lora.key in added_loras:
continue
if not context.models.exists(lora.lora.key):
raise Exception(f"Unknown lora: {lora.lora.key}!")
assert lora.lora.base in (BaseModelType.StableDiffusion1, BaseModelType.StableDiffusion2)
added_loras.append(lora.lora.key)
if self.unet is not None:
if output.unet is None:
output.unet = self.unet.model_copy(deep=True)
output.unet.loras.append(lora)
if self.clip is not None:
if output.clip is None:
output.clip = self.clip.model_copy(deep=True)
output.clip.loras.append(lora)
return output
@invocation_output("sdxl_lora_loader_output") @invocation_output("sdxl_lora_loader_output")
class SDXLLoRALoaderOutput(BaseInvocationOutput): class SDXLLoRALoaderOutput(BaseInvocationOutput):
"""SDXL LoRA Loader Output""" """SDXL LoRA Loader Output"""
@ -279,6 +348,72 @@ class SDXLLoRALoaderInvocation(BaseInvocation):
return output return output
@invocation(
"sdxl_lora_collection_loader",
title="SDXL LoRA Collection Loader",
tags=["model"],
category="model",
version="1.0.0",
)
class SDXLLoRACollectionLoader(BaseInvocation):
"""Applies a collection of SDXL LoRAs to the provided UNet and CLIP models."""
loras: LoRAField | list[LoRAField] = InputField(
description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
)
unet: Optional[UNetField] = InputField(
default=None,
description=FieldDescriptions.unet,
input=Input.Connection,
title="UNet",
)
clip: Optional[CLIPField] = InputField(
default=None,
description=FieldDescriptions.clip,
input=Input.Connection,
title="CLIP",
)
clip2: Optional[CLIPField] = InputField(
default=None,
description=FieldDescriptions.clip,
input=Input.Connection,
title="CLIP 2",
)
def invoke(self, context: InvocationContext) -> SDXLLoRALoaderOutput:
output = SDXLLoRALoaderOutput()
loras = self.loras if isinstance(self.loras, list) else [self.loras]
added_loras: list[str] = []
for lora in loras:
if lora.lora.key in added_loras:
continue
if not context.models.exists(lora.lora.key):
raise Exception(f"Unknown lora: {lora.lora.key}!")
assert lora.lora.base is BaseModelType.StableDiffusionXL
added_loras.append(lora.lora.key)
if self.unet is not None:
if output.unet is None:
output.unet = self.unet.model_copy(deep=True)
output.unet.loras.append(lora)
if self.clip is not None:
if output.clip is None:
output.clip = self.clip.model_copy(deep=True)
output.clip.loras.append(lora)
if self.clip2 is not None:
if output.clip2 is None:
output.clip2 = self.clip2.model_copy(deep=True)
output.clip2.loras.append(lora)
return output
@invocation("vae_loader", title="VAE", tags=["vae", "model"], category="model", version="1.0.2") @invocation("vae_loader", title="VAE", tags=["vae", "model"], category="model", version="1.0.2")
class VAELoaderInvocation(BaseInvocation): class VAELoaderInvocation(BaseInvocation):
"""Loads a VAE model, outputting a VaeLoaderOutput""" """Loads a VAE model, outputting a VaeLoaderOutput"""

View File

@ -122,6 +122,8 @@ class EventServiceBase:
source_node_id: str, source_node_id: str,
error_type: str, error_type: str,
error: str, error: str,
user_id: str | None,
project_id: str | None,
) -> None: ) -> None:
"""Emitted when an invocation has completed""" """Emitted when an invocation has completed"""
self.__emit_queue_event( self.__emit_queue_event(
@ -135,6 +137,8 @@ class EventServiceBase:
"source_node_id": source_node_id, "source_node_id": source_node_id,
"error_type": error_type, "error_type": error_type,
"error": error, "error": error,
"user_id": user_id,
"project_id": project_id,
}, },
) )

View File

@ -4,9 +4,6 @@ from typing import Optional
from PIL.Image import Image as PILImageType from PIL.Image import Image as PILImageType
from invokeai.app.invocations.fields import MetadataField
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutID
class ImageFileStorageBase(ABC): class ImageFileStorageBase(ABC):
"""Low-level service responsible for storing and retrieving image files.""" """Low-level service responsible for storing and retrieving image files."""
@ -33,8 +30,9 @@ class ImageFileStorageBase(ABC):
self, self,
image: PILImageType, image: PILImageType,
image_name: str, image_name: str,
metadata: Optional[MetadataField] = None, metadata: Optional[str] = None,
workflow: Optional[WorkflowWithoutID] = None, workflow: Optional[str] = None,
graph: Optional[str] = None,
thumbnail_size: int = 256, thumbnail_size: int = 256,
) -> None: ) -> None:
"""Saves an image and a 256x256 WEBP thumbnail. Returns a tuple of the image name, thumbnail name, and created timestamp.""" """Saves an image and a 256x256 WEBP thumbnail. Returns a tuple of the image name, thumbnail name, and created timestamp."""
@ -46,6 +44,11 @@ class ImageFileStorageBase(ABC):
pass pass
@abstractmethod @abstractmethod
def get_workflow(self, image_name: str) -> Optional[WorkflowWithoutID]: def get_workflow(self, image_name: str) -> Optional[str]:
"""Gets the workflow of an image.""" """Gets the workflow of an image."""
pass pass
@abstractmethod
def get_graph(self, image_name: str) -> Optional[str]:
"""Gets the graph of an image."""
pass

View File

@ -7,9 +7,7 @@ from PIL import Image, PngImagePlugin
from PIL.Image import Image as PILImageType from PIL.Image import Image as PILImageType
from send2trash import send2trash from send2trash import send2trash
from invokeai.app.invocations.fields import MetadataField
from invokeai.app.services.invoker import Invoker from invokeai.app.services.invoker import Invoker
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutID
from invokeai.app.util.thumbnails import get_thumbnail_name, make_thumbnail from invokeai.app.util.thumbnails import get_thumbnail_name, make_thumbnail
from .image_files_base import ImageFileStorageBase from .image_files_base import ImageFileStorageBase
@ -56,8 +54,9 @@ class DiskImageFileStorage(ImageFileStorageBase):
self, self,
image: PILImageType, image: PILImageType,
image_name: str, image_name: str,
metadata: Optional[MetadataField] = None, metadata: Optional[str] = None,
workflow: Optional[WorkflowWithoutID] = None, workflow: Optional[str] = None,
graph: Optional[str] = None,
thumbnail_size: int = 256, thumbnail_size: int = 256,
) -> None: ) -> None:
try: try:
@ -68,13 +67,14 @@ class DiskImageFileStorage(ImageFileStorageBase):
info_dict = {} info_dict = {}
if metadata is not None: if metadata is not None:
metadata_json = metadata.model_dump_json() info_dict["invokeai_metadata"] = metadata
info_dict["invokeai_metadata"] = metadata_json pnginfo.add_text("invokeai_metadata", metadata)
pnginfo.add_text("invokeai_metadata", metadata_json)
if workflow is not None: if workflow is not None:
workflow_json = workflow.model_dump_json() info_dict["invokeai_workflow"] = workflow
info_dict["invokeai_workflow"] = workflow_json pnginfo.add_text("invokeai_workflow", workflow)
pnginfo.add_text("invokeai_workflow", workflow_json) if graph is not None:
info_dict["invokeai_graph"] = graph
pnginfo.add_text("invokeai_graph", graph)
# When saving the image, the image object's info field is not populated. We need to set it # When saving the image, the image object's info field is not populated. We need to set it
image.info = info_dict image.info = info_dict
@ -129,11 +129,18 @@ class DiskImageFileStorage(ImageFileStorageBase):
path = path if isinstance(path, Path) else Path(path) path = path if isinstance(path, Path) else Path(path)
return path.exists() return path.exists()
def get_workflow(self, image_name: str) -> WorkflowWithoutID | None: def get_workflow(self, image_name: str) -> str | None:
image = self.get(image_name) image = self.get(image_name)
workflow = image.info.get("invokeai_workflow", None) workflow = image.info.get("invokeai_workflow", None)
if workflow is not None: if isinstance(workflow, str):
return WorkflowWithoutID.model_validate_json(workflow) return workflow
return None
def get_graph(self, image_name: str) -> str | None:
image = self.get(image_name)
graph = image.info.get("invokeai_graph", None)
if isinstance(graph, str):
return graph
return None return None
def __validate_storage_folders(self) -> None: def __validate_storage_folders(self) -> None:

View File

@ -80,7 +80,7 @@ class ImageRecordStorageBase(ABC):
starred: Optional[bool] = False, starred: Optional[bool] = False,
session_id: Optional[str] = None, session_id: Optional[str] = None,
node_id: Optional[str] = None, node_id: Optional[str] = None,
metadata: Optional[MetadataField] = None, metadata: Optional[str] = None,
) -> datetime: ) -> datetime:
"""Saves an image record.""" """Saves an image record."""
pass pass

View File

@ -328,10 +328,9 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
starred: Optional[bool] = False, starred: Optional[bool] = False,
session_id: Optional[str] = None, session_id: Optional[str] = None,
node_id: Optional[str] = None, node_id: Optional[str] = None,
metadata: Optional[MetadataField] = None, metadata: Optional[str] = None,
) -> datetime: ) -> datetime:
try: try:
metadata_json = metadata.model_dump_json() if metadata is not None else None
self._lock.acquire() self._lock.acquire()
self._cursor.execute( self._cursor.execute(
"""--sql """--sql
@ -358,7 +357,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
height, height,
node_id, node_id,
session_id, session_id,
metadata_json, metadata,
is_intermediate, is_intermediate,
starred, starred,
has_workflow, has_workflow,

View File

@ -12,7 +12,6 @@ from invokeai.app.services.image_records.image_records_common import (
) )
from invokeai.app.services.images.images_common import ImageDTO from invokeai.app.services.images.images_common import ImageDTO
from invokeai.app.services.shared.pagination import OffsetPaginatedResults from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutID
class ImageServiceABC(ABC): class ImageServiceABC(ABC):
@ -51,8 +50,9 @@ class ImageServiceABC(ABC):
session_id: Optional[str] = None, session_id: Optional[str] = None,
board_id: Optional[str] = None, board_id: Optional[str] = None,
is_intermediate: Optional[bool] = False, is_intermediate: Optional[bool] = False,
metadata: Optional[MetadataField] = None, metadata: Optional[str] = None,
workflow: Optional[WorkflowWithoutID] = None, workflow: Optional[str] = None,
graph: Optional[str] = None,
) -> ImageDTO: ) -> ImageDTO:
"""Creates an image, storing the file and its metadata.""" """Creates an image, storing the file and its metadata."""
pass pass
@ -87,7 +87,12 @@ class ImageServiceABC(ABC):
pass pass
@abstractmethod @abstractmethod
def get_workflow(self, image_name: str) -> Optional[WorkflowWithoutID]: def get_workflow(self, image_name: str) -> Optional[str]:
"""Gets an image's workflow."""
pass
@abstractmethod
def get_graph(self, image_name: str) -> Optional[str]:
"""Gets an image's workflow.""" """Gets an image's workflow."""
pass pass

View File

@ -5,7 +5,6 @@ from PIL.Image import Image as PILImageType
from invokeai.app.invocations.fields import MetadataField from invokeai.app.invocations.fields import MetadataField
from invokeai.app.services.invoker import Invoker from invokeai.app.services.invoker import Invoker
from invokeai.app.services.shared.pagination import OffsetPaginatedResults from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutID
from ..image_files.image_files_common import ( from ..image_files.image_files_common import (
ImageFileDeleteException, ImageFileDeleteException,
@ -42,8 +41,9 @@ class ImageService(ImageServiceABC):
session_id: Optional[str] = None, session_id: Optional[str] = None,
board_id: Optional[str] = None, board_id: Optional[str] = None,
is_intermediate: Optional[bool] = False, is_intermediate: Optional[bool] = False,
metadata: Optional[MetadataField] = None, metadata: Optional[str] = None,
workflow: Optional[WorkflowWithoutID] = None, workflow: Optional[str] = None,
graph: Optional[str] = None,
) -> ImageDTO: ) -> ImageDTO:
if image_origin not in ResourceOrigin: if image_origin not in ResourceOrigin:
raise InvalidOriginException raise InvalidOriginException
@ -64,7 +64,7 @@ class ImageService(ImageServiceABC):
image_category=image_category, image_category=image_category,
width=width, width=width,
height=height, height=height,
has_workflow=workflow is not None, has_workflow=workflow is not None or graph is not None,
# Meta fields # Meta fields
is_intermediate=is_intermediate, is_intermediate=is_intermediate,
# Nullable fields # Nullable fields
@ -75,7 +75,7 @@ class ImageService(ImageServiceABC):
if board_id is not None: if board_id is not None:
self.__invoker.services.board_image_records.add_image_to_board(board_id=board_id, image_name=image_name) self.__invoker.services.board_image_records.add_image_to_board(board_id=board_id, image_name=image_name)
self.__invoker.services.image_files.save( self.__invoker.services.image_files.save(
image_name=image_name, image=image, metadata=metadata, workflow=workflow image_name=image_name, image=image, metadata=metadata, workflow=workflow, graph=graph
) )
image_dto = self.get_dto(image_name) image_dto = self.get_dto(image_name)
@ -157,7 +157,7 @@ class ImageService(ImageServiceABC):
self.__invoker.services.logger.error("Problem getting image metadata") self.__invoker.services.logger.error("Problem getting image metadata")
raise e raise e
def get_workflow(self, image_name: str) -> Optional[WorkflowWithoutID]: def get_workflow(self, image_name: str) -> Optional[str]:
try: try:
return self.__invoker.services.image_files.get_workflow(image_name) return self.__invoker.services.image_files.get_workflow(image_name)
except ImageFileNotFoundException: except ImageFileNotFoundException:
@ -167,6 +167,16 @@ class ImageService(ImageServiceABC):
self.__invoker.services.logger.error("Problem getting image workflow") self.__invoker.services.logger.error("Problem getting image workflow")
raise raise
def get_graph(self, image_name: str) -> Optional[str]:
try:
return self.__invoker.services.image_files.get_graph(image_name)
except ImageFileNotFoundException:
self.__invoker.services.logger.error("Image file not found")
raise
except Exception:
self.__invoker.services.logger.error("Problem getting image graph")
raise
def get_path(self, image_name: str, thumbnail: bool = False) -> str: def get_path(self, image_name: str, thumbnail: bool = False) -> str:
try: try:
return str(self.__invoker.services.image_files.get_path(image_name, thumbnail)) return str(self.__invoker.services.image_files.get_path(image_name, thumbnail))

View File

@ -237,6 +237,8 @@ class DefaultSessionProcessor(SessionProcessorBase):
source_node_id=source_invocation_id, source_node_id=source_invocation_id,
error_type=e.__class__.__name__, error_type=e.__class__.__name__,
error=error, error=error,
user_id=None,
project_id=None,
) )
pass pass

View File

@ -181,9 +181,9 @@ class ImagesInterface(InvocationContextInterface):
# If `metadata` is provided directly, use that. Else, use the metadata provided by `WithMetadata`, falling back to None. # If `metadata` is provided directly, use that. Else, use the metadata provided by `WithMetadata`, falling back to None.
metadata_ = None metadata_ = None
if metadata: if metadata:
metadata_ = metadata metadata_ = metadata.model_dump_json()
elif isinstance(self._data.invocation, WithMetadata): elif isinstance(self._data.invocation, WithMetadata) and self._data.invocation.metadata:
metadata_ = self._data.invocation.metadata metadata_ = self._data.invocation.metadata.model_dump_json()
# If `board_id` is provided directly, use that. Else, use the board provided by `WithBoard`, falling back to None. # If `board_id` is provided directly, use that. Else, use the board provided by `WithBoard`, falling back to None.
board_id_ = None board_id_ = None
@ -192,6 +192,14 @@ class ImagesInterface(InvocationContextInterface):
elif isinstance(self._data.invocation, WithBoard) and self._data.invocation.board: elif isinstance(self._data.invocation, WithBoard) and self._data.invocation.board:
board_id_ = self._data.invocation.board.board_id board_id_ = self._data.invocation.board.board_id
workflow_ = None
if self._data.queue_item.workflow:
workflow_ = self._data.queue_item.workflow.model_dump_json()
graph_ = None
if self._data.queue_item.session.graph:
graph_ = self._data.queue_item.session.graph.model_dump_json()
return self._services.images.create( return self._services.images.create(
image=image, image=image,
is_intermediate=self._data.invocation.is_intermediate, is_intermediate=self._data.invocation.is_intermediate,
@ -199,7 +207,8 @@ class ImagesInterface(InvocationContextInterface):
board_id=board_id_, board_id=board_id_,
metadata=metadata_, metadata=metadata_,
image_origin=ResourceOrigin.INTERNAL, image_origin=ResourceOrigin.INTERNAL,
workflow=self._data.queue_item.workflow, workflow=workflow_,
graph=graph_,
session_id=self._data.queue_item.session_id, session_id=self._data.queue_item.session_id,
node_id=self._data.invocation.id, node_id=self._data.invocation.id,
) )

View File

@ -4,5 +4,4 @@ Initialization file for invokeai.backend.image_util methods.
from .infill_methods.patchmatch import PatchMatch # noqa: F401 from .infill_methods.patchmatch import PatchMatch # noqa: F401
from .pngwriter import PngWriter, PromptFormatter, retrieve_metadata, write_metadata # noqa: F401 from .pngwriter import PngWriter, PromptFormatter, retrieve_metadata, write_metadata # noqa: F401
from .seamless import configure_model_padding # noqa: F401
from .util import InitImageResizer, make_grid # noqa: F401 from .util import InitImageResizer, make_grid # noqa: F401

View File

@ -8,7 +8,7 @@ from pathlib import Path
import numpy as np import numpy as np
from diffusers.pipelines.stable_diffusion.safety_checker import StableDiffusionSafetyChecker from diffusers.pipelines.stable_diffusion.safety_checker import StableDiffusionSafetyChecker
from PIL import Image from PIL import Image, ImageFilter
from transformers import AutoFeatureExtractor from transformers import AutoFeatureExtractor
import invokeai.backend.util.logging as logger import invokeai.backend.util.logging as logger
@ -16,6 +16,7 @@ from invokeai.app.services.config.config_default import get_config
from invokeai.backend.util.devices import TorchDevice from invokeai.backend.util.devices import TorchDevice
from invokeai.backend.util.silence_warnings import SilenceWarnings from invokeai.backend.util.silence_warnings import SilenceWarnings
repo_id = "CompVis/stable-diffusion-safety-checker"
CHECKER_PATH = "core/convert/stable-diffusion-safety-checker" CHECKER_PATH = "core/convert/stable-diffusion-safety-checker"
@ -24,30 +25,30 @@ class SafetyChecker:
Wrapper around SafetyChecker model. Wrapper around SafetyChecker model.
""" """
safety_checker = None
feature_extractor = None feature_extractor = None
tried_load: bool = False safety_checker = None
@classmethod @classmethod
def _load_safety_checker(cls): def _load_safety_checker(cls):
if cls.tried_load: if cls.safety_checker is not None and cls.feature_extractor is not None:
return return
try: try:
cls.safety_checker = StableDiffusionSafetyChecker.from_pretrained(get_config().models_path / CHECKER_PATH) model_path = get_config().models_path / CHECKER_PATH
cls.feature_extractor = AutoFeatureExtractor.from_pretrained(get_config().models_path / CHECKER_PATH) if model_path.exists():
cls.feature_extractor = AutoFeatureExtractor.from_pretrained(model_path)
cls.safety_checker = StableDiffusionSafetyChecker.from_pretrained(model_path)
else:
model_path.mkdir(parents=True, exist_ok=True)
cls.feature_extractor = AutoFeatureExtractor.from_pretrained(repo_id)
cls.feature_extractor.save_pretrained(model_path, safe_serialization=True)
cls.safety_checker = StableDiffusionSafetyChecker.from_pretrained(repo_id)
cls.safety_checker.save_pretrained(model_path, safe_serialization=True)
except Exception as e: except Exception as e:
logger.warning(f"Could not load NSFW checker: {str(e)}") logger.warning(f"Could not load NSFW checker: {str(e)}")
cls.tried_load = True
@classmethod
def safety_checker_available(cls) -> bool:
return Path(get_config().models_path, CHECKER_PATH).exists()
@classmethod @classmethod
def has_nsfw_concept(cls, image: Image.Image) -> bool: def has_nsfw_concept(cls, image: Image.Image) -> bool:
if not cls.safety_checker_available() and cls.tried_load:
return False
cls._load_safety_checker() cls._load_safety_checker()
if cls.safety_checker is None or cls.feature_extractor is None: if cls.safety_checker is None or cls.feature_extractor is None:
return False return False
@ -60,3 +61,24 @@ class SafetyChecker:
with SilenceWarnings(): with SilenceWarnings():
checked_image, has_nsfw_concept = cls.safety_checker(images=x_image, clip_input=features.pixel_values) checked_image, has_nsfw_concept = cls.safety_checker(images=x_image, clip_input=features.pixel_values)
return has_nsfw_concept[0] return has_nsfw_concept[0]
@classmethod
def blur_if_nsfw(cls, image: Image.Image) -> Image.Image:
if cls.has_nsfw_concept(image):
logger.warning("A potentially NSFW image has been detected. Image will be blurred.")
blurry_image = image.filter(filter=ImageFilter.GaussianBlur(radius=32))
caution = cls._get_caution_img()
# Center the caution image on the blurred image
x = (blurry_image.width - caution.width) // 2
y = (blurry_image.height - caution.height) // 2
blurry_image.paste(caution, (x, y), caution)
image = blurry_image
return image
@classmethod
def _get_caution_img(cls) -> Image.Image:
import invokeai.app.assets.images as image_assets
caution = Image.open(Path(image_assets.__path__[0]) / "caution.png")
return caution.resize((caution.width // 2, caution.height // 2))

View File

@ -1,52 +0,0 @@
import torch.nn as nn
def _conv_forward_asymmetric(self, input, weight, bias):
"""
Patch for Conv2d._conv_forward that supports asymmetric padding
"""
working = nn.functional.pad(input, self.asymmetric_padding["x"], mode=self.asymmetric_padding_mode["x"])
working = nn.functional.pad(working, self.asymmetric_padding["y"], mode=self.asymmetric_padding_mode["y"])
return nn.functional.conv2d(
working,
weight,
bias,
self.stride,
nn.modules.utils._pair(0),
self.dilation,
self.groups,
)
def configure_model_padding(model, seamless, seamless_axes):
"""
Modifies the 2D convolution layers to use a circular padding mode based on
the `seamless` and `seamless_axes` options.
"""
# TODO: get an explicit interface for this in diffusers: https://github.com/huggingface/diffusers/issues/556
for m in model.modules():
if isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)):
if seamless:
m.asymmetric_padding_mode = {}
m.asymmetric_padding = {}
m.asymmetric_padding_mode["x"] = "circular" if ("x" in seamless_axes) else "constant"
m.asymmetric_padding["x"] = (
m._reversed_padding_repeated_twice[0],
m._reversed_padding_repeated_twice[1],
0,
0,
)
m.asymmetric_padding_mode["y"] = "circular" if ("y" in seamless_axes) else "constant"
m.asymmetric_padding["y"] = (
0,
0,
m._reversed_padding_repeated_twice[2],
m._reversed_padding_repeated_twice[3],
)
m._conv_forward = _conv_forward_asymmetric.__get__(m, nn.Conv2d)
else:
m._conv_forward = nn.Conv2d._conv_forward.__get__(m, nn.Conv2d)
if hasattr(m, "asymmetric_padding_mode"):
del m.asymmetric_padding_mode
if hasattr(m, "asymmetric_padding"):
del m.asymmetric_padding

View File

@ -1,89 +1,51 @@
from __future__ import annotations
from contextlib import contextmanager from contextlib import contextmanager
from typing import Callable, List, Union from typing import Callable, List, Optional, Tuple, Union
import torch
import torch.nn as nn import torch.nn as nn
from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL
from diffusers.models.autoencoders.autoencoder_tiny import AutoencoderTiny from diffusers.models.autoencoders.autoencoder_tiny import AutoencoderTiny
from diffusers.models.lora import LoRACompatibleConv
from diffusers.models.unets.unet_2d_condition import UNet2DConditionModel from diffusers.models.unets.unet_2d_condition import UNet2DConditionModel
def _conv_forward_asymmetric(self, input, weight, bias):
"""
Patch for Conv2d._conv_forward that supports asymmetric padding
"""
working = nn.functional.pad(input, self.asymmetric_padding["x"], mode=self.asymmetric_padding_mode["x"])
working = nn.functional.pad(working, self.asymmetric_padding["y"], mode=self.asymmetric_padding_mode["y"])
return nn.functional.conv2d(
working,
weight,
bias,
self.stride,
nn.modules.utils._pair(0),
self.dilation,
self.groups,
)
@contextmanager @contextmanager
def set_seamless(model: Union[UNet2DConditionModel, AutoencoderKL, AutoencoderTiny], seamless_axes: List[str]): def set_seamless(model: Union[UNet2DConditionModel, AutoencoderKL, AutoencoderTiny], seamless_axes: List[str]):
if not seamless_axes: if not seamless_axes:
yield yield
return return
# Callable: (input: Tensor, weight: Tensor, bias: Optional[Tensor]) -> Tensor # override conv_forward
to_restore: list[tuple[nn.Conv2d | nn.ConvTranspose2d, Callable]] = [] # https://github.com/huggingface/diffusers/issues/556#issuecomment-1993287019
def _conv_forward_asymmetric(self, input: torch.Tensor, weight: torch.Tensor, bias: Optional[torch.Tensor] = None):
self.paddingX = (self._reversed_padding_repeated_twice[0], self._reversed_padding_repeated_twice[1], 0, 0)
self.paddingY = (0, 0, self._reversed_padding_repeated_twice[2], self._reversed_padding_repeated_twice[3])
working = torch.nn.functional.pad(input, self.paddingX, mode=x_mode)
working = torch.nn.functional.pad(working, self.paddingY, mode=y_mode)
return torch.nn.functional.conv2d(
working, weight, bias, self.stride, torch.nn.modules.utils._pair(0), self.dilation, self.groups
)
original_layers: List[Tuple[nn.Conv2d, Callable]] = []
try: try:
# Hard coded to skip down block layers, allowing for seamless tiling at the expense of prompt adherence x_mode = "circular" if "x" in seamless_axes else "constant"
skipped_layers = 1 y_mode = "circular" if "y" in seamless_axes else "constant"
for m_name, m in model.named_modules():
if not isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)):
continue
if isinstance(model, UNet2DConditionModel) and m_name.startswith("down_blocks.") and ".resnets." in m_name: conv_layers: List[torch.nn.Conv2d] = []
# down_blocks.1.resnets.1.conv1
_, block_num, _, resnet_num, submodule_name = m_name.split(".")
block_num = int(block_num)
resnet_num = int(resnet_num)
if block_num >= len(model.down_blocks) - skipped_layers: for module in model.modules():
continue if isinstance(module, torch.nn.Conv2d):
conv_layers.append(module)
# Skip the second resnet (could be configurable) for layer in conv_layers:
if resnet_num > 0: if isinstance(layer, LoRACompatibleConv) and layer.lora_layer is None:
continue layer.lora_layer = lambda *x: 0
original_layers.append((layer, layer._conv_forward))
# Skip Conv2d layers (could be configurable) layer._conv_forward = _conv_forward_asymmetric.__get__(layer, torch.nn.Conv2d)
if submodule_name == "conv2":
continue
m.asymmetric_padding_mode = {}
m.asymmetric_padding = {}
m.asymmetric_padding_mode["x"] = "circular" if ("x" in seamless_axes) else "constant"
m.asymmetric_padding["x"] = (
m._reversed_padding_repeated_twice[0],
m._reversed_padding_repeated_twice[1],
0,
0,
)
m.asymmetric_padding_mode["y"] = "circular" if ("y" in seamless_axes) else "constant"
m.asymmetric_padding["y"] = (
0,
0,
m._reversed_padding_repeated_twice[2],
m._reversed_padding_repeated_twice[3],
)
to_restore.append((m, m._conv_forward))
m._conv_forward = _conv_forward_asymmetric.__get__(m, nn.Conv2d)
yield yield
finally: finally:
for module, orig_conv_forward in to_restore: for layer, orig_conv_forward in original_layers:
module._conv_forward = orig_conv_forward layer._conv_forward = orig_conv_forward
if hasattr(module, "asymmetric_padding_mode"):
del module.asymmetric_padding_mode
if hasattr(module, "asymmetric_padding"):
del module.asymmetric_padding

View File

@ -10,6 +10,8 @@ module.exports = {
'path/no-relative-imports': ['error', { maxDepth: 0 }], 'path/no-relative-imports': ['error', { maxDepth: 0 }],
// https://github.com/edvardchen/eslint-plugin-i18next/blob/HEAD/docs/rules/no-literal-string.md // https://github.com/edvardchen/eslint-plugin-i18next/blob/HEAD/docs/rules/no-literal-string.md
'i18next/no-literal-string': 'error', 'i18next/no-literal-string': 'error',
// https://eslint.org/docs/latest/rules/no-console
'no-console': 'error',
}, },
overrides: [ overrides: [
/** /**

View File

@ -43,4 +43,5 @@ stats.html
yalc.lock yalc.lock
# vitest # vitest
tsconfig.vitest-temp.json tsconfig.vitest-temp.json
coverage/

View File

@ -35,6 +35,7 @@
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build", "build-storybook": "storybook build",
"test": "vitest", "test": "vitest",
"test:ui": "vitest --coverage --ui",
"test:no-watch": "vitest --no-watch" "test:no-watch": "vitest --no-watch"
}, },
"madge": { "madge": {
@ -52,47 +53,48 @@
}, },
"dependencies": { "dependencies": {
"@chakra-ui/react-use-size": "^2.1.0", "@chakra-ui/react-use-size": "^2.1.0",
"@dagrejs/dagre": "^1.1.1", "@dagrejs/dagre": "^1.1.2",
"@dagrejs/graphlib": "^2.2.1", "@dagrejs/graphlib": "^2.2.2",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.0.17", "@fontsource-variable/inter": "^5.0.18",
"@invoke-ai/ui-library": "^0.0.25", "@invoke-ai/ui-library": "^0.0.25",
"@nanostores/react": "^0.7.2", "@nanostores/react": "^0.7.2",
"@reduxjs/toolkit": "2.2.2", "@reduxjs/toolkit": "2.2.3",
"@roarr/browser-log-writer": "^1.3.0", "@roarr/browser-log-writer": "^1.3.0",
"chakra-react-select": "^4.7.6", "chakra-react-select": "^4.7.6",
"compare-versions": "^6.1.0", "compare-versions": "^6.1.0",
"dateformat": "^5.0.3", "dateformat": "^5.0.3",
"framer-motion": "^11.0.22", "fracturedjsonjs": "^4.0.1",
"i18next": "^23.10.1", "framer-motion": "^11.1.8",
"i18next-http-backend": "^2.5.0", "i18next": "^23.11.3",
"i18next-http-backend": "^2.5.1",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"jsondiffpatch": "^0.6.0", "jsondiffpatch": "^0.6.0",
"konva": "^9.3.6", "konva": "^9.3.6",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"nanostores": "^0.10.0", "nanostores": "^0.10.3",
"new-github-issue-url": "^1.0.0", "new-github-issue-url": "^1.0.0",
"overlayscrollbars": "^2.6.1", "overlayscrollbars": "^2.7.3",
"overlayscrollbars-react": "^0.5.5", "overlayscrollbars-react": "^0.5.6",
"query-string": "^9.0.0", "query-string": "^9.0.0",
"react": "^18.2.0", "react": "^18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.2.0", "react-dom": "^18.3.1",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"react-hook-form": "^7.51.2", "react-hook-form": "^7.51.4",
"react-hotkeys-hook": "4.5.0", "react-hotkeys-hook": "4.5.0",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.1",
"react-icons": "^5.0.1", "react-icons": "^5.2.0",
"react-konva": "^18.2.10", "react-konva": "^18.2.10",
"react-redux": "9.1.0", "react-redux": "9.1.2",
"react-resizable-panels": "^2.0.16", "react-resizable-panels": "^2.0.19",
"react-select": "5.8.0", "react-select": "5.8.0",
"react-use": "^17.5.0", "react-use": "^17.5.0",
"react-virtuoso": "^4.7.5", "react-virtuoso": "^4.7.10",
"reactflow": "^11.10.4", "reactflow": "^11.11.3",
"redux-dynamic-middlewares": "^2.2.0", "redux-dynamic-middlewares": "^2.2.0",
"redux-remember": "^5.1.0", "redux-remember": "^5.1.0",
"redux-undo": "^1.1.0", "redux-undo": "^1.1.0",
@ -104,8 +106,8 @@
"use-device-pixel-ratio": "^1.1.2", "use-device-pixel-ratio": "^1.1.2",
"use-image": "^1.1.1", "use-image": "^1.1.1",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"zod": "^3.22.4", "zod": "^3.23.6",
"zod-validation-error": "^3.0.3" "zod-validation-error": "^3.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"@chakra-ui/react": "^2.8.2", "@chakra-ui/react": "^2.8.2",
@ -116,40 +118,42 @@
"devDependencies": { "devDependencies": {
"@invoke-ai/eslint-config-react": "^0.0.14", "@invoke-ai/eslint-config-react": "^0.0.14",
"@invoke-ai/prettier-config-react": "^0.0.7", "@invoke-ai/prettier-config-react": "^0.0.7",
"@storybook/addon-essentials": "^8.0.4", "@storybook/addon-essentials": "^8.0.10",
"@storybook/addon-interactions": "^8.0.4", "@storybook/addon-interactions": "^8.0.10",
"@storybook/addon-links": "^8.0.4", "@storybook/addon-links": "^8.0.10",
"@storybook/addon-storysource": "^8.0.4", "@storybook/addon-storysource": "^8.0.10",
"@storybook/manager-api": "^8.0.4", "@storybook/manager-api": "^8.0.10",
"@storybook/react": "^8.0.4", "@storybook/react": "^8.0.10",
"@storybook/react-vite": "^8.0.4", "@storybook/react-vite": "^8.0.10",
"@storybook/theming": "^8.0.4", "@storybook/theming": "^8.0.10",
"@types/dateformat": "^5.0.2", "@types/dateformat": "^5.0.2",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.30", "@types/node": "^20.12.10",
"@types/react": "^18.2.73", "@types/react": "^18.3.1",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.3.0",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-v8": "^1.5.0",
"@vitest/ui": "^1.5.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"dpdm": "^3.14.0", "dpdm": "^3.14.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-i18next": "^6.0.3", "eslint-plugin-i18next": "^6.0.3",
"eslint-plugin-path": "^1.3.0", "eslint-plugin-path": "^1.3.0",
"knip": "^5.6.1", "knip": "^5.12.3",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"openapi-typescript": "^6.7.5", "openapi-typescript": "^6.7.5",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"storybook": "^8.0.4", "storybook": "^8.0.10",
"ts-toolbelt": "^9.6.0", "ts-toolbelt": "^9.6.0",
"tsafe": "^1.6.6", "tsafe": "^1.6.6",
"typescript": "^5.4.3", "typescript": "^5.4.5",
"vite": "^5.2.6", "vite": "^5.2.11",
"vite-plugin-css-injected-by-js": "^3.5.0", "vite-plugin-css-injected-by-js": "^3.5.1",
"vite-plugin-dts": "^3.8.0", "vite-plugin-dts": "^3.9.1",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^4.3.2", "vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.4.0" "vitest": "^1.6.0"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -76,7 +76,9 @@
"aboutHeading": "Nutzen Sie Ihre kreative Energie", "aboutHeading": "Nutzen Sie Ihre kreative Energie",
"toResolve": "Lösen", "toResolve": "Lösen",
"add": "Hinzufügen", "add": "Hinzufügen",
"loglevel": "Protokoll Stufe" "loglevel": "Protokoll Stufe",
"selected": "Ausgewählt",
"beta": "Beta"
}, },
"gallery": { "gallery": {
"galleryImageSize": "Bildgröße", "galleryImageSize": "Bildgröße",
@ -86,7 +88,7 @@
"noImagesInGallery": "Keine Bilder in der Galerie", "noImagesInGallery": "Keine Bilder in der Galerie",
"loading": "Lade", "loading": "Lade",
"deleteImage_one": "Lösche Bild", "deleteImage_one": "Lösche Bild",
"deleteImage_other": "", "deleteImage_other": "Lösche {{count}} Bilder",
"copy": "Kopieren", "copy": "Kopieren",
"download": "Runterladen", "download": "Runterladen",
"setCurrentImage": "Setze aktuelle Bild", "setCurrentImage": "Setze aktuelle Bild",
@ -397,7 +399,14 @@
"cancel": "Stornieren", "cancel": "Stornieren",
"defaultSettingsSaved": "Standardeinstellungen gespeichert", "defaultSettingsSaved": "Standardeinstellungen gespeichert",
"addModels": "Model hinzufügen", "addModels": "Model hinzufügen",
"deleteModelImage": "Lösche Model Bild" "deleteModelImage": "Lösche Model Bild",
"hfTokenInvalidErrorMessage": "Falscher oder fehlender HuggingFace Schlüssel.",
"huggingFaceRepoID": "HuggingFace Repo ID",
"hfToken": "HuggingFace Schlüssel",
"hfTokenInvalid": "Falscher oder fehlender HF Schlüssel",
"huggingFacePlaceholder": "besitzer/model-name",
"hfTokenSaved": "HF Schlüssel gespeichert",
"hfTokenUnableToVerify": "Konnte den HF Schlüssel nicht validieren"
}, },
"parameters": { "parameters": {
"images": "Bilder", "images": "Bilder",
@ -686,7 +695,11 @@
"hands": "Hände", "hands": "Hände",
"dwOpenpose": "DW Openpose", "dwOpenpose": "DW Openpose",
"dwOpenposeDescription": "Posenschätzung mit DW Openpose", "dwOpenposeDescription": "Posenschätzung mit DW Openpose",
"selectCLIPVisionModel": "Wähle ein CLIP Vision Model aus" "selectCLIPVisionModel": "Wähle ein CLIP Vision Model aus",
"ipAdapterMethod": "Methode",
"composition": "Nur Komposition",
"full": "Voll",
"style": "Nur Style"
}, },
"queue": { "queue": {
"status": "Status", "status": "Status",
@ -717,7 +730,6 @@
"resume": "Wieder aufnehmen", "resume": "Wieder aufnehmen",
"item": "Auftrag", "item": "Auftrag",
"notReady": "Warteschlange noch nicht bereit", "notReady": "Warteschlange noch nicht bereit",
"queueCountPrediction": "{{promptsCount}} Prompts × {{iterations}} Iterationen -> {{count}} Generationen",
"clearQueueAlertDialog": "\"Die Warteschlange leeren\" stoppt den aktuellen Prozess und leert die Warteschlange komplett.", "clearQueueAlertDialog": "\"Die Warteschlange leeren\" stoppt den aktuellen Prozess und leert die Warteschlange komplett.",
"completedIn": "Fertig in", "completedIn": "Fertig in",
"cancelBatchSucceeded": "Stapel abgebrochen", "cancelBatchSucceeded": "Stapel abgebrochen",

View File

@ -142,8 +142,11 @@
"blue": "Blue", "blue": "Blue",
"alpha": "Alpha", "alpha": "Alpha",
"selected": "Selected", "selected": "Selected",
"viewer": "Viewer", "tab": "Tab",
"tab": "Tab" "viewing": "Viewing",
"viewingDesc": "Review images in a large gallery view",
"editing": "Editing",
"editingDesc": "Edit on the Control Layers canvas"
}, },
"controlnet": { "controlnet": {
"controlAdapter_one": "Control Adapter", "controlAdapter_one": "Control Adapter",
@ -258,7 +261,6 @@
"queue": "Queue", "queue": "Queue",
"queueFront": "Add to Front of Queue", "queueFront": "Add to Front of Queue",
"queueBack": "Add to Queue", "queueBack": "Add to Queue",
"queueCountPrediction": "{{promptsCount}} prompts \u00d7 {{iterations}} iterations -> {{count}} generations",
"queueEmpty": "Queue Empty", "queueEmpty": "Queue Empty",
"enqueueing": "Queueing Batch", "enqueueing": "Queueing Batch",
"resume": "Resume", "resume": "Resume",
@ -311,7 +313,13 @@
"batchFailedToQueue": "Failed to Queue Batch", "batchFailedToQueue": "Failed to Queue Batch",
"graphQueued": "Graph queued", "graphQueued": "Graph queued",
"graphFailedToQueue": "Failed to queue graph", "graphFailedToQueue": "Failed to queue graph",
"openQueue": "Open Queue" "openQueue": "Open Queue",
"prompts_one": "Prompt",
"prompts_other": "Prompts",
"iterations_one": "Iteration",
"iterations_other": "Iterations",
"generations_one": "Generation",
"generations_other": "Generations"
}, },
"invocationCache": { "invocationCache": {
"invocationCache": "Invocation Cache", "invocationCache": "Invocation Cache",
@ -364,8 +372,7 @@
"bulkDownloadRequestFailed": "Problem Preparing Download", "bulkDownloadRequestFailed": "Problem Preparing Download",
"bulkDownloadFailed": "Download Failed", "bulkDownloadFailed": "Download Failed",
"problemDeletingImages": "Problem Deleting Images", "problemDeletingImages": "Problem Deleting Images",
"problemDeletingImagesDesc": "One or more images could not be deleted", "problemDeletingImagesDesc": "One or more images could not be deleted"
"switchTo": "Switch to {{ tab }} (Z)"
}, },
"hotkeys": { "hotkeys": {
"searchHotkeys": "Search Hotkeys", "searchHotkeys": "Search Hotkeys",
@ -589,13 +596,9 @@
"desc": "Upscale the current image", "desc": "Upscale the current image",
"title": "Upscale" "title": "Upscale"
}, },
"backToEditor": { "toggleViewer": {
"desc": "Closes the Image Viewer and shows the Editor View (Text to Image tab only)", "desc": "Switches between the Image Viewer and workspace for the current tab.",
"title": "Back to Editor" "title": "Toggle Image Viewer"
},
"openImageViewer": {
"desc": "Opens the Image Viewer (Text to Image tab only)",
"title": "Open Image Viewer"
} }
}, },
"metadata": { "metadata": {
@ -771,6 +774,7 @@
"cannotConnectOutputToOutput": "Cannot connect output to output", "cannotConnectOutputToOutput": "Cannot connect output to output",
"cannotConnectToSelf": "Cannot connect to self", "cannotConnectToSelf": "Cannot connect to self",
"cannotDuplicateConnection": "Cannot create duplicate connections", "cannotDuplicateConnection": "Cannot create duplicate connections",
"cannotMixAndMatchCollectionItemTypes": "Cannot mix and match collection item types",
"nodePack": "Node pack", "nodePack": "Node pack",
"collection": "Collection", "collection": "Collection",
"collectionFieldType": "{{name}} Collection", "collectionFieldType": "{{name}} Collection",
@ -876,6 +880,7 @@
"versionUnknown": " Version Unknown", "versionUnknown": " Version Unknown",
"workflow": "Workflow", "workflow": "Workflow",
"graph": "Graph", "graph": "Graph",
"noGraph": "No Graph",
"workflowAuthor": "Author", "workflowAuthor": "Author",
"workflowContact": "Contact", "workflowContact": "Contact",
"workflowDescription": "Short Description", "workflowDescription": "Short Description",
@ -936,17 +941,30 @@
"noModelSelected": "No model selected", "noModelSelected": "No model selected",
"noPrompts": "No prompts generated", "noPrompts": "No prompts generated",
"noNodesInGraph": "No nodes in graph", "noNodesInGraph": "No nodes in graph",
"systemDisconnected": "System disconnected" "systemDisconnected": "System disconnected",
"layer": {
"initialImageNoImageSelected": "no initial image selected",
"controlAdapterNoModelSelected": "no Control Adapter model selected",
"controlAdapterIncompatibleBaseModel": "incompatible Control Adapter base model",
"controlAdapterNoImageSelected": "no Control Adapter image selected",
"controlAdapterImageNotProcessed": "Control Adapter image not processed",
"t2iAdapterIncompatibleDimensions": "T2I Adapter requires image dimension to be multiples of 64",
"ipAdapterNoModelSelected": "no IP adapter selected",
"ipAdapterIncompatibleBaseModel": "incompatible IP Adapter base model",
"ipAdapterNoImageSelected": "no IP Adapter image selected",
"rgNoPromptsOrIPAdapters": "no text prompts or IP Adapters",
"rgNoRegion": "no region selected"
}
}, },
"maskBlur": "Mask Blur", "maskBlur": "Mask Blur",
"negativePromptPlaceholder": "Negative Prompt", "negativePromptPlaceholder": "Negative Prompt",
"globalNegativePromptPlaceholder": "Global Negative Prompt",
"noiseThreshold": "Noise Threshold", "noiseThreshold": "Noise Threshold",
"patchmatchDownScaleSize": "Downscale", "patchmatchDownScaleSize": "Downscale",
"perlinNoise": "Perlin Noise", "perlinNoise": "Perlin Noise",
"positivePromptPlaceholder": "Positive Prompt", "positivePromptPlaceholder": "Positive Prompt",
"globalPositivePromptPlaceholder": "Global Positive Prompt",
"iterations": "Iterations", "iterations": "Iterations",
"iterationsWithCount_one": "{{count}} Iteration",
"iterationsWithCount_other": "{{count}} Iterations",
"scale": "Scale", "scale": "Scale",
"scaleBeforeProcessing": "Scale Before Processing", "scaleBeforeProcessing": "Scale Before Processing",
"scaledHeight": "Scaled H", "scaledHeight": "Scaled H",
@ -1548,8 +1566,6 @@
"addIPAdapter": "Add $t(common.ipAdapter)", "addIPAdapter": "Add $t(common.ipAdapter)",
"regionalGuidance": "Regional Guidance", "regionalGuidance": "Regional Guidance",
"regionalGuidanceLayer": "$t(controlLayers.regionalGuidance) $t(unifiedCanvas.layer)", "regionalGuidanceLayer": "$t(controlLayers.regionalGuidance) $t(unifiedCanvas.layer)",
"controlNetLayer": "$t(common.controlNet) $t(unifiedCanvas.layer)",
"ipAdapterLayer": "$t(common.ipAdapter) $t(unifiedCanvas.layer)",
"opacity": "Opacity", "opacity": "Opacity",
"globalControlAdapter": "Global $t(controlnet.controlAdapter_one)", "globalControlAdapter": "Global $t(controlnet.controlAdapter_one)",
"globalControlAdapterLayer": "Global $t(controlnet.controlAdapter_one) $t(unifiedCanvas.layer)", "globalControlAdapterLayer": "Global $t(controlnet.controlAdapter_one) $t(unifiedCanvas.layer)",
@ -1560,7 +1576,9 @@
"opacityFilter": "Opacity Filter", "opacityFilter": "Opacity Filter",
"clearProcessor": "Clear Processor", "clearProcessor": "Clear Processor",
"resetProcessor": "Reset Processor to Defaults", "resetProcessor": "Reset Processor to Defaults",
"noLayersAdded": "No Layers Added" "noLayersAdded": "No Layers Added",
"layers_one": "Layer",
"layers_other": "Layers"
}, },
"ui": { "ui": {
"tabs": { "tabs": {

View File

@ -25,7 +25,24 @@
"areYouSure": "¿Estas seguro?", "areYouSure": "¿Estas seguro?",
"batch": "Administrador de lotes", "batch": "Administrador de lotes",
"modelManager": "Administrador de modelos", "modelManager": "Administrador de modelos",
"communityLabel": "Comunidad" "communityLabel": "Comunidad",
"direction": "Dirección",
"ai": "Ia",
"add": "Añadir",
"auto": "Automático",
"copyError": "Error $t(gallery.copy)",
"details": "Detalles",
"or": "o",
"checkpoint": "Punto de control",
"controlNet": "ControlNet",
"aboutHeading": "Sea dueño de su poder creativo",
"advanced": "Avanzado",
"data": "Fecha",
"delete": "Borrar",
"copy": "Copiar",
"beta": "Beta",
"on": "En",
"aboutDesc": "¿Utilizas Invoke para trabajar? Mira aquí:"
}, },
"gallery": { "gallery": {
"galleryImageSize": "Tamaño de la imagen", "galleryImageSize": "Tamaño de la imagen",
@ -443,7 +460,13 @@
"previousImage": "Imagen anterior", "previousImage": "Imagen anterior",
"nextImage": "Siguiente imagen", "nextImage": "Siguiente imagen",
"showOptionsPanel": "Mostrar el panel lateral", "showOptionsPanel": "Mostrar el panel lateral",
"menu": "Menú" "menu": "Menú",
"showGalleryPanel": "Mostrar panel de galería",
"loadMore": "Cargar más",
"about": "Acerca de",
"createIssue": "Crear un problema",
"resetUI": "Interfaz de usuario $t(accessibility.reset)",
"mode": "Modo"
}, },
"nodes": { "nodes": {
"zoomInNodes": "Acercar", "zoomInNodes": "Acercar",
@ -456,5 +479,68 @@
"reloadNodeTemplates": "Recargar las plantillas de nodos", "reloadNodeTemplates": "Recargar las plantillas de nodos",
"loadWorkflow": "Cargar el flujo de trabajo", "loadWorkflow": "Cargar el flujo de trabajo",
"downloadWorkflow": "Descargar el flujo de trabajo en un archivo JSON" "downloadWorkflow": "Descargar el flujo de trabajo en un archivo JSON"
},
"boards": {
"autoAddBoard": "Agregar panel automáticamente",
"changeBoard": "Cambiar el panel",
"clearSearch": "Borrar la búsqueda",
"deleteBoard": "Borrar el panel",
"selectBoard": "Seleccionar un panel",
"uncategorized": "Sin categoría",
"cancel": "Cancelar",
"addBoard": "Agregar un panel",
"movingImagesToBoard_one": "Moviendo {{count}} imagen al panel:",
"movingImagesToBoard_many": "Moviendo {{count}} imágenes al panel:",
"movingImagesToBoard_other": "Moviendo {{count}} imágenes al panel:",
"bottomMessage": "Al eliminar este panel y las imágenes que contiene, se restablecerán las funciones que los estén utilizando actualmente.",
"deleteBoardAndImages": "Borrar el panel y las imágenes",
"loading": "Cargando...",
"deletedBoardsCannotbeRestored": "Los paneles eliminados no se pueden restaurar",
"move": "Mover",
"menuItemAutoAdd": "Agregar automáticamente a este panel",
"searchBoard": "Buscando paneles…",
"topMessage": "Este panel contiene imágenes utilizadas en las siguientes funciones:",
"downloadBoard": "Descargar panel",
"deleteBoardOnly": "Borrar solo el panel",
"myBoard": "Mi panel",
"noMatching": "No hay paneles que coincidan"
},
"accordions": {
"compositing": {
"title": "Composición",
"infillTab": "Relleno"
},
"generation": {
"title": "Generación"
},
"image": {
"title": "Imagen"
},
"control": {
"title": "Control"
},
"advanced": {
"options": "$t(accordions.advanced.title) opciones",
"title": "Avanzado"
}
},
"ui": {
"tabs": {
"generationTab": "$t(ui.tabs.generation) $t(common.tab)",
"canvas": "Lienzo",
"generation": "Generación",
"queue": "Cola",
"queueTab": "$t(ui.tabs.queue) $t(common.tab)",
"workflows": "Flujos de trabajo",
"models": "Modelos",
"modelsTab": "$t(ui.tabs.models) $t(common.tab)",
"canvasTab": "$t(ui.tabs.canvas) $t(common.tab)",
"workflowsTab": "$t(ui.tabs.workflows) $t(common.tab)"
}
},
"controlLayers": {
"layers_one": "Capa",
"layers_many": "Capas",
"layers_other": "Capas"
} }
} }

View File

@ -5,7 +5,7 @@
"reportBugLabel": "Segnala un errore", "reportBugLabel": "Segnala un errore",
"settingsLabel": "Impostazioni", "settingsLabel": "Impostazioni",
"img2img": "Immagine a Immagine", "img2img": "Immagine a Immagine",
"unifiedCanvas": "Tela unificata", "unifiedCanvas": "Tela",
"nodes": "Flussi di lavoro", "nodes": "Flussi di lavoro",
"upload": "Caricamento", "upload": "Caricamento",
"load": "Carica", "load": "Carica",
@ -74,7 +74,18 @@
"file": "File", "file": "File",
"toResolve": "Da risolvere", "toResolve": "Da risolvere",
"add": "Aggiungi", "add": "Aggiungi",
"loglevel": "Livello di log" "loglevel": "Livello di log",
"beta": "Beta",
"positivePrompt": "Prompt positivo",
"negativePrompt": "Prompt negativo",
"selected": "Selezionato",
"goTo": "Vai a",
"editor": "Editor",
"tab": "Scheda",
"viewing": "Visualizza",
"viewingDesc": "Rivedi le immagini in un'ampia vista della galleria",
"editing": "Modifica",
"editingDesc": "Modifica nell'area Livelli di controllo"
}, },
"gallery": { "gallery": {
"galleryImageSize": "Dimensione dell'immagine", "galleryImageSize": "Dimensione dell'immagine",
@ -180,8 +191,8 @@
"desc": "Mostra le informazioni sui metadati dell'immagine corrente" "desc": "Mostra le informazioni sui metadati dell'immagine corrente"
}, },
"sendToImageToImage": { "sendToImageToImage": {
"title": "Invia a Immagine a Immagine", "title": "Invia a Generazione da immagine",
"desc": "Invia l'immagine corrente a da Immagine a Immagine" "desc": "Invia l'immagine corrente a Generazione da immagine"
}, },
"deleteImage": { "deleteImage": {
"title": "Elimina immagine", "title": "Elimina immagine",
@ -334,6 +345,10 @@
"remixImage": { "remixImage": {
"desc": "Utilizza tutti i parametri tranne il seme dell'immagine corrente", "desc": "Utilizza tutti i parametri tranne il seme dell'immagine corrente",
"title": "Remixa l'immagine" "title": "Remixa l'immagine"
},
"toggleViewer": {
"title": "Attiva/disattiva il visualizzatore di immagini",
"desc": "Passa dal Visualizzatore immagini all'area di lavoro per la scheda corrente."
} }
}, },
"modelManager": { "modelManager": {
@ -471,8 +486,8 @@
"scaledHeight": "Altezza ridimensionata", "scaledHeight": "Altezza ridimensionata",
"infillMethod": "Metodo di riempimento", "infillMethod": "Metodo di riempimento",
"tileSize": "Dimensione piastrella", "tileSize": "Dimensione piastrella",
"sendToImg2Img": "Invia a Immagine a Immagine", "sendToImg2Img": "Invia a Generazione da immagine",
"sendToUnifiedCanvas": "Invia a Tela Unificata", "sendToUnifiedCanvas": "Invia alla Tela",
"downloadImage": "Scarica l'immagine", "downloadImage": "Scarica l'immagine",
"usePrompt": "Usa Prompt", "usePrompt": "Usa Prompt",
"useSeed": "Usa Seme", "useSeed": "Usa Seme",
@ -508,13 +523,11 @@
"incompatibleBaseModelForControlAdapter": "Il modello dell'adattatore di controllo #{{number}} non è compatibile con il modello principale.", "incompatibleBaseModelForControlAdapter": "Il modello dell'adattatore di controllo #{{number}} non è compatibile con il modello principale.",
"missingNodeTemplate": "Modello di nodo mancante", "missingNodeTemplate": "Modello di nodo mancante",
"missingInputForField": "{{nodeLabel}} -> {{fieldLabel}} ingresso mancante", "missingInputForField": "{{nodeLabel}} -> {{fieldLabel}} ingresso mancante",
"missingFieldTemplate": "Modello di campo mancante" "missingFieldTemplate": "Modello di campo mancante",
"imageNotProcessedForControlAdapter": "L'immagine dell'adattatore di controllo #{{number}} non è stata elaborata"
}, },
"useCpuNoise": "Usa la CPU per generare rumore", "useCpuNoise": "Usa la CPU per generare rumore",
"iterations": "Iterazioni", "iterations": "Iterazioni",
"iterationsWithCount_one": "{{count}} Iterazione",
"iterationsWithCount_many": "{{count}} Iterazioni",
"iterationsWithCount_other": "{{count}} Iterazioni",
"isAllowedToUpscale": { "isAllowedToUpscale": {
"useX2Model": "L'immagine è troppo grande per l'ampliamento con il modello x4, utilizza il modello x2", "useX2Model": "L'immagine è troppo grande per l'ampliamento con il modello x4, utilizza il modello x2",
"tooLarge": "L'immagine è troppo grande per l'ampliamento, seleziona un'immagine più piccola" "tooLarge": "L'immagine è troppo grande per l'ampliamento, seleziona un'immagine più piccola"
@ -534,7 +547,10 @@
"infillMosaicMinColor": "Colore minimo", "infillMosaicMinColor": "Colore minimo",
"infillMosaicMaxColor": "Colore massimo", "infillMosaicMaxColor": "Colore massimo",
"infillMosaicTileHeight": "Altezza piastrella", "infillMosaicTileHeight": "Altezza piastrella",
"infillColorValue": "Colore di riempimento" "infillColorValue": "Colore di riempimento",
"globalSettings": "Impostazioni globali",
"globalPositivePromptPlaceholder": "Prompt positivo globale",
"globalNegativePromptPlaceholder": "Prompt negativo globale"
}, },
"settings": { "settings": {
"models": "Modelli", "models": "Modelli",
@ -559,7 +575,7 @@
"intermediatesCleared_one": "Cancellata {{count}} immagine intermedia", "intermediatesCleared_one": "Cancellata {{count}} immagine intermedia",
"intermediatesCleared_many": "Cancellate {{count}} immagini intermedie", "intermediatesCleared_many": "Cancellate {{count}} immagini intermedie",
"intermediatesCleared_other": "Cancellate {{count}} immagini intermedie", "intermediatesCleared_other": "Cancellate {{count}} immagini intermedie",
"clearIntermediatesDesc1": "La cancellazione delle immagini intermedie ripristinerà lo stato di Tela Unificata e ControlNet.", "clearIntermediatesDesc1": "La cancellazione delle immagini intermedie ripristinerà lo stato della Tela e degli Adattatori di Controllo.",
"intermediatesClearedFailed": "Problema con la cancellazione delle immagini intermedie", "intermediatesClearedFailed": "Problema con la cancellazione delle immagini intermedie",
"clearIntermediatesWithCount_one": "Cancella {{count}} immagine intermedia", "clearIntermediatesWithCount_one": "Cancella {{count}} immagine intermedia",
"clearIntermediatesWithCount_many": "Cancella {{count}} immagini intermedie", "clearIntermediatesWithCount_many": "Cancella {{count}} immagini intermedie",
@ -575,8 +591,8 @@
"imageCopied": "Immagine copiata", "imageCopied": "Immagine copiata",
"imageNotLoadedDesc": "Impossibile trovare l'immagine", "imageNotLoadedDesc": "Impossibile trovare l'immagine",
"canvasMerged": "Tela unita", "canvasMerged": "Tela unita",
"sentToImageToImage": "Inviato a Immagine a Immagine", "sentToImageToImage": "Inviato a Generazione da immagine",
"sentToUnifiedCanvas": "Inviato a Tela Unificata", "sentToUnifiedCanvas": "Inviato alla Tela",
"parametersNotSet": "Parametri non impostati", "parametersNotSet": "Parametri non impostati",
"metadataLoadFailed": "Impossibile caricare i metadati", "metadataLoadFailed": "Impossibile caricare i metadati",
"serverError": "Errore del Server", "serverError": "Errore del Server",
@ -795,7 +811,7 @@
"float": "In virgola mobile", "float": "In virgola mobile",
"currentImageDescription": "Visualizza l'immagine corrente nell'editor dei nodi", "currentImageDescription": "Visualizza l'immagine corrente nell'editor dei nodi",
"fieldTypesMustMatch": "I tipi di campo devono corrispondere", "fieldTypesMustMatch": "I tipi di campo devono corrispondere",
"edge": "Bordo", "edge": "Collegamento",
"currentImage": "Immagine corrente", "currentImage": "Immagine corrente",
"integer": "Numero Intero", "integer": "Numero Intero",
"inputMayOnlyHaveOneConnection": "L'ingresso può avere solo una connessione", "inputMayOnlyHaveOneConnection": "L'ingresso può avere solo una connessione",
@ -845,7 +861,9 @@
"resetToDefaultValue": "Ripristina il valore predefinito", "resetToDefaultValue": "Ripristina il valore predefinito",
"noFieldsViewMode": "Questo flusso di lavoro non ha campi selezionati da visualizzare. Visualizza il flusso di lavoro completo per configurare i valori.", "noFieldsViewMode": "Questo flusso di lavoro non ha campi selezionati da visualizzare. Visualizza il flusso di lavoro completo per configurare i valori.",
"edit": "Modifica", "edit": "Modifica",
"graph": "Grafico" "graph": "Grafico",
"showEdgeLabelsHelp": "Mostra etichette sui collegamenti, che indicano i nodi collegati",
"showEdgeLabels": "Mostra le etichette del collegamento"
}, },
"boards": { "boards": {
"autoAddBoard": "Aggiungi automaticamente bacheca", "autoAddBoard": "Aggiungi automaticamente bacheca",
@ -922,7 +940,7 @@
"colorMapTileSize": "Dimensione piastrella", "colorMapTileSize": "Dimensione piastrella",
"mediapipeFaceDescription": "Rilevamento dei volti tramite Mediapipe", "mediapipeFaceDescription": "Rilevamento dei volti tramite Mediapipe",
"hedDescription": "Rilevamento dei bordi nidificati olisticamente", "hedDescription": "Rilevamento dei bordi nidificati olisticamente",
"setControlImageDimensions": "Imposta le dimensioni dell'immagine di controllo su L/A", "setControlImageDimensions": "Copia le dimensioni in L/A (ottimizza per il modello)",
"maxFaces": "Numero massimo di volti", "maxFaces": "Numero massimo di volti",
"addT2IAdapter": "Aggiungi $t(common.t2iAdapter)", "addT2IAdapter": "Aggiungi $t(common.t2iAdapter)",
"addControlNet": "Aggiungi $t(common.controlNet)", "addControlNet": "Aggiungi $t(common.controlNet)",
@ -951,12 +969,17 @@
"mediapipeFace": "Mediapipe Volto", "mediapipeFace": "Mediapipe Volto",
"ip_adapter": "$t(controlnet.controlAdapter_one) #{{number}} ($t(common.ipAdapter))", "ip_adapter": "$t(controlnet.controlAdapter_one) #{{number}} ($t(common.ipAdapter))",
"t2i_adapter": "$t(controlnet.controlAdapter_one) #{{number}} ($t(common.t2iAdapter))", "t2i_adapter": "$t(controlnet.controlAdapter_one) #{{number}} ($t(common.t2iAdapter))",
"selectCLIPVisionModel": "Seleziona un modello CLIP Vision" "selectCLIPVisionModel": "Seleziona un modello CLIP Vision",
"ipAdapterMethod": "Metodo",
"full": "Completo",
"composition": "Solo la composizione",
"style": "Solo lo stile",
"beginEndStepPercentShort": "Inizio/Fine %",
"setControlImageDimensionsForce": "Copia le dimensioni in L/A (ignora il modello)"
}, },
"queue": { "queue": {
"queueFront": "Aggiungi all'inizio della coda", "queueFront": "Aggiungi all'inizio della coda",
"queueBack": "Aggiungi alla coda", "queueBack": "Aggiungi alla coda",
"queueCountPrediction": "{{promptsCount}} prompt × {{iterations}} iterazioni -> {{count}} generazioni",
"queue": "Coda", "queue": "Coda",
"status": "Stato", "status": "Stato",
"pruneSucceeded": "Rimossi {{item_count}} elementi completati dalla coda", "pruneSucceeded": "Rimossi {{item_count}} elementi completati dalla coda",
@ -993,7 +1016,7 @@
"cancelBatchSucceeded": "Lotto annullato", "cancelBatchSucceeded": "Lotto annullato",
"clearTooltip": "Annulla e cancella tutti gli elementi", "clearTooltip": "Annulla e cancella tutti gli elementi",
"current": "Attuale", "current": "Attuale",
"pauseTooltip": "Sospende l'elaborazione", "pauseTooltip": "Sospendi l'elaborazione",
"failed": "Falliti", "failed": "Falliti",
"cancelItem": "Annulla l'elemento", "cancelItem": "Annulla l'elemento",
"next": "Prossimo", "next": "Prossimo",
@ -1394,6 +1417,12 @@
"paragraphs": [ "paragraphs": [
"La dimensione del bordo del passaggio di coerenza." "La dimensione del bordo del passaggio di coerenza."
] ]
},
"ipAdapterMethod": {
"heading": "Metodo",
"paragraphs": [
"Metodo con cui applicare l'adattatore IP corrente."
]
} }
}, },
"sdxl": { "sdxl": {
@ -1522,5 +1551,56 @@
"compatibleEmbeddings": "Incorporamenti compatibili", "compatibleEmbeddings": "Incorporamenti compatibili",
"addPromptTrigger": "Aggiungi Trigger nel prompt", "addPromptTrigger": "Aggiungi Trigger nel prompt",
"noMatchingTriggers": "Nessun Trigger corrispondente" "noMatchingTriggers": "Nessun Trigger corrispondente"
},
"controlLayers": {
"opacityFilter": "Filtro opacità",
"deleteAll": "Cancella tutto",
"addLayer": "Aggiungi Livello",
"moveToFront": "Sposta in primo piano",
"moveToBack": "Sposta in fondo",
"moveForward": "Sposta avanti",
"moveBackward": "Sposta indietro",
"brushSize": "Dimensioni del pennello",
"globalMaskOpacity": "Opacità globale della maschera",
"autoNegative": "Auto Negativo",
"toggleVisibility": "Attiva/disattiva la visibilità dei livelli",
"deletePrompt": "Cancella il prompt",
"debugLayers": "Debug dei Livelli",
"rectangle": "Rettangolo",
"maskPreviewColor": "Colore anteprima maschera",
"addPositivePrompt": "Aggiungi $t(common.positivePrompt)",
"addNegativePrompt": "Aggiungi $t(common.negativePrompt)",
"addIPAdapter": "Aggiungi $t(common.ipAdapter)",
"regionalGuidance": "Guida regionale",
"regionalGuidanceLayer": "$t(unifiedCanvas.layer) $t(controlLayers.regionalGuidance)",
"opacity": "Opacità",
"globalControlAdapter": "$t(controlnet.controlAdapter_one) Globale",
"globalControlAdapterLayer": "$t(controlnet.controlAdapter_one) - $t(unifiedCanvas.layer) Globale",
"globalIPAdapter": "$t(common.ipAdapter) Globale",
"globalIPAdapterLayer": "$t(common.ipAdapter) - $t(unifiedCanvas.layer) Globale",
"globalInitialImage": "Immagine iniziale",
"globalInitialImageLayer": "$t(controlLayers.globalInitialImage) - $t(unifiedCanvas.layer) Globale",
"clearProcessor": "Cancella processore",
"resetProcessor": "Ripristina il processore alle impostazioni predefinite",
"noLayersAdded": "Nessun livello aggiunto",
"resetRegion": "Reimposta la regione",
"controlLayers": "Livelli di controllo",
"layers_one": "Livello",
"layers_many": "Livelli",
"layers_other": "Livelli"
},
"ui": {
"tabs": {
"generation": "Generazione",
"generationTab": "$t(ui.tabs.generation) $t(common.tab)",
"canvas": "Tela",
"canvasTab": "$t(ui.tabs.canvas) $t(common.tab)",
"workflows": "Flussi di lavoro",
"workflowsTab": "$t(ui.tabs.workflows) $t(common.tab)",
"models": "Modelli",
"modelsTab": "$t(ui.tabs.models) $t(common.tab)",
"queue": "Coda",
"queueTab": "$t(ui.tabs.queue) $t(common.tab)"
}
} }
} }

View File

@ -570,7 +570,6 @@
"pauseSucceeded": "処理が一時停止されました", "pauseSucceeded": "処理が一時停止されました",
"queueFront": "キューの先頭へ追加", "queueFront": "キューの先頭へ追加",
"queueBack": "キューに追加", "queueBack": "キューに追加",
"queueCountPrediction": "{{promptsCount}} プロンプト × {{iterations}} イテレーション -> {{count}} 枚生成",
"pause": "一時停止", "pause": "一時停止",
"queue": "キュー", "queue": "キュー",
"pauseTooltip": "処理を一時停止", "pauseTooltip": "処理を一時停止",

View File

@ -505,7 +505,6 @@
"completed": "완성된", "completed": "완성된",
"queueBack": "Queue에 추가", "queueBack": "Queue에 추가",
"cancelFailed": "항목 취소 중 발생한 문제", "cancelFailed": "항목 취소 중 발생한 문제",
"queueCountPrediction": "Queue에 {{predicted}} 추가",
"batchQueued": "Batch Queued", "batchQueued": "Batch Queued",
"pauseFailed": "프로세서 중지 중 발생한 문제", "pauseFailed": "프로세서 중지 중 발생한 문제",
"clearFailed": "Queue 제거 중 발생한 문제", "clearFailed": "Queue 제거 중 발생한 문제",

View File

@ -383,8 +383,6 @@
"useCpuNoise": "Gebruik CPU-ruis", "useCpuNoise": "Gebruik CPU-ruis",
"imageActions": "Afbeeldingshandeling", "imageActions": "Afbeeldingshandeling",
"iterations": "Iteraties", "iterations": "Iteraties",
"iterationsWithCount_one": "{{count}} iteratie",
"iterationsWithCount_other": "{{count}} iteraties",
"coherenceMode": "Modus" "coherenceMode": "Modus"
}, },
"settings": { "settings": {
@ -940,7 +938,6 @@
"completed": "Voltooid", "completed": "Voltooid",
"queueBack": "Voeg toe aan wachtrij", "queueBack": "Voeg toe aan wachtrij",
"cancelFailed": "Fout bij annuleren onderdeel", "cancelFailed": "Fout bij annuleren onderdeel",
"queueCountPrediction": "Voeg {{predicted}} toe aan wachtrij",
"batchQueued": "Reeks in wachtrij geplaatst", "batchQueued": "Reeks in wachtrij geplaatst",
"pauseFailed": "Fout bij onderbreken verwerker", "pauseFailed": "Fout bij onderbreken verwerker",
"clearFailed": "Fout bij wissen van wachtrij", "clearFailed": "Fout bij wissen van wachtrij",

View File

@ -76,7 +76,18 @@
"localSystem": "Локальная система", "localSystem": "Локальная система",
"aboutDesc": "Используя Invoke для работы? Проверьте это:", "aboutDesc": "Используя Invoke для работы? Проверьте это:",
"add": "Добавить", "add": "Добавить",
"loglevel": "Уровень логов" "loglevel": "Уровень логов",
"beta": "Бета",
"selected": "Выбрано",
"positivePrompt": "Позитивный запрос",
"negativePrompt": "Негативный запрос",
"editor": "Редактор",
"goTo": "Перейти к",
"tab": "Вкладка",
"viewing": "Просмотр",
"editing": "Редактирование",
"viewingDesc": "Просмотр изображений в режиме большой галереи",
"editingDesc": "Редактировать на холсте слоёв управления"
}, },
"gallery": { "gallery": {
"galleryImageSize": "Размер изображений", "galleryImageSize": "Размер изображений",
@ -87,8 +98,8 @@
"deleteImagePermanent": "Удаленные изображения невозможно восстановить.", "deleteImagePermanent": "Удаленные изображения невозможно восстановить.",
"deleteImageBin": "Удаленные изображения будут отправлены в корзину вашей операционной системы.", "deleteImageBin": "Удаленные изображения будут отправлены в корзину вашей операционной системы.",
"deleteImage_one": "Удалить изображение", "deleteImage_one": "Удалить изображение",
"deleteImage_few": "", "deleteImage_few": "Удалить {{count}} изображения",
"deleteImage_many": "", "deleteImage_many": "Удалить {{count}} изображений",
"assets": "Ресурсы", "assets": "Ресурсы",
"autoAssignBoardOnClick": "Авто-назначение доски по клику", "autoAssignBoardOnClick": "Авто-назначение доски по клику",
"deleteSelection": "Удалить выделенное", "deleteSelection": "Удалить выделенное",
@ -336,6 +347,10 @@
"remixImage": { "remixImage": {
"desc": "Используйте все параметры, кроме сида из текущего изображения", "desc": "Используйте все параметры, кроме сида из текущего изображения",
"title": "Ремикс изображения" "title": "Ремикс изображения"
},
"toggleViewer": {
"title": "Переключить просмотр изображений",
"desc": "Переключение между средством просмотра изображений и рабочей областью для текущей вкладки."
} }
}, },
"modelManager": { "modelManager": {
@ -512,7 +527,8 @@
"missingNodeTemplate": "Отсутствует шаблон узла", "missingNodeTemplate": "Отсутствует шаблон узла",
"missingFieldTemplate": "Отсутствует шаблон поля", "missingFieldTemplate": "Отсутствует шаблон поля",
"addingImagesTo": "Добавление изображений в", "addingImagesTo": "Добавление изображений в",
"invoke": "Создать" "invoke": "Создать",
"imageNotProcessedForControlAdapter": "Изображение адаптера контроля №{{number}} не обрабатывается"
}, },
"isAllowedToUpscale": { "isAllowedToUpscale": {
"useX2Model": "Изображение слишком велико для увеличения с помощью модели x4. Используйте модель x2", "useX2Model": "Изображение слишком велико для увеличения с помощью модели x4. Используйте модель x2",
@ -523,9 +539,6 @@
"useCpuNoise": "Использовать шум CPU", "useCpuNoise": "Использовать шум CPU",
"imageActions": "Действия с изображениями", "imageActions": "Действия с изображениями",
"iterations": "Кол-во", "iterations": "Кол-во",
"iterationsWithCount_one": "{{count}} Интеграция",
"iterationsWithCount_few": "{{count}} Итерации",
"iterationsWithCount_many": "{{count}} Итераций",
"useSize": "Использовать размер", "useSize": "Использовать размер",
"coherenceMode": "Режим", "coherenceMode": "Режим",
"aspect": "Соотношение", "aspect": "Соотношение",
@ -541,7 +554,10 @@
"infillMosaicTileHeight": "Высота плиток", "infillMosaicTileHeight": "Высота плиток",
"infillMosaicMinColor": "Мин цвет", "infillMosaicMinColor": "Мин цвет",
"infillMosaicMaxColor": "Макс цвет", "infillMosaicMaxColor": "Макс цвет",
"infillColorValue": "Цвет заливки" "infillColorValue": "Цвет заливки",
"globalSettings": "Глобальные настройки",
"globalNegativePromptPlaceholder": "Глобальный негативный запрос",
"globalPositivePromptPlaceholder": "Глобальный запрос"
}, },
"settings": { "settings": {
"models": "Модели", "models": "Модели",
@ -706,7 +722,9 @@
"coherenceModeBoxBlur": "коробчатое размытие", "coherenceModeBoxBlur": "коробчатое размытие",
"discardCurrent": "Отбросить текущее", "discardCurrent": "Отбросить текущее",
"invertBrushSizeScrollDirection": "Инвертировать прокрутку для размера кисти", "invertBrushSizeScrollDirection": "Инвертировать прокрутку для размера кисти",
"initialFitImageSize": "Подогнать размер изображения при перебросе" "initialFitImageSize": "Подогнать размер изображения при перебросе",
"hideBoundingBox": "Скрыть ограничительную рамку",
"showBoundingBox": "Показать ограничительную рамку"
}, },
"accessibility": { "accessibility": {
"uploadImage": "Загрузить изображение", "uploadImage": "Загрузить изображение",
@ -849,7 +867,10 @@
"editMode": "Открыть в редакторе узлов", "editMode": "Открыть в редакторе узлов",
"resetToDefaultValue": "Сбросить к стандартному значкнию", "resetToDefaultValue": "Сбросить к стандартному значкнию",
"edit": "Редактировать", "edit": "Редактировать",
"noFieldsViewMode": "В этом рабочем процессе нет выбранных полей для отображения. Просмотрите полный рабочий процесс для настройки значений." "noFieldsViewMode": "В этом рабочем процессе нет выбранных полей для отображения. Просмотрите полный рабочий процесс для настройки значений.",
"graph": "График",
"showEdgeLabels": "Показать метки на ребрах",
"showEdgeLabelsHelp": "Показать метки на ребрах, указывающие на соединенные узлы"
}, },
"controlnet": { "controlnet": {
"amult": "a_mult", "amult": "a_mult",
@ -917,8 +938,8 @@
"lineartAnime": "Контурный рисунок в стиле аниме", "lineartAnime": "Контурный рисунок в стиле аниме",
"mediapipeFaceDescription": "Обнаружение лиц с помощью Mediapipe", "mediapipeFaceDescription": "Обнаружение лиц с помощью Mediapipe",
"hedDescription": "Целостное обнаружение границ", "hedDescription": "Целостное обнаружение границ",
"setControlImageDimensions": "Установите размеры контрольного изображения на Ш/В", "setControlImageDimensions": "Скопируйте размер в Ш/В (оптимизируйте для модели)",
"scribble": "каракули", "scribble": "Штрихи",
"maxFaces": "Макс Лица", "maxFaces": "Макс Лица",
"mlsdDescription": "Минималистичный детектор отрезков линии", "mlsdDescription": "Минималистичный детектор отрезков линии",
"resizeSimple": "Изменить размер (простой)", "resizeSimple": "Изменить размер (простой)",
@ -933,7 +954,18 @@
"small": "Маленький", "small": "Маленький",
"body": "Тело", "body": "Тело",
"hands": "Руки", "hands": "Руки",
"selectCLIPVisionModel": "Выбрать модель CLIP Vision" "selectCLIPVisionModel": "Выбрать модель CLIP Vision",
"ipAdapterMethod": "Метод",
"full": "Всё",
"mlsd": "M-LSD",
"h": "H",
"style": "Только стиль",
"dwOpenpose": "DW Openpose",
"pidi": "PIDI",
"composition": "Только композиция",
"hed": "HED",
"beginEndStepPercentShort": "Начало/конец %",
"setControlImageDimensionsForce": "Скопируйте размер в Ш/В (игнорируйте модель)"
}, },
"boards": { "boards": {
"autoAddBoard": "Авто добавление Доски", "autoAddBoard": "Авто добавление Доски",
@ -1312,6 +1344,12 @@
"paragraphs": [ "paragraphs": [
"Плавно укладывайте изображение вдоль вертикальной оси." "Плавно укладывайте изображение вдоль вертикальной оси."
] ]
},
"ipAdapterMethod": {
"heading": "Метод",
"paragraphs": [
"Метод, с помощью которого применяется текущий IP-адаптер."
]
} }
}, },
"metadata": { "metadata": {
@ -1359,7 +1397,6 @@
"completed": "Выполнено", "completed": "Выполнено",
"queueBack": "Добавить в очередь", "queueBack": "Добавить в очередь",
"cancelFailed": "Проблема с отменой элемента", "cancelFailed": "Проблема с отменой элемента",
"queueCountPrediction": "{{promptsCount}} запросов × {{iterations}} изображений -> {{count}} генераций",
"batchQueued": "Пакетная очередь", "batchQueued": "Пакетная очередь",
"pauseFailed": "Проблема с приостановкой рендеринга", "pauseFailed": "Проблема с приостановкой рендеринга",
"clearFailed": "Проблема с очисткой очереди", "clearFailed": "Проблема с очисткой очереди",
@ -1475,7 +1512,11 @@
"projectWorkflows": "Рабочие процессы проекта", "projectWorkflows": "Рабочие процессы проекта",
"defaultWorkflows": "Стандартные рабочие процессы", "defaultWorkflows": "Стандартные рабочие процессы",
"name": "Имя", "name": "Имя",
"noRecentWorkflows": "Нет последних рабочих процессов" "noRecentWorkflows": "Нет последних рабочих процессов",
"loadWorkflow": "Рабочий процесс $t(common.load)",
"convertGraph": "Конвертировать график",
"loadFromGraph": "Загрузка рабочего процесса из графика",
"autoLayout": "Автоматическое расположение"
}, },
"hrf": { "hrf": {
"enableHrf": "Включить исправление высокого разрешения", "enableHrf": "Включить исправление высокого разрешения",
@ -1528,5 +1569,56 @@
"addPromptTrigger": "Добавить триггер запроса", "addPromptTrigger": "Добавить триггер запроса",
"compatibleEmbeddings": "Совместимые встраивания", "compatibleEmbeddings": "Совместимые встраивания",
"noMatchingTriggers": "Нет соответствующих триггеров" "noMatchingTriggers": "Нет соответствующих триггеров"
},
"controlLayers": {
"moveToBack": "На задний план",
"moveForward": "Переместить вперёд",
"moveBackward": "Переместить назад",
"brushSize": "Размер кисти",
"controlLayers": "Слои управления",
"globalMaskOpacity": "Глобальная непрозрачность маски",
"autoNegative": "Авто негатив",
"deletePrompt": "Удалить запрос",
"resetRegion": "Сбросить регион",
"debugLayers": "Слои отладки",
"rectangle": "Прямоугольник",
"maskPreviewColor": "Цвет предпросмотра маски",
"addNegativePrompt": "Добавить $t(common.negativePrompt)",
"regionalGuidance": "Региональная точность",
"opacity": "Непрозрачность",
"globalControlAdapter": "Глобальный $t(controlnet.controlAdapter_one)",
"globalControlAdapterLayer": "Глобальный $t(controlnet.controlAdapter_one) $t(unifiedCanvas.layer)",
"globalIPAdapter": "Глобальный $t(common.ipAdapter)",
"globalIPAdapterLayer": "Глобальный $t(common.ipAdapter) $t(unifiedCanvas.layer)",
"opacityFilter": "Фильтр непрозрачности",
"deleteAll": "Удалить всё",
"addLayer": "Добавить слой",
"moveToFront": "На передний план",
"toggleVisibility": "Переключить видимость слоя",
"addPositivePrompt": "Добавить $t(common.positivePrompt)",
"addIPAdapter": "Добавить $t(common.ipAdapter)",
"regionalGuidanceLayer": "$t(controlLayers.regionalGuidance) $t(unifiedCanvas.layer)",
"resetProcessor": "Сброс процессора по умолчанию",
"clearProcessor": "Чистый процессор",
"globalInitialImage": "Глобальное исходное изображение",
"globalInitialImageLayer": "$t(controlLayers.globalInitialImage) $t(unifiedCanvas.layer)",
"noLayersAdded": "Без слоев",
"layers_one": "Слой",
"layers_few": "Слоя",
"layers_many": "Слоев"
},
"ui": {
"tabs": {
"generation": "Генерация",
"canvas": "Холст",
"workflowsTab": "$t(ui.tabs.workflows) $t(common.tab)",
"models": "Модели",
"generationTab": "$t(ui.tabs.generation) $t(common.tab)",
"workflows": "Рабочие процессы",
"canvasTab": "$t(ui.tabs.canvas) $t(common.tab)",
"queueTab": "$t(ui.tabs.queue) $t(common.tab)",
"modelsTab": "$t(ui.tabs.models) $t(common.tab)",
"queue": "Очередь"
}
} }
} }

View File

@ -66,7 +66,7 @@
"saveAs": "保存为", "saveAs": "保存为",
"ai": "ai", "ai": "ai",
"or": "或", "or": "或",
"aboutDesc": "使用 Invoke 工作?看:", "aboutDesc": "使用 Invoke 工作?来看看:",
"add": "添加", "add": "添加",
"loglevel": "日志级别", "loglevel": "日志级别",
"copy": "复制", "copy": "复制",
@ -445,7 +445,6 @@
"useX2Model": "图像太大,无法使用 x4 模型,使用 x2 模型作为替代", "useX2Model": "图像太大,无法使用 x4 模型,使用 x2 模型作为替代",
"tooLarge": "图像太大无法进行放大,请选择更小的图像" "tooLarge": "图像太大无法进行放大,请选择更小的图像"
}, },
"iterationsWithCount_other": "{{count}} 次迭代生成",
"cfgRescaleMultiplier": "CFG 重缩放倍数", "cfgRescaleMultiplier": "CFG 重缩放倍数",
"useSize": "使用尺寸", "useSize": "使用尺寸",
"setToOptimalSize": "优化模型大小", "setToOptimalSize": "优化模型大小",
@ -853,7 +852,6 @@
"pruneSucceeded": "从队列修剪 {{item_count}} 个已完成的项目", "pruneSucceeded": "从队列修剪 {{item_count}} 个已完成的项目",
"notReady": "无法排队", "notReady": "无法排队",
"batchFailedToQueue": "批次加入队列失败", "batchFailedToQueue": "批次加入队列失败",
"queueCountPrediction": "{{promptsCount}} 提示词 × {{iterations}} 迭代次数 -> {{count}} 次生成",
"batchQueued": "加入队列的批次", "batchQueued": "加入队列的批次",
"front": "前", "front": "前",
"pruneTooltip": "修剪 {{item_count}} 个已完成的项目", "pruneTooltip": "修剪 {{item_count}} 个已完成的项目",

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
import fs from 'node:fs'; import fs from 'node:fs';
import openapiTS from 'openapi-typescript'; import openapiTS from 'openapi-typescript';

View File

@ -67,6 +67,8 @@ export const useSocketIO = () => {
if ($isDebugging.get() || import.meta.env.MODE === 'development') { if ($isDebugging.get() || import.meta.env.MODE === 'development') {
window.$socketOptions = $socketOptions; window.$socketOptions = $socketOptions;
// This is only enabled manually for debugging, console is allowed.
/* eslint-disable-next-line no-console */
console.log('Socket initialized', socket); console.log('Socket initialized', socket);
} }
@ -75,6 +77,8 @@ export const useSocketIO = () => {
return () => { return () => {
if ($isDebugging.get() || import.meta.env.MODE === 'development') { if ($isDebugging.get() || import.meta.env.MODE === 'development') {
window.$socketOptions = undefined; window.$socketOptions = undefined;
// This is only enabled manually for debugging, console is allowed.
/* eslint-disable-next-line no-console */
console.log('Socket teardown', socket); console.log('Socket teardown', socket);
} }
socket.disconnect(); socket.disconnect();

View File

@ -1,3 +1,6 @@
/* eslint-disable no-console */
// This is only enabled manually for debugging, console is allowed.
import type { Middleware, MiddlewareAPI } from '@reduxjs/toolkit'; import type { Middleware, MiddlewareAPI } from '@reduxjs/toolkit';
import { diff } from 'jsondiffpatch'; import { diff } from 'jsondiffpatch';

View File

@ -1,7 +1,6 @@
import type { UnknownAction } from '@reduxjs/toolkit'; import type { UnknownAction } from '@reduxjs/toolkit';
import { deepClone } from 'common/util/deepClone'; import { deepClone } from 'common/util/deepClone';
import { isAnyGraphBuilt } from 'features/nodes/store/actions'; import { isAnyGraphBuilt } from 'features/nodes/store/actions';
import { nodeTemplatesBuilt } from 'features/nodes/store/nodesSlice';
import { appInfoApi } from 'services/api/endpoints/appInfo'; import { appInfoApi } from 'services/api/endpoints/appInfo';
import type { Graph } from 'services/api/types'; import type { Graph } from 'services/api/types';
import { socketGeneratorProgress } from 'services/events/actions'; import { socketGeneratorProgress } from 'services/events/actions';
@ -25,13 +24,6 @@ export const actionSanitizer = <A extends UnknownAction>(action: A): A => {
}; };
} }
if (nodeTemplatesBuilt.match(action)) {
return {
...action,
payload: '<Node templates omitted>',
};
}
if (socketGeneratorProgress.match(action)) { if (socketGeneratorProgress.match(action)) {
const sanitized = deepClone(action); const sanitized = deepClone(action);
if (sanitized.payload.data.progress_image) { if (sanitized.payload.data.progress_image) {

View File

@ -21,7 +21,7 @@ export const addDeleteBoardAndImagesFulfilledListener = (startAppListening: AppS
const { canvas, nodes, controlAdapters, controlLayers } = getState(); const { canvas, nodes, controlAdapters, controlLayers } = getState();
deleted_images.forEach((image_name) => { deleted_images.forEach((image_name) => {
const imageUsage = getImageUsage(canvas, nodes, controlAdapters, controlLayers.present, image_name); const imageUsage = getImageUsage(canvas, nodes.present, controlAdapters, controlLayers.present, image_name);
if (imageUsage.isCanvasImage && !wasCanvasReset) { if (imageUsage.isCanvasImage && !wasCanvasReset) {
dispatch(resetCanvas()); dispatch(resetCanvas());

View File

@ -1,60 +1,54 @@
import { isAnyOf } from '@reduxjs/toolkit'; import { isAnyOf } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger'; import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import type { AppDispatch } from 'app/store/store';
import { parseify } from 'common/util/serialize'; import { parseify } from 'common/util/serialize';
import { import {
caLayerImageChanged, caLayerImageChanged,
caLayerIsProcessingImageChanged,
caLayerModelChanged, caLayerModelChanged,
caLayerProcessedImageChanged, caLayerProcessedImageChanged,
caLayerProcessorConfigChanged, caLayerProcessorConfigChanged,
caLayerProcessorPendingBatchIdChanged,
caLayerRecalled,
isControlAdapterLayer, isControlAdapterLayer,
} from 'features/controlLayers/store/controlLayersSlice'; } from 'features/controlLayers/store/controlLayersSlice';
import { CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters'; import { CA_PROCESSOR_DATA } from 'features/controlLayers/util/controlAdapters';
import { isImageOutput } from 'features/nodes/types/common'; import { isImageOutput } from 'features/nodes/types/common';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { t } from 'i18next'; import { t } from 'i18next';
import { isEqual } from 'lodash-es'; import { getImageDTO } from 'services/api/endpoints/images';
import { imagesApi } from 'services/api/endpoints/images';
import { queueApi } from 'services/api/endpoints/queue'; import { queueApi } from 'services/api/endpoints/queue';
import type { BatchConfig, ImageDTO } from 'services/api/types'; import type { BatchConfig } from 'services/api/types';
import { socketInvocationComplete } from 'services/events/actions'; import { socketInvocationComplete } from 'services/events/actions';
import { assert } from 'tsafe';
const matcher = isAnyOf(caLayerImageChanged, caLayerProcessorConfigChanged, caLayerModelChanged); const matcher = isAnyOf(caLayerImageChanged, caLayerProcessorConfigChanged, caLayerModelChanged, caLayerRecalled);
const DEBOUNCE_MS = 300; const DEBOUNCE_MS = 300;
const log = logger('session'); const log = logger('session');
/**
* Simple helper to cancel a batch and reset the pending batch ID
*/
const cancelProcessorBatch = async (dispatch: AppDispatch, layerId: string, batchId: string) => {
const req = dispatch(queueApi.endpoints.cancelByBatchIds.initiate({ batch_ids: [batchId] }));
log.trace({ batchId }, 'Cancelling existing preprocessor batch');
try {
await req.unwrap();
} catch {
// no-op
} finally {
req.reset();
// Always reset the pending batch ID - the cancel req could fail if the batch doesn't exist
dispatch(caLayerProcessorPendingBatchIdChanged({ layerId, batchId: null }));
}
};
export const addControlAdapterPreprocessor = (startAppListening: AppStartListening) => { export const addControlAdapterPreprocessor = (startAppListening: AppStartListening) => {
startAppListening({ startAppListening({
matcher, matcher,
effect: async (action, { dispatch, getState, getOriginalState, cancelActiveListeners, delay, take }) => { effect: async (action, { dispatch, getState, cancelActiveListeners, delay, take, signal }) => {
const { layerId } = action.payload; const layerId = caLayerRecalled.match(action) ? action.payload.id : action.payload.layerId;
const precheckLayerOriginal = getOriginalState()
.controlLayers.present.layers.filter(isControlAdapterLayer)
.find((l) => l.id === layerId);
const precheckLayer = getState()
.controlLayers.present.layers.filter(isControlAdapterLayer)
.find((l) => l.id === layerId);
// Conditions to bail
const layerDoesNotExist = !precheckLayer;
const layerHasNoImage = !precheckLayer?.controlAdapter.image;
const layerHasNoProcessorConfig = !precheckLayer?.controlAdapter.processorConfig;
const layerIsAlreadyProcessingImage = precheckLayer?.controlAdapter.isProcessingImage;
const areImageAndProcessorUnchanged =
isEqual(precheckLayer?.controlAdapter.image, precheckLayerOriginal?.controlAdapter.image) &&
isEqual(precheckLayer?.controlAdapter.processorConfig, precheckLayerOriginal?.controlAdapter.processorConfig);
if (
layerDoesNotExist ||
layerHasNoImage ||
layerHasNoProcessorConfig ||
areImageAndProcessorUnchanged ||
layerIsAlreadyProcessingImage
) {
return;
}
// Cancel any in-progress instances of this listener // Cancel any in-progress instances of this listener
cancelActiveListeners(); cancelActiveListeners();
@ -62,19 +56,31 @@ export const addControlAdapterPreprocessor = (startAppListening: AppStartListeni
// Delay before starting actual work // Delay before starting actual work
await delay(DEBOUNCE_MS); await delay(DEBOUNCE_MS);
dispatch(caLayerIsProcessingImageChanged({ layerId, isProcessingImage: true }));
// Double-check that we are still eligible for processing // Double-check that we are still eligible for processing
const state = getState(); const state = getState();
const layer = state.controlLayers.present.layers.filter(isControlAdapterLayer).find((l) => l.id === layerId); const layer = state.controlLayers.present.layers.filter(isControlAdapterLayer).find((l) => l.id === layerId);
const image = layer?.controlAdapter.image;
const config = layer?.controlAdapter.processorConfig;
// If we have no image or there is no processor config, bail // If we have no image or there is no processor config, bail
if (!layer || !image || !config) { if (!layer) {
return; return;
} }
const image = layer.controlAdapter.image;
const config = layer.controlAdapter.processorConfig;
if (!image || !config) {
// The user has reset the image or config, so we should clear the processed image
dispatch(caLayerProcessedImageChanged({ layerId, imageDTO: null }));
}
// At this point, the user has stopped fiddling with the processor settings and there is a processor selected.
// If there is a pending processor batch, cancel it.
if (layer.controlAdapter.processorPendingBatchId) {
cancelProcessorBatch(dispatch, layerId, layer.controlAdapter.processorPendingBatchId);
}
// @ts-expect-error: TS isn't able to narrow the typing of buildNode and `config` will error... // @ts-expect-error: TS isn't able to narrow the typing of buildNode and `config` will error...
const processorNode = CA_PROCESSOR_DATA[config.type].buildNode(image, config); const processorNode = CA_PROCESSOR_DATA[config.type].buildNode(image, config);
const enqueueBatchArg: BatchConfig = { const enqueueBatchArg: BatchConfig = {
@ -82,7 +88,11 @@ export const addControlAdapterPreprocessor = (startAppListening: AppStartListeni
batch: { batch: {
graph: { graph: {
nodes: { nodes: {
[processorNode.id]: { ...processorNode, is_intermediate: true }, [processorNode.id]: {
...processorNode,
// Control images are always intermediate - do not save to gallery
is_intermediate: true,
},
}, },
edges: [], edges: [],
}, },
@ -90,16 +100,21 @@ export const addControlAdapterPreprocessor = (startAppListening: AppStartListeni
}, },
}; };
// Kick off the processor batch
const req = dispatch(
queueApi.endpoints.enqueueBatch.initiate(enqueueBatchArg, {
fixedCacheKey: 'enqueueBatch',
})
);
try { try {
const req = dispatch(
queueApi.endpoints.enqueueBatch.initiate(enqueueBatchArg, {
fixedCacheKey: 'enqueueBatch',
})
);
const enqueueResult = await req.unwrap(); const enqueueResult = await req.unwrap();
req.reset(); // TODO(psyche): Update the pydantic models, pretty sure we will _always_ have a batch_id here, but the model says it's optional
assert(enqueueResult.batch.batch_id, 'Batch ID not returned from queue');
dispatch(caLayerProcessorPendingBatchIdChanged({ layerId, batchId: enqueueResult.batch.batch_id }));
log.debug({ enqueueResult: parseify(enqueueResult) }, t('queue.graphQueued')); log.debug({ enqueueResult: parseify(enqueueResult) }, t('queue.graphQueued'));
// Wait for the processor node to complete
const [invocationCompleteAction] = await take( const [invocationCompleteAction] = await take(
(action): action is ReturnType<typeof socketInvocationComplete> => (action): action is ReturnType<typeof socketInvocationComplete> =>
socketInvocationComplete.match(action) && socketInvocationComplete.match(action) &&
@ -108,48 +123,51 @@ export const addControlAdapterPreprocessor = (startAppListening: AppStartListeni
); );
// We still have to check the output type // We still have to check the output type
if (isImageOutput(invocationCompleteAction.payload.data.result)) { assert(
const { image_name } = invocationCompleteAction.payload.data.result.image; isImageOutput(invocationCompleteAction.payload.data.result),
`Processor did not return an image output, got: ${invocationCompleteAction.payload.data.result}`
);
const { image_name } = invocationCompleteAction.payload.data.result.image;
// Wait for the ImageDTO to be received const imageDTO = await getImageDTO(image_name);
const [{ payload }] = await take( assert(imageDTO, "Failed to fetch processor output's image DTO");
(action) =>
imagesApi.endpoints.getImageDTO.matchFulfilled(action) && action.payload.image_name === image_name
);
const imageDTO = payload as ImageDTO; // Whew! We made it. Update the layer with the processed image
log.debug({ layerId, imageDTO }, 'ControlNet image processed');
log.debug({ layerId, imageDTO }, 'ControlNet image processed'); dispatch(caLayerProcessedImageChanged({ layerId, imageDTO }));
dispatch(caLayerProcessorPendingBatchIdChanged({ layerId, batchId: null }));
// Update the processed image in the store
dispatch(
caLayerProcessedImageChanged({
layerId,
imageDTO,
})
);
dispatch(caLayerIsProcessingImageChanged({ layerId, isProcessingImage: false }));
}
} catch (error) { } catch (error) {
console.log(error); if (signal.aborted) {
log.error({ enqueueBatchArg: parseify(enqueueBatchArg) }, t('queue.graphFailedToQueue')); // The listener was canceled - we need to cancel the pending processor batch, if there is one (could have changed by now).
dispatch(caLayerIsProcessingImageChanged({ layerId, isProcessingImage: false })); const pendingBatchId = getState()
.controlLayers.present.layers.filter(isControlAdapterLayer)
.find((l) => l.id === layerId)?.controlAdapter.processorPendingBatchId;
if (pendingBatchId) {
cancelProcessorBatch(dispatch, layerId, pendingBatchId);
}
log.trace('Control Adapter preprocessor cancelled');
} else {
// Some other error condition...
log.error({ enqueueBatchArg: parseify(enqueueBatchArg) }, t('queue.graphFailedToQueue'));
if (error instanceof Object) { if (error instanceof Object) {
if ('data' in error && 'status' in error) { if ('data' in error && 'status' in error) {
if (error.status === 403) { if (error.status === 403) {
dispatch(caLayerImageChanged({ layerId, imageDTO: null })); dispatch(caLayerImageChanged({ layerId, imageDTO: null }));
return; return;
}
} }
} }
}
dispatch( dispatch(
addToast({ addToast({
title: t('queue.graphFailedToQueue'), title: t('queue.graphFailedToQueue'),
status: 'error', status: 'error',
}) })
); );
}
} finally {
req.reset();
} }
}, },
}); });

View File

@ -8,8 +8,8 @@ import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
import { getCanvasData } from 'features/canvas/util/getCanvasData'; import { getCanvasData } from 'features/canvas/util/getCanvasData';
import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode'; import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode';
import { canvasGraphBuilt } from 'features/nodes/store/actions'; import { canvasGraphBuilt } from 'features/nodes/store/actions';
import { buildCanvasGraph } from 'features/nodes/util/graph/buildCanvasGraph';
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
import { buildCanvasGraph } from 'features/nodes/util/graph/canvas/buildCanvasGraph';
import { imagesApi } from 'services/api/endpoints/images'; import { imagesApi } from 'services/api/endpoints/images';
import { queueApi } from 'services/api/endpoints/queue'; import { queueApi } from 'services/api/endpoints/queue';
import type { ImageDTO } from 'services/api/types'; import type { ImageDTO } from 'services/api/types';

View File

@ -1,8 +1,9 @@
import { enqueueRequested } from 'app/store/actions'; import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { buildGenerationTabGraph } from 'features/nodes/util/graph/buildGenerationTabGraph'; import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
import { buildGenerationTabSDXLGraph } from 'features/nodes/util/graph/buildGenerationTabSDXLGraph';
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
import { buildGenerationTabGraph } from 'features/nodes/util/graph/generation/buildGenerationTabGraph';
import { buildGenerationTabSDXLGraph } from 'features/nodes/util/graph/generation/buildGenerationTabSDXLGraph';
import { queueApi } from 'services/api/endpoints/queue'; import { queueApi } from 'services/api/endpoints/queue';
export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) => { export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) => {
@ -11,12 +12,13 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
enqueueRequested.match(action) && action.payload.tabName === 'generation', enqueueRequested.match(action) && action.payload.tabName === 'generation',
effect: async (action, { getState, dispatch }) => { effect: async (action, { getState, dispatch }) => {
const state = getState(); const state = getState();
const { shouldShowProgressInViewer } = state.ui;
const model = state.generation.model; const model = state.generation.model;
const { prepend } = action.payload; const { prepend } = action.payload;
let graph; let graph;
if (model && model.base === 'sdxl') { if (model?.base === 'sdxl') {
graph = await buildGenerationTabSDXLGraph(state); graph = await buildGenerationTabSDXLGraph(state);
} else { } else {
graph = await buildGenerationTabGraph(state); graph = await buildGenerationTabGraph(state);
@ -29,7 +31,14 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
fixedCacheKey: 'enqueueBatch', fixedCacheKey: 'enqueueBatch',
}) })
); );
req.reset(); try {
await req.unwrap();
if (shouldShowProgressInViewer) {
dispatch(isImageViewerOpenChanged(true));
}
} finally {
req.reset();
}
}, },
}); });
}; };

View File

@ -11,9 +11,9 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
enqueueRequested.match(action) && action.payload.tabName === 'workflows', enqueueRequested.match(action) && action.payload.tabName === 'workflows',
effect: async (action, { getState, dispatch }) => { effect: async (action, { getState, dispatch }) => {
const state = getState(); const state = getState();
const { nodes, edges } = state.nodes; const { nodes, edges } = state.nodes.present;
const workflow = state.workflow; const workflow = state.workflow;
const graph = buildNodesGraph(state.nodes); const graph = buildNodesGraph(state.nodes.present);
const builtWorkflow = buildWorkflowWithValidation({ const builtWorkflow = buildWorkflowWithValidation({
nodes, nodes,
edges, edges,
@ -39,7 +39,11 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
fixedCacheKey: 'enqueueBatch', fixedCacheKey: 'enqueueBatch',
}) })
); );
req.reset(); try {
await req.unwrap();
} finally {
req.reset();
}
}, },
}); });
}; };

View File

@ -1,7 +1,7 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { isImageViewerOpenChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images'; import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types'; import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util'; import { imagesSelectors } from 'services/api/util';
@ -62,7 +62,6 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen
} else { } else {
dispatch(selectionChanged([imageDTO])); dispatch(selectionChanged([imageDTO]));
} }
dispatch(isImageViewerOpenChanged(true));
}, },
}); });
}; };

View File

@ -1,7 +1,7 @@
import { logger } from 'app/logging/logger'; import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { parseify } from 'common/util/serialize'; import { parseify } from 'common/util/serialize';
import { nodeTemplatesBuilt } from 'features/nodes/store/nodesSlice'; import { $templates } from 'features/nodes/store/nodesSlice';
import { parseSchema } from 'features/nodes/util/schema/parseSchema'; import { parseSchema } from 'features/nodes/util/schema/parseSchema';
import { size } from 'lodash-es'; import { size } from 'lodash-es';
import { appInfoApi } from 'services/api/endpoints/appInfo'; import { appInfoApi } from 'services/api/endpoints/appInfo';
@ -9,7 +9,7 @@ import { appInfoApi } from 'services/api/endpoints/appInfo';
export const addGetOpenAPISchemaListener = (startAppListening: AppStartListening) => { export const addGetOpenAPISchemaListener = (startAppListening: AppStartListening) => {
startAppListening({ startAppListening({
matcher: appInfoApi.endpoints.getOpenAPISchema.matchFulfilled, matcher: appInfoApi.endpoints.getOpenAPISchema.matchFulfilled,
effect: (action, { dispatch, getState }) => { effect: (action, { getState }) => {
const log = logger('system'); const log = logger('system');
const schemaJSON = action.payload; const schemaJSON = action.payload;
@ -20,7 +20,7 @@ export const addGetOpenAPISchemaListener = (startAppListening: AppStartListening
log.debug({ nodeTemplates: parseify(nodeTemplates) }, `Built ${size(nodeTemplates)} node templates`); log.debug({ nodeTemplates: parseify(nodeTemplates) }, `Built ${size(nodeTemplates)} node templates`);
dispatch(nodeTemplatesBuilt(nodeTemplates)); $templates.set(nodeTemplates);
}, },
}); });

View File

@ -29,7 +29,7 @@ import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util'; import { imagesSelectors } from 'services/api/util';
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
state.nodes.nodes.forEach((node) => { state.nodes.present.nodes.forEach((node) => {
if (!isInvocationNode(node)) { if (!isInvocationNode(node)) {
return; return;
} }
@ -73,25 +73,25 @@ const deleteControlAdapterImages = (state: RootState, dispatch: AppDispatch, ima
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
state.controlLayers.present.layers.forEach((l) => { state.controlLayers.present.layers.forEach((l) => {
if (isRegionalGuidanceLayer(l)) { if (isRegionalGuidanceLayer(l)) {
if (l.ipAdapters.some((ipa) => ipa.image?.imageName === imageDTO.image_name)) { if (l.ipAdapters.some((ipa) => ipa.image?.name === imageDTO.image_name)) {
dispatch(layerDeleted(l.id)); dispatch(layerDeleted(l.id));
} }
} }
if (isControlAdapterLayer(l)) { if (isControlAdapterLayer(l)) {
if ( if (
l.controlAdapter.image?.imageName === imageDTO.image_name || l.controlAdapter.image?.name === imageDTO.image_name ||
l.controlAdapter.processedImage?.imageName === imageDTO.image_name l.controlAdapter.processedImage?.name === imageDTO.image_name
) { ) {
dispatch(layerDeleted(l.id)); dispatch(layerDeleted(l.id));
} }
} }
if (isIPAdapterLayer(l)) { if (isIPAdapterLayer(l)) {
if (l.ipAdapter.image?.imageName === imageDTO.image_name) { if (l.ipAdapter.image?.name === imageDTO.image_name) {
dispatch(layerDeleted(l.id)); dispatch(layerDeleted(l.id));
} }
} }
if (isInitialImageLayer(l)) { if (isInitialImageLayer(l)) {
if (l.image?.imageName === imageDTO.image_name) { if (l.image?.name === imageDTO.image_name) {
dispatch(layerDeleted(l.id)); dispatch(layerDeleted(l.id));
} }
} }

View File

@ -1,5 +1,8 @@
import { logger } from 'app/logging/logger'; import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { deepClone } from 'common/util/deepClone';
import { $nodeExecutionStates } from 'features/nodes/hooks/useExecutionState';
import { zNodeStatus } from 'features/nodes/types/invocation';
import { socketGeneratorProgress } from 'services/events/actions'; import { socketGeneratorProgress } from 'services/events/actions';
const log = logger('socketio'); const log = logger('socketio');
@ -9,6 +12,13 @@ export const addGeneratorProgressEventListener = (startAppListening: AppStartLis
actionCreator: socketGeneratorProgress, actionCreator: socketGeneratorProgress,
effect: (action) => { effect: (action) => {
log.trace(action.payload, `Generator progress`); log.trace(action.payload, `Generator progress`);
const { source_node_id, step, total_steps, progress_image } = action.payload.data;
const nes = deepClone($nodeExecutionStates.get()[source_node_id]);
if (nes) {
nes.status = zNodeStatus.enum.IN_PROGRESS;
nes.progress = (step + 1) / total_steps;
nes.progressImage = progress_image ?? null;
}
}, },
}); });
}; };

View File

@ -1,10 +1,18 @@
import { logger } from 'app/logging/logger'; import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { deepClone } from 'common/util/deepClone';
import { parseify } from 'common/util/serialize'; import { parseify } from 'common/util/serialize';
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice'; import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; import {
boardIdSelected,
galleryViewChanged,
imageSelected,
isImageViewerOpenChanged,
} from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types'; import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState';
import { isImageOutput } from 'features/nodes/types/common'; import { isImageOutput } from 'features/nodes/types/common';
import { zNodeStatus } from 'features/nodes/types/invocation';
import { CANVAS_OUTPUT } from 'features/nodes/util/graph/constants'; import { CANVAS_OUTPUT } from 'features/nodes/util/graph/constants';
import { boardsApi } from 'services/api/endpoints/boards'; import { boardsApi } from 'services/api/endpoints/boards';
import { imagesApi } from 'services/api/endpoints/images'; import { imagesApi } from 'services/api/endpoints/images';
@ -23,7 +31,7 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
const { data } = action.payload; const { data } = action.payload;
log.debug({ data: parseify(data) }, `Invocation complete (${action.payload.data.node.type})`); log.debug({ data: parseify(data) }, `Invocation complete (${action.payload.data.node.type})`);
const { result, node, queue_batch_id } = data; const { result, node, queue_batch_id, source_node_id } = data;
// This complete event has an associated image output // This complete event has an associated image output
if (isImageOutput(result) && !nodeTypeDenylist.includes(node.type)) { if (isImageOutput(result) && !nodeTypeDenylist.includes(node.type)) {
const { image_name } = result.image; const { image_name } = result.image;
@ -101,9 +109,20 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
} }
dispatch(imageSelected(imageDTO)); dispatch(imageSelected(imageDTO));
dispatch(isImageViewerOpenChanged(true));
} }
} }
} }
const nes = deepClone($nodeExecutionStates.get()[source_node_id]);
if (nes) {
nes.status = zNodeStatus.enum.COMPLETED;
if (nes.progress !== null) {
nes.progress = 1;
}
nes.outputs.push(result);
upsertExecutionState(nes.nodeId, nes);
}
}, },
}); });
}; };

View File

@ -1,5 +1,8 @@
import { logger } from 'app/logging/logger'; import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { deepClone } from 'common/util/deepClone';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState';
import { zNodeStatus } from 'features/nodes/types/invocation';
import { socketInvocationError } from 'services/events/actions'; import { socketInvocationError } from 'services/events/actions';
const log = logger('socketio'); const log = logger('socketio');
@ -9,6 +12,15 @@ export const addInvocationErrorEventListener = (startAppListening: AppStartListe
actionCreator: socketInvocationError, actionCreator: socketInvocationError,
effect: (action) => { effect: (action) => {
log.error(action.payload, `Invocation error (${action.payload.data.node.type})`); log.error(action.payload, `Invocation error (${action.payload.data.node.type})`);
const { source_node_id } = action.payload.data;
const nes = deepClone($nodeExecutionStates.get()[source_node_id]);
if (nes) {
nes.status = zNodeStatus.enum.FAILED;
nes.error = action.payload.data.error;
nes.progress = null;
nes.progressImage = null;
upsertExecutionState(nes.nodeId, nes);
}
}, },
}); });
}; };

View File

@ -1,5 +1,8 @@
import { logger } from 'app/logging/logger'; import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { deepClone } from 'common/util/deepClone';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState';
import { zNodeStatus } from 'features/nodes/types/invocation';
import { socketInvocationStarted } from 'services/events/actions'; import { socketInvocationStarted } from 'services/events/actions';
const log = logger('socketio'); const log = logger('socketio');
@ -9,6 +12,12 @@ export const addInvocationStartedEventListener = (startAppListening: AppStartLis
actionCreator: socketInvocationStarted, actionCreator: socketInvocationStarted,
effect: (action) => { effect: (action) => {
log.debug(action.payload, `Invocation started (${action.payload.data.node.type})`); log.debug(action.payload, `Invocation started (${action.payload.data.node.type})`);
const { source_node_id } = action.payload.data;
const nes = deepClone($nodeExecutionStates.get()[source_node_id]);
if (nes) {
nes.status = zNodeStatus.enum.IN_PROGRESS;
upsertExecutionState(nes.nodeId, nes);
}
}, },
}); });
}; };

View File

@ -1,5 +1,9 @@
import { logger } from 'app/logging/logger'; import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { deepClone } from 'common/util/deepClone';
import { $nodeExecutionStates } from 'features/nodes/hooks/useExecutionState';
import { zNodeStatus } from 'features/nodes/types/invocation';
import { forEach } from 'lodash-es';
import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue'; import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue';
import { socketQueueItemStatusChanged } from 'services/events/actions'; import { socketQueueItemStatusChanged } from 'services/events/actions';
@ -54,6 +58,21 @@ export const addSocketQueueItemStatusChangedEventListener = (startAppListening:
dispatch( dispatch(
queueApi.util.invalidateTags(['CurrentSessionQueueItem', 'NextSessionQueueItem', 'InvocationCacheStatus']) queueApi.util.invalidateTags(['CurrentSessionQueueItem', 'NextSessionQueueItem', 'InvocationCacheStatus'])
); );
if (['in_progress'].includes(action.payload.data.queue_item.status)) {
forEach($nodeExecutionStates.get(), (nes) => {
if (!nes) {
return;
}
const clone = deepClone(nes);
clone.status = zNodeStatus.enum.PENDING;
clone.error = null;
clone.progress = null;
clone.progressImage = null;
clone.outputs = [];
$nodeExecutionStates.setKey(clone.nodeId, clone);
});
}
}, },
}); });
}; };

View File

@ -1,7 +1,7 @@
import { logger } from 'app/logging/logger'; import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { updateAllNodesRequested } from 'features/nodes/store/actions'; import { updateAllNodesRequested } from 'features/nodes/store/actions';
import { nodeReplaced } from 'features/nodes/store/nodesSlice'; import { $templates, nodeReplaced } from 'features/nodes/store/nodesSlice';
import { NodeUpdateError } from 'features/nodes/types/error'; import { NodeUpdateError } from 'features/nodes/types/error';
import { isInvocationNode } from 'features/nodes/types/invocation'; import { isInvocationNode } from 'features/nodes/types/invocation';
import { getNeedsUpdate, updateNode } from 'features/nodes/util/node/nodeUpdate'; import { getNeedsUpdate, updateNode } from 'features/nodes/util/node/nodeUpdate';
@ -14,7 +14,8 @@ export const addUpdateAllNodesRequestedListener = (startAppListening: AppStartLi
actionCreator: updateAllNodesRequested, actionCreator: updateAllNodesRequested,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
const log = logger('nodes'); const log = logger('nodes');
const { nodes, templates } = getState().nodes; const { nodes } = getState().nodes.present;
const templates = $templates.get();
let unableToUpdateCount = 0; let unableToUpdateCount = 0;
@ -24,7 +25,7 @@ export const addUpdateAllNodesRequestedListener = (startAppListening: AppStartLi
unableToUpdateCount++; unableToUpdateCount++;
return; return;
} }
if (!getNeedsUpdate(node, template)) { if (!getNeedsUpdate(node.data, template)) {
// No need to increment the count here, since we're not actually updating // No need to increment the count here, since we're not actually updating
return; return;
} }

View File

@ -2,32 +2,51 @@ import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { parseify } from 'common/util/serialize'; import { parseify } from 'common/util/serialize';
import { workflowLoaded, workflowLoadRequested } from 'features/nodes/store/actions'; import { workflowLoaded, workflowLoadRequested } from 'features/nodes/store/actions';
import { $templates } from 'features/nodes/store/nodesSlice';
import { $flow } from 'features/nodes/store/reactFlowInstance'; import { $flow } from 'features/nodes/store/reactFlowInstance';
import type { Templates } from 'features/nodes/store/types';
import { WorkflowMigrationError, WorkflowVersionError } from 'features/nodes/types/error'; import { WorkflowMigrationError, WorkflowVersionError } from 'features/nodes/types/error';
import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow';
import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow'; import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { makeToast } from 'features/system/util/makeToast'; import { makeToast } from 'features/system/util/makeToast';
import { t } from 'i18next'; import { t } from 'i18next';
import type { GraphAndWorkflowResponse, NonNullableGraph } from 'services/api/types';
import { z } from 'zod'; import { z } from 'zod';
import { fromZodError } from 'zod-validation-error'; import { fromZodError } from 'zod-validation-error';
const getWorkflow = (data: GraphAndWorkflowResponse, templates: Templates) => {
if (data.workflow) {
// Prefer to load the workflow if it's available - it has more information
const parsed = JSON.parse(data.workflow);
return validateWorkflow(parsed, templates);
} else if (data.graph) {
// Else we fall back on the graph, using the graphToWorkflow function to convert and do layout
const parsed = JSON.parse(data.graph);
const workflow = graphToWorkflow(parsed as NonNullableGraph, true);
return validateWorkflow(workflow, templates);
} else {
throw new Error('No workflow or graph provided');
}
};
export const addWorkflowLoadRequestedListener = (startAppListening: AppStartListening) => { export const addWorkflowLoadRequestedListener = (startAppListening: AppStartListening) => {
startAppListening({ startAppListening({
actionCreator: workflowLoadRequested, actionCreator: workflowLoadRequested,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch }) => {
const log = logger('nodes'); const log = logger('nodes');
const { workflow, asCopy } = action.payload; const { data, asCopy } = action.payload;
const nodeTemplates = getState().nodes.templates; const nodeTemplates = $templates.get();
try { try {
const { workflow: validatedWorkflow, warnings } = validateWorkflow(workflow, nodeTemplates); const { workflow, warnings } = getWorkflow(data, nodeTemplates);
if (asCopy) { if (asCopy) {
// If we're loading a copy, we need to remove the ID so that the backend will create a new workflow // If we're loading a copy, we need to remove the ID so that the backend will create a new workflow
delete validatedWorkflow.id; delete workflow.id;
} }
dispatch(workflowLoaded(validatedWorkflow)); dispatch(workflowLoaded(workflow));
if (!warnings.length) { if (!warnings.length) {
dispatch( dispatch(
addToast( addToast(

View File

@ -21,7 +21,8 @@ import { galleryPersistConfig, gallerySlice } from 'features/gallery/store/galle
import { hrfPersistConfig, hrfSlice } from 'features/hrf/store/hrfSlice'; import { hrfPersistConfig, hrfSlice } from 'features/hrf/store/hrfSlice';
import { loraPersistConfig, loraSlice } from 'features/lora/store/loraSlice'; import { loraPersistConfig, loraSlice } from 'features/lora/store/loraSlice';
import { modelManagerV2PersistConfig, modelManagerV2Slice } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { modelManagerV2PersistConfig, modelManagerV2Slice } from 'features/modelManagerV2/store/modelManagerV2Slice';
import { nodesPersistConfig, nodesSlice } from 'features/nodes/store/nodesSlice'; import { nodesPersistConfig, nodesSlice, nodesUndoableConfig } from 'features/nodes/store/nodesSlice';
import { workflowSettingsPersistConfig, workflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice';
import { workflowPersistConfig, workflowSlice } from 'features/nodes/store/workflowSlice'; import { workflowPersistConfig, workflowSlice } from 'features/nodes/store/workflowSlice';
import { generationPersistConfig, generationSlice } from 'features/parameters/store/generationSlice'; import { generationPersistConfig, generationSlice } from 'features/parameters/store/generationSlice';
import { postprocessingPersistConfig, postprocessingSlice } from 'features/parameters/store/postprocessingSlice'; import { postprocessingPersistConfig, postprocessingSlice } from 'features/parameters/store/postprocessingSlice';
@ -50,7 +51,7 @@ const allReducers = {
[canvasSlice.name]: canvasSlice.reducer, [canvasSlice.name]: canvasSlice.reducer,
[gallerySlice.name]: gallerySlice.reducer, [gallerySlice.name]: gallerySlice.reducer,
[generationSlice.name]: generationSlice.reducer, [generationSlice.name]: generationSlice.reducer,
[nodesSlice.name]: nodesSlice.reducer, [nodesSlice.name]: undoable(nodesSlice.reducer, nodesUndoableConfig),
[postprocessingSlice.name]: postprocessingSlice.reducer, [postprocessingSlice.name]: postprocessingSlice.reducer,
[systemSlice.name]: systemSlice.reducer, [systemSlice.name]: systemSlice.reducer,
[configSlice.name]: configSlice.reducer, [configSlice.name]: configSlice.reducer,
@ -66,6 +67,7 @@ const allReducers = {
[workflowSlice.name]: workflowSlice.reducer, [workflowSlice.name]: workflowSlice.reducer,
[hrfSlice.name]: hrfSlice.reducer, [hrfSlice.name]: hrfSlice.reducer,
[controlLayersSlice.name]: undoable(controlLayersSlice.reducer, controlLayersUndoableConfig), [controlLayersSlice.name]: undoable(controlLayersSlice.reducer, controlLayersUndoableConfig),
[workflowSettingsSlice.name]: workflowSettingsSlice.reducer,
[api.reducerPath]: api.reducer, [api.reducerPath]: api.reducer,
}; };
@ -111,6 +113,7 @@ const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = {
[modelManagerV2PersistConfig.name]: modelManagerV2PersistConfig, [modelManagerV2PersistConfig.name]: modelManagerV2PersistConfig,
[hrfPersistConfig.name]: hrfPersistConfig, [hrfPersistConfig.name]: hrfPersistConfig,
[controlLayersPersistConfig.name]: controlLayersPersistConfig, [controlLayersPersistConfig.name]: controlLayersPersistConfig,
[workflowSettingsPersistConfig.name]: workflowSettingsPersistConfig,
}; };
const unserialize: UnserializeFunction = (data, key) => { const unserialize: UnserializeFunction = (data, key) => {

View File

@ -70,6 +70,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
onMouseOver, onMouseOver,
onMouseOut, onMouseOut,
dataTestId, dataTestId,
...rest
} = props; } = props;
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@ -138,6 +139,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
minH={minSize ? minSize : undefined} minH={minSize ? minSize : undefined}
userSelect="none" userSelect="none"
cursor={isDragDisabled || !imageDTO ? 'default' : 'pointer'} cursor={isDragDisabled || !imageDTO ? 'default' : 'pointer'}
{...rest}
> >
{imageDTO && ( {imageDTO && (
<Flex <Flex

View File

@ -13,6 +13,7 @@ type UseGroupedModelComboboxArg<T extends AnyModelConfig> = {
onChange: (value: T | null) => void; onChange: (value: T | null) => void;
getIsDisabled?: (model: T) => boolean; getIsDisabled?: (model: T) => boolean;
isLoading?: boolean; isLoading?: boolean;
groupByType?: boolean;
}; };
type UseGroupedModelComboboxReturn = { type UseGroupedModelComboboxReturn = {
@ -23,17 +24,21 @@ type UseGroupedModelComboboxReturn = {
noOptionsMessage: () => string; noOptionsMessage: () => string;
}; };
const groupByBaseFunc = <T extends AnyModelConfig>(model: T) => model.base.toUpperCase();
const groupByBaseAndTypeFunc = <T extends AnyModelConfig>(model: T) =>
`${model.base.toUpperCase()} / ${model.type.replaceAll('_', ' ').toUpperCase()}`;
export const useGroupedModelCombobox = <T extends AnyModelConfig>( export const useGroupedModelCombobox = <T extends AnyModelConfig>(
arg: UseGroupedModelComboboxArg<T> arg: UseGroupedModelComboboxArg<T>
): UseGroupedModelComboboxReturn => { ): UseGroupedModelComboboxReturn => {
const { t } = useTranslation(); const { t } = useTranslation();
const base_model = useAppSelector((s) => s.generation.model?.base ?? 'sdxl'); const base_model = useAppSelector((s) => s.generation.model?.base ?? 'sdxl');
const { modelConfigs, selectedModel, getIsDisabled, onChange, isLoading } = arg; const { modelConfigs, selectedModel, getIsDisabled, onChange, isLoading, groupByType = false } = arg;
const options = useMemo<GroupBase<ComboboxOption>[]>(() => { const options = useMemo<GroupBase<ComboboxOption>[]>(() => {
if (!modelConfigs) { if (!modelConfigs) {
return []; return [];
} }
const groupedModels = groupBy(modelConfigs, 'base'); const groupedModels = groupBy(modelConfigs, groupByType ? groupByBaseAndTypeFunc : groupByBaseFunc);
const _options = reduce( const _options = reduce(
groupedModels, groupedModels,
(acc, val, label) => { (acc, val, label) => {
@ -49,9 +54,9 @@ export const useGroupedModelCombobox = <T extends AnyModelConfig>(
}, },
[] as GroupBase<ComboboxOption>[] [] as GroupBase<ComboboxOption>[]
); );
_options.sort((a) => (a.label === base_model ? -1 : 1)); _options.sort((a) => (a.label?.split('/')[0]?.toLowerCase().includes(base_model) ? -1 : 1));
return _options; return _options;
}, [getIsDisabled, modelConfigs, base_model]); }, [modelConfigs, groupByType, getIsDisabled, base_model]);
const value = useMemo( const value = useMemo(
() => () =>

View File

@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { import {
@ -6,187 +7,230 @@ import {
} from 'features/controlAdapters/store/controlAdaptersSlice'; } from 'features/controlAdapters/store/controlAdaptersSlice';
import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types'; import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice'; import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
import type { Layer } from 'features/controlLayers/store/types';
import { selectDynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; import { selectDynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt'; import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
import { selectNodesSlice } from 'features/nodes/store/nodesSlice'; import { $templates, selectNodesSlice } from 'features/nodes/store/nodesSlice';
import type { Templates } from 'features/nodes/store/types';
import { selectWorkflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice';
import { isInvocationNode } from 'features/nodes/types/invocation'; import { isInvocationNode } from 'features/nodes/types/invocation';
import { selectGenerationSlice } from 'features/parameters/store/generationSlice'; import { selectGenerationSlice } from 'features/parameters/store/generationSlice';
import { selectSystemSlice } from 'features/system/store/systemSlice'; import { selectSystemSlice } from 'features/system/store/systemSlice';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import i18n from 'i18next'; import i18n from 'i18next';
import { forEach } from 'lodash-es'; import { forEach, upperFirst } from 'lodash-es';
import { useMemo } from 'react';
import { getConnectedEdges } from 'reactflow'; import { getConnectedEdges } from 'reactflow';
const selector = createMemoizedSelector( const LAYER_TYPE_TO_TKEY: Record<Layer['type'], string> = {
[ initial_image_layer: 'controlLayers.globalInitialImage',
selectControlAdaptersSlice, control_adapter_layer: 'controlLayers.globalControlAdapter',
selectGenerationSlice, ip_adapter_layer: 'controlLayers.globalIPAdapter',
selectSystemSlice, regional_guidance_layer: 'controlLayers.regionalGuidance',
selectNodesSlice, };
selectDynamicPromptsSlice,
selectControlLayersSlice,
activeTabNameSelector,
],
(controlAdapters, generation, system, nodes, dynamicPrompts, controlLayers, activeTabName) => {
const { model } = generation;
const { positivePrompt } = controlLayers.present;
const { isConnected } = system; const createSelector = (templates: Templates) =>
createMemoizedSelector(
[
selectControlAdaptersSlice,
selectGenerationSlice,
selectSystemSlice,
selectNodesSlice,
selectWorkflowSettingsSlice,
selectDynamicPromptsSlice,
selectControlLayersSlice,
activeTabNameSelector,
],
(controlAdapters, generation, system, nodes, workflowSettings, dynamicPrompts, controlLayers, activeTabName) => {
const { model } = generation;
const { size } = controlLayers.present;
const { positivePrompt } = controlLayers.present;
const reasons: string[] = []; const { isConnected } = system;
// Cannot generate if not connected const reasons: { prefix?: string; content: string }[] = [];
if (!isConnected) {
reasons.push(i18n.t('parameters.invoke.systemDisconnected'));
}
if (activeTabName === 'workflows') { // Cannot generate if not connected
if (nodes.shouldValidateGraph) { if (!isConnected) {
if (!nodes.nodes.length) { reasons.push({ content: i18n.t('parameters.invoke.systemDisconnected') });
reasons.push(i18n.t('parameters.invoke.noNodesInGraph')); }
if (activeTabName === 'workflows') {
if (workflowSettings.shouldValidateGraph) {
if (!nodes.nodes.length) {
reasons.push({ content: i18n.t('parameters.invoke.noNodesInGraph') });
}
nodes.nodes.forEach((node) => {
if (!isInvocationNode(node)) {
return;
}
const nodeTemplate = templates[node.data.type];
if (!nodeTemplate) {
// Node type not found
reasons.push({ content: i18n.t('parameters.invoke.missingNodeTemplate') });
return;
}
const connectedEdges = getConnectedEdges([node], nodes.edges);
forEach(node.data.inputs, (field) => {
const fieldTemplate = nodeTemplate.inputs[field.name];
const hasConnection = connectedEdges.some(
(edge) => edge.target === node.id && edge.targetHandle === field.name
);
if (!fieldTemplate) {
reasons.push({ content: i18n.t('parameters.invoke.missingFieldTemplate') });
return;
}
if (fieldTemplate.required && field.value === undefined && !hasConnection) {
reasons.push({
content: i18n.t('parameters.invoke.missingInputForField', {
nodeLabel: node.data.label || nodeTemplate.title,
fieldLabel: field.label || fieldTemplate.title,
}),
});
return;
}
});
});
}
} else {
if (dynamicPrompts.prompts.length === 0 && getShouldProcessPrompt(positivePrompt)) {
reasons.push({ content: i18n.t('parameters.invoke.noPrompts') });
} }
nodes.nodes.forEach((node) => { if (!model) {
if (!isInvocationNode(node)) { reasons.push({ content: i18n.t('parameters.invoke.noModelSelected') });
return; }
}
const nodeTemplate = nodes.templates[node.data.type]; if (activeTabName === 'generation') {
// Handling for generation tab
controlLayers.present.layers
.filter((l) => l.isEnabled)
.forEach((l, i) => {
const layerLiteral = i18n.t('controlLayers.layers_one');
const layerNumber = i + 1;
const layerType = i18n.t(LAYER_TYPE_TO_TKEY[l.type]);
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;
const problems: string[] = [];
if (l.type === 'control_adapter_layer') {
// Must have model
if (!l.controlAdapter.model) {
problems.push(i18n.t('parameters.invoke.layer.controlAdapterNoModelSelected'));
}
// Model base must match
if (l.controlAdapter.model?.base !== model?.base) {
problems.push(i18n.t('parameters.invoke.layer.controlAdapterIncompatibleBaseModel'));
}
// Must have a control image OR, if it has a processor, it must have a processed image
if (!l.controlAdapter.image) {
problems.push(i18n.t('parameters.invoke.layer.controlAdapterNoImageSelected'));
} else if (l.controlAdapter.processorConfig && !l.controlAdapter.processedImage) {
problems.push(i18n.t('parameters.invoke.layer.controlAdapterImageNotProcessed'));
}
// T2I Adapters require images have dimensions that are multiples of 64 (SD1.5) or 32 (SDXL)
if (l.controlAdapter.type === 't2i_adapter') {
const multiple = model?.base === 'sdxl' ? 32 : 64;
if (size.width % multiple !== 0 || size.height % multiple !== 0) {
problems.push(i18n.t('parameters.invoke.layer.t2iAdapterIncompatibleDimensions'));
}
}
}
if (!nodeTemplate) { if (l.type === 'ip_adapter_layer') {
// Node type not found // Must have model
reasons.push(i18n.t('parameters.invoke.missingNodeTemplate')); if (!l.ipAdapter.model) {
return; problems.push(i18n.t('parameters.invoke.layer.ipAdapterNoModelSelected'));
} }
// Model base must match
if (l.ipAdapter.model?.base !== model?.base) {
problems.push(i18n.t('parameters.invoke.layer.ipAdapterIncompatibleBaseModel'));
}
// Must have an image
if (!l.ipAdapter.image) {
problems.push(i18n.t('parameters.invoke.layer.ipAdapterNoImageSelected'));
}
}
const connectedEdges = getConnectedEdges([node], nodes.edges); if (l.type === 'initial_image_layer') {
// Must have an image
if (!l.image) {
problems.push(i18n.t('parameters.invoke.layer.initialImageNoImageSelected'));
}
}
forEach(node.data.inputs, (field) => { if (l.type === 'regional_guidance_layer') {
const fieldTemplate = nodeTemplate.inputs[field.name]; // Must have a region
const hasConnection = connectedEdges.some( if (l.maskObjects.length === 0) {
(edge) => edge.target === node.id && edge.targetHandle === field.name problems.push(i18n.t('parameters.invoke.layer.rgNoRegion'));
); }
// Must have at least 1 prompt or IP Adapter
if (l.positivePrompt === null && l.negativePrompt === null && l.ipAdapters.length === 0) {
problems.push(i18n.t('parameters.invoke.layer.rgNoPromptsOrIPAdapters'));
}
l.ipAdapters.forEach((ipAdapter) => {
// Must have model
if (!ipAdapter.model) {
problems.push(i18n.t('parameters.invoke.layer.ipAdapterNoModelSelected'));
}
// Model base must match
if (ipAdapter.model?.base !== model?.base) {
problems.push(i18n.t('parameters.invoke.layer.ipAdapterIncompatibleBaseModel'));
}
// Must have an image
if (!ipAdapter.image) {
problems.push(i18n.t('parameters.invoke.layer.ipAdapterNoImageSelected'));
}
});
}
if (!fieldTemplate) { if (problems.length) {
reasons.push(i18n.t('parameters.invoke.missingFieldTemplate')); const content = upperFirst(problems.join(', '));
return; reasons.push({ prefix, content });
} }
});
} else {
// Handling for all other tabs
selectControlAdapterAll(controlAdapters)
.filter((ca) => ca.isEnabled)
.forEach((ca, i) => {
if (!ca.isEnabled) {
return;
}
if (fieldTemplate.required && field.value === undefined && !hasConnection) { if (!ca.model) {
reasons.push( reasons.push({ content: i18n.t('parameters.invoke.noModelForControlAdapter', { number: i + 1 }) });
i18n.t('parameters.invoke.missingInputForField', { } else if (ca.model.base !== model?.base) {
nodeLabel: node.data.label || nodeTemplate.title, // This should never happen, just a sanity check
fieldLabel: field.label || fieldTemplate.title, reasons.push({
}) content: i18n.t('parameters.invoke.incompatibleBaseModelForControlAdapter', { number: i + 1 }),
); });
return; }
}
}); if (
}); !ca.controlImage ||
} (isControlNetOrT2IAdapter(ca) && !ca.processedControlImage && ca.processorType !== 'none')
} else { ) {
if (dynamicPrompts.prompts.length === 0 && getShouldProcessPrompt(positivePrompt)) { reasons.push({
reasons.push(i18n.t('parameters.invoke.noPrompts')); content: i18n.t('parameters.invoke.noControlImageForControlAdapter', { number: i + 1 }),
});
}
});
}
} }
if (!model) { return { isReady: !reasons.length, reasons };
reasons.push(i18n.t('parameters.invoke.noModelSelected'));
}
if (activeTabName === 'generation') {
// Handling for generation tab
controlLayers.present.layers
.filter((l) => l.isEnabled)
.flatMap((l) => {
if (l.type === 'control_adapter_layer') {
return l.controlAdapter;
} else if (l.type === 'ip_adapter_layer') {
return l.ipAdapter;
} else if (l.type === 'regional_guidance_layer') {
return l.ipAdapters;
}
return [];
})
.forEach((ca, i) => {
const hasNoModel = !ca.model;
const mismatchedModelBase = ca.model?.base !== model?.base;
const hasNoImage = !ca.image;
const imageNotProcessed =
(ca.type === 'controlnet' || ca.type === 't2i_adapter') && !ca.processedImage && ca.processorConfig;
if (hasNoModel) {
reasons.push(
i18n.t('parameters.invoke.noModelForControlAdapter', {
number: i + 1,
})
);
}
if (mismatchedModelBase) {
// This should never happen, just a sanity check
reasons.push(
i18n.t('parameters.invoke.incompatibleBaseModelForControlAdapter', {
number: i + 1,
})
);
}
if (hasNoImage) {
reasons.push(
i18n.t('parameters.invoke.noControlImageForControlAdapter', {
number: i + 1,
})
);
}
if (imageNotProcessed) {
reasons.push(
i18n.t('parameters.invoke.imageNotProcessedForControlAdapter', {
number: i + 1,
})
);
}
});
} else {
// Handling for all other tabs
selectControlAdapterAll(controlAdapters)
.filter((ca) => ca.isEnabled)
.forEach((ca, i) => {
if (!ca.isEnabled) {
return;
}
if (!ca.model) {
reasons.push(
i18n.t('parameters.invoke.noModelForControlAdapter', {
number: i + 1,
})
);
} else if (ca.model.base !== model?.base) {
// This should never happen, just a sanity check
reasons.push(
i18n.t('parameters.invoke.incompatibleBaseModelForControlAdapter', {
number: i + 1,
})
);
}
if (
!ca.controlImage ||
(isControlNetOrT2IAdapter(ca) && !ca.processedControlImage && ca.processorType !== 'none')
) {
reasons.push(
i18n.t('parameters.invoke.noControlImageForControlAdapter', {
number: i + 1,
})
);
}
});
}
} }
);
return { isReady: !reasons.length, reasons };
}
);
export const useIsReadyToEnqueue = () => { export const useIsReadyToEnqueue = () => {
const { isReady, reasons } = useAppSelector(selector); const templates = useStore($templates);
return { isReady, reasons }; const selector = useMemo(() => createSelector(templates), [templates]);
const value = useAppSelector(selector);
return value;
}; };

View File

@ -21,8 +21,6 @@ import {
setShouldShowBoundingBox, setShouldShowBoundingBox,
} from 'features/canvas/store/canvasSlice'; } from 'features/canvas/store/canvasSlice';
import type { CanvasLayer } from 'features/canvas/store/canvasTypes'; import type { CanvasLayer } from 'features/canvas/store/canvasTypes';
import { LAYER_NAMES_DICT } from 'features/canvas/store/canvasTypes';
import { ViewerButton } from 'features/gallery/components/ImageViewer/ViewerButton';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -217,110 +215,107 @@ const IAICanvasToolbar = () => {
[dispatch, isMaskEnabled] [dispatch, isMaskEnabled]
); );
const value = useMemo(() => LAYER_NAMES_DICT.filter((o) => o.value === layer)[0], [layer]); const layerOptions = useMemo<{ label: string; value: CanvasLayer }[]>(
() => [
{ label: t('unifiedCanvas.base'), value: 'base' },
{ label: t('unifiedCanvas.mask'), value: 'mask' },
],
[t]
);
const layerValue = useMemo(() => layerOptions.filter((o) => o.value === layer)[0] ?? null, [layer, layerOptions]);
return ( return (
<Flex w="full" gap={2} alignItems="center"> <Flex alignItems="center" gap={2} flexWrap="wrap">
<Flex flex={1} justifyContent="center"> <Tooltip label={`${t('unifiedCanvas.layer')} (Q)`}>
<Flex gap={2} marginInlineEnd="auto" /> <FormControl isDisabled={isStaging} w="5rem">
</Flex> <Combobox value={layerValue} options={layerOptions} onChange={handleChangeLayer} />
<Flex flex={1} gap={2} justifyContent="center"> </FormControl>
<Tooltip label={`${t('unifiedCanvas.layer')} (Q)`}> </Tooltip>
<FormControl isDisabled={isStaging} w="5rem">
<Combobox value={value} options={LAYER_NAMES_DICT} onChange={handleChangeLayer} />
</FormControl>
</Tooltip>
<IAICanvasMaskOptions /> <IAICanvasMaskOptions />
<IAICanvasToolChooserOptions /> <IAICanvasToolChooserOptions />
<ButtonGroup> <ButtonGroup>
<IconButton <IconButton
aria-label={`${t('unifiedCanvas.move')} (V)`} aria-label={`${t('unifiedCanvas.move')} (V)`}
tooltip={`${t('unifiedCanvas.move')} (V)`} tooltip={`${t('unifiedCanvas.move')} (V)`}
icon={<PiHandGrabbingBold />} icon={<PiHandGrabbingBold />}
isChecked={tool === 'move' || isStaging} isChecked={tool === 'move' || isStaging}
onClick={handleSelectMoveTool} onClick={handleSelectMoveTool}
/> />
<IconButton <IconButton
aria-label={`${shouldShowBoundingBox ? t('unifiedCanvas.hideBoundingBox') : t('unifiedCanvas.showBoundingBox')} (Shift + H)`} aria-label={`${shouldShowBoundingBox ? t('unifiedCanvas.hideBoundingBox') : t('unifiedCanvas.showBoundingBox')} (Shift + H)`}
tooltip={`${shouldShowBoundingBox ? t('unifiedCanvas.hideBoundingBox') : t('unifiedCanvas.showBoundingBox')} (Shift + H)`} tooltip={`${shouldShowBoundingBox ? t('unifiedCanvas.hideBoundingBox') : t('unifiedCanvas.showBoundingBox')} (Shift + H)`}
icon={shouldShowBoundingBox ? <PiEyeBold /> : <PiEyeSlashBold />} icon={shouldShowBoundingBox ? <PiEyeBold /> : <PiEyeSlashBold />}
onClick={handleSetShouldShowBoundingBox} onClick={handleSetShouldShowBoundingBox}
isDisabled={isStaging} isDisabled={isStaging}
/> />
<IconButton <IconButton
aria-label={`${t('unifiedCanvas.resetView')} (R)`} aria-label={`${t('unifiedCanvas.resetView')} (R)`}
tooltip={`${t('unifiedCanvas.resetView')} (R)`} tooltip={`${t('unifiedCanvas.resetView')} (R)`}
icon={<PiCrosshairSimpleBold />} icon={<PiCrosshairSimpleBold />}
onClick={handleClickResetCanvasView} onClick={handleClickResetCanvasView}
/> />
</ButtonGroup> </ButtonGroup>
<ButtonGroup> <ButtonGroup>
<IconButton
aria-label={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
tooltip={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
icon={<PiStackBold />}
onClick={handleMergeVisible}
isDisabled={isStaging}
/>
<IconButton
aria-label={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
tooltip={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
icon={<PiFloppyDiskBold />}
onClick={handleSaveToGallery}
isDisabled={isStaging}
/>
{isClipboardAPIAvailable && (
<IconButton <IconButton
aria-label={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`} aria-label={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
tooltip={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`} tooltip={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
icon={<PiStackBold />} icon={<PiCopyBold />}
onClick={handleMergeVisible} onClick={handleCopyImageToClipboard}
isDisabled={isStaging} isDisabled={isStaging}
/> />
<IconButton )}
aria-label={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`} <IconButton
tooltip={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`} aria-label={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
icon={<PiFloppyDiskBold />} tooltip={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
onClick={handleSaveToGallery} icon={<PiDownloadSimpleBold />}
isDisabled={isStaging} onClick={handleDownloadAsImage}
/> isDisabled={isStaging}
{isClipboardAPIAvailable && ( />
<IconButton </ButtonGroup>
aria-label={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`} <ButtonGroup>
tooltip={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`} <IAICanvasUndoButton />
icon={<PiCopyBold />} <IAICanvasRedoButton />
onClick={handleCopyImageToClipboard} </ButtonGroup>
isDisabled={isStaging}
/>
)}
<IconButton
aria-label={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
tooltip={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
icon={<PiDownloadSimpleBold />}
onClick={handleDownloadAsImage}
isDisabled={isStaging}
/>
</ButtonGroup>
<ButtonGroup>
<IAICanvasUndoButton />
<IAICanvasRedoButton />
</ButtonGroup>
<ButtonGroup> <ButtonGroup>
<IconButton <IconButton
aria-label={`${t('common.upload')}`} aria-label={`${t('common.upload')}`}
tooltip={`${t('common.upload')}`} tooltip={`${t('common.upload')}`}
icon={<PiUploadSimpleBold />} icon={<PiUploadSimpleBold />}
isDisabled={isStaging} isDisabled={isStaging}
{...getUploadButtonProps()} {...getUploadButtonProps()}
/> />
<input {...getUploadInputProps()} /> <input {...getUploadInputProps()} />
<IconButton <IconButton
aria-label={`${t('unifiedCanvas.clearCanvas')}`} aria-label={`${t('unifiedCanvas.clearCanvas')}`}
tooltip={`${t('unifiedCanvas.clearCanvas')}`} tooltip={`${t('unifiedCanvas.clearCanvas')}`}
icon={<PiTrashSimpleBold />} icon={<PiTrashSimpleBold />}
onClick={handleResetCanvas} onClick={handleResetCanvas}
colorScheme="error" colorScheme="error"
isDisabled={isStaging} isDisabled={isStaging}
/> />
</ButtonGroup> </ButtonGroup>
<ButtonGroup> <ButtonGroup>
<IAICanvasSettingsButtonPopover /> <IAICanvasSettingsButtonPopover />
</ButtonGroup> </ButtonGroup>
</Flex>
<Flex flex={1} justifyContent="center">
<Flex gap={2} marginInlineStart="auto">
<ViewerButton />
</Flex>
</Flex>
</Flex> </Flex>
); );
}; };

View File

@ -5,11 +5,6 @@ import { z } from 'zod';
export type CanvasLayer = 'base' | 'mask'; export type CanvasLayer = 'base' | 'mask';
export const LAYER_NAMES_DICT: { label: string; value: CanvasLayer }[] = [
{ label: 'Base', value: 'base' },
{ label: 'Mask', value: 'mask' },
];
const zBoundingBoxScaleMethod = z.enum(['none', 'auto', 'manual']); const zBoundingBoxScaleMethod = z.enum(['none', 'auto', 'manual']);
export type BoundingBoxScaleMethod = z.infer<typeof zBoundingBoxScaleMethod>; export type BoundingBoxScaleMethod = z.infer<typeof zBoundingBoxScaleMethod>;
export const isBoundingBoxScaleMethod = (v: unknown): v is BoundingBoxScaleMethod => export const isBoundingBoxScaleMethod = (v: unknown): v is BoundingBoxScaleMethod =>

View File

@ -5,22 +5,7 @@ import type {
ParameterT2IAdapterModel, ParameterT2IAdapterModel,
} from 'features/parameters/types/parameterSchemas'; } from 'features/parameters/types/parameterSchemas';
import type { components } from 'services/api/schema'; import type { components } from 'services/api/schema';
import type { import type { Invocation } from 'services/api/types';
CannyImageProcessorInvocation,
ColorMapImageProcessorInvocation,
ContentShuffleImageProcessorInvocation,
DepthAnythingImageProcessorInvocation,
DWOpenposeImageProcessorInvocation,
HedImageProcessorInvocation,
LineartAnimeImageProcessorInvocation,
LineartImageProcessorInvocation,
MediapipeFaceProcessorInvocation,
MidasDepthImageProcessorInvocation,
MlsdImageProcessorInvocation,
NormalbaeImageProcessorInvocation,
PidiImageProcessorInvocation,
ZoeDepthImageProcessorInvocation,
} from 'services/api/types';
import type { O } from 'ts-toolbelt'; import type { O } from 'ts-toolbelt';
import { z } from 'zod'; import { z } from 'zod';
@ -28,20 +13,20 @@ import { z } from 'zod';
* Any ControlNet processor node * Any ControlNet processor node
*/ */
export type ControlAdapterProcessorNode = export type ControlAdapterProcessorNode =
| CannyImageProcessorInvocation | Invocation<'canny_image_processor'>
| ColorMapImageProcessorInvocation | Invocation<'color_map_image_processor'>
| ContentShuffleImageProcessorInvocation | Invocation<'content_shuffle_image_processor'>
| DepthAnythingImageProcessorInvocation | Invocation<'depth_anything_image_processor'>
| HedImageProcessorInvocation | Invocation<'hed_image_processor'>
| LineartAnimeImageProcessorInvocation | Invocation<'lineart_anime_image_processor'>
| LineartImageProcessorInvocation | Invocation<'lineart_image_processor'>
| MediapipeFaceProcessorInvocation | Invocation<'mediapipe_face_processor'>
| MidasDepthImageProcessorInvocation | Invocation<'midas_depth_image_processor'>
| MlsdImageProcessorInvocation | Invocation<'mlsd_image_processor'>
| NormalbaeImageProcessorInvocation | Invocation<'normalbae_image_processor'>
| DWOpenposeImageProcessorInvocation | Invocation<'dw_openpose_image_processor'>
| PidiImageProcessorInvocation | Invocation<'pidi_image_processor'>
| ZoeDepthImageProcessorInvocation; | Invocation<'zoe_depth_image_processor'>;
/** /**
* Any ControlNet processor type * Any ControlNet processor type
@ -71,7 +56,7 @@ export const isControlAdapterProcessorType = (v: unknown): v is ControlAdapterPr
* The Canny processor node, with parameters flagged as required * The Canny processor node, with parameters flagged as required
*/ */
export type RequiredCannyImageProcessorInvocation = O.Required< export type RequiredCannyImageProcessorInvocation = O.Required<
CannyImageProcessorInvocation, Invocation<'canny_image_processor'>,
'type' | 'low_threshold' | 'high_threshold' | 'image_resolution' | 'detect_resolution' 'type' | 'low_threshold' | 'high_threshold' | 'image_resolution' | 'detect_resolution'
>; >;
@ -79,7 +64,7 @@ export type RequiredCannyImageProcessorInvocation = O.Required<
* The Color Map processor node, with parameters flagged as required * The Color Map processor node, with parameters flagged as required
*/ */
export type RequiredColorMapImageProcessorInvocation = O.Required< export type RequiredColorMapImageProcessorInvocation = O.Required<
ColorMapImageProcessorInvocation, Invocation<'color_map_image_processor'>,
'type' | 'color_map_tile_size' 'type' | 'color_map_tile_size'
>; >;
@ -87,7 +72,7 @@ export type RequiredColorMapImageProcessorInvocation = O.Required<
* The ContentShuffle processor node, with parameters flagged as required * The ContentShuffle processor node, with parameters flagged as required
*/ */
export type RequiredContentShuffleImageProcessorInvocation = O.Required< export type RequiredContentShuffleImageProcessorInvocation = O.Required<
ContentShuffleImageProcessorInvocation, Invocation<'content_shuffle_image_processor'>,
'type' | 'detect_resolution' | 'image_resolution' | 'w' | 'h' | 'f' 'type' | 'detect_resolution' | 'image_resolution' | 'w' | 'h' | 'f'
>; >;
@ -95,7 +80,7 @@ export type RequiredContentShuffleImageProcessorInvocation = O.Required<
* The DepthAnything processor node, with parameters flagged as required * The DepthAnything processor node, with parameters flagged as required
*/ */
export type RequiredDepthAnythingImageProcessorInvocation = O.Required< export type RequiredDepthAnythingImageProcessorInvocation = O.Required<
DepthAnythingImageProcessorInvocation, Invocation<'depth_anything_image_processor'>,
'type' | 'model_size' | 'resolution' | 'offload' 'type' | 'model_size' | 'resolution' | 'offload'
>; >;
@ -108,7 +93,7 @@ export const isDepthAnythingModelSize = (v: unknown): v is DepthAnythingModelSiz
* The HED processor node, with parameters flagged as required * The HED processor node, with parameters flagged as required
*/ */
export type RequiredHedImageProcessorInvocation = O.Required< export type RequiredHedImageProcessorInvocation = O.Required<
HedImageProcessorInvocation, Invocation<'hed_image_processor'>,
'type' | 'detect_resolution' | 'image_resolution' | 'scribble' 'type' | 'detect_resolution' | 'image_resolution' | 'scribble'
>; >;
@ -116,7 +101,7 @@ export type RequiredHedImageProcessorInvocation = O.Required<
* The Lineart Anime processor node, with parameters flagged as required * The Lineart Anime processor node, with parameters flagged as required
*/ */
export type RequiredLineartAnimeImageProcessorInvocation = O.Required< export type RequiredLineartAnimeImageProcessorInvocation = O.Required<
LineartAnimeImageProcessorInvocation, Invocation<'lineart_anime_image_processor'>,
'type' | 'detect_resolution' | 'image_resolution' 'type' | 'detect_resolution' | 'image_resolution'
>; >;
@ -124,7 +109,7 @@ export type RequiredLineartAnimeImageProcessorInvocation = O.Required<
* The Lineart processor node, with parameters flagged as required * The Lineart processor node, with parameters flagged as required
*/ */
export type RequiredLineartImageProcessorInvocation = O.Required< export type RequiredLineartImageProcessorInvocation = O.Required<
LineartImageProcessorInvocation, Invocation<'lineart_image_processor'>,
'type' | 'detect_resolution' | 'image_resolution' | 'coarse' 'type' | 'detect_resolution' | 'image_resolution' | 'coarse'
>; >;
@ -132,7 +117,7 @@ export type RequiredLineartImageProcessorInvocation = O.Required<
* The MediapipeFace processor node, with parameters flagged as required * The MediapipeFace processor node, with parameters flagged as required
*/ */
export type RequiredMediapipeFaceProcessorInvocation = O.Required< export type RequiredMediapipeFaceProcessorInvocation = O.Required<
MediapipeFaceProcessorInvocation, Invocation<'mediapipe_face_processor'>,
'type' | 'max_faces' | 'min_confidence' | 'image_resolution' | 'detect_resolution' 'type' | 'max_faces' | 'min_confidence' | 'image_resolution' | 'detect_resolution'
>; >;
@ -140,7 +125,7 @@ export type RequiredMediapipeFaceProcessorInvocation = O.Required<
* The MidasDepth processor node, with parameters flagged as required * The MidasDepth processor node, with parameters flagged as required
*/ */
export type RequiredMidasDepthImageProcessorInvocation = O.Required< export type RequiredMidasDepthImageProcessorInvocation = O.Required<
MidasDepthImageProcessorInvocation, Invocation<'midas_depth_image_processor'>,
'type' | 'a_mult' | 'bg_th' | 'image_resolution' | 'detect_resolution' 'type' | 'a_mult' | 'bg_th' | 'image_resolution' | 'detect_resolution'
>; >;
@ -148,7 +133,7 @@ export type RequiredMidasDepthImageProcessorInvocation = O.Required<
* The MLSD processor node, with parameters flagged as required * The MLSD processor node, with parameters flagged as required
*/ */
export type RequiredMlsdImageProcessorInvocation = O.Required< export type RequiredMlsdImageProcessorInvocation = O.Required<
MlsdImageProcessorInvocation, Invocation<'mlsd_image_processor'>,
'type' | 'detect_resolution' | 'image_resolution' | 'thr_v' | 'thr_d' 'type' | 'detect_resolution' | 'image_resolution' | 'thr_v' | 'thr_d'
>; >;
@ -156,7 +141,7 @@ export type RequiredMlsdImageProcessorInvocation = O.Required<
* The NormalBae processor node, with parameters flagged as required * The NormalBae processor node, with parameters flagged as required
*/ */
export type RequiredNormalbaeImageProcessorInvocation = O.Required< export type RequiredNormalbaeImageProcessorInvocation = O.Required<
NormalbaeImageProcessorInvocation, Invocation<'normalbae_image_processor'>,
'type' | 'detect_resolution' | 'image_resolution' 'type' | 'detect_resolution' | 'image_resolution'
>; >;
@ -164,7 +149,7 @@ export type RequiredNormalbaeImageProcessorInvocation = O.Required<
* The DW Openpose processor node, with parameters flagged as required * The DW Openpose processor node, with parameters flagged as required
*/ */
export type RequiredDWOpenposeImageProcessorInvocation = O.Required< export type RequiredDWOpenposeImageProcessorInvocation = O.Required<
DWOpenposeImageProcessorInvocation, Invocation<'dw_openpose_image_processor'>,
'type' | 'image_resolution' | 'draw_body' | 'draw_face' | 'draw_hands' 'type' | 'image_resolution' | 'draw_body' | 'draw_face' | 'draw_hands'
>; >;
@ -172,14 +157,14 @@ export type RequiredDWOpenposeImageProcessorInvocation = O.Required<
* The Pidi processor node, with parameters flagged as required * The Pidi processor node, with parameters flagged as required
*/ */
export type RequiredPidiImageProcessorInvocation = O.Required< export type RequiredPidiImageProcessorInvocation = O.Required<
PidiImageProcessorInvocation, Invocation<'pidi_image_processor'>,
'type' | 'detect_resolution' | 'image_resolution' | 'safe' | 'scribble' 'type' | 'detect_resolution' | 'image_resolution' | 'safe' | 'scribble'
>; >;
/** /**
* The ZoeDepth processor node, with parameters flagged as required * The ZoeDepth processor node, with parameters flagged as required
*/ */
export type RequiredZoeDepthImageProcessorInvocation = O.Required<ZoeDepthImageProcessorInvocation, 'type'>; export type RequiredZoeDepthImageProcessorInvocation = O.Required<Invocation<'zoe_depth_image_processor'>, 'type'>;
/** /**
* Any ControlNet Processor node, with its parameters flagged as required * Any ControlNet Processor node, with its parameters flagged as required

View File

@ -18,7 +18,12 @@ export const AddLayerButton = memo(() => {
return ( return (
<Menu> <Menu>
<MenuButton as={Button} leftIcon={<PiPlusBold />} variant="ghost"> <MenuButton
as={Button}
leftIcon={<PiPlusBold />}
variant="ghost"
data-testid="control-layers-add-layer-menu-button"
>
{t('controlLayers.addLayer')} {t('controlLayers.addLayer')}
</MenuButton> </MenuButton>
<MenuList> <MenuList>

View File

@ -19,7 +19,6 @@ export const CALayer = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => selectCALayerOrThrow(s.controlLayers.present, layerId).isSelected); const isSelected = useAppSelector((s) => selectCALayerOrThrow(s.controlLayers.present, layerId).isSelected);
const onClick = useCallback(() => { const onClick = useCallback(() => {
// Must be capture so that the layer is selected before deleting/resetting/etc
dispatch(layerSelected(layerId)); dispatch(layerSelected(layerId));
}, [dispatch, layerId]); }, [dispatch, layerId]);
const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true }); const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true });

View File

@ -42,10 +42,10 @@ export const ControlAdapterImagePreview = memo(
const [isMouseOverImage, setIsMouseOverImage] = useState(false); const [isMouseOverImage, setIsMouseOverImage] = useState(false);
const { currentData: controlImage, isError: isErrorControlImage } = useGetImageDTOQuery( const { currentData: controlImage, isError: isErrorControlImage } = useGetImageDTOQuery(
controlAdapter.image?.imageName ?? skipToken controlAdapter.image?.name ?? skipToken
); );
const { currentData: processedControlImage, isError: isErrorProcessedControlImage } = useGetImageDTOQuery( const { currentData: processedControlImage, isError: isErrorProcessedControlImage } = useGetImageDTOQuery(
controlAdapter.processedImage?.imageName ?? skipToken controlAdapter.processedImage?.name ?? skipToken
); );
const [changeIsIntermediate] = useChangeImageIsIntermediateMutation(); const [changeIsIntermediate] = useChangeImageIsIntermediateMutation();
@ -124,7 +124,7 @@ export const ControlAdapterImagePreview = memo(
controlImage && controlImage &&
processedControlImage && processedControlImage &&
!isMouseOverImage && !isMouseOverImage &&
!controlAdapter.isProcessingImage && !controlAdapter.processorPendingBatchId &&
controlAdapter.processorConfig !== null; controlAdapter.processorConfig !== null;
useEffect(() => { useEffect(() => {
@ -190,7 +190,7 @@ export const ControlAdapterImagePreview = memo(
/> />
</> </>
{controlAdapter.isProcessingImage && ( {controlAdapter.processorPendingBatchId !== null && (
<Flex <Flex
position="absolute" position="absolute"
top={0} top={0}

View File

@ -42,6 +42,7 @@ export const ControlAdapterModelCombobox = memo(({ modelKey, onChange: onChangeM
selectedModel, selectedModel,
getIsDisabled, getIsDisabled,
isLoading, isLoading,
groupByType: true,
}); });
return ( return (

View File

@ -34,9 +34,7 @@ export const IPAdapterImagePreview = memo(
const optimalDimension = useAppSelector(selectOptimalDimension); const optimalDimension = useAppSelector(selectOptimalDimension);
const shift = useShiftModifier(); const shift = useShiftModifier();
const { currentData: controlImage, isError: isErrorControlImage } = useGetImageDTOQuery( const { currentData: controlImage, isError: isErrorControlImage } = useGetImageDTOQuery(image?.name ?? skipToken);
image?.imageName ?? skipToken
);
const handleResetControlImage = useCallback(() => { const handleResetControlImage = useCallback(() => {
onChangeImage(null); onChangeImage(null);
}, [onChangeImage]); }, [onChangeImage]);

View File

@ -2,14 +2,13 @@ import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library'; import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types'; import type { ProcessorComponentProps } from 'features/controlLayers/components/ControlAndIPAdapter/processors/types';
import type { DepthAnythingModelSize, DepthAnythingProcessorConfig } from 'features/controlLayers/util/controlAdapters'; import type { DepthAnythingModelSize, DepthAnythingProcessorConfig } from 'features/controlLayers/util/controlAdapters';
import { CA_PROCESSOR_DATA, isDepthAnythingModelSize } from 'features/controlLayers/util/controlAdapters'; import { isDepthAnythingModelSize } from 'features/controlLayers/util/controlAdapters';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ProcessorWrapper from './ProcessorWrapper'; import ProcessorWrapper from './ProcessorWrapper';
type Props = ProcessorComponentProps<DepthAnythingProcessorConfig>; type Props = ProcessorComponentProps<DepthAnythingProcessorConfig>;
const DEFAULTS = CA_PROCESSOR_DATA['depth_anything_image_processor'].buildDefaults();
export const DepthAnythingProcessor = memo(({ onChange, config }: Props) => { export const DepthAnythingProcessor = memo(({ onChange, config }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -38,12 +37,7 @@ export const DepthAnythingProcessor = memo(({ onChange, config }: Props) => {
<ProcessorWrapper> <ProcessorWrapper>
<FormControl> <FormControl>
<FormLabel m={0}>{t('controlnet.modelSize')}</FormLabel> <FormLabel m={0}>{t('controlnet.modelSize')}</FormLabel>
<Combobox <Combobox value={value} options={options} onChange={handleModelSizeChange} isSearchable={false} />
value={value}
defaultInputValue={DEFAULTS.model_size}
options={options}
onChange={handleModelSizeChange}
/>
</FormControl> </FormControl>
</ProcessorWrapper> </ProcessorWrapper>
); );

View File

@ -32,7 +32,7 @@ export const ControlLayersPanelContent = memo(() => {
</Flex> </Flex>
{layerIdTypePairs.length > 0 && ( {layerIdTypePairs.length > 0 && (
<ScrollableContent> <ScrollableContent>
<Flex flexDir="column" gap={2}> <Flex flexDir="column" gap={2} data-testid="control-layers-layer-list">
{layerIdTypePairs.map(({ id, type }) => ( {layerIdTypePairs.map(({ id, type }) => (
<LayerWrapper key={id} id={id} type={type} /> <LayerWrapper key={id} id={id} type={type} />
))} ))}

View File

@ -1,12 +1,30 @@
import { Flex, IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library'; import {
Checkbox,
Flex,
FormControl,
FormLabel,
IconButton,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { setShouldInvertBrushSizeScrollDirection } from 'features/canvas/store/canvasSlice';
import { GlobalMaskLayerOpacity } from 'features/controlLayers/components/GlobalMaskLayerOpacity'; import { GlobalMaskLayerOpacity } from 'features/controlLayers/components/GlobalMaskLayerOpacity';
import { memo } from 'react'; import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RiSettings4Fill } from 'react-icons/ri'; import { RiSettings4Fill } from 'react-icons/ri';
const ControlLayersSettingsPopover = () => { const ControlLayersSettingsPopover = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch();
const shouldInvertBrushSizeScrollDirection = useAppSelector((s) => s.canvas.shouldInvertBrushSizeScrollDirection);
const handleChangeShouldInvertBrushSizeScrollDirection = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldInvertBrushSizeScrollDirection(e.target.checked)),
[dispatch]
);
return ( return (
<Popover isLazy> <Popover isLazy>
<PopoverTrigger> <PopoverTrigger>
@ -16,6 +34,13 @@ const ControlLayersSettingsPopover = () => {
<PopoverBody> <PopoverBody>
<Flex direction="column" gap={2}> <Flex direction="column" gap={2}>
<GlobalMaskLayerOpacity /> <GlobalMaskLayerOpacity />
<FormControl w="full">
<FormLabel flexGrow={1}>{t('unifiedCanvas.invertBrushSizeScrollDirection')}</FormLabel>
<Checkbox
isChecked={shouldInvertBrushSizeScrollDirection}
onChange={handleChangeShouldInvertBrushSizeScrollDirection}
/>
</FormControl>
</Flex> </Flex>
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>

View File

@ -4,14 +4,17 @@ import { BrushSize } from 'features/controlLayers/components/BrushSize';
import ControlLayersSettingsPopover from 'features/controlLayers/components/ControlLayersSettingsPopover'; import ControlLayersSettingsPopover from 'features/controlLayers/components/ControlLayersSettingsPopover';
import { ToolChooser } from 'features/controlLayers/components/ToolChooser'; import { ToolChooser } from 'features/controlLayers/components/ToolChooser';
import { UndoRedoButtonGroup } from 'features/controlLayers/components/UndoRedoButtonGroup'; import { UndoRedoButtonGroup } from 'features/controlLayers/components/UndoRedoButtonGroup';
import { ViewerButton } from 'features/gallery/components/ImageViewer/ViewerButton'; import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
import { ViewerToggleMenu } from 'features/gallery/components/ImageViewer/ViewerToggleMenu';
import { memo } from 'react'; import { memo } from 'react';
export const ControlLayersToolbar = memo(() => { export const ControlLayersToolbar = memo(() => {
return ( return (
<Flex w="full" gap={2}> <Flex w="full" gap={2}>
<Flex flex={1} justifyContent="center"> <Flex flex={1} justifyContent="center">
<Flex gap={2} marginInlineEnd="auto" /> <Flex gap={2} marginInlineEnd="auto">
<ToggleProgressButton />
</Flex>
</Flex> </Flex>
<Flex flex={1} gap={2} justifyContent="center"> <Flex flex={1} gap={2} justifyContent="center">
<BrushSize /> <BrushSize />
@ -21,7 +24,7 @@ export const ControlLayersToolbar = memo(() => {
</Flex> </Flex>
<Flex flex={1} justifyContent="center"> <Flex flex={1} justifyContent="center">
<Flex gap={2} marginInlineStart="auto"> <Flex gap={2} marginInlineStart="auto">
<ViewerButton /> <ViewerToggleMenu />
</Flex> </Flex>
</Flex> </Flex>
</Flex> </Flex>

View File

@ -20,6 +20,7 @@ export const DeleteAllLayersButton = memo(() => {
variant="ghost" variant="ghost"
colorScheme="error" colorScheme="error"
isDisabled={isDisabled} isDisabled={isDisabled}
data-testid="control-layers-delete-all-layers-button"
> >
{t('controlLayers.deleteAll')} {t('controlLayers.deleteAll')}
</Button> </Button>

View File

@ -8,6 +8,7 @@ import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerT
import { LayerVisibilityToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle'; import { LayerVisibilityToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle';
import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper'; import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper';
import { import {
iiLayerDenoisingStrengthChanged,
iiLayerImageChanged, iiLayerImageChanged,
layerSelected, layerSelected,
selectIILayerOrThrow, selectIILayerOrThrow,
@ -36,6 +37,13 @@ export const IILayer = memo(({ layerId }: Props) => {
[dispatch, layerId] [dispatch, layerId]
); );
const onChangeDenoisingStrength = useCallback(
(denoisingStrength: number) => {
dispatch(iiLayerDenoisingStrengthChanged({ layerId, denoisingStrength }));
},
[dispatch, layerId]
);
const droppableData = useMemo<IILayerImageDropData>( const droppableData = useMemo<IILayerImageDropData>(
() => ({ () => ({
actionType: 'SET_II_LAYER_IMAGE', actionType: 'SET_II_LAYER_IMAGE',
@ -67,7 +75,7 @@ export const IILayer = memo(({ layerId }: Props) => {
</Flex> </Flex>
{isOpen && ( {isOpen && (
<Flex flexDir="column" gap={3} px={3} pb={3}> <Flex flexDir="column" gap={3} px={3} pb={3}>
<ImageToImageStrength /> <ImageToImageStrength value={layer.denoisingStrength} onChange={onChangeDenoisingStrength} />
<InitialImagePreview <InitialImagePreview
image={layer.image} image={layer.image}
onChangeImage={onChangeImage} onChangeImage={onChangeImage}

View File

@ -32,7 +32,7 @@ export const InitialImagePreview = memo(({ image, onChangeImage, droppableData,
const optimalDimension = useAppSelector(selectOptimalDimension); const optimalDimension = useAppSelector(selectOptimalDimension);
const shift = useShiftModifier(); const shift = useShiftModifier();
const { currentData: imageDTO, isError: isErrorControlImage } = useGetImageDTOQuery(image?.imageName ?? skipToken); const { currentData: imageDTO, isError: isErrorControlImage } = useGetImageDTOQuery(image?.name ?? skipToken);
const onReset = useCallback(() => { const onReset = useCallback(() => {
onChangeImage(null); onChangeImage(null);

View File

@ -1,19 +1,26 @@
import { Flex, Spacer, useDisclosure } from '@invoke-ai/ui-library'; import { Flex, Spacer, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { IPALayerIPAdapterWrapper } from 'features/controlLayers/components/IPALayer/IPALayerIPAdapterWrapper'; import { IPALayerIPAdapterWrapper } from 'features/controlLayers/components/IPALayer/IPALayerIPAdapterWrapper';
import { LayerDeleteButton } from 'features/controlLayers/components/LayerCommon/LayerDeleteButton'; import { LayerDeleteButton } from 'features/controlLayers/components/LayerCommon/LayerDeleteButton';
import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle'; import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle';
import { LayerVisibilityToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle'; import { LayerVisibilityToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle';
import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper'; import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper';
import { memo } from 'react'; import { layerSelected, selectIPALayerOrThrow } from 'features/controlLayers/store/controlLayersSlice';
import { memo, useCallback } from 'react';
type Props = { type Props = {
layerId: string; layerId: string;
}; };
export const IPALayer = memo(({ layerId }: Props) => { export const IPALayer = memo(({ layerId }: Props) => {
const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => selectIPALayerOrThrow(s.controlLayers.present, layerId).isSelected);
const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true }); const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true });
const onClick = useCallback(() => {
dispatch(layerSelected(layerId));
}, [dispatch, layerId]);
return ( return (
<LayerWrapper borderColor="base.800"> <LayerWrapper onClick={onClick} borderColor={isSelected ? 'base.400' : 'base.800'}>
<Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}> <Flex gap={3} alignItems="center" p={3} cursor="pointer" onDoubleClick={onToggle}>
<LayerVisibilityToggle layerId={layerId} /> <LayerVisibilityToggle layerId={layerId} />
<LayerTitle type="ip_adapter_layer" /> <LayerTitle type="ip_adapter_layer" />

View File

@ -10,7 +10,16 @@ type Props = PropsWithChildren<{
export const LayerWrapper = memo(({ onClick, borderColor, children }: Props) => { export const LayerWrapper = memo(({ onClick, borderColor, children }: Props) => {
return ( return (
<Flex gap={2} onClick={onClick} bg={borderColor} px={2} borderRadius="base" py="1px"> <Flex
gap={2}
onClick={onClick}
bg={borderColor}
px={2}
borderRadius="base"
py="1px"
transitionProperty="all"
transitionDuration="0.2s"
>
<Flex flexDir="column" w="full" bg="base.850" borderRadius="base"> <Flex flexDir="column" w="full" bg="base.850" borderRadius="base">
{children} {children}
</Flex> </Flex>

View File

@ -45,6 +45,7 @@ export const RGLayerNegativePrompt = memo(({ layerId }: Props) => {
variant="darkFilled" variant="darkFilled"
paddingRight={30} paddingRight={30}
fontSize="sm" fontSize="sm"
spellCheck={false}
/> />
<PromptOverlayButtonWrapper> <PromptOverlayButtonWrapper>
<RGLayerPromptDeleteButton layerId={layerId} polarity="negative" /> <RGLayerPromptDeleteButton layerId={layerId} polarity="negative" />

View File

@ -45,6 +45,7 @@ export const RGLayerPositivePrompt = memo(({ layerId }: Props) => {
variant="darkFilled" variant="darkFilled"
paddingRight={30} paddingRight={30}
minH={28} minH={28}
spellCheck={false}
/> />
<PromptOverlayButtonWrapper> <PromptOverlayButtonWrapper>
<RGLayerPromptDeleteButton layerId={layerId} polarity="positive" /> <RGLayerPromptDeleteButton layerId={layerId} polarity="positive" />

View File

@ -130,11 +130,11 @@ const useStageRenderer = (
}, [stage, state.size.width, state.size.height, wrapper]); }, [stage, state.size.width, state.size.height, wrapper]);
useLayoutEffect(() => { useLayoutEffect(() => {
log.trace('Rendering tool preview');
if (asPreview) { if (asPreview) {
// Preview should not display tool // Preview should not display tool
return; return;
} }
log.trace('Rendering tool preview');
renderers.renderToolPreview( renderers.renderToolPreview(
stage, stage,
tool, tool,
@ -178,15 +178,24 @@ const useStageRenderer = (
// Preview should not display bboxes // Preview should not display bboxes
return; return;
} }
renderers.renderBbox(stage, state.layers, tool, onBboxChanged); renderers.renderBboxes(stage, state.layers, tool);
}, [stage, asPreview, state.layers, tool, onBboxChanged, renderers]); }, [stage, asPreview, state.layers, tool, onBboxChanged, renderers]);
useLayoutEffect(() => { useLayoutEffect(() => {
log.trace('Rendering background'); if (asPreview) {
// Preview should not check for transparency
return;
}
log.trace('Updating bboxes');
debouncedRenderers.updateBboxes(stage, state.layers, onBboxChanged);
}, [stage, asPreview, state.layers, onBboxChanged]);
useLayoutEffect(() => {
if (asPreview) { if (asPreview) {
// The preview should not have a background // The preview should not have a background
return; return;
} }
log.trace('Rendering background');
renderers.renderBackground(stage, state.size.width, state.size.height); renderers.renderBackground(stage, state.size.width, state.size.height);
}, [stage, asPreview, state.size.width, state.size.height, renderers]); }, [stage, asPreview, state.size.width, state.size.height, renderers]);
@ -196,11 +205,11 @@ const useStageRenderer = (
}, [stage, layerIds, renderers]); }, [stage, layerIds, renderers]);
useLayoutEffect(() => { useLayoutEffect(() => {
log.trace('Rendering no layers message');
if (asPreview) { if (asPreview) {
// The preview should not display the no layers message // The preview should not display the no layers message
return; return;
} }
log.trace('Rendering no layers message');
renderers.renderNoLayersMessage(stage, layerCount, state.size.width, state.size.height); renderers.renderNoLayersMessage(stage, layerCount, state.size.width, state.size.height);
}, [stage, layerCount, renderers, asPreview, state.size.width, state.size.height]); }, [stage, layerCount, renderers, asPreview, state.size.width, state.size.height]);
@ -233,7 +242,14 @@ export const StageComponent = memo(({ asPreview = false }: Props) => {
return ( return (
<Flex overflow="hidden" w="full" h="full"> <Flex overflow="hidden" w="full" h="full">
<Flex ref={wrapperRef} w="full" h="full" alignItems="center" justifyContent="center"> <Flex ref={wrapperRef} w="full" h="full" alignItems="center" justifyContent="center">
<Flex ref={containerRef} tabIndex={-1} bg="base.850" borderRadius="base" overflow="hidden" /> <Flex
ref={containerRef}
tabIndex={-1}
bg="base.850"
borderRadius="base"
overflow="hidden"
data-testid="control-layers-canvas"
/>
</Flex> </Flex>
</Flex> </Flex>
); );

View File

@ -188,15 +188,15 @@ export const useMouseEvents = () => {
return; return;
} }
const pos = syncCursorPos(stage); const pos = syncCursorPos(stage);
$isDrawing.set(false);
$lastCursorPos.set(null);
$lastMouseDownPos.set(null);
if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') { if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
return; return;
} }
if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) { if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) {
dispatch(rgLayerPointsAdded({ layerId: selectedLayerId, point: [pos.x, pos.y] })); dispatch(rgLayerPointsAdded({ layerId: selectedLayerId, point: [pos.x, pos.y] }));
} }
$isDrawing.set(false);
$lastCursorPos.set(null);
$lastMouseDownPos.set(null);
}, },
[selectedLayerId, selectedLayerType, tool, dispatch] [selectedLayerId, selectedLayerType, tool, dispatch]
); );
@ -224,5 +224,10 @@ export const useMouseEvents = () => {
[selectedLayerType, tool, shouldInvertBrushSizeScrollDirection, dispatch, brushSize] [selectedLayerType, tool, shouldInvertBrushSizeScrollDirection, dispatch, brushSize]
); );
return { onMouseDown, onMouseUp, onMouseMove, onMouseLeave, onMouseWheel }; const handlers = useMemo(
() => ({ onMouseDown, onMouseUp, onMouseMove, onMouseLeave, onMouseWheel }),
[onMouseDown, onMouseUp, onMouseMove, onMouseLeave, onMouseWheel]
);
return handlers;
}; };

View File

@ -1,51 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import {
isControlAdapterLayer,
isInitialImageLayer,
isIPAdapterLayer,
isRegionalGuidanceLayer,
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
const selectValidLayerCount = createSelector(selectControlLayersSlice, (controlLayers) => {
let count = 0;
controlLayers.present.layers.forEach((l) => {
if (isRegionalGuidanceLayer(l)) {
const hasTextPrompt = Boolean(l.positivePrompt || l.negativePrompt);
const hasAtLeastOneImagePrompt = l.ipAdapters.filter((ipa) => Boolean(ipa.image)).length > 0;
if (hasTextPrompt || hasAtLeastOneImagePrompt) {
count += 1;
}
}
if (isControlAdapterLayer(l)) {
if (l.controlAdapter.image || l.controlAdapter.processedImage) {
count += 1;
}
}
if (isIPAdapterLayer(l)) {
if (l.ipAdapter.image) {
count += 1;
}
}
if (isInitialImageLayer(l)) {
if (l.image) {
count += 1;
}
}
});
return count;
});
export const useControlLayersTitle = () => {
const { t } = useTranslation();
const validLayerCount = useAppSelector(selectValidLayerCount);
const title = useMemo(() => {
const suffix = validLayerCount > 0 ? ` (${validLayerCount})` : '';
return `${t('controlLayers.controlLayers')}${suffix}`;
}, [t, validLayerCount]);
return title;
};

View File

@ -27,7 +27,7 @@ import { modelChanged } from 'features/parameters/store/generationSlice';
import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas'; import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas';
import { getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension'; import { getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension';
import type { IRect, Vector2d } from 'konva/lib/types'; import type { IRect, Vector2d } from 'konva/lib/types';
import { isEqual, partition } from 'lodash-es'; import { isEqual, partition, unset } from 'lodash-es';
import { atom } from 'nanostores'; import { atom } from 'nanostores';
import type { RgbColor } from 'react-colorful'; import type { RgbColor } from 'react-colorful';
import type { UndoableOptions } from 'redux-undo'; import type { UndoableOptions } from 'redux-undo';
@ -49,7 +49,7 @@ import type {
} from './types'; } from './types';
export const initialControlLayersState: ControlLayersState = { export const initialControlLayersState: ControlLayersState = {
_version: 1, _version: 3,
selectedLayerId: null, selectedLayerId: null,
brushSize: 100, brushSize: 100,
layers: [], layers: [],
@ -124,6 +124,12 @@ const getVectorMaskPreviewColor = (state: ControlLayersState): RgbColor => {
const lastColor = rgLayers[rgLayers.length - 1]?.previewColor; const lastColor = rgLayers[rgLayers.length - 1]?.previewColor;
return LayerColors.next(lastColor); return LayerColors.next(lastColor);
}; };
const exclusivelySelectLayer = (state: ControlLayersState, layerId: string) => {
for (const layer of state.layers) {
layer.isSelected = layer.id === layerId;
}
state.selectedLayerId = layerId;
};
export const controlLayersSlice = createSlice({ export const controlLayersSlice = createSlice({
name: 'controlLayers', name: 'controlLayers',
@ -131,14 +137,7 @@ export const controlLayersSlice = createSlice({
reducers: { reducers: {
//#region Any Layer Type //#region Any Layer Type
layerSelected: (state, action: PayloadAction<string>) => { layerSelected: (state, action: PayloadAction<string>) => {
for (const layer of state.layers.filter(isRenderableLayer)) { exclusivelySelectLayer(state, action.payload);
if (layer.id === action.payload) {
layer.isSelected = true;
state.selectedLayerId = action.payload;
} else {
layer.isSelected = false;
}
}
}, },
layerVisibilityToggled: (state, action: PayloadAction<string>) => { layerVisibilityToggled: (state, action: PayloadAction<string>) => {
const layer = state.layers.find((l) => l.id === action.payload); const layer = state.layers.find((l) => l.id === action.payload);
@ -167,7 +166,6 @@ export const controlLayersSlice = createSlice({
// The layer was fully erased, empty its objects to prevent accumulation of invisible objects // The layer was fully erased, empty its objects to prevent accumulation of invisible objects
layer.maskObjects = []; layer.maskObjects = [];
layer.uploadedMaskImage = null; layer.uploadedMaskImage = null;
layer.needsPixelBbox = false;
} }
} }
}, },
@ -178,7 +176,6 @@ export const controlLayersSlice = createSlice({
layer.maskObjects = []; layer.maskObjects = [];
layer.bbox = null; layer.bbox = null;
layer.isEnabled = true; layer.isEnabled = true;
layer.needsPixelBbox = false;
layer.bboxNeedsUpdate = false; layer.bboxNeedsUpdate = false;
layer.uploadedMaskImage = null; layer.uploadedMaskImage = null;
} }
@ -244,17 +241,16 @@ export const controlLayersSlice = createSlice({
controlAdapter, controlAdapter,
}; };
state.layers.push(layer); state.layers.push(layer);
state.selectedLayerId = layer.id; exclusivelySelectLayer(state, layer.id);
for (const layer of state.layers.filter(isRenderableLayer)) {
if (layer.id !== layerId) {
layer.isSelected = false;
}
}
}, },
prepare: (controlAdapter: ControlNetConfigV2 | T2IAdapterConfigV2) => ({ prepare: (controlAdapter: ControlNetConfigV2 | T2IAdapterConfigV2) => ({
payload: { layerId: uuidv4(), controlAdapter }, payload: { layerId: uuidv4(), controlAdapter },
}), }),
}, },
caLayerRecalled: (state, action: PayloadAction<ControlAdapterLayer>) => {
state.layers.push({ ...action.payload, isSelected: true });
exclusivelySelectLayer(state, action.payload.id);
},
caLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => { caLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
const { layerId, imageDTO } = action.payload; const { layerId, imageDTO } = action.payload;
const layer = selectCALayerOrThrow(state, layerId); const layer = selectCALayerOrThrow(state, layerId);
@ -338,19 +334,13 @@ export const controlLayersSlice = createSlice({
const layer = selectCALayerOrThrow(state, layerId); const layer = selectCALayerOrThrow(state, layerId);
layer.opacity = opacity; layer.opacity = opacity;
}, },
caLayerIsProcessingImageChanged: ( caLayerProcessorPendingBatchIdChanged: (
state, state,
action: PayloadAction<{ layerId: string; isProcessingImage: boolean }> action: PayloadAction<{ layerId: string; batchId: string | null }>
) => { ) => {
const { layerId, isProcessingImage } = action.payload; const { layerId, batchId } = action.payload;
const layer = selectCALayerOrThrow(state, layerId); const layer = selectCALayerOrThrow(state, layerId);
layer.controlAdapter.isProcessingImage = isProcessingImage; layer.controlAdapter.processorPendingBatchId = batchId;
},
caLayerControlNetsDeleted: (state) => {
state.layers = state.layers.filter((l) => !isControlAdapterLayer(l) || l.controlAdapter.type !== 'controlnet');
},
caLayerT2IAdaptersDeleted: (state) => {
state.layers = state.layers.filter((l) => !isControlAdapterLayer(l) || l.controlAdapter.type !== 't2i_adapter');
}, },
//#endregion //#endregion
@ -362,12 +352,17 @@ export const controlLayersSlice = createSlice({
id: getIPALayerId(layerId), id: getIPALayerId(layerId),
type: 'ip_adapter_layer', type: 'ip_adapter_layer',
isEnabled: true, isEnabled: true,
isSelected: true,
ipAdapter, ipAdapter,
}; };
state.layers.push(layer); state.layers.push(layer);
exclusivelySelectLayer(state, layer.id);
}, },
prepare: (ipAdapter: IPAdapterConfigV2) => ({ payload: { layerId: uuidv4(), ipAdapter } }), prepare: (ipAdapter: IPAdapterConfigV2) => ({ payload: { layerId: uuidv4(), ipAdapter } }),
}, },
ipaLayerRecalled: (state, action: PayloadAction<IPAdapterLayer>) => {
state.layers.push(action.payload);
},
ipaLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => { ipaLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
const { layerId, imageDTO } = action.payload; const { layerId, imageDTO } = action.payload;
const layer = selectIPALayerOrThrow(state, layerId); const layer = selectIPALayerOrThrow(state, layerId);
@ -401,9 +396,6 @@ export const controlLayersSlice = createSlice({
const layer = selectIPALayerOrThrow(state, layerId); const layer = selectIPALayerOrThrow(state, layerId);
layer.ipAdapter.clipVisionModel = clipVisionModel; layer.ipAdapter.clipVisionModel = clipVisionModel;
}, },
ipaLayersDeleted: (state) => {
state.layers = state.layers.filter((l) => !isIPAdapterLayer(l));
},
//#endregion //#endregion
//#region CA or IPA Layers //#region CA or IPA Layers
@ -445,7 +437,6 @@ export const controlLayersSlice = createSlice({
x: 0, x: 0,
y: 0, y: 0,
autoNegative: 'invert', autoNegative: 'invert',
needsPixelBbox: false,
positivePrompt: '', positivePrompt: '',
negativePrompt: null, negativePrompt: null,
ipAdapters: [], ipAdapters: [],
@ -453,15 +444,14 @@ export const controlLayersSlice = createSlice({
uploadedMaskImage: null, uploadedMaskImage: null,
}; };
state.layers.push(layer); state.layers.push(layer);
state.selectedLayerId = layer.id; exclusivelySelectLayer(state, layer.id);
for (const layer of state.layers.filter(isRenderableLayer)) {
if (layer.id !== layerId) {
layer.isSelected = false;
}
}
}, },
prepare: () => ({ payload: { layerId: uuidv4() } }), prepare: () => ({ payload: { layerId: uuidv4() } }),
}, },
rgLayerRecalled: (state, action: PayloadAction<RegionalGuidanceLayer>) => {
state.layers.push({ ...action.payload, isSelected: true });
exclusivelySelectLayer(state, action.payload.id);
},
rgLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string | null }>) => { rgLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string | null }>) => {
const { layerId, prompt } = action.payload; const { layerId, prompt } = action.payload;
const layer = selectRGLayerOrThrow(state, layerId); const layer = selectRGLayerOrThrow(state, layerId);
@ -501,9 +491,6 @@ export const controlLayersSlice = createSlice({
}); });
layer.bboxNeedsUpdate = true; layer.bboxNeedsUpdate = true;
layer.uploadedMaskImage = null; layer.uploadedMaskImage = null;
if (!layer.needsPixelBbox && tool === 'eraser') {
layer.needsPixelBbox = true;
}
}, },
prepare: (payload: { layerId: string; points: [number, number, number, number]; tool: DrawingTool }) => ({ prepare: (payload: { layerId: string; points: [number, number, number, number]; tool: DrawingTool }) => ({
payload: { ...payload, lineUuid: uuidv4() }, payload: { ...payload, lineUuid: uuidv4() },
@ -642,16 +629,17 @@ export const controlLayersSlice = createSlice({
isEnabled: true, isEnabled: true,
image: imageDTO ? imageDTOToImageWithDims(imageDTO) : null, image: imageDTO ? imageDTOToImageWithDims(imageDTO) : null,
isSelected: true, isSelected: true,
denoisingStrength: 0.75,
}; };
state.layers.push(layer); state.layers.push(layer);
state.selectedLayerId = layer.id; exclusivelySelectLayer(state, layer.id);
for (const layer of state.layers.filter(isRenderableLayer)) {
if (layer.id !== layerId) {
layer.isSelected = false;
}
}
}, },
prepare: (imageDTO: ImageDTO | null) => ({ payload: { layerId: 'initial_image_layer', imageDTO } }), prepare: (imageDTO: ImageDTO | null) => ({ payload: { layerId: INITIAL_IMAGE_LAYER_ID, imageDTO } }),
},
iiLayerRecalled: (state, action: PayloadAction<InitialImageLayer>) => {
state.layers = state.layers.filter((l) => (isInitialImageLayer(l) ? false : true));
state.layers.push({ ...action.payload, isSelected: true });
exclusivelySelectLayer(state, action.payload.id);
}, },
iiLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => { iiLayerImageChanged: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO | null }>) => {
const { layerId, imageDTO } = action.payload; const { layerId, imageDTO } = action.payload;
@ -666,6 +654,11 @@ export const controlLayersSlice = createSlice({
const layer = selectIILayerOrThrow(state, layerId); const layer = selectIILayerOrThrow(state, layerId);
layer.opacity = opacity; layer.opacity = opacity;
}, },
iiLayerDenoisingStrengthChanged: (state, action: PayloadAction<{ layerId: string; denoisingStrength: number }>) => {
const { layerId, denoisingStrength } = action.payload;
const layer = selectIILayerOrThrow(state, layerId);
layer.denoisingStrength = denoisingStrength;
},
//#endregion //#endregion
//#region Globals //#region Globals
@ -799,6 +792,7 @@ export const {
allLayersDeleted, allLayersDeleted,
// CA Layers // CA Layers
caLayerAdded, caLayerAdded,
caLayerRecalled,
caLayerImageChanged, caLayerImageChanged,
caLayerProcessedImageChanged, caLayerProcessedImageChanged,
caLayerModelChanged, caLayerModelChanged,
@ -806,21 +800,20 @@ export const {
caLayerProcessorConfigChanged, caLayerProcessorConfigChanged,
caLayerIsFilterEnabledChanged, caLayerIsFilterEnabledChanged,
caLayerOpacityChanged, caLayerOpacityChanged,
caLayerIsProcessingImageChanged, caLayerProcessorPendingBatchIdChanged,
caLayerControlNetsDeleted,
caLayerT2IAdaptersDeleted,
// IPA Layers // IPA Layers
ipaLayerAdded, ipaLayerAdded,
ipaLayerRecalled,
ipaLayerImageChanged, ipaLayerImageChanged,
ipaLayerMethodChanged, ipaLayerMethodChanged,
ipaLayerModelChanged, ipaLayerModelChanged,
ipaLayerCLIPVisionModelChanged, ipaLayerCLIPVisionModelChanged,
ipaLayersDeleted,
// CA or IPA Layers // CA or IPA Layers
caOrIPALayerWeightChanged, caOrIPALayerWeightChanged,
caOrIPALayerBeginEndStepPctChanged, caOrIPALayerBeginEndStepPctChanged,
// RG Layers // RG Layers
rgLayerAdded, rgLayerAdded,
rgLayerRecalled,
rgLayerPositivePromptChanged, rgLayerPositivePromptChanged,
rgLayerNegativePromptChanged, rgLayerNegativePromptChanged,
rgLayerPreviewColorChanged, rgLayerPreviewColorChanged,
@ -839,8 +832,10 @@ export const {
rgLayerIPAdapterCLIPVisionModelChanged, rgLayerIPAdapterCLIPVisionModelChanged,
// II Layer // II Layer
iiLayerAdded, iiLayerAdded,
iiLayerRecalled,
iiLayerImageChanged, iiLayerImageChanged,
iiLayerOpacityChanged, iiLayerOpacityChanged,
iiLayerDenoisingStrengthChanged,
// Globals // Globals
positivePromptChanged, positivePromptChanged,
negativePromptChanged, negativePromptChanged,
@ -860,6 +855,19 @@ export const selectControlLayersSlice = (state: RootState) => state.controlLayer
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const migrateControlLayersState = (state: any): any => { const migrateControlLayersState = (state: any): any => {
if (state._version === 1) {
// Reset state for users on v1 (e.g. beta users), some changes could cause
state = deepClone(initialControlLayersState);
}
if (state._version === 2) {
// The CA `isProcessingImage` flag was replaced with a `processorPendingBatchId` property, fix up CA layers
for (const layer of (state as ControlLayersState).layers) {
if (layer.type === 'control_adapter_layer') {
layer.controlAdapter.processorPendingBatchId = null;
unset(layer.controlAdapter, 'isProcessingImage');
}
}
}
return state; return state;
}; };
@ -886,21 +894,22 @@ export const RG_LAYER_NAME = 'regional_guidance_layer';
export const RG_LAYER_LINE_NAME = 'regional_guidance_layer.line'; export const RG_LAYER_LINE_NAME = 'regional_guidance_layer.line';
export const RG_LAYER_OBJECT_GROUP_NAME = 'regional_guidance_layer.object_group'; export const RG_LAYER_OBJECT_GROUP_NAME = 'regional_guidance_layer.object_group';
export const RG_LAYER_RECT_NAME = 'regional_guidance_layer.rect'; export const RG_LAYER_RECT_NAME = 'regional_guidance_layer.rect';
export const INITIAL_IMAGE_LAYER_ID = 'singleton_initial_image_layer';
export const INITIAL_IMAGE_LAYER_NAME = 'initial_image_layer'; export const INITIAL_IMAGE_LAYER_NAME = 'initial_image_layer';
export const INITIAL_IMAGE_LAYER_IMAGE_NAME = 'initial_image_layer.image'; export const INITIAL_IMAGE_LAYER_IMAGE_NAME = 'initial_image_layer.image';
export const LAYER_BBOX_NAME = 'layer.bbox'; export const LAYER_BBOX_NAME = 'layer.bbox';
export const COMPOSITING_RECT_NAME = 'compositing-rect'; export const COMPOSITING_RECT_NAME = 'compositing-rect';
// Getters for non-singleton layer and object IDs // Getters for non-singleton layer and object IDs
const getRGLayerId = (layerId: string) => `${RG_LAYER_NAME}_${layerId}`; export const getRGLayerId = (layerId: string) => `${RG_LAYER_NAME}_${layerId}`;
const getRGLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`; const getRGLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`;
const getRGLayerRectId = (layerId: string, lineId: string) => `${layerId}.rect_${lineId}`; const getRGLayerRectId = (layerId: string, lineId: string) => `${layerId}.rect_${lineId}`;
export const getRGLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`; export const getRGLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`;
export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`; export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`;
const getCALayerId = (layerId: string) => `control_adapter_layer_${layerId}`; export const getCALayerId = (layerId: string) => `control_adapter_layer_${layerId}`;
export const getCALayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`; export const getCALayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`;
export const getIILayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`; export const getIILayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`;
const getIPALayerId = (layerId: string) => `ip_adapter_layer_${layerId}`; export const getIPALayerId = (layerId: string) => `ip_adapter_layer_${layerId}`;
export const controlLayersPersistConfig: PersistConfig<ControlLayersState> = { export const controlLayersPersistConfig: PersistConfig<ControlLayersState> = {
name: controlLayersSlice.name, name: controlLayersSlice.name,

View File

@ -1,90 +1,119 @@
import type { import {
ControlNetConfigV2, zControlNetConfigV2,
ImageWithDims, zImageWithDims,
IPAdapterConfigV2, zIPAdapterConfigV2,
T2IAdapterConfigV2, zT2IAdapterConfigV2,
} from 'features/controlLayers/util/controlAdapters'; } from 'features/controlLayers/util/controlAdapters';
import type { AspectRatioState } from 'features/parameters/components/ImageSize/types'; import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
import type { import {
ParameterAutoNegative, type ParameterHeight,
ParameterHeight, type ParameterNegativePrompt,
ParameterNegativePrompt, type ParameterNegativeStylePromptSDXL,
ParameterNegativeStylePromptSDXL, type ParameterPositivePrompt,
ParameterPositivePrompt, type ParameterPositiveStylePromptSDXL,
ParameterPositiveStylePromptSDXL, type ParameterWidth,
ParameterWidth, zAutoNegative,
zParameterNegativePrompt,
zParameterPositivePrompt,
zParameterStrength,
} from 'features/parameters/types/parameterSchemas'; } from 'features/parameters/types/parameterSchemas';
import type { IRect } from 'konva/lib/types'; import { z } from 'zod';
import type { RgbColor } from 'react-colorful';
export type DrawingTool = 'brush' | 'eraser'; const zTool = z.enum(['brush', 'eraser', 'move', 'rect']);
export type Tool = z.infer<typeof zTool>;
const zDrawingTool = zTool.extract(['brush', 'eraser']);
export type DrawingTool = z.infer<typeof zDrawingTool>;
export type Tool = DrawingTool | 'move' | 'rect'; const zPoints = z.array(z.number()).refine((points) => points.length % 2 === 0, {
message: 'Must have an even number of points',
});
const zVectorMaskLine = z.object({
id: z.string(),
type: z.literal('vector_mask_line'),
tool: zDrawingTool,
strokeWidth: z.number().min(1),
points: zPoints,
});
export type VectorMaskLine = z.infer<typeof zVectorMaskLine>;
export type VectorMaskLine = { const zVectorMaskRect = z.object({
id: string; id: z.string(),
type: 'vector_mask_line'; type: z.literal('vector_mask_rect'),
tool: DrawingTool; x: z.number(),
strokeWidth: number; y: z.number(),
points: number[]; width: z.number().min(1),
}; height: z.number().min(1),
});
export type VectorMaskRect = z.infer<typeof zVectorMaskRect>;
export type VectorMaskRect = { const zLayerBase = z.object({
id: string; id: z.string(),
type: 'vector_mask_rect'; isEnabled: z.boolean().default(true),
x: number; isSelected: z.boolean().default(true),
y: number; });
width: number;
height: number;
};
type LayerBase = { const zRect = z.object({
id: string; x: z.number(),
isEnabled: boolean; y: z.number(),
}; width: z.number().min(1),
height: z.number().min(1),
});
const zRenderableLayerBase = zLayerBase.extend({
x: z.number(),
y: z.number(),
bbox: zRect.nullable(),
bboxNeedsUpdate: z.boolean(),
});
type RenderableLayerBase = LayerBase & { const zControlAdapterLayer = zRenderableLayerBase.extend({
x: number; type: z.literal('control_adapter_layer'),
y: number; opacity: z.number().gte(0).lte(1),
bbox: IRect | null; isFilterEnabled: z.boolean(),
bboxNeedsUpdate: boolean; controlAdapter: z.discriminatedUnion('type', [zControlNetConfigV2, zT2IAdapterConfigV2]),
isSelected: boolean; });
}; export type ControlAdapterLayer = z.infer<typeof zControlAdapterLayer>;
export type ControlAdapterLayer = RenderableLayerBase & { const zIPAdapterLayer = zLayerBase.extend({
type: 'control_adapter_layer'; // technically, also t2i adapter layer type: z.literal('ip_adapter_layer'),
opacity: number; ipAdapter: zIPAdapterConfigV2,
isFilterEnabled: boolean; });
controlAdapter: ControlNetConfigV2 | T2IAdapterConfigV2; export type IPAdapterLayer = z.infer<typeof zIPAdapterLayer>;
};
export type IPAdapterLayer = LayerBase & { const zRgbColor = z.object({
type: 'ip_adapter_layer'; r: z.number().int().min(0).max(255),
ipAdapter: IPAdapterConfigV2; g: z.number().int().min(0).max(255),
}; b: z.number().int().min(0).max(255),
});
const zRegionalGuidanceLayer = zRenderableLayerBase.extend({
type: z.literal('regional_guidance_layer'),
maskObjects: z.array(z.discriminatedUnion('type', [zVectorMaskLine, zVectorMaskRect])),
positivePrompt: zParameterPositivePrompt.nullable(),
negativePrompt: zParameterNegativePrompt.nullable(),
ipAdapters: z.array(zIPAdapterConfigV2),
previewColor: zRgbColor,
autoNegative: zAutoNegative,
uploadedMaskImage: zImageWithDims.nullable(),
});
export type RegionalGuidanceLayer = z.infer<typeof zRegionalGuidanceLayer>;
export type RegionalGuidanceLayer = RenderableLayerBase & { const zInitialImageLayer = zRenderableLayerBase.extend({
type: 'regional_guidance_layer'; type: z.literal('initial_image_layer'),
maskObjects: (VectorMaskLine | VectorMaskRect)[]; opacity: z.number().gte(0).lte(1),
positivePrompt: ParameterPositivePrompt | null; image: zImageWithDims.nullable(),
negativePrompt: ParameterNegativePrompt | null; // Up to one text prompt per mask denoisingStrength: zParameterStrength,
ipAdapters: IPAdapterConfigV2[]; // Any number of image prompts });
previewColor: RgbColor; export type InitialImageLayer = z.infer<typeof zInitialImageLayer>;
autoNegative: ParameterAutoNegative;
needsPixelBbox: boolean; // Needs the slower pixel-based bbox calculation - set to true when an there is an eraser object
uploadedMaskImage: ImageWithDims | null;
};
export type InitialImageLayer = RenderableLayerBase & { export const zLayer = z.discriminatedUnion('type', [
type: 'initial_image_layer'; zRegionalGuidanceLayer,
opacity: number; zControlAdapterLayer,
image: ImageWithDims | null; zIPAdapterLayer,
}; zInitialImageLayer,
]);
export type Layer = RegionalGuidanceLayer | ControlAdapterLayer | IPAdapterLayer | InitialImageLayer; export type Layer = z.infer<typeof zLayer>;
export type ControlLayersState = { export type ControlLayersState = {
_version: 1; _version: 3;
selectedLayerId: string | null; selectedLayerId: string | null;
layers: Layer[]; layers: Layer[];
brushSize: number; brushSize: number;

View File

@ -2,7 +2,6 @@ import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { imageDataToDataURL } from 'features/canvas/util/blobToDataURL'; import { imageDataToDataURL } from 'features/canvas/util/blobToDataURL';
import { RG_LAYER_OBJECT_GROUP_NAME } from 'features/controlLayers/store/controlLayersSlice'; import { RG_LAYER_OBJECT_GROUP_NAME } from 'features/controlLayers/store/controlLayersSlice';
import Konva from 'konva'; import Konva from 'konva';
import type { Layer as KonvaLayerType } from 'konva/lib/Layer';
import type { IRect } from 'konva/lib/types'; import type { IRect } from 'konva/lib/types';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
@ -54,34 +53,30 @@ const getImageDataBbox = (imageData: ImageData): Extents | null => {
}; };
/** /**
* Get the bounding box of a regional prompt konva layer. This function has special handling for regional prompt layers. * Clones a regional guidance konva layer onto an offscreen stage/canvas. This allows the pixel data for a given layer
* @param layer The konva layer to get the bounding box of. * to be captured, manipulated or analyzed without interference from other layers.
* @param preview Whether to open a new tab displaying the rendered layer, which is used to calculate the bbox. * @param layer The konva layer to clone.
* @returns The cloned stage and layer.
*/ */
export const getLayerBboxPixels = (layer: KonvaLayerType, preview: boolean = false): IRect | null => { const getIsolatedRGLayerClone = (layer: Konva.Layer): { stageClone: Konva.Stage; layerClone: Konva.Layer } => {
// To calculate the layer's bounding box, we must first export it to a pixel array, then do some math.
//
// Though it is relatively fast, we can't use Konva's `getClientRect`. It programmatically determines the rect
// by calculating the extents of individual shapes from their "vector" shape data.
//
// This doesn't work when some shapes are drawn with composite operations that "erase" pixels, like eraser lines.
// These shapes' extents are still calculated as if they were solid, leading to a bounding box that is too large.
const stage = layer.getStage(); const stage = layer.getStage();
// Construct and offscreen canvas on which we will do the bbox calculations. // Construct an offscreen canvas with the same dimensions as the layer's stage.
const offscreenStageContainer = document.createElement('div'); const offscreenStageContainer = document.createElement('div');
const offscreenStage = new Konva.Stage({ const stageClone = new Konva.Stage({
container: offscreenStageContainer, container: offscreenStageContainer,
x: stage.x(),
y: stage.y(),
width: stage.width(), width: stage.width(),
height: stage.height(), height: stage.height(),
}); });
// Clone the layer and filter out unwanted children. // Clone the layer and filter out unwanted children.
const layerClone = layer.clone(); const layerClone = layer.clone();
offscreenStage.add(layerClone); stageClone.add(layerClone);
for (const child of layerClone.getChildren()) { for (const child of layerClone.getChildren()) {
if (child.name() === RG_LAYER_OBJECT_GROUP_NAME) { if (child.name() === RG_LAYER_OBJECT_GROUP_NAME && child.hasChildren()) {
// We need to cache the group to ensure it composites out eraser strokes correctly // We need to cache the group to ensure it composites out eraser strokes correctly
child.opacity(1); child.opacity(1);
child.cache(); child.cache();
@ -91,11 +86,31 @@ export const getLayerBboxPixels = (layer: KonvaLayerType, preview: boolean = fal
} }
} }
return { stageClone, layerClone };
};
/**
* Get the bounding box of a regional prompt konva layer. This function has special handling for regional prompt layers.
* @param layer The konva layer to get the bounding box of.
* @param preview Whether to open a new tab displaying the rendered layer, which is used to calculate the bbox.
*/
export const getLayerBboxPixels = (layer: Konva.Layer, preview: boolean = false): IRect | null => {
// To calculate the layer's bounding box, we must first export it to a pixel array, then do some math.
//
// Though it is relatively fast, we can't use Konva's `getClientRect`. It programmatically determines the rect
// by calculating the extents of individual shapes from their "vector" shape data.
//
// This doesn't work when some shapes are drawn with composite operations that "erase" pixels, like eraser lines.
// These shapes' extents are still calculated as if they were solid, leading to a bounding box that is too large.
const { stageClone, layerClone } = getIsolatedRGLayerClone(layer);
// Get a worst-case rect using the relatively fast `getClientRect`. // Get a worst-case rect using the relatively fast `getClientRect`.
const layerRect = layerClone.getClientRect(); const layerRect = layerClone.getClientRect();
if (layerRect.width === 0 || layerRect.height === 0) {
return null;
}
// Capture the image data with the above rect. // Capture the image data with the above rect.
const layerImageData = offscreenStage const layerImageData = stageClone
.toCanvas(layerRect) .toCanvas(layerRect)
.getContext('2d') .getContext('2d')
?.getImageData(0, 0, layerRect.width, layerRect.height); ?.getImageData(0, 0, layerRect.width, layerRect.height);
@ -114,8 +129,8 @@ export const getLayerBboxPixels = (layer: KonvaLayerType, preview: boolean = fal
// Correct the bounding box to be relative to the layer's position. // Correct the bounding box to be relative to the layer's position.
const correctedLayerBbox = { const correctedLayerBbox = {
x: layerBbox.minX - Math.floor(stage.x()) + layerRect.x - Math.floor(layer.x()), x: layerBbox.minX - Math.floor(stageClone.x()) + layerRect.x - Math.floor(layer.x()),
y: layerBbox.minY - Math.floor(stage.y()) + layerRect.y - Math.floor(layer.y()), y: layerBbox.minY - Math.floor(stageClone.y()) + layerRect.y - Math.floor(layer.y()),
width: layerBbox.maxX - layerBbox.minX, width: layerBbox.maxX - layerBbox.minX,
height: layerBbox.maxY - layerBbox.minY, height: layerBbox.maxY - layerBbox.minY,
}; };
@ -123,7 +138,13 @@ export const getLayerBboxPixels = (layer: KonvaLayerType, preview: boolean = fal
return correctedLayerBbox; return correctedLayerBbox;
}; };
export const getLayerBboxFast = (layer: KonvaLayerType): IRect => { /**
* Get the bounding box of a konva layer. This function is faster than `getLayerBboxPixels` but less accurate. It
* should only be used when there are no eraser strokes or shapes in the layer.
* @param layer The konva layer to get the bounding box of.
* @returns The bounding box of the layer.
*/
export const getLayerBboxFast = (layer: Konva.Layer): IRect => {
const bbox = layer.getClientRect(GET_CLIENT_RECT_CONFIG); const bbox = layer.getClientRect(GET_CLIENT_RECT_CONFIG);
return { return {
x: Math.floor(bbox.x), x: Math.floor(bbox.x),

View File

@ -1,23 +1,93 @@
import type { S } from 'services/api/types'; import type { Invocation } from 'services/api/types';
import type { Equals } from 'tsafe'; import type { Equals } from 'tsafe';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
import { describe, test } from 'vitest'; import { describe, test } from 'vitest';
import type { import type {
CannyProcessorConfig,
CLIPVisionModelV2, CLIPVisionModelV2,
ColorMapProcessorConfig,
ContentShuffleProcessorConfig,
ControlModeV2, ControlModeV2,
DepthAnythingModelSize, DepthAnythingModelSize,
DepthAnythingProcessorConfig,
DWOpenposeProcessorConfig,
HedProcessorConfig,
IPMethodV2, IPMethodV2,
LineartAnimeProcessorConfig,
LineartProcessorConfig,
MediapipeFaceProcessorConfig,
MidasDepthProcessorConfig,
MlsdProcessorConfig,
NormalbaeProcessorConfig,
PidiProcessorConfig,
ProcessorConfig, ProcessorConfig,
ProcessorTypeV2, ProcessorTypeV2,
ZoeDepthProcessorConfig,
} from './controlAdapters'; } from './controlAdapters';
describe('Control Adapter Types', () => { describe('Control Adapter Types', () => {
test('ProcessorType', () => assert<Equals<ProcessorConfig['type'], ProcessorTypeV2>>()); test('ProcessorType', () => {
test('IP Adapter Method', () => assert<Equals<NonNullable<S['IPAdapterInvocation']['method']>, IPMethodV2>>()); assert<Equals<ProcessorConfig['type'], ProcessorTypeV2>>();
test('CLIP Vision Model', () => });
assert<Equals<NonNullable<S['IPAdapterInvocation']['clip_vision_model']>, CLIPVisionModelV2>>()); test('IP Adapter Method', () => {
test('Control Mode', () => assert<Equals<NonNullable<S['ControlNetInvocation']['control_mode']>, ControlModeV2>>()); assert<Equals<NonNullable<Invocation<'ip_adapter'>['method']>, IPMethodV2>>();
test('DepthAnything Model Size', () => });
assert<Equals<NonNullable<S['DepthAnythingImageProcessorInvocation']['model_size']>, DepthAnythingModelSize>>()); test('CLIP Vision Model', () => {
assert<Equals<NonNullable<Invocation<'ip_adapter'>['clip_vision_model']>, CLIPVisionModelV2>>();
});
test('Control Mode', () => {
assert<Equals<NonNullable<Invocation<'controlnet'>['control_mode']>, ControlModeV2>>();
});
test('DepthAnything Model Size', () => {
assert<Equals<NonNullable<Invocation<'depth_anything_image_processor'>['model_size']>, DepthAnythingModelSize>>();
});
test('Processor Configs', () => {
// The processor configs are manually modeled zod schemas. This test ensures that the inferred types are correct.
// The types prefixed with `_` are types generated from OpenAPI, while the types without the prefix are manually modeled.
assert<Equals<_CannyProcessorConfig, CannyProcessorConfig>>();
assert<Equals<_ColorMapProcessorConfig, ColorMapProcessorConfig>>();
assert<Equals<_ContentShuffleProcessorConfig, ContentShuffleProcessorConfig>>();
assert<Equals<_DepthAnythingProcessorConfig, DepthAnythingProcessorConfig>>();
assert<Equals<_HedProcessorConfig, HedProcessorConfig>>();
assert<Equals<_LineartAnimeProcessorConfig, LineartAnimeProcessorConfig>>();
assert<Equals<_LineartProcessorConfig, LineartProcessorConfig>>();
assert<Equals<_MediapipeFaceProcessorConfig, MediapipeFaceProcessorConfig>>();
assert<Equals<_MidasDepthProcessorConfig, MidasDepthProcessorConfig>>();
assert<Equals<_MlsdProcessorConfig, MlsdProcessorConfig>>();
assert<Equals<_NormalbaeProcessorConfig, NormalbaeProcessorConfig>>();
assert<Equals<_DWOpenposeProcessorConfig, DWOpenposeProcessorConfig>>();
assert<Equals<_PidiProcessorConfig, PidiProcessorConfig>>();
assert<Equals<_ZoeDepthProcessorConfig, ZoeDepthProcessorConfig>>();
});
}); });
// Types derived from OpenAPI
type _CannyProcessorConfig = Required<
Pick<Invocation<'canny_image_processor'>, 'id' | 'type' | 'low_threshold' | 'high_threshold'>
>;
type _ColorMapProcessorConfig = Required<
Pick<Invocation<'color_map_image_processor'>, 'id' | 'type' | 'color_map_tile_size'>
>;
type _ContentShuffleProcessorConfig = Required<
Pick<Invocation<'content_shuffle_image_processor'>, 'id' | 'type' | 'w' | 'h' | 'f'>
>;
type _DepthAnythingProcessorConfig = Required<
Pick<Invocation<'depth_anything_image_processor'>, 'id' | 'type' | 'model_size'>
>;
type _HedProcessorConfig = Required<Pick<Invocation<'hed_image_processor'>, 'id' | 'type' | 'scribble'>>;
type _LineartAnimeProcessorConfig = Required<Pick<Invocation<'lineart_anime_image_processor'>, 'id' | 'type'>>;
type _LineartProcessorConfig = Required<Pick<Invocation<'lineart_image_processor'>, 'id' | 'type' | 'coarse'>>;
type _MediapipeFaceProcessorConfig = Required<
Pick<Invocation<'mediapipe_face_processor'>, 'id' | 'type' | 'max_faces' | 'min_confidence'>
>;
type _MidasDepthProcessorConfig = Required<
Pick<Invocation<'midas_depth_image_processor'>, 'id' | 'type' | 'a_mult' | 'bg_th'>
>;
type _MlsdProcessorConfig = Required<Pick<Invocation<'mlsd_image_processor'>, 'id' | 'type' | 'thr_v' | 'thr_d'>>;
type _NormalbaeProcessorConfig = Required<Pick<Invocation<'normalbae_image_processor'>, 'id' | 'type'>>;
type _DWOpenposeProcessorConfig = Required<
Pick<Invocation<'dw_openpose_image_processor'>, 'id' | 'type' | 'draw_body' | 'draw_face' | 'draw_hands'>
>;
type _PidiProcessorConfig = Required<Pick<Invocation<'pidi_image_processor'>, 'id' | 'type' | 'safe' | 'scribble'>>;
type _ZoeDepthProcessorConfig = Required<Pick<Invocation<'zoe_depth_image_processor'>, 'id' | 'type'>>;

View File

@ -1,117 +1,176 @@
import { deepClone } from 'common/util/deepClone'; import { deepClone } from 'common/util/deepClone';
import type { import { zModelIdentifierField } from 'features/nodes/types/common';
ParameterControlNetModel,
ParameterIPAdapterModel,
ParameterT2IAdapterModel,
} from 'features/parameters/types/parameterSchemas';
import { merge, omit } from 'lodash-es'; import { merge, omit } from 'lodash-es';
import type { import type { BaseModelType, ControlNetModelConfig, Graph, ImageDTO, T2IAdapterModelConfig } from 'services/api/types';
BaseModelType,
CannyImageProcessorInvocation,
ColorMapImageProcessorInvocation,
ContentShuffleImageProcessorInvocation,
ControlNetModelConfig,
DepthAnythingImageProcessorInvocation,
DWOpenposeImageProcessorInvocation,
Graph,
HedImageProcessorInvocation,
ImageDTO,
LineartAnimeImageProcessorInvocation,
LineartImageProcessorInvocation,
MediapipeFaceProcessorInvocation,
MidasDepthImageProcessorInvocation,
MlsdImageProcessorInvocation,
NormalbaeImageProcessorInvocation,
PidiImageProcessorInvocation,
T2IAdapterModelConfig,
ZoeDepthImageProcessorInvocation,
} from 'services/api/types';
import { z } from 'zod'; import { z } from 'zod';
const zId = z.string().min(1);
const zCannyProcessorConfig = z.object({
id: zId,
type: z.literal('canny_image_processor'),
low_threshold: z.number().int().gte(0).lte(255),
high_threshold: z.number().int().gte(0).lte(255),
});
export type CannyProcessorConfig = z.infer<typeof zCannyProcessorConfig>;
const zColorMapProcessorConfig = z.object({
id: zId,
type: z.literal('color_map_image_processor'),
color_map_tile_size: z.number().int().gte(1),
});
export type ColorMapProcessorConfig = z.infer<typeof zColorMapProcessorConfig>;
const zContentShuffleProcessorConfig = z.object({
id: zId,
type: z.literal('content_shuffle_image_processor'),
w: z.number().int().gte(0),
h: z.number().int().gte(0),
f: z.number().int().gte(0),
});
export type ContentShuffleProcessorConfig = z.infer<typeof zContentShuffleProcessorConfig>;
const zDepthAnythingModelSize = z.enum(['large', 'base', 'small']); const zDepthAnythingModelSize = z.enum(['large', 'base', 'small']);
export type DepthAnythingModelSize = z.infer<typeof zDepthAnythingModelSize>; export type DepthAnythingModelSize = z.infer<typeof zDepthAnythingModelSize>;
export const isDepthAnythingModelSize = (v: unknown): v is DepthAnythingModelSize => export const isDepthAnythingModelSize = (v: unknown): v is DepthAnythingModelSize =>
zDepthAnythingModelSize.safeParse(v).success; zDepthAnythingModelSize.safeParse(v).success;
const zDepthAnythingProcessorConfig = z.object({
id: zId,
type: z.literal('depth_anything_image_processor'),
model_size: zDepthAnythingModelSize,
});
export type DepthAnythingProcessorConfig = z.infer<typeof zDepthAnythingProcessorConfig>;
export type CannyProcessorConfig = Required< const zHedProcessorConfig = z.object({
Pick<CannyImageProcessorInvocation, 'id' | 'type' | 'low_threshold' | 'high_threshold'> id: zId,
>; type: z.literal('hed_image_processor'),
export type ColorMapProcessorConfig = Required< scribble: z.boolean(),
Pick<ColorMapImageProcessorInvocation, 'id' | 'type' | 'color_map_tile_size'> });
>; export type HedProcessorConfig = z.infer<typeof zHedProcessorConfig>;
export type ContentShuffleProcessorConfig = Required<
Pick<ContentShuffleImageProcessorInvocation, 'id' | 'type' | 'w' | 'h' | 'f'>
>;
export type DepthAnythingProcessorConfig = Required<
Pick<DepthAnythingImageProcessorInvocation, 'id' | 'type' | 'model_size'>
>;
export type HedProcessorConfig = Required<Pick<HedImageProcessorInvocation, 'id' | 'type' | 'scribble'>>;
type LineartAnimeProcessorConfig = Required<Pick<LineartAnimeImageProcessorInvocation, 'id' | 'type'>>;
export type LineartProcessorConfig = Required<Pick<LineartImageProcessorInvocation, 'id' | 'type' | 'coarse'>>;
export type MediapipeFaceProcessorConfig = Required<
Pick<MediapipeFaceProcessorInvocation, 'id' | 'type' | 'max_faces' | 'min_confidence'>
>;
export type MidasDepthProcessorConfig = Required<
Pick<MidasDepthImageProcessorInvocation, 'id' | 'type' | 'a_mult' | 'bg_th'>
>;
export type MlsdProcessorConfig = Required<Pick<MlsdImageProcessorInvocation, 'id' | 'type' | 'thr_v' | 'thr_d'>>;
type NormalbaeProcessorConfig = Required<Pick<NormalbaeImageProcessorInvocation, 'id' | 'type'>>;
export type DWOpenposeProcessorConfig = Required<
Pick<DWOpenposeImageProcessorInvocation, 'id' | 'type' | 'draw_body' | 'draw_face' | 'draw_hands'>
>;
export type PidiProcessorConfig = Required<Pick<PidiImageProcessorInvocation, 'id' | 'type' | 'safe' | 'scribble'>>;
type ZoeDepthProcessorConfig = Required<Pick<ZoeDepthImageProcessorInvocation, 'id' | 'type'>>;
export type ProcessorConfig = const zLineartAnimeProcessorConfig = z.object({
| CannyProcessorConfig id: zId,
| ColorMapProcessorConfig type: z.literal('lineart_anime_image_processor'),
| ContentShuffleProcessorConfig });
| DepthAnythingProcessorConfig export type LineartAnimeProcessorConfig = z.infer<typeof zLineartAnimeProcessorConfig>;
| HedProcessorConfig
| LineartAnimeProcessorConfig
| LineartProcessorConfig
| MediapipeFaceProcessorConfig
| MidasDepthProcessorConfig
| MlsdProcessorConfig
| NormalbaeProcessorConfig
| DWOpenposeProcessorConfig
| PidiProcessorConfig
| ZoeDepthProcessorConfig;
export type ImageWithDims = { const zLineartProcessorConfig = z.object({
imageName: string; id: zId,
width: number; type: z.literal('lineart_image_processor'),
height: number; coarse: z.boolean(),
}; });
export type LineartProcessorConfig = z.infer<typeof zLineartProcessorConfig>;
type ControlAdapterBase = { const zMediapipeFaceProcessorConfig = z.object({
id: string; id: zId,
weight: number; type: z.literal('mediapipe_face_processor'),
image: ImageWithDims | null; max_faces: z.number().int().gte(1),
processedImage: ImageWithDims | null; min_confidence: z.number().gte(0).lte(1),
isProcessingImage: boolean; });
processorConfig: ProcessorConfig | null; export type MediapipeFaceProcessorConfig = z.infer<typeof zMediapipeFaceProcessorConfig>;
beginEndStepPct: [number, number];
}; const zMidasDepthProcessorConfig = z.object({
id: zId,
type: z.literal('midas_depth_image_processor'),
a_mult: z.number().gte(0),
bg_th: z.number().gte(0),
});
export type MidasDepthProcessorConfig = z.infer<typeof zMidasDepthProcessorConfig>;
const zMlsdProcessorConfig = z.object({
id: zId,
type: z.literal('mlsd_image_processor'),
thr_v: z.number().gte(0),
thr_d: z.number().gte(0),
});
export type MlsdProcessorConfig = z.infer<typeof zMlsdProcessorConfig>;
const zNormalbaeProcessorConfig = z.object({
id: zId,
type: z.literal('normalbae_image_processor'),
});
export type NormalbaeProcessorConfig = z.infer<typeof zNormalbaeProcessorConfig>;
const zDWOpenposeProcessorConfig = z.object({
id: zId,
type: z.literal('dw_openpose_image_processor'),
draw_body: z.boolean(),
draw_face: z.boolean(),
draw_hands: z.boolean(),
});
export type DWOpenposeProcessorConfig = z.infer<typeof zDWOpenposeProcessorConfig>;
const zPidiProcessorConfig = z.object({
id: zId,
type: z.literal('pidi_image_processor'),
safe: z.boolean(),
scribble: z.boolean(),
});
export type PidiProcessorConfig = z.infer<typeof zPidiProcessorConfig>;
const zZoeDepthProcessorConfig = z.object({
id: zId,
type: z.literal('zoe_depth_image_processor'),
});
export type ZoeDepthProcessorConfig = z.infer<typeof zZoeDepthProcessorConfig>;
const zProcessorConfig = z.discriminatedUnion('type', [
zCannyProcessorConfig,
zColorMapProcessorConfig,
zContentShuffleProcessorConfig,
zDepthAnythingProcessorConfig,
zHedProcessorConfig,
zLineartAnimeProcessorConfig,
zLineartProcessorConfig,
zMediapipeFaceProcessorConfig,
zMidasDepthProcessorConfig,
zMlsdProcessorConfig,
zNormalbaeProcessorConfig,
zDWOpenposeProcessorConfig,
zPidiProcessorConfig,
zZoeDepthProcessorConfig,
]);
export type ProcessorConfig = z.infer<typeof zProcessorConfig>;
export const zImageWithDims = z.object({
name: z.string(),
width: z.number().int().positive(),
height: z.number().int().positive(),
});
export type ImageWithDims = z.infer<typeof zImageWithDims>;
const zBeginEndStepPct = z
.tuple([z.number().gte(0).lte(1), z.number().gte(0).lte(1)])
.refine(([begin, end]) => begin < end, {
message: 'Begin must be less than end',
});
const zControlAdapterBase = z.object({
id: zId,
weight: z.number().gte(0).lte(1),
image: zImageWithDims.nullable(),
processedImage: zImageWithDims.nullable(),
processorConfig: zProcessorConfig.nullable(),
processorPendingBatchId: z.string().nullable().default(null),
beginEndStepPct: zBeginEndStepPct,
});
const zControlModeV2 = z.enum(['balanced', 'more_prompt', 'more_control', 'unbalanced']); const zControlModeV2 = z.enum(['balanced', 'more_prompt', 'more_control', 'unbalanced']);
export type ControlModeV2 = z.infer<typeof zControlModeV2>; export type ControlModeV2 = z.infer<typeof zControlModeV2>;
export const isControlModeV2 = (v: unknown): v is ControlModeV2 => zControlModeV2.safeParse(v).success; export const isControlModeV2 = (v: unknown): v is ControlModeV2 => zControlModeV2.safeParse(v).success;
export type ControlNetConfigV2 = ControlAdapterBase & { export const zControlNetConfigV2 = zControlAdapterBase.extend({
type: 'controlnet'; type: z.literal('controlnet'),
model: ParameterControlNetModel | null; model: zModelIdentifierField.nullable(),
controlMode: ControlModeV2; controlMode: zControlModeV2,
}; });
export const isControlNetConfigV2 = (ca: ControlNetConfigV2 | T2IAdapterConfigV2): ca is ControlNetConfigV2 => export type ControlNetConfigV2 = z.infer<typeof zControlNetConfigV2>;
ca.type === 'controlnet';
export type T2IAdapterConfigV2 = ControlAdapterBase & { export const zT2IAdapterConfigV2 = zControlAdapterBase.extend({
type: 't2i_adapter'; type: z.literal('t2i_adapter'),
model: ParameterT2IAdapterModel | null; model: zModelIdentifierField.nullable(),
}; });
export const isT2IAdapterConfigV2 = (ca: ControlNetConfigV2 | T2IAdapterConfigV2): ca is T2IAdapterConfigV2 => export type T2IAdapterConfigV2 = z.infer<typeof zT2IAdapterConfigV2>;
ca.type === 't2i_adapter';
const zCLIPVisionModelV2 = z.enum(['ViT-H', 'ViT-G']); const zCLIPVisionModelV2 = z.enum(['ViT-H', 'ViT-G']);
export type CLIPVisionModelV2 = z.infer<typeof zCLIPVisionModelV2>; export type CLIPVisionModelV2 = z.infer<typeof zCLIPVisionModelV2>;
@ -121,16 +180,17 @@ const zIPMethodV2 = z.enum(['full', 'style', 'composition']);
export type IPMethodV2 = z.infer<typeof zIPMethodV2>; export type IPMethodV2 = z.infer<typeof zIPMethodV2>;
export const isIPMethodV2 = (v: unknown): v is IPMethodV2 => zIPMethodV2.safeParse(v).success; export const isIPMethodV2 = (v: unknown): v is IPMethodV2 => zIPMethodV2.safeParse(v).success;
export type IPAdapterConfigV2 = { export const zIPAdapterConfigV2 = z.object({
id: string; id: zId,
type: 'ip_adapter'; type: z.literal('ip_adapter'),
weight: number; weight: z.number().gte(0).lte(1),
method: IPMethodV2; method: zIPMethodV2,
image: ImageWithDims | null; image: zImageWithDims.nullable(),
model: ParameterIPAdapterModel | null; model: zModelIdentifierField.nullable(),
clipVisionModel: CLIPVisionModelV2; clipVisionModel: zCLIPVisionModelV2,
beginEndStepPct: [number, number]; beginEndStepPct: zBeginEndStepPct,
}; });
export type IPAdapterConfigV2 = z.infer<typeof zIPAdapterConfigV2>;
const zProcessorTypeV2 = z.enum([ const zProcessorTypeV2 = z.enum([
'canny_image_processor', 'canny_image_processor',
@ -190,7 +250,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
type: 'canny_image_processor', type: 'canny_image_processor',
image: { image_name: image.imageName }, image: { image_name: image.name },
detect_resolution: minDim(image), detect_resolution: minDim(image),
image_resolution: minDim(image), image_resolution: minDim(image),
}), }),
@ -207,7 +267,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
type: 'color_map_image_processor', type: 'color_map_image_processor',
image: { image_name: image.imageName }, image: { image_name: image.name },
}), }),
}, },
content_shuffle_image_processor: { content_shuffle_image_processor: {
@ -223,7 +283,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
detect_resolution: minDim(image), detect_resolution: minDim(image),
image_resolution: minDim(image), image_resolution: minDim(image),
}), }),
@ -239,7 +299,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
resolution: minDim(image), resolution: minDim(image),
}), }),
}, },
@ -254,7 +314,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
detect_resolution: minDim(image), detect_resolution: minDim(image),
image_resolution: minDim(image), image_resolution: minDim(image),
}), }),
@ -269,7 +329,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
detect_resolution: minDim(image), detect_resolution: minDim(image),
image_resolution: minDim(image), image_resolution: minDim(image),
}), }),
@ -285,7 +345,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
detect_resolution: minDim(image), detect_resolution: minDim(image),
image_resolution: minDim(image), image_resolution: minDim(image),
}), }),
@ -302,7 +362,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
detect_resolution: minDim(image), detect_resolution: minDim(image),
image_resolution: minDim(image), image_resolution: minDim(image),
}), }),
@ -319,7 +379,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
detect_resolution: minDim(image), detect_resolution: minDim(image),
image_resolution: minDim(image), image_resolution: minDim(image),
}), }),
@ -336,7 +396,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
detect_resolution: minDim(image), detect_resolution: minDim(image),
image_resolution: minDim(image), image_resolution: minDim(image),
}), }),
@ -351,7 +411,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
detect_resolution: minDim(image), detect_resolution: minDim(image),
image_resolution: minDim(image), image_resolution: minDim(image),
}), }),
@ -369,7 +429,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
image_resolution: minDim(image), image_resolution: minDim(image),
}), }),
}, },
@ -385,7 +445,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
detect_resolution: minDim(image), detect_resolution: minDim(image),
image_resolution: minDim(image), image_resolution: minDim(image),
}), }),
@ -400,7 +460,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}), }),
buildNode: (image, config) => ({ buildNode: (image, config) => ({
...config, ...config,
image: { image_name: image.imageName }, image: { image_name: image.name },
}), }),
}, },
}; };
@ -413,8 +473,8 @@ export const initialControlNetV2: Omit<ControlNetConfigV2, 'id'> = {
controlMode: 'balanced', controlMode: 'balanced',
image: null, image: null,
processedImage: null, processedImage: null,
isProcessingImage: false,
processorConfig: CA_PROCESSOR_DATA.canny_image_processor.buildDefaults(), processorConfig: CA_PROCESSOR_DATA.canny_image_processor.buildDefaults(),
processorPendingBatchId: null,
}; };
export const initialT2IAdapterV2: Omit<T2IAdapterConfigV2, 'id'> = { export const initialT2IAdapterV2: Omit<T2IAdapterConfigV2, 'id'> = {
@ -424,8 +484,8 @@ export const initialT2IAdapterV2: Omit<T2IAdapterConfigV2, 'id'> = {
beginEndStepPct: [0, 1], beginEndStepPct: [0, 1],
image: null, image: null,
processedImage: null, processedImage: null,
isProcessingImage: false,
processorConfig: CA_PROCESSOR_DATA.canny_image_processor.buildDefaults(), processorConfig: CA_PROCESSOR_DATA.canny_image_processor.buildDefaults(),
processorPendingBatchId: null,
}; };
export const initialIPAdapterV2: Omit<IPAdapterConfigV2, 'id'> = { export const initialIPAdapterV2: Omit<IPAdapterConfigV2, 'id'> = {
@ -462,7 +522,7 @@ export const buildControlAdapterProcessorV2 = (
}; };
export const imageDTOToImageWithDims = ({ image_name, width, height }: ImageDTO): ImageWithDims => ({ export const imageDTOToImageWithDims = ({ image_name, width, height }: ImageDTO): ImageWithDims => ({
imageName: image_name, name: image_name,
width, width,
height, height,
}); });

View File

@ -437,8 +437,8 @@ const renderRegionalGuidanceLayer = (
konvaObjectGroup.opacity(1); konvaObjectGroup.opacity(1);
compositingRect.setAttrs({ compositingRect.setAttrs({
// The rect should be the size of the layer - use the fast method bc it's OK if the rect is larger // The rect should be the size of the layer - use the fast method if we don't have a pixel-perfect bbox already
...getLayerBboxFast(konvaLayer), ...(!reduxLayer.bboxNeedsUpdate && reduxLayer.bbox ? reduxLayer.bbox : getLayerBboxFast(konvaLayer)),
fill: rgbColor, fill: rgbColor,
opacity: globalMaskLayerOpacity, opacity: globalMaskLayerOpacity,
// Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes) // Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes)
@ -464,6 +464,7 @@ const createInitialImageLayer = (stage: Konva.Stage, reduxLayer: InitialImageLay
id: reduxLayer.id, id: reduxLayer.id,
name: INITIAL_IMAGE_LAYER_NAME, name: INITIAL_IMAGE_LAYER_NAME,
imageSmoothingEnabled: true, imageSmoothingEnabled: true,
listening: false,
}); });
stage.add(konvaLayer); stage.add(konvaLayer);
return konvaLayer; return konvaLayer;
@ -483,6 +484,9 @@ const updateInitialImageLayerImageAttrs = (
konvaImage: Konva.Image, konvaImage: Konva.Image,
reduxLayer: InitialImageLayer reduxLayer: InitialImageLayer
) => { ) => {
// Konva erroneously reports NaN for width and height when the stage is hidden. This causes errors when caching,
// but it doesn't seem to break anything.
// TODO(psyche): Investigate and report upstream.
const newWidth = stage.width() / stage.scaleX(); const newWidth = stage.width() / stage.scaleX();
const newHeight = stage.height() / stage.scaleY(); const newHeight = stage.height() / stage.scaleY();
if ( if (
@ -510,7 +514,7 @@ const updateInitialImageLayerImageSource = async (
reduxLayer: InitialImageLayer reduxLayer: InitialImageLayer
) => { ) => {
if (reduxLayer.image) { if (reduxLayer.image) {
const { imageName } = reduxLayer.image; const imageName = reduxLayer.image.name;
const req = getStore().dispatch(imagesApi.endpoints.getImageDTO.initiate(imageName)); const req = getStore().dispatch(imagesApi.endpoints.getImageDTO.initiate(imageName));
const imageDTO = await req.unwrap(); const imageDTO = await req.unwrap();
req.unsubscribe(); req.unsubscribe();
@ -543,7 +547,7 @@ const renderInitialImageLayer = (stage: Konva.Stage, reduxLayer: InitialImageLay
let imageSourceNeedsUpdate = false; let imageSourceNeedsUpdate = false;
if (canvasImageSource instanceof HTMLImageElement) { if (canvasImageSource instanceof HTMLImageElement) {
const image = reduxLayer.image; const image = reduxLayer.image;
if (image && canvasImageSource.id !== getCALayerImageId(reduxLayer.id, image.imageName)) { if (image && canvasImageSource.id !== getCALayerImageId(reduxLayer.id, image.name)) {
imageSourceNeedsUpdate = true; imageSourceNeedsUpdate = true;
} else if (!image) { } else if (!image) {
imageSourceNeedsUpdate = true; imageSourceNeedsUpdate = true;
@ -564,6 +568,7 @@ const createControlNetLayer = (stage: Konva.Stage, reduxLayer: ControlAdapterLay
id: reduxLayer.id, id: reduxLayer.id,
name: CA_LAYER_NAME, name: CA_LAYER_NAME,
imageSmoothingEnabled: true, imageSmoothingEnabled: true,
listening: false,
}); });
stage.add(konvaLayer); stage.add(konvaLayer);
return konvaLayer; return konvaLayer;
@ -585,7 +590,7 @@ const updateControlNetLayerImageSource = async (
) => { ) => {
const image = reduxLayer.controlAdapter.processedImage ?? reduxLayer.controlAdapter.image; const image = reduxLayer.controlAdapter.processedImage ?? reduxLayer.controlAdapter.image;
if (image) { if (image) {
const { imageName } = image; const imageName = image.name;
const req = getStore().dispatch(imagesApi.endpoints.getImageDTO.initiate(imageName)); const req = getStore().dispatch(imagesApi.endpoints.getImageDTO.initiate(imageName));
const imageDTO = await req.unwrap(); const imageDTO = await req.unwrap();
req.unsubscribe(); req.unsubscribe();
@ -618,6 +623,9 @@ const updateControlNetLayerImageAttrs = (
reduxLayer: ControlAdapterLayer reduxLayer: ControlAdapterLayer
) => { ) => {
let needsCache = false; let needsCache = false;
// Konva erroneously reports NaN for width and height when the stage is hidden. This causes errors when caching,
// but it doesn't seem to break anything.
// TODO(psyche): Investigate and report upstream.
const newWidth = stage.width() / stage.scaleX(); const newWidth = stage.width() / stage.scaleX();
const newHeight = stage.height() / stage.scaleY(); const newHeight = stage.height() / stage.scaleY();
const hasFilter = konvaImage.filters() !== null && konvaImage.filters().length > 0; const hasFilter = konvaImage.filters() !== null && konvaImage.filters().length > 0;
@ -653,7 +661,7 @@ const renderControlNetLayer = (stage: Konva.Stage, reduxLayer: ControlAdapterLay
let imageSourceNeedsUpdate = false; let imageSourceNeedsUpdate = false;
if (canvasImageSource instanceof HTMLImageElement) { if (canvasImageSource instanceof HTMLImageElement) {
const image = reduxLayer.controlAdapter.processedImage ?? reduxLayer.controlAdapter.image; const image = reduxLayer.controlAdapter.processedImage ?? reduxLayer.controlAdapter.image;
if (image && canvasImageSource.id !== getCALayerImageId(reduxLayer.id, image.imageName)) { if (image && canvasImageSource.id !== getCALayerImageId(reduxLayer.id, image.name)) {
imageSourceNeedsUpdate = true; imageSourceNeedsUpdate = true;
} else if (!image) { } else if (!image) {
imageSourceNeedsUpdate = true; imageSourceNeedsUpdate = true;
@ -702,6 +710,7 @@ const renderLayers = (
if (isInitialImageLayer(reduxLayer)) { if (isInitialImageLayer(reduxLayer)) {
renderInitialImageLayer(stage, reduxLayer); renderInitialImageLayer(stage, reduxLayer);
} }
// IP Adapter layers are not rendered
} }
}; };
@ -716,6 +725,7 @@ const createBboxRect = (reduxLayer: Layer, konvaLayer: Konva.Layer) => {
id: getLayerBboxId(reduxLayer.id), id: getLayerBboxId(reduxLayer.id),
name: LAYER_BBOX_NAME, name: LAYER_BBOX_NAME,
strokeWidth: 1, strokeWidth: 1,
visible: false,
}); });
konvaLayer.add(rect); konvaLayer.add(rect);
return rect; return rect;
@ -725,18 +735,10 @@ const createBboxRect = (reduxLayer: Layer, konvaLayer: Konva.Layer) => {
* Renders the bounding boxes for the layers. * Renders the bounding boxes for the layers.
* @param stage The konva stage to render on * @param stage The konva stage to render on
* @param reduxLayers An array of all redux layers to draw bboxes for * @param reduxLayers An array of all redux layers to draw bboxes for
* @param selectedLayerId The selected layer's id
* @param tool The current tool * @param tool The current tool
* @param onBboxChanged Callback for when the bbox is changed
* @param onBboxMouseDown Callback for when the bbox is clicked
* @returns * @returns
*/ */
const renderBbox = ( const renderBboxes = (stage: Konva.Stage, reduxLayers: Layer[], tool: Tool) => {
stage: Konva.Stage,
reduxLayers: Layer[],
tool: Tool,
onBboxChanged: (layerId: string, bbox: IRect | null) => void
) => {
// Hide all bboxes so they don't interfere with getClientRect // Hide all bboxes so they don't interfere with getClientRect
for (const bboxRect of stage.find<Konva.Rect>(`.${LAYER_BBOX_NAME}`)) { for (const bboxRect of stage.find<Konva.Rect>(`.${LAYER_BBOX_NAME}`)) {
bboxRect.visible(false); bboxRect.visible(false);
@ -747,36 +749,59 @@ const renderBbox = (
return; return;
} }
for (const reduxLayer of reduxLayers) { for (const reduxLayer of reduxLayers.filter(isRegionalGuidanceLayer)) {
if (reduxLayer.type === 'regional_guidance_layer') { if (!reduxLayer.bbox) {
const konvaLayer = stage.findOne<Konva.Layer>(`#${reduxLayer.id}`); continue;
assert(konvaLayer, `Layer ${reduxLayer.id} not found in stage`); }
const konvaLayer = stage.findOne<Konva.Layer>(`#${reduxLayer.id}`);
assert(konvaLayer, `Layer ${reduxLayer.id} not found in stage`);
let bbox = reduxLayer.bbox; const bboxRect = konvaLayer.findOne<Konva.Rect>(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(reduxLayer, konvaLayer);
// We only need to recalculate the bbox if the layer has changed and it has objects bboxRect.setAttrs({
if (reduxLayer.bboxNeedsUpdate && reduxLayer.maskObjects.length) { visible: !reduxLayer.bboxNeedsUpdate,
// We only need to use the pixel-perfect bounding box if the layer has eraser strokes listening: reduxLayer.isSelected,
bbox = reduxLayer.needsPixelBbox ? getLayerBboxPixels(konvaLayer) : getLayerBboxFast(konvaLayer); x: reduxLayer.bbox.x,
// Update the layer's bbox in the redux store y: reduxLayer.bbox.y,
onBboxChanged(reduxLayer.id, bbox); width: reduxLayer.bbox.width,
height: reduxLayer.bbox.height,
stroke: reduxLayer.isSelected ? BBOX_SELECTED_STROKE : '',
});
}
};
/**
* Calculates the bbox of each regional guidance layer. Only calculates if the mask has changed.
* @param stage The konva stage to render on.
* @param reduxLayers An array of redux layers to calculate bboxes for
* @param onBboxChanged Callback for when the bounding box changes
*/
const updateBboxes = (
stage: Konva.Stage,
reduxLayers: Layer[],
onBboxChanged: (layerId: string, bbox: IRect | null) => void
) => {
for (const rgLayer of reduxLayers.filter(isRegionalGuidanceLayer)) {
const konvaLayer = stage.findOne<Konva.Layer>(`#${rgLayer.id}`);
assert(konvaLayer, `Layer ${rgLayer.id} not found in stage`);
// We only need to recalculate the bbox if the layer has changed
if (rgLayer.bboxNeedsUpdate) {
const bboxRect = konvaLayer.findOne<Konva.Rect>(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(rgLayer, konvaLayer);
// Hide the bbox while we calculate the new bbox, else the bbox will be included in the calculation
const visible = bboxRect.visible();
bboxRect.visible(false);
if (rgLayer.maskObjects.length === 0) {
// No objects - no bbox to calculate
onBboxChanged(rgLayer.id, null);
} else {
// Calculate the bbox by rendering the layer and checking its pixels
onBboxChanged(rgLayer.id, getLayerBboxPixels(konvaLayer));
} }
if (!bbox) { // Restore the visibility of the bbox
continue; bboxRect.visible(visible);
}
const rect = konvaLayer.findOne<Konva.Rect>(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(reduxLayer, konvaLayer);
rect.setAttrs({
visible: true,
listening: reduxLayer.isSelected,
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
stroke: reduxLayer.isSelected ? BBOX_SELECTED_STROKE : '',
});
} }
} }
}; };
@ -893,10 +918,11 @@ const renderNoLayersMessage = (stage: Konva.Stage, layerCount: number, width: nu
export const renderers = { export const renderers = {
renderToolPreview, renderToolPreview,
renderLayers, renderLayers,
renderBbox, renderBboxes,
renderBackground, renderBackground,
renderNoLayersMessage, renderNoLayersMessage,
arrangeLayers, arrangeLayers,
updateBboxes,
}; };
const DEBOUNCE_MS = 300; const DEBOUNCE_MS = 300;
@ -904,10 +930,11 @@ const DEBOUNCE_MS = 300;
export const debouncedRenderers = { export const debouncedRenderers = {
renderToolPreview: debounce(renderToolPreview, DEBOUNCE_MS), renderToolPreview: debounce(renderToolPreview, DEBOUNCE_MS),
renderLayers: debounce(renderLayers, DEBOUNCE_MS), renderLayers: debounce(renderLayers, DEBOUNCE_MS),
renderBbox: debounce(renderBbox, DEBOUNCE_MS), renderBboxes: debounce(renderBboxes, DEBOUNCE_MS),
renderBackground: debounce(renderBackground, DEBOUNCE_MS), renderBackground: debounce(renderBackground, DEBOUNCE_MS),
renderNoLayersMessage: debounce(renderNoLayersMessage, DEBOUNCE_MS), renderNoLayersMessage: debounce(renderNoLayersMessage, DEBOUNCE_MS),
arrangeLayers: debounce(arrangeLayers, DEBOUNCE_MS), arrangeLayers: debounce(arrangeLayers, DEBOUNCE_MS),
updateBboxes: debounce(updateBboxes, DEBOUNCE_MS),
}; };
/** /**

View File

@ -46,18 +46,16 @@ export const getImageUsage = (
const isControlLayerImage = controlLayers.layers.some((l) => { const isControlLayerImage = controlLayers.layers.some((l) => {
if (isRegionalGuidanceLayer(l)) { if (isRegionalGuidanceLayer(l)) {
return l.ipAdapters.some((ipa) => ipa.image?.imageName === image_name); return l.ipAdapters.some((ipa) => ipa.image?.name === image_name);
} }
if (isControlAdapterLayer(l)) { if (isControlAdapterLayer(l)) {
return ( return l.controlAdapter.image?.name === image_name || l.controlAdapter.processedImage?.name === image_name;
l.controlAdapter.image?.imageName === image_name || l.controlAdapter.processedImage?.imageName === image_name
);
} }
if (isIPAdapterLayer(l)) { if (isIPAdapterLayer(l)) {
return l.ipAdapter.image?.imageName === image_name; return l.ipAdapter.image?.name === image_name;
} }
if (isInitialImageLayer(l)) { if (isInitialImageLayer(l)) {
return l.image?.imageName === image_name; return l.image?.name === image_name;
} }
return false; return false;
}); });

View File

@ -1,23 +1,21 @@
import type { Modifier } from '@dnd-kit/core'; import type { Modifier } from '@dnd-kit/core';
import { getEventCoordinates } from '@dnd-kit/utilities'; import { getEventCoordinates } from '@dnd-kit/utilities';
import { createSelector } from '@reduxjs/toolkit'; import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { selectNodesSlice } from 'features/nodes/store/nodesSlice'; import { $viewport } from 'features/nodes/store/nodesSlice';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { useCallback } from 'react'; import { useCallback } from 'react';
const selectZoom = createSelector([selectNodesSlice, activeTabNameSelector], (nodes, activeTabName) =>
activeTabName === 'workflows' ? nodes.viewport.zoom : 1
);
/** /**
* Applies scaling to the drag transform (if on node editor tab) and centers it on cursor. * Applies scaling to the drag transform (if on node editor tab) and centers it on cursor.
*/ */
export const useScaledModifer = () => { export const useScaledModifer = () => {
const zoom = useAppSelector(selectZoom); const activeTabName = useAppSelector(activeTabNameSelector);
const workflowsViewport = useStore($viewport);
const modifier: Modifier = useCallback( const modifier: Modifier = useCallback(
({ activatorEvent, draggingNodeRect, transform }) => { ({ activatorEvent, draggingNodeRect, transform }) => {
if (draggingNodeRect && activatorEvent) { if (draggingNodeRect && activatorEvent) {
const zoom = activeTabName === 'workflows' ? workflowsViewport.zoom : 1;
const activatorCoordinates = getEventCoordinates(activatorEvent); const activatorCoordinates = getEventCoordinates(activatorEvent);
if (!activatorCoordinates) { if (!activatorCoordinates) {
@ -42,7 +40,7 @@ export const useScaledModifer = () => {
return transform; return transform;
}, },
[zoom] [activeTabName, workflowsViewport.zoom]
); );
return modifier; return modifier;

View File

@ -73,6 +73,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const handleSendToImageToImage = useCallback(() => { const handleSendToImageToImage = useCallback(() => {
dispatch(sentImageToImg2Img()); dispatch(sentImageToImg2Img());
dispatch(iiLayerAdded(imageDTO)); dispatch(iiLayerAdded(imageDTO));
dispatch(setActiveTab('generation'));
}, [dispatch, imageDTO]); }, [dispatch, imageDTO]);
const handleSendToCanvas = useCallback(() => { const handleSendToCanvas = useCallback(() => {

View File

@ -11,6 +11,7 @@ import type { GallerySelectionDraggableData, ImageDraggableData, TypesafeDraggab
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { useMultiselect } from 'features/gallery/hooks/useMultiselect'; import { useMultiselect } from 'features/gallery/hooks/useMultiselect';
import { useScrollIntoView } from 'features/gallery/hooks/useScrollIntoView'; import { useScrollIntoView } from 'features/gallery/hooks/useScrollIntoView';
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -102,6 +103,10 @@ const GalleryImage = (props: HoverableImageProps) => {
setIsHovered(true); setIsHovered(true);
}, []); }, []);
const onDoubleClick = useCallback(() => {
dispatch(isImageViewerOpenChanged(true));
}, [dispatch]);
const handleMouseOut = useCallback(() => { const handleMouseOut = useCallback(() => {
setIsHovered(false); setIsHovered(false);
}, []); }, []);
@ -143,6 +148,7 @@ const GalleryImage = (props: HoverableImageProps) => {
> >
<IAIDndImage <IAIDndImage
onClick={handleClick} onClick={handleClick}
onDoubleClick={onDoubleClick}
imageDTO={imageDTO} imageDTO={imageDTO}
draggableData={draggableData} draggableData={draggableData}
isSelected={isSelected} isSelected={isSelected}

View File

@ -1,5 +1,6 @@
import { Box, Flex, IconButton, Tooltip, useShiftModifier } from '@invoke-ai/ui-library'; import { Box, Flex, IconButton, Tooltip, useShiftModifier } from '@invoke-ai/ui-library';
import { getOverlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; import { getOverlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { Formatter } from 'fracturedjsonjs';
import { isString } from 'lodash-es'; import { isString } from 'lodash-es';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
@ -7,6 +8,8 @@ import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiCopyBold, PiDownloadSimpleBold } from 'react-icons/pi'; import { PiCopyBold, PiDownloadSimpleBold } from 'react-icons/pi';
const formatter = new Formatter();
type Props = { type Props = {
label: string; label: string;
data: unknown; data: unknown;
@ -20,7 +23,7 @@ const overlayscrollbarsOptions = getOverlayScrollbarsParams('scroll', 'scroll').
const DataViewer = (props: Props) => { const DataViewer = (props: Props) => {
const { label, data, fileName, withDownload = true, withCopy = true, extraCopyActions } = props; const { label, data, fileName, withDownload = true, withCopy = true, extraCopyActions } = props;
const dataString = useMemo(() => (isString(data) ? data : JSON.stringify(data, null, 2)), [data]); const dataString = useMemo(() => (isString(data) ? data : formatter.Serialize(data)) ?? '', [data]);
const shift = useShiftModifier(); const shift = useShiftModifier();
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
navigator.clipboard.writeText(dataString); navigator.clipboard.writeText(dataString);

View File

@ -1,12 +1,10 @@
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { MetadataControlNets } from 'features/metadata/components/MetadataControlNets'; import { MetadataControlNets } from 'features/metadata/components/MetadataControlNets';
import { MetadataControlNetsV2 } from 'features/metadata/components/MetadataControlNetsV2';
import { MetadataIPAdapters } from 'features/metadata/components/MetadataIPAdapters'; import { MetadataIPAdapters } from 'features/metadata/components/MetadataIPAdapters';
import { MetadataIPAdaptersV2 } from 'features/metadata/components/MetadataIPAdaptersV2';
import { MetadataItem } from 'features/metadata/components/MetadataItem'; import { MetadataItem } from 'features/metadata/components/MetadataItem';
import { MetadataLayers } from 'features/metadata/components/MetadataLayers';
import { MetadataLoRAs } from 'features/metadata/components/MetadataLoRAs'; import { MetadataLoRAs } from 'features/metadata/components/MetadataLoRAs';
import { MetadataT2IAdapters } from 'features/metadata/components/MetadataT2IAdapters'; import { MetadataT2IAdapters } from 'features/metadata/components/MetadataT2IAdapters';
import { MetadataT2IAdaptersV2 } from 'features/metadata/components/MetadataT2IAdaptersV2';
import { handlers } from 'features/metadata/util/handlers'; import { handlers } from 'features/metadata/util/handlers';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo } from 'react'; import { memo } from 'react';
@ -39,8 +37,7 @@ const ImageMetadataActions = (props: Props) => {
<MetadataItem metadata={metadata} handlers={handlers.scheduler} /> <MetadataItem metadata={metadata} handlers={handlers.scheduler} />
<MetadataItem metadata={metadata} handlers={handlers.cfgScale} /> <MetadataItem metadata={metadata} handlers={handlers.cfgScale} />
<MetadataItem metadata={metadata} handlers={handlers.cfgRescaleMultiplier} /> <MetadataItem metadata={metadata} handlers={handlers.cfgRescaleMultiplier} />
<MetadataItem metadata={metadata} handlers={handlers.initialImage} /> {activeTabName !== 'generation' && <MetadataItem metadata={metadata} handlers={handlers.strength} />}
<MetadataItem metadata={metadata} handlers={handlers.strength} />
<MetadataItem metadata={metadata} handlers={handlers.hrfEnabled} /> <MetadataItem metadata={metadata} handlers={handlers.hrfEnabled} />
<MetadataItem metadata={metadata} handlers={handlers.hrfMethod} /> <MetadataItem metadata={metadata} handlers={handlers.hrfMethod} />
<MetadataItem metadata={metadata} handlers={handlers.hrfStrength} /> <MetadataItem metadata={metadata} handlers={handlers.hrfStrength} />
@ -52,12 +49,10 @@ const ImageMetadataActions = (props: Props) => {
<MetadataItem metadata={metadata} handlers={handlers.refinerStart} /> <MetadataItem metadata={metadata} handlers={handlers.refinerStart} />
<MetadataItem metadata={metadata} handlers={handlers.refinerSteps} /> <MetadataItem metadata={metadata} handlers={handlers.refinerSteps} />
<MetadataLoRAs metadata={metadata} /> <MetadataLoRAs metadata={metadata} />
{activeTabName === 'generation' && <MetadataLayers metadata={metadata} />}
{activeTabName !== 'generation' && <MetadataControlNets metadata={metadata} />} {activeTabName !== 'generation' && <MetadataControlNets metadata={metadata} />}
{activeTabName !== 'generation' && <MetadataT2IAdapters metadata={metadata} />} {activeTabName !== 'generation' && <MetadataT2IAdapters metadata={metadata} />}
{activeTabName !== 'generation' && <MetadataIPAdapters metadata={metadata} />} {activeTabName !== 'generation' && <MetadataIPAdapters metadata={metadata} />}
{activeTabName === 'generation' && <MetadataControlNetsV2 metadata={metadata} />}
{activeTabName === 'generation' && <MetadataT2IAdaptersV2 metadata={metadata} />}
{activeTabName === 'generation' && <MetadataIPAdaptersV2 metadata={metadata} />}
</> </>
); );
}; };

View File

@ -0,0 +1,34 @@
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebouncedImageWorkflow } from 'services/api/hooks/useDebouncedImageWorkflow';
import type { ImageDTO } from 'services/api/types';
import DataViewer from './DataViewer';
type Props = {
image: ImageDTO;
};
const ImageMetadataGraphTabContent = ({ image }: Props) => {
const { t } = useTranslation();
const { currentData } = useDebouncedImageWorkflow(image);
const graph = useMemo(() => {
if (currentData?.graph) {
try {
return JSON.parse(currentData.graph);
} catch {
return null;
}
}
return null;
}, [currentData]);
if (!graph) {
return <IAINoContentFallback label={t('nodes.noGraph')} />;
}
return <DataViewer data={graph} label={t('nodes.graph')} />;
};
export default memo(ImageMetadataGraphTabContent);

View File

@ -1,6 +1,7 @@
import { ExternalLink, Flex, Tab, TabList, TabPanel, TabPanels, Tabs, Text } from '@invoke-ai/ui-library'; import { ExternalLink, Flex, Tab, TabList, TabPanel, TabPanels, Tabs, Text } from '@invoke-ai/ui-library';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import ImageMetadataGraphTabContent from 'features/gallery/components/ImageMetadataViewer/ImageMetadataGraphTabContent';
import { useMetadataItem } from 'features/metadata/hooks/useMetadataItem'; import { useMetadataItem } from 'features/metadata/hooks/useMetadataItem';
import { handlers } from 'features/metadata/util/handlers'; import { handlers } from 'features/metadata/util/handlers';
import { memo } from 'react'; import { memo } from 'react';
@ -52,6 +53,7 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
<Tab>{t('metadata.metadata')}</Tab> <Tab>{t('metadata.metadata')}</Tab>
<Tab>{t('metadata.imageDetails')}</Tab> <Tab>{t('metadata.imageDetails')}</Tab>
<Tab>{t('metadata.workflow')}</Tab> <Tab>{t('metadata.workflow')}</Tab>
<Tab>{t('nodes.graph')}</Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
@ -81,6 +83,9 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
<TabPanel> <TabPanel>
<ImageMetadataWorkflowTabContent image={image} /> <ImageMetadataWorkflowTabContent image={image} />
</TabPanel> </TabPanel>
<TabPanel>
<ImageMetadataGraphTabContent image={image} />
</TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>
</Flex> </Flex>

View File

@ -1,5 +1,5 @@
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDebouncedImageWorkflow } from 'services/api/hooks/useDebouncedImageWorkflow'; import { useDebouncedImageWorkflow } from 'services/api/hooks/useDebouncedImageWorkflow';
import type { ImageDTO } from 'services/api/types'; import type { ImageDTO } from 'services/api/types';
@ -12,7 +12,17 @@ type Props = {
const ImageMetadataWorkflowTabContent = ({ image }: Props) => { const ImageMetadataWorkflowTabContent = ({ image }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { workflow } = useDebouncedImageWorkflow(image); const { currentData } = useDebouncedImageWorkflow(image);
const workflow = useMemo(() => {
if (currentData?.workflow) {
try {
return JSON.parse(currentData.workflow);
} catch {
return null;
}
}
return null;
}, [currentData]);
if (!workflow) { if (!workflow) {
return <IAINoContentFallback label={t('nodes.noWorkflow')} />; return <IAINoContentFallback label={t('nodes.noWorkflow')} />;

View File

@ -16,6 +16,7 @@ import ParamUpscalePopover from 'features/parameters/components/Upscale/ParamUps
import { useIsQueueMutationInProgress } from 'features/queue/hooks/useIsQueueMutationInProgress'; import { useIsQueueMutationInProgress } from 'features/queue/hooks/useIsQueueMutationInProgress';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectSystemSlice } from 'features/system/store/systemSlice'; import { selectSystemSlice } from 'features/system/store/systemSlice';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow'; import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@ -84,6 +85,7 @@ const CurrentImageButtons = () => {
} }
dispatch(sentImageToImg2Img()); dispatch(sentImageToImg2Img());
dispatch(iiLayerAdded(imageDTO)); dispatch(iiLayerAdded(imageDTO));
dispatch(setActiveTab('generation'));
}, [dispatch, imageDTO]); }, [dispatch, imageDTO]);
useHotkeys('shift+i', handleSendToImageToImage, [imageDTO]); useHotkeys('shift+i', handleSendToImageToImage, [imageDTO]);

View File

@ -22,7 +22,21 @@ const selectLastSelectedImageName = createSelector(
(lastSelectedImage) => lastSelectedImage?.image_name (lastSelectedImage) => lastSelectedImage?.image_name
); );
const CurrentImagePreview = () => { type Props = {
isDragDisabled?: boolean;
isDropDisabled?: boolean;
withNextPrevButtons?: boolean;
withMetadata?: boolean;
alwaysShowProgress?: boolean;
};
const CurrentImagePreview = ({
isDragDisabled = false,
isDropDisabled = false,
withNextPrevButtons = true,
withMetadata = true,
alwaysShowProgress = false,
}: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails); const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails);
const imageName = useAppSelector(selectLastSelectedImageName); const imageName = useAppSelector(selectLastSelectedImageName);
@ -72,13 +86,15 @@ const CurrentImagePreview = () => {
justifyContent="center" justifyContent="center"
position="relative" position="relative"
> >
{hasDenoiseProgress && shouldShowProgressInViewer ? ( {hasDenoiseProgress && (shouldShowProgressInViewer || alwaysShowProgress) ? (
<ProgressImage /> <ProgressImage />
) : ( ) : (
<IAIDndImage <IAIDndImage
imageDTO={imageDTO} imageDTO={imageDTO}
droppableData={droppableData} droppableData={droppableData}
draggableData={draggableData} draggableData={draggableData}
isDragDisabled={isDragDisabled}
isDropDisabled={isDropDisabled}
isUploadDisabled={true} isUploadDisabled={true}
fitContainer fitContainer
useThumbailFallback useThumbailFallback
@ -87,26 +103,13 @@ const CurrentImagePreview = () => {
dataTestId="image-preview" dataTestId="image-preview"
/> />
)} )}
{shouldShowImageDetails && imageDTO && withMetadata && (
<Box position="absolute" opacity={0.8} top={0} width="full" height="full" borderRadius="base">
<ImageMetadataViewer image={imageDTO} />
</Box>
)}
<AnimatePresence> <AnimatePresence>
{shouldShowImageDetails && imageDTO && ( {withNextPrevButtons && shouldShowNextPrevButtons && imageDTO && (
<Box
as={motion.div}
key="metadataViewer"
initial={initial}
animate={animateMetadata}
exit={exit}
position="absolute"
top={0}
width="full"
height="full"
borderRadius="base"
>
<ImageMetadataViewer image={imageDTO} />
</Box>
)}
</AnimatePresence>
<AnimatePresence>
{shouldShowNextPrevButtons && imageDTO && (
<Box <Box
as={motion.div} as={motion.div}
key="nextPrevButtons" key="nextPrevButtons"
@ -136,10 +139,6 @@ const animateArrows: AnimationProps['animate'] = {
opacity: 1, opacity: 1,
transition: { duration: 0.07 }, transition: { duration: 0.07 },
}; };
const animateMetadata: AnimationProps['animate'] = {
opacity: 0.8,
transition: { duration: 0.07 },
};
const exit: AnimationProps['exit'] = { const exit: AnimationProps['exit'] = {
opacity: 0, opacity: 0,
transition: { duration: 0.07 }, transition: { duration: 0.07 },

View File

@ -1,39 +0,0 @@
import { Button } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import type { InvokeTabName } from 'features/ui/store/tabMap';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsDownUpBold } from 'react-icons/pi';
import { useImageViewer } from './useImageViewer';
const TAB_NAME_TO_TKEY_SHORT: Record<InvokeTabName, string> = {
generation: 'controlLayers.controlLayers',
canvas: 'ui.tabs.canvas',
workflows: 'ui.tabs.workflows',
models: 'ui.tabs.models',
queue: 'ui.tabs.queue',
};
export const EditorButton = () => {
const { t } = useTranslation();
const { onClose } = useImageViewer();
const activeTabName = useAppSelector(activeTabNameSelector);
const tooltip = useMemo(
() => t('gallery.switchTo', { tab: t(TAB_NAME_TO_TKEY_SHORT[activeTabName]) }),
[t, activeTabName]
);
return (
<Button
aria-label={tooltip}
tooltip={tooltip}
onClick={onClose}
variant="outline"
leftIcon={<PiArrowsDownUpBold />}
>
{t(TAB_NAME_TO_TKEY_SHORT[activeTabName])}
</Button>
);
};

View File

@ -5,26 +5,12 @@ import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/To
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import type { InvokeTabName } from 'features/ui/store/tabMap'; import type { InvokeTabName } from 'features/ui/store/tabMap';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import CurrentImageButtons from './CurrentImageButtons'; import CurrentImageButtons from './CurrentImageButtons';
import CurrentImagePreview from './CurrentImagePreview'; import CurrentImagePreview from './CurrentImagePreview';
import { EditorButton } from './EditorButton'; import { ViewerToggleMenu } from './ViewerToggleMenu';
const initial: AnimationProps['initial'] = {
opacity: 0,
};
const animate: AnimationProps['animate'] = {
opacity: 1,
transition: { duration: 0.07 },
};
const exit: AnimationProps['exit'] = {
opacity: 0,
transition: { duration: 0.07 },
};
const VIEWER_ENABLED_TABS: InvokeTabName[] = ['canvas', 'generation', 'workflows']; const VIEWER_ENABLED_TABS: InvokeTabName[] = ['canvas', 'generation', 'workflows'];
@ -42,50 +28,44 @@ export const ImageViewer = memo(() => {
useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]); useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]);
useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]); useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]);
// The AnimatePresence mode must be wait - else framer can get confused if you spam the toggle button if (!shouldShowViewer) {
return null;
}
return ( return (
<AnimatePresence mode="wait"> <Flex
{shouldShowViewer && ( layerStyle="first"
<Flex borderRadius="base"
key="imageViewer" position="absolute"
as={motion.div} flexDirection="column"
initial={initial} top={0}
animate={animate} right={0}
exit={exit} bottom={0}
layerStyle="first" left={0}
borderRadius="base" p={2}
position="absolute" rowGap={4}
flexDirection="column" alignItems="center"
top={0} justifyContent="center"
right={0} zIndex={10} // reactflow puts its minimap at 5, so we need to be above that
bottom={0} >
left={0} <Flex w="full" gap={2}>
p={2} <Flex flex={1} justifyContent="center">
rowGap={4} <Flex gap={2} marginInlineEnd="auto">
alignItems="center" <ToggleProgressButton />
justifyContent="center" <ToggleMetadataViewerButton />
zIndex={10} // reactflow puts its minimap at 5, so we need to be above that
>
<Flex w="full" gap={2}>
<Flex flex={1} justifyContent="center">
<Flex gap={2} marginInlineEnd="auto">
<ToggleProgressButton />
<ToggleMetadataViewerButton />
</Flex>
</Flex>
<Flex flex={1} gap={2} justifyContent="center">
<CurrentImageButtons />
</Flex>
<Flex flex={1} justifyContent="center">
<Flex gap={2} marginInlineStart="auto">
<EditorButton />
</Flex>
</Flex>
</Flex> </Flex>
<CurrentImagePreview />
</Flex> </Flex>
)} <Flex flex={1} gap={2} justifyContent="center">
</AnimatePresence> <CurrentImageButtons />
</Flex>
<Flex flex={1} justifyContent="center">
<Flex gap={2} marginInlineStart="auto">
<ViewerToggleMenu />
</Flex>
</Flex>
</Flex>
<CurrentImagePreview />
</Flex>
); );
}); });

View File

@ -0,0 +1,45 @@
import { Flex } from '@invoke-ai/ui-library';
import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton';
import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
import { memo } from 'react';
import CurrentImageButtons from './CurrentImageButtons';
import CurrentImagePreview from './CurrentImagePreview';
export const ImageViewerWorkflows = memo(() => {
return (
<Flex
layerStyle="first"
borderRadius="base"
position="absolute"
flexDirection="column"
top={0}
right={0}
bottom={0}
left={0}
p={2}
rowGap={4}
alignItems="center"
justifyContent="center"
zIndex={10} // reactflow puts its minimap at 5, so we need to be above that
>
<Flex w="full" gap={2}>
<Flex flex={1} justifyContent="center">
<Flex gap={2} marginInlineEnd="auto">
<ToggleProgressButton />
<ToggleMetadataViewerButton />
</Flex>
</Flex>
<Flex flex={1} gap={2} justifyContent="center">
<CurrentImageButtons />
</Flex>
<Flex flex={1} justifyContent="center">
<Flex gap={2} marginInlineStart="auto" />
</Flex>
</Flex>
<CurrentImagePreview />
</Flex>
);
});
ImageViewerWorkflows.displayName = 'ImageViewerWorkflows';

Some files were not shown because too many files have changed in this diff Show More