diff --git a/docs/contributing/MODEL_MANAGER.md b/docs/contributing/MODEL_MANAGER.md
index dfa724fee8..9699db4f1a 100644
--- a/docs/contributing/MODEL_MANAGER.md
+++ b/docs/contributing/MODEL_MANAGER.md
@@ -1366,12 +1366,20 @@ the in-memory loaded model:
| `model` | AnyModel | The instantiated model (details below) |
| `locker` | ModelLockerBase | A context manager that mediates the movement of the model into VRAM |
-Because the loader can return multiple model types, it is typed to
-return `AnyModel`, a Union `ModelMixin`, `torch.nn.Module`,
-`IAIOnnxRuntimeModel`, `IPAdapter`, `IPAdapterPlus`, and
-`EmbeddingModelRaw`. `ModelMixin` is the base class of all diffusers
-models, `EmbeddingModelRaw` is used for LoRA and TextualInversion
-models. The others are obvious.
+### get_model_by_key(key, [submodel]) -> LoadedModel
+
+The `get_model_by_key()` method will retrieve the model using its
+unique database key. For example:
+
+loaded_model = loader.get_model_by_key('f13dd932c0c35c22dcb8d6cda4203764', SubModelType('vae'))
+
+`get_model_by_key()` may raise any of the following exceptions:
+
+* `UnknownModelException` -- key not in database
+* `ModelNotFoundException` -- key in database but model not found at path
+* `NotImplementedException` -- the loader doesn't know how to load this type of model
+
+### Using the Loaded Model in Inference
`LoadedModel` acts as a context manager. The context loads the model
into the execution device (e.g. VRAM on CUDA systems), locks the model
@@ -1379,17 +1387,33 @@ in the execution device for the duration of the context, and returns
the model. Use it like this:
```
-model_info = loader.get_model_by_key('f13dd932c0c35c22dcb8d6cda4203764', SubModelType('vae'))
-with model_info as vae:
+loaded_model_= loader.get_model_by_key('f13dd932c0c35c22dcb8d6cda4203764', SubModelType('vae'))
+with loaded_model as vae:
image = vae.decode(latents)[0]
```
-`get_model_by_key()` may raise any of the following exceptions:
+The object returned by the LoadedModel context manager is an
+`AnyModel`, which is a Union of `ModelMixin`, `torch.nn.Module`,
+`IAIOnnxRuntimeModel`, `IPAdapter`, `IPAdapterPlus`, and
+`EmbeddingModelRaw`. `ModelMixin` is the base class of all diffusers
+models, `EmbeddingModelRaw` is used for LoRA and TextualInversion
+models. The others are obvious.
+
+In addition, you may call `LoadedModel.model_on_device()`, a context
+manager that returns a tuple of the model's state dict in CPU and the
+model itself in VRAM. It is used to optimize the LoRA patching and
+unpatching process:
+
+```
+loaded_model_= loader.get_model_by_key('f13dd932c0c35c22dcb8d6cda4203764', SubModelType('vae'))
+with loaded_model.model_on_device() as (state_dict, vae):
+ image = vae.decode(latents)[0]
+```
+
+Since not all models have state dicts, the `state_dict` return value
+can be None.
+
-* `UnknownModelException` -- key not in database
-* `ModelNotFoundException` -- key in database but model not found at path
-* `NotImplementedException` -- the loader doesn't know how to load this type of model
-
### Emitting model loading events
When the `context` argument is passed to `load_model_*()`, it will
diff --git a/invokeai/app/invocations/compel.py b/invokeai/app/invocations/compel.py
index 766b44fdc8..1e78e10d38 100644
--- a/invokeai/app/invocations/compel.py
+++ b/invokeai/app/invocations/compel.py
@@ -81,9 +81,13 @@ class CompelInvocation(BaseInvocation):
with (
# apply all patches while the model is on the target device
- text_encoder_info as text_encoder,
+ text_encoder_info.model_on_device() as (model_state_dict, text_encoder),
tokenizer_info as tokenizer,
- ModelPatcher.apply_lora_text_encoder(text_encoder, _lora_loader()),
+ ModelPatcher.apply_lora_text_encoder(
+ text_encoder,
+ loras=_lora_loader(),
+ model_state_dict=model_state_dict,
+ ),
# Apply CLIP Skip after LoRA to prevent LoRA application from failing on skipped layers.
ModelPatcher.apply_clip_skip(text_encoder, self.clip.skipped_layers),
ModelPatcher.apply_ti(tokenizer, text_encoder, ti_list) as (
@@ -172,9 +176,14 @@ class SDXLPromptInvocationBase:
with (
# apply all patches while the model is on the target device
- text_encoder_info as text_encoder,
+ text_encoder_info.model_on_device() as (state_dict, text_encoder),
tokenizer_info as tokenizer,
- ModelPatcher.apply_lora(text_encoder, _lora_loader(), lora_prefix),
+ ModelPatcher.apply_lora(
+ text_encoder,
+ loras=_lora_loader(),
+ prefix=lora_prefix,
+ model_state_dict=state_dict,
+ ),
# Apply CLIP Skip after LoRA to prevent LoRA application from failing on skipped layers.
ModelPatcher.apply_clip_skip(text_encoder, clip_field.skipped_layers),
ModelPatcher.apply_ti(tokenizer, text_encoder, ti_list) as (
diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py
index a88eff0fcb..8fb9b93f4c 100644
--- a/invokeai/app/invocations/latent.py
+++ b/invokeai/app/invocations/latent.py
@@ -50,7 +50,7 @@ from invokeai.app.invocations.primitives import DenoiseMaskOutput, ImageOutput,
from invokeai.app.invocations.t2i_adapter import T2IAdapterField
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.controlnet_utils import prepare_control_image
-from invokeai.backend.ip_adapter.ip_adapter import IPAdapter, IPAdapterPlus
+from invokeai.backend.ip_adapter.ip_adapter import IPAdapter
from invokeai.backend.lora import LoRAModelRaw
from invokeai.backend.model_manager import BaseModelType, LoadedModel
from invokeai.backend.model_manager.config import MainConfigBase, ModelVariantType
@@ -672,54 +672,52 @@ class DenoiseLatentsInvocation(BaseInvocation):
return controlnet_data
+ def prep_ip_adapter_image_prompts(
+ self,
+ context: InvocationContext,
+ ip_adapters: List[IPAdapterField],
+ ) -> List[Tuple[torch.Tensor, torch.Tensor]]:
+ """Run the IPAdapter CLIPVisionModel, returning image prompt embeddings."""
+ image_prompts = []
+ for single_ip_adapter in ip_adapters:
+ with context.models.load(single_ip_adapter.ip_adapter_model) as ip_adapter_model:
+ assert isinstance(ip_adapter_model, IPAdapter)
+ image_encoder_model_info = context.models.load(single_ip_adapter.image_encoder_model)
+ # `single_ip_adapter.image` could be a list or a single ImageField. Normalize to a list here.
+ single_ipa_image_fields = single_ip_adapter.image
+ if not isinstance(single_ipa_image_fields, list):
+ single_ipa_image_fields = [single_ipa_image_fields]
+
+ single_ipa_images = [context.images.get_pil(image.image_name) for image in single_ipa_image_fields]
+ with image_encoder_model_info as image_encoder_model:
+ assert isinstance(image_encoder_model, CLIPVisionModelWithProjection)
+ # Get image embeddings from CLIP and ImageProjModel.
+ image_prompt_embeds, uncond_image_prompt_embeds = ip_adapter_model.get_image_embeds(
+ single_ipa_images, image_encoder_model
+ )
+ image_prompts.append((image_prompt_embeds, uncond_image_prompt_embeds))
+
+ return image_prompts
+
def prep_ip_adapter_data(
self,
context: InvocationContext,
- ip_adapter: Optional[Union[IPAdapterField, list[IPAdapterField]]],
+ ip_adapters: List[IPAdapterField],
+ image_prompts: List[Tuple[torch.Tensor, torch.Tensor]],
exit_stack: ExitStack,
latent_height: int,
latent_width: int,
dtype: torch.dtype,
- ) -> Optional[list[IPAdapterData]]:
- """If IP-Adapter is enabled, then this function loads the requisite models, and adds the image prompt embeddings
- to the `conditioning_data` (in-place).
- """
- if ip_adapter is None:
- return None
-
- # ip_adapter could be a list or a single IPAdapterField. Normalize to a list here.
- if not isinstance(ip_adapter, list):
- ip_adapter = [ip_adapter]
-
- if len(ip_adapter) == 0:
- return None
-
+ ) -> Optional[List[IPAdapterData]]:
+ """If IP-Adapter is enabled, then this function loads the requisite models and adds the image prompt conditioning data."""
ip_adapter_data_list = []
- for single_ip_adapter in ip_adapter:
- ip_adapter_model: Union[IPAdapter, IPAdapterPlus] = exit_stack.enter_context(
- context.models.load(single_ip_adapter.ip_adapter_model)
- )
+ for single_ip_adapter, (image_prompt_embeds, uncond_image_prompt_embeds) in zip(
+ ip_adapters, image_prompts, strict=True
+ ):
+ ip_adapter_model = exit_stack.enter_context(context.models.load(single_ip_adapter.ip_adapter_model))
- image_encoder_model_info = context.models.load(single_ip_adapter.image_encoder_model)
- # `single_ip_adapter.image` could be a list or a single ImageField. Normalize to a list here.
- single_ipa_image_fields = single_ip_adapter.image
- if not isinstance(single_ipa_image_fields, list):
- single_ipa_image_fields = [single_ipa_image_fields]
-
- single_ipa_images = [context.images.get_pil(image.image_name) for image in single_ipa_image_fields]
-
- # TODO(ryand): With some effort, the step of running the CLIP Vision encoder could be done before any other
- # models are needed in memory. This would help to reduce peak memory utilization in low-memory environments.
- with image_encoder_model_info as image_encoder_model:
- assert isinstance(image_encoder_model, CLIPVisionModelWithProjection)
- # Get image embeddings from CLIP and ImageProjModel.
- image_prompt_embeds, uncond_image_prompt_embeds = ip_adapter_model.get_image_embeds(
- single_ipa_images, image_encoder_model
- )
-
- mask = single_ip_adapter.mask
- if mask is not None:
- mask = context.tensors.load(mask.tensor_name)
+ mask_field = single_ip_adapter.mask
+ mask = context.tensors.load(mask_field.tensor_name) if mask_field is not None else None
mask = self._preprocess_regional_prompt_mask(mask, latent_height, latent_width, dtype=dtype)
ip_adapter_data_list.append(
@@ -734,7 +732,7 @@ class DenoiseLatentsInvocation(BaseInvocation):
)
)
- return ip_adapter_data_list
+ return ip_adapter_data_list if len(ip_adapter_data_list) > 0 else None
def run_t2i_adapters(
self,
@@ -855,6 +853,16 @@ class DenoiseLatentsInvocation(BaseInvocation):
# At some point, someone decided that schedulers that accept a generator should use the original seed with
# all bits flipped. I don't know the original rationale for this, but now we must keep it like this for
# reproducibility.
+ #
+ # These Invoke-supported schedulers accept a generator as of 2024-06-04:
+ # - DDIMScheduler
+ # - DDPMScheduler
+ # - DPMSolverMultistepScheduler
+ # - EulerAncestralDiscreteScheduler
+ # - EulerDiscreteScheduler
+ # - KDPM2AncestralDiscreteScheduler
+ # - LCMScheduler
+ # - TCDScheduler
scheduler_step_kwargs.update({"generator": torch.Generator(device=device).manual_seed(seed ^ 0xFFFFFFFF)})
if isinstance(scheduler, TCDScheduler):
scheduler_step_kwargs.update({"eta": 1.0})
@@ -912,6 +920,20 @@ class DenoiseLatentsInvocation(BaseInvocation):
do_classifier_free_guidance=True,
)
+ ip_adapters: List[IPAdapterField] = []
+ if self.ip_adapter is not None:
+ # ip_adapter could be a list or a single IPAdapterField. Normalize to a list here.
+ if isinstance(self.ip_adapter, list):
+ ip_adapters = self.ip_adapter
+ else:
+ ip_adapters = [self.ip_adapter]
+
+ # If there are IP adapters, the following line runs the adapters' CLIPVision image encoders to return
+ # a series of image conditioning embeddings. This is being done here rather than in the
+ # big model context below in order to use less VRAM on low-VRAM systems.
+ # The image prompts are then passed to prep_ip_adapter_data().
+ image_prompts = self.prep_ip_adapter_image_prompts(context=context, ip_adapters=ip_adapters)
+
# get the unet's config so that we can pass the base to dispatch_progress()
unet_config = context.models.get_config(self.unet.unet.key)
@@ -930,11 +952,15 @@ class DenoiseLatentsInvocation(BaseInvocation):
assert isinstance(unet_info.model, UNet2DConditionModel)
with (
ExitStack() as exit_stack,
- unet_info as unet,
+ unet_info.model_on_device() as (model_state_dict, unet),
ModelPatcher.apply_freeu(unet, self.unet.freeu_config),
set_seamless(unet, self.unet.seamless_axes), # FIXME
# Apply the LoRA after unet has been moved to its target device for faster patching.
- ModelPatcher.apply_lora_unet(unet, _lora_loader()),
+ ModelPatcher.apply_lora_unet(
+ unet,
+ loras=_lora_loader(),
+ model_state_dict=model_state_dict,
+ ),
):
assert isinstance(unet, UNet2DConditionModel)
latents = latents.to(device=unet.device, dtype=unet.dtype)
@@ -970,7 +996,8 @@ class DenoiseLatentsInvocation(BaseInvocation):
ip_adapter_data = self.prep_ip_adapter_data(
context=context,
- ip_adapter=self.ip_adapter,
+ ip_adapters=ip_adapters,
+ image_prompts=image_prompts,
exit_stack=exit_stack,
latent_height=latent_height,
latent_width=latent_width,
@@ -1285,7 +1312,7 @@ class ImageToLatentsInvocation(BaseInvocation):
title="Blend Latents",
tags=["latents", "blend"],
category="latents",
- version="1.0.2",
+ version="1.0.3",
)
class BlendLatentsInvocation(BaseInvocation):
"""Blend two latents using a given alpha. Latents must have same size."""
@@ -1364,7 +1391,7 @@ class BlendLatentsInvocation(BaseInvocation):
TorchDevice.empty_cache()
name = context.tensors.save(tensor=blended_latents)
- return LatentsOutput.build(latents_name=name, latents=blended_latents)
+ return LatentsOutput.build(latents_name=name, latents=blended_latents, seed=self.latents_a.seed)
# The Crop Latents node was copied from @skunkworxdark's implementation here:
diff --git a/invokeai/backend/model_manager/load/load_base.py b/invokeai/backend/model_manager/load/load_base.py
index a7c080ed2b..6748e85dca 100644
--- a/invokeai/backend/model_manager/load/load_base.py
+++ b/invokeai/backend/model_manager/load/load_base.py
@@ -4,10 +4,13 @@ Base class for model loading in InvokeAI.
"""
from abc import ABC, abstractmethod
+from contextlib import contextmanager
from dataclasses import dataclass
from logging import Logger
from pathlib import Path
-from typing import Any, Optional
+from typing import Any, Dict, Generator, Optional, Tuple
+
+import torch
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.backend.model_manager.config import (
@@ -21,7 +24,42 @@ from invokeai.backend.model_manager.load.model_cache.model_cache_base import Mod
@dataclass
class LoadedModelWithoutConfig:
- """Context manager object that mediates transfer from RAM<->VRAM."""
+ """
+ Context manager object that mediates transfer from RAM<->VRAM.
+
+ This is a context manager object that has two distinct APIs:
+
+ 1. Older API (deprecated):
+ Use the LoadedModel object directly as a context manager.
+ It will move the model into VRAM (on CUDA devices), and
+ return the model in a form suitable for passing to torch.
+ Example:
+ ```
+ loaded_model_= loader.get_model_by_key('f13dd932', SubModelType('vae'))
+ with loaded_model as vae:
+ image = vae.decode(latents)[0]
+ ```
+
+ 2. Newer API (recommended):
+ Call the LoadedModel's `model_on_device()` method in a
+ context. It returns a tuple consisting of a copy of
+ the model's state dict in CPU RAM followed by a copy
+ of the model in VRAM. The state dict is provided to allow
+ LoRAs and other model patchers to return the model to
+ its unpatched state without expensive copy and restore
+ operations.
+
+ Example:
+ ```
+ loaded_model_= loader.get_model_by_key('f13dd932', SubModelType('vae'))
+ with loaded_model.model_on_device() as (state_dict, vae):
+ image = vae.decode(latents)[0]
+ ```
+
+ The state_dict should be treated as a read-only object and
+ never modified. Also be aware that some loadable models do
+ not have a state_dict, in which case this value will be None.
+ """
_locker: ModelLockerBase
@@ -34,6 +72,16 @@ class LoadedModelWithoutConfig:
"""Context exit."""
self._locker.unlock()
+ @contextmanager
+ def model_on_device(self) -> Generator[Tuple[Optional[Dict[str, torch.Tensor]], AnyModel], None, None]:
+ """Return a tuple consisting of the model's state dict (if it exists) and the locked model on execution device."""
+ locked_model = self._locker.lock()
+ try:
+ state_dict = self._locker.get_state_dict()
+ yield (state_dict, locked_model)
+ finally:
+ self._locker.unlock()
+
@property
def model(self) -> AnyModel:
"""Return the model without locking it."""
diff --git a/invokeai/backend/model_manager/load/model_cache/model_cache_base.py b/invokeai/backend/model_manager/load/model_cache/model_cache_base.py
index bb7fd6f1d4..012fd42d55 100644
--- a/invokeai/backend/model_manager/load/model_cache/model_cache_base.py
+++ b/invokeai/backend/model_manager/load/model_cache/model_cache_base.py
@@ -30,6 +30,11 @@ class ModelLockerBase(ABC):
"""Unlock the contained model, and remove it from VRAM."""
pass
+ @abstractmethod
+ def get_state_dict(self) -> Optional[Dict[str, torch.Tensor]]:
+ """Return the state dict (if any) for the cached model."""
+ pass
+
@property
@abstractmethod
def model(self) -> AnyModel:
@@ -56,6 +61,11 @@ class CacheRecord(Generic[T]):
and then injected into the model. When the model is finished, the VRAM
copy of the state dict is deleted, and the RAM version is reinjected
into the model.
+
+ The state_dict should be treated as a read-only attribute. Do not attempt
+ to patch or otherwise modify it. Instead, patch the copy of the state_dict
+ after it is loaded into the execution device (e.g. CUDA) using the `LoadedModel`
+ context manager call `model_on_device()`.
"""
key: str
diff --git a/invokeai/backend/model_manager/load/model_cache/model_locker.py b/invokeai/backend/model_manager/load/model_cache/model_locker.py
index 57f5fcd657..9de17ca5f5 100644
--- a/invokeai/backend/model_manager/load/model_cache/model_locker.py
+++ b/invokeai/backend/model_manager/load/model_cache/model_locker.py
@@ -2,6 +2,8 @@
Base class and implementation of a class that moves models in and out of VRAM.
"""
+from typing import Dict, Optional
+
import torch
from invokeai.backend.model_manager import AnyModel
@@ -27,16 +29,18 @@ class ModelLocker(ModelLockerBase):
"""Return the model without moving it around."""
return self._cache_entry.model
+ def get_state_dict(self) -> Optional[Dict[str, torch.Tensor]]:
+ """Return the state dict (if any) for the cached model."""
+ return self._cache_entry.state_dict
+
def lock(self) -> AnyModel:
"""Move the model into the execution device (GPU) and lock it."""
self._cache_entry.lock()
try:
if self._cache.lazy_offloading:
self._cache.offload_unlocked_models(self._cache_entry.size)
-
self._cache.move_model_to_device(self._cache_entry, self._cache.execution_device)
self._cache_entry.loaded = True
-
self._cache.logger.debug(f"Locking {self._cache_entry.key} in {self._cache.execution_device}")
self._cache.print_cuda_stats()
except torch.cuda.OutOfMemoryError:
diff --git a/invokeai/backend/model_patcher.py b/invokeai/backend/model_patcher.py
index 76271fc025..c407cd8472 100644
--- a/invokeai/backend/model_patcher.py
+++ b/invokeai/backend/model_patcher.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import pickle
from contextlib import contextmanager
-from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
+from typing import Any, Dict, Generator, Iterator, List, Optional, Tuple, Union
import numpy as np
import torch
@@ -66,8 +66,14 @@ class ModelPatcher:
cls,
unet: UNet2DConditionModel,
loras: Iterator[Tuple[LoRAModelRaw, float]],
+ model_state_dict: Optional[Dict[str, torch.Tensor]] = None,
) -> None:
- with cls.apply_lora(unet, loras, "lora_unet_"):
+ with cls.apply_lora(
+ unet,
+ loras=loras,
+ prefix="lora_unet_",
+ model_state_dict=model_state_dict,
+ ):
yield
@classmethod
@@ -76,28 +82,9 @@ class ModelPatcher:
cls,
text_encoder: CLIPTextModel,
loras: Iterator[Tuple[LoRAModelRaw, float]],
+ model_state_dict: Optional[Dict[str, torch.Tensor]] = None,
) -> None:
- with cls.apply_lora(text_encoder, loras, "lora_te_"):
- yield
-
- @classmethod
- @contextmanager
- def apply_sdxl_lora_text_encoder(
- cls,
- text_encoder: CLIPTextModel,
- loras: List[Tuple[LoRAModelRaw, float]],
- ) -> None:
- with cls.apply_lora(text_encoder, loras, "lora_te1_"):
- yield
-
- @classmethod
- @contextmanager
- def apply_sdxl_lora_text_encoder2(
- cls,
- text_encoder: CLIPTextModel,
- loras: List[Tuple[LoRAModelRaw, float]],
- ) -> None:
- with cls.apply_lora(text_encoder, loras, "lora_te2_"):
+ with cls.apply_lora(text_encoder, loras=loras, prefix="lora_te_", model_state_dict=model_state_dict):
yield
@classmethod
@@ -107,7 +94,16 @@ class ModelPatcher:
model: AnyModel,
loras: Iterator[Tuple[LoRAModelRaw, float]],
prefix: str,
- ) -> None:
+ model_state_dict: Optional[Dict[str, torch.Tensor]] = None,
+ ) -> Generator[Any, None, None]:
+ """
+ Apply one or more LoRAs to a model.
+
+ :param model: The model to patch.
+ :param loras: An iterator that returns the LoRA to patch in and its patch weight.
+ :param prefix: A string prefix that precedes keys used in the LoRAs weight layers.
+ :model_state_dict: Read-only copy of the model's state dict in CPU, for unpatching purposes.
+ """
original_weights = {}
try:
with torch.no_grad():
@@ -133,7 +129,10 @@ class ModelPatcher:
dtype = module.weight.dtype
if module_key not in original_weights:
- original_weights[module_key] = module.weight.detach().to(device="cpu", copy=True)
+ if model_state_dict is not None: # we were provided with the CPU copy of the state dict
+ original_weights[module_key] = model_state_dict[module_key + ".weight"]
+ else:
+ original_weights[module_key] = module.weight.detach().to(device="cpu", copy=True)
layer_scale = layer.alpha / layer.rank if (layer.alpha and layer.rank) else 1.0
diff --git a/invokeai/frontend/web/public/locales/de.json b/invokeai/frontend/web/public/locales/de.json
index 1db283aabd..2da27264a1 100644
--- a/invokeai/frontend/web/public/locales/de.json
+++ b/invokeai/frontend/web/public/locales/de.json
@@ -1021,7 +1021,8 @@
"float": "Kommazahlen",
"enum": "Aufzählung",
"fullyContainNodes": "Vollständig ausgewählte Nodes auswählen",
- "editMode": "Im Workflow-Editor bearbeiten"
+ "editMode": "Im Workflow-Editor bearbeiten",
+ "resetToDefaultValue": "Auf Standardwert zurücksetzen"
},
"hrf": {
"enableHrf": "Korrektur für hohe Auflösungen",
diff --git a/invokeai/frontend/web/public/locales/es.json b/invokeai/frontend/web/public/locales/es.json
index 169bfdb066..52ee3b5fe3 100644
--- a/invokeai/frontend/web/public/locales/es.json
+++ b/invokeai/frontend/web/public/locales/es.json
@@ -6,7 +6,7 @@
"settingsLabel": "Ajustes",
"img2img": "Imagen a Imagen",
"unifiedCanvas": "Lienzo Unificado",
- "nodes": "Editor del flujo de trabajo",
+ "nodes": "Flujos de trabajo",
"upload": "Subir imagen",
"load": "Cargar",
"statusDisconnected": "Desconectado",
@@ -14,7 +14,7 @@
"discordLabel": "Discord",
"back": "Atrás",
"loading": "Cargando",
- "postprocessing": "Tratamiento posterior",
+ "postprocessing": "Postprocesado",
"txt2img": "De texto a imagen",
"accept": "Aceptar",
"cancel": "Cancelar",
@@ -42,7 +42,42 @@
"copy": "Copiar",
"beta": "Beta",
"on": "En",
- "aboutDesc": "¿Utilizas Invoke para trabajar? Mira aquí:"
+ "aboutDesc": "¿Utilizas Invoke para trabajar? Mira aquí:",
+ "installed": "Instalado",
+ "green": "Verde",
+ "editor": "Editor",
+ "orderBy": "Ordenar por",
+ "file": "Archivo",
+ "goTo": "Ir a",
+ "imageFailedToLoad": "No se puede cargar la imagen",
+ "saveAs": "Guardar Como",
+ "somethingWentWrong": "Algo salió mal",
+ "nextPage": "Página Siguiente",
+ "selected": "Seleccionado",
+ "tab": "Tabulador",
+ "positivePrompt": "Prompt Positivo",
+ "negativePrompt": "Prompt Negativo",
+ "error": "Error",
+ "format": "formato",
+ "unknown": "Desconocido",
+ "input": "Entrada",
+ "nodeEditor": "Editor de nodos",
+ "template": "Plantilla",
+ "prevPage": "Página Anterior",
+ "red": "Rojo",
+ "alpha": "Transparencia",
+ "outputs": "Salidas",
+ "editing": "Editando",
+ "learnMore": "Aprende más",
+ "enabled": "Activado",
+ "disabled": "Desactivado",
+ "folder": "Carpeta",
+ "updated": "Actualizado",
+ "created": "Creado",
+ "save": "Guardar",
+ "unknownError": "Error Desconocido",
+ "blue": "Azul",
+ "viewingDesc": "Revisar imágenes en una vista de galería grande"
},
"gallery": {
"galleryImageSize": "Tamaño de la imagen",
@@ -467,7 +502,8 @@
"about": "Acerca de",
"createIssue": "Crear un problema",
"resetUI": "Interfaz de usuario $t(accessibility.reset)",
- "mode": "Modo"
+ "mode": "Modo",
+ "submitSupportTicket": "Enviar Ticket de Soporte"
},
"nodes": {
"zoomInNodes": "Acercar",
@@ -543,5 +579,17 @@
"layers_one": "Capa",
"layers_many": "Capas",
"layers_other": "Capas"
+ },
+ "controlnet": {
+ "crop": "Cortar",
+ "delete": "Eliminar",
+ "depthAnythingDescription": "Generación de mapa de profundidad usando la técnica de Depth Anything",
+ "duplicate": "Duplicar",
+ "colorMapDescription": "Genera un mapa de color desde la imagen",
+ "depthMidasDescription": "Crea un mapa de profundidad con Midas",
+ "balanced": "Equilibrado",
+ "beginEndStepPercent": "Inicio / Final Porcentaje de pasos",
+ "detectResolution": "Detectar resolución",
+ "beginEndStepPercentShort": "Inicio / Final %"
}
}
diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json
index bd82dd9a5b..3c0079de59 100644
--- a/invokeai/frontend/web/public/locales/it.json
+++ b/invokeai/frontend/web/public/locales/it.json
@@ -45,7 +45,7 @@
"outputs": "Risultati",
"data": "Dati",
"somethingWentWrong": "Qualcosa è andato storto",
- "copyError": "$t(gallery.copy) Errore",
+ "copyError": "Errore $t(gallery.copy)",
"input": "Ingresso",
"notInstalled": "Non $t(common.installed)",
"unknownError": "Errore sconosciuto",
@@ -85,7 +85,11 @@
"viewing": "Visualizza",
"viewingDesc": "Rivedi le immagini in un'ampia vista della galleria",
"editing": "Modifica",
- "editingDesc": "Modifica nell'area Livelli di controllo"
+ "editingDesc": "Modifica nell'area Livelli di controllo",
+ "enabled": "Abilitato",
+ "disabled": "Disabilitato",
+ "comparingDesc": "Confronta due immagini",
+ "comparing": "Confronta"
},
"gallery": {
"galleryImageSize": "Dimensione dell'immagine",
@@ -122,14 +126,30 @@
"bulkDownloadRequestedDesc": "La tua richiesta di download è in preparazione. L'operazione potrebbe richiedere alcuni istanti.",
"bulkDownloadRequestFailed": "Problema durante la preparazione del download",
"bulkDownloadFailed": "Scaricamento fallito",
- "alwaysShowImageSizeBadge": "Mostra sempre le dimensioni dell'immagine"
+ "alwaysShowImageSizeBadge": "Mostra sempre le dimensioni dell'immagine",
+ "openInViewer": "Apri nel visualizzatore",
+ "selectForCompare": "Seleziona per il confronto",
+ "selectAnImageToCompare": "Seleziona un'immagine da confrontare",
+ "slider": "Cursore",
+ "sideBySide": "Fianco a Fianco",
+ "compareImage": "Immagine di confronto",
+ "viewerImage": "Immagine visualizzata",
+ "hover": "Al passaggio del mouse",
+ "swapImages": "Scambia le immagini",
+ "compareOptions": "Opzioni di confronto",
+ "stretchToFit": "Scala per adattare",
+ "exitCompare": "Esci dal confronto",
+ "compareHelp1": "Tieni premuto Alt mentre fai clic su un'immagine della galleria o usi i tasti freccia per cambiare l'immagine di confronto.",
+ "compareHelp2": "Premi M per scorrere le modalità di confronto.",
+ "compareHelp3": "Premi C per scambiare le immagini confrontate.",
+ "compareHelp4": "Premi Z o Esc per uscire."
},
"hotkeys": {
"keyboardShortcuts": "Tasti di scelta rapida",
"appHotkeys": "Applicazione",
"generalHotkeys": "Generale",
"galleryHotkeys": "Galleria",
- "unifiedCanvasHotkeys": "Tela Unificata",
+ "unifiedCanvasHotkeys": "Tela",
"invoke": {
"title": "Invoke",
"desc": "Genera un'immagine"
@@ -147,8 +167,8 @@
"desc": "Apre e chiude il pannello delle opzioni"
},
"pinOptions": {
- "title": "Appunta le opzioni",
- "desc": "Blocca il pannello delle opzioni"
+ "title": "Fissa le opzioni",
+ "desc": "Fissa il pannello delle opzioni"
},
"toggleGallery": {
"title": "Attiva/disattiva galleria",
@@ -332,14 +352,14 @@
"title": "Annulla e cancella"
},
"resetOptionsAndGallery": {
- "title": "Ripristina Opzioni e Galleria",
- "desc": "Reimposta le opzioni e i pannelli della galleria"
+ "title": "Ripristina le opzioni e la galleria",
+ "desc": "Reimposta i pannelli delle opzioni e della galleria"
},
"searchHotkeys": "Cerca tasti di scelta rapida",
"noHotkeysFound": "Nessun tasto di scelta rapida trovato",
"toggleOptionsAndGallery": {
"desc": "Apre e chiude le opzioni e i pannelli della galleria",
- "title": "Attiva/disattiva le Opzioni e la Galleria"
+ "title": "Attiva/disattiva le opzioni e la galleria"
},
"clearSearch": "Cancella ricerca",
"remixImage": {
@@ -348,7 +368,7 @@
},
"toggleViewer": {
"title": "Attiva/disattiva il visualizzatore di immagini",
- "desc": "Passa dal Visualizzatore immagini all'area di lavoro per la scheda corrente."
+ "desc": "Passa dal visualizzatore immagini all'area di lavoro per la scheda corrente."
}
},
"modelManager": {
@@ -378,7 +398,7 @@
"convertToDiffusers": "Converti in Diffusori",
"convertToDiffusersHelpText2": "Questo processo sostituirà la voce in Gestione Modelli con la versione Diffusori dello stesso modello.",
"convertToDiffusersHelpText4": "Questo è un processo una tantum. Potrebbero essere necessari circa 30-60 secondi a seconda delle specifiche del tuo computer.",
- "convertToDiffusersHelpText5": "Assicurati di avere spazio su disco sufficiente. I modelli generalmente variano tra 2 GB e 7 GB di dimensioni.",
+ "convertToDiffusersHelpText5": "Assicurati di avere spazio su disco sufficiente. I modelli generalmente variano tra 2 GB e 7 GB in dimensione.",
"convertToDiffusersHelpText6": "Vuoi convertire questo modello?",
"modelConverted": "Modello convertito",
"alpha": "Alpha",
@@ -528,7 +548,7 @@
"layer": {
"initialImageNoImageSelected": "Nessuna immagine iniziale selezionata",
"t2iAdapterIncompatibleDimensions": "L'adattatore T2I richiede che la dimensione dell'immagine sia un multiplo di {{multiple}}",
- "controlAdapterNoModelSelected": "Nessun modello di Adattatore di Controllo selezionato",
+ "controlAdapterNoModelSelected": "Nessun modello di adattatore di controllo selezionato",
"controlAdapterIncompatibleBaseModel": "Il modello base dell'adattatore di controllo non è compatibile",
"controlAdapterNoImageSelected": "Nessuna immagine dell'adattatore di controllo selezionata",
"controlAdapterImageNotProcessed": "Immagine dell'adattatore di controllo non elaborata",
@@ -606,25 +626,25 @@
"canvasMerged": "Tela unita",
"sentToImageToImage": "Inviato a Generazione da immagine",
"sentToUnifiedCanvas": "Inviato alla Tela",
- "parametersNotSet": "Parametri non impostati",
+ "parametersNotSet": "Parametri non richiamati",
"metadataLoadFailed": "Impossibile caricare i metadati",
"serverError": "Errore del Server",
- "connected": "Connesso al Server",
+ "connected": "Connesso al server",
"canceled": "Elaborazione annullata",
"uploadFailedInvalidUploadDesc": "Deve essere una singola immagine PNG o JPEG",
- "parameterSet": "{{parameter}} impostato",
- "parameterNotSet": "{{parameter}} non impostato",
+ "parameterSet": "Parametro richiamato",
+ "parameterNotSet": "Parametro non richiamato",
"problemCopyingImage": "Impossibile copiare l'immagine",
- "baseModelChangedCleared_one": "Il modello base è stato modificato, cancellato o disabilitato {{count}} sotto-modello incompatibile",
- "baseModelChangedCleared_many": "Il modello base è stato modificato, cancellato o disabilitato {{count}} sotto-modelli incompatibili",
- "baseModelChangedCleared_other": "Il modello base è stato modificato, cancellato o disabilitato {{count}} sotto-modelli incompatibili",
+ "baseModelChangedCleared_one": "Cancellato o disabilitato {{count}} sottomodello incompatibile",
+ "baseModelChangedCleared_many": "Cancellati o disabilitati {{count}} sottomodelli incompatibili",
+ "baseModelChangedCleared_other": "Cancellati o disabilitati {{count}} sottomodelli incompatibili",
"imageSavingFailed": "Salvataggio dell'immagine non riuscito",
"canvasSentControlnetAssets": "Tela inviata a ControlNet & Risorse",
"problemCopyingCanvasDesc": "Impossibile copiare la tela",
"loadedWithWarnings": "Flusso di lavoro caricato con avvisi",
"canvasCopiedClipboard": "Tela copiata negli appunti",
"maskSavedAssets": "Maschera salvata nelle risorse",
- "problemDownloadingCanvas": "Problema durante il download della tela",
+ "problemDownloadingCanvas": "Problema durante lo scarico della tela",
"problemMergingCanvas": "Problema nell'unione delle tele",
"imageUploaded": "Immagine caricata",
"addedToBoard": "Aggiunto alla bacheca",
@@ -658,7 +678,17 @@
"problemDownloadingImage": "Impossibile scaricare l'immagine",
"prunedQueue": "Coda ripulita",
"modelImportCanceled": "Importazione del modello annullata",
- "parameters": "Parametri"
+ "parameters": "Parametri",
+ "parameterSetDesc": "{{parameter}} richiamato",
+ "parameterNotSetDesc": "Impossibile richiamare {{parameter}}",
+ "parameterNotSetDescWithMessage": "Impossibile richiamare {{parameter}}: {{message}}",
+ "parametersSet": "Parametri richiamati",
+ "errorCopied": "Errore copiato",
+ "outOfMemoryError": "Errore di memoria esaurita",
+ "baseModelChanged": "Modello base modificato",
+ "sessionRef": "Sessione: {{sessionId}}",
+ "somethingWentWrong": "Qualcosa è andato storto",
+ "outOfMemoryErrorDesc": "Le impostazioni della generazione attuale superano la capacità del sistema. Modifica le impostazioni e riprova."
},
"tooltip": {
"feature": {
@@ -674,7 +704,7 @@
"layer": "Livello",
"base": "Base",
"mask": "Maschera",
- "maskingOptions": "Opzioni di mascheramento",
+ "maskingOptions": "Opzioni maschera",
"enableMask": "Abilita maschera",
"preserveMaskedArea": "Mantieni area mascherata",
"clearMask": "Cancella maschera (Shift+C)",
@@ -745,7 +775,8 @@
"mode": "Modalità",
"resetUI": "$t(accessibility.reset) l'Interfaccia Utente",
"createIssue": "Segnala un problema",
- "about": "Informazioni"
+ "about": "Informazioni",
+ "submitSupportTicket": "Invia ticket di supporto"
},
"nodes": {
"zoomOutNodes": "Rimpicciolire",
@@ -790,7 +821,7 @@
"workflowNotes": "Note",
"versionUnknown": " Versione sconosciuta",
"unableToValidateWorkflow": "Impossibile convalidare il flusso di lavoro",
- "updateApp": "Aggiorna App",
+ "updateApp": "Aggiorna Applicazione",
"unableToLoadWorkflow": "Impossibile caricare il flusso di lavoro",
"updateNode": "Aggiorna nodo",
"version": "Versione",
@@ -882,11 +913,14 @@
"missingNode": "Nodo di invocazione mancante",
"missingInvocationTemplate": "Modello di invocazione mancante",
"missingFieldTemplate": "Modello di campo mancante",
- "singleFieldType": "{{name}} (Singola)"
+ "singleFieldType": "{{name}} (Singola)",
+ "imageAccessError": "Impossibile trovare l'immagine {{image_name}}, ripristino delle impostazioni predefinite",
+ "boardAccessError": "Impossibile trovare la bacheca {{board_id}}, ripristino ai valori predefiniti",
+ "modelAccessError": "Impossibile trovare il modello {{key}}, ripristino ai valori predefiniti"
},
"boards": {
"autoAddBoard": "Aggiungi automaticamente bacheca",
- "menuItemAutoAdd": "Aggiungi automaticamente a questa Bacheca",
+ "menuItemAutoAdd": "Aggiungi automaticamente a questa bacheca",
"cancel": "Annulla",
"addBoard": "Aggiungi Bacheca",
"bottomMessage": "L'eliminazione di questa bacheca e delle sue immagini ripristinerà tutte le funzionalità che le stanno attualmente utilizzando.",
@@ -898,7 +932,7 @@
"myBoard": "Bacheca",
"searchBoard": "Cerca bacheche ...",
"noMatching": "Nessuna bacheca corrispondente",
- "selectBoard": "Seleziona una Bacheca",
+ "selectBoard": "Seleziona una bacheca",
"uncategorized": "Non categorizzato",
"downloadBoard": "Scarica la bacheca",
"deleteBoardOnly": "solo la Bacheca",
@@ -919,7 +953,7 @@
"control": "Controllo",
"crop": "Ritaglia",
"depthMidas": "Profondità (Midas)",
- "detectResolution": "Rileva risoluzione",
+ "detectResolution": "Rileva la risoluzione",
"controlMode": "Modalità di controllo",
"cannyDescription": "Canny rilevamento bordi",
"depthZoe": "Profondità (Zoe)",
@@ -930,7 +964,7 @@
"showAdvanced": "Mostra opzioni Avanzate",
"bgth": "Soglia rimozione sfondo",
"importImageFromCanvas": "Importa immagine dalla Tela",
- "lineartDescription": "Converte l'immagine in lineart",
+ "lineartDescription": "Converte l'immagine in linea",
"importMaskFromCanvas": "Importa maschera dalla Tela",
"hideAdvanced": "Nascondi opzioni avanzate",
"resetControlImage": "Reimposta immagine di controllo",
@@ -946,7 +980,7 @@
"pidiDescription": "Elaborazione immagini PIDI",
"fill": "Riempie",
"colorMapDescription": "Genera una mappa dei colori dall'immagine",
- "lineartAnimeDescription": "Elaborazione lineart in stile anime",
+ "lineartAnimeDescription": "Elaborazione linea in stile anime",
"imageResolution": "Risoluzione dell'immagine",
"colorMap": "Colore",
"lowThreshold": "Soglia inferiore",
diff --git a/invokeai/frontend/web/public/locales/ru.json b/invokeai/frontend/web/public/locales/ru.json
index 03ff7eb706..2f7c711bf2 100644
--- a/invokeai/frontend/web/public/locales/ru.json
+++ b/invokeai/frontend/web/public/locales/ru.json
@@ -87,7 +87,11 @@
"viewing": "Просмотр",
"editing": "Редактирование",
"viewingDesc": "Просмотр изображений в режиме большой галереи",
- "editingDesc": "Редактировать на холсте слоёв управления"
+ "editingDesc": "Редактировать на холсте слоёв управления",
+ "enabled": "Включено",
+ "disabled": "Отключено",
+ "comparingDesc": "Сравнение двух изображений",
+ "comparing": "Сравнение"
},
"gallery": {
"galleryImageSize": "Размер изображений",
@@ -124,7 +128,23 @@
"bulkDownloadRequested": "Подготовка к скачиванию",
"bulkDownloadRequestedDesc": "Ваш запрос на скачивание готовится. Это может занять несколько минут.",
"bulkDownloadRequestFailed": "Возникла проблема при подготовке скачивания",
- "alwaysShowImageSizeBadge": "Всегда показывать значок размера изображения"
+ "alwaysShowImageSizeBadge": "Всегда показывать значок размера изображения",
+ "openInViewer": "Открыть в просмотрщике",
+ "selectForCompare": "Выбрать для сравнения",
+ "hover": "Наведение",
+ "swapImages": "Поменять местами",
+ "stretchToFit": "Растягивание до нужного размера",
+ "exitCompare": "Выйти из сравнения",
+ "compareHelp4": "Нажмите Z или Esc для выхода.",
+ "compareImage": "Сравнить изображение",
+ "viewerImage": "Изображение просмотрщика",
+ "selectAnImageToCompare": "Выберите изображение для сравнения",
+ "slider": "Слайдер",
+ "sideBySide": "Бок о бок",
+ "compareOptions": "Варианты сравнения",
+ "compareHelp1": "Удерживайте Alt при нажатии на изображение в галерее или при помощи клавиш со стрелками, чтобы изменить сравниваемое изображение.",
+ "compareHelp2": "Нажмите M, чтобы переключиться между режимами сравнения.",
+ "compareHelp3": "Нажмите C, чтобы поменять местами сравниваемые изображения."
},
"hotkeys": {
"keyboardShortcuts": "Горячие клавиши",
@@ -528,7 +548,20 @@
"missingFieldTemplate": "Отсутствует шаблон поля",
"addingImagesTo": "Добавление изображений в",
"invoke": "Создать",
- "imageNotProcessedForControlAdapter": "Изображение адаптера контроля №{{number}} не обрабатывается"
+ "imageNotProcessedForControlAdapter": "Изображение адаптера контроля №{{number}} не обрабатывается",
+ "layer": {
+ "controlAdapterImageNotProcessed": "Изображение адаптера контроля не обработано",
+ "ipAdapterNoModelSelected": "IP адаптер не выбран",
+ "controlAdapterNoModelSelected": "не выбрана модель адаптера контроля",
+ "controlAdapterIncompatibleBaseModel": "несовместимая базовая модель адаптера контроля",
+ "controlAdapterNoImageSelected": "не выбрано изображение контрольного адаптера",
+ "initialImageNoImageSelected": "начальное изображение не выбрано",
+ "rgNoRegion": "регион не выбран",
+ "rgNoPromptsOrIPAdapters": "нет текстовых запросов или IP-адаптеров",
+ "ipAdapterIncompatibleBaseModel": "несовместимая базовая модель IP-адаптера",
+ "t2iAdapterIncompatibleDimensions": "Адаптер T2I требует, чтобы размеры изображения были кратны {{multiple}}",
+ "ipAdapterNoImageSelected": "изображение IP-адаптера не выбрано"
+ }
},
"isAllowedToUpscale": {
"useX2Model": "Изображение слишком велико для увеличения с помощью модели x4. Используйте модель x2",
@@ -606,12 +639,12 @@
"connected": "Подключено к серверу",
"canceled": "Обработка отменена",
"uploadFailedInvalidUploadDesc": "Должно быть одно изображение в формате PNG или JPEG",
- "parameterNotSet": "Параметр {{parameter}} не задан",
- "parameterSet": "Параметр {{parameter}} задан",
+ "parameterNotSet": "Параметр не задан",
+ "parameterSet": "Параметр задан",
"problemCopyingImage": "Не удается скопировать изображение",
- "baseModelChangedCleared_one": "Базовая модель изменила, очистила или отключила {{count}} несовместимую подмодель",
- "baseModelChangedCleared_few": "Базовая модель изменила, очистила или отключила {{count}} несовместимые подмодели",
- "baseModelChangedCleared_many": "Базовая модель изменила, очистила или отключила {{count}} несовместимых подмоделей",
+ "baseModelChangedCleared_one": "Очищена или отключена {{count}} несовместимая подмодель",
+ "baseModelChangedCleared_few": "Очищены или отключены {{count}} несовместимые подмодели",
+ "baseModelChangedCleared_many": "Очищены или отключены {{count}} несовместимых подмоделей",
"imageSavingFailed": "Не удалось сохранить изображение",
"canvasSentControlnetAssets": "Холст отправлен в ControlNet и ресурсы",
"problemCopyingCanvasDesc": "Невозможно экспортировать базовый слой",
@@ -652,7 +685,17 @@
"resetInitialImage": "Сбросить начальное изображение",
"prunedQueue": "Урезанная очередь",
"modelImportCanceled": "Импорт модели отменен",
- "parameters": "Параметры"
+ "parameters": "Параметры",
+ "parameterSetDesc": "Задан {{parameter}}",
+ "parameterNotSetDesc": "Невозможно задать {{parameter}}",
+ "baseModelChanged": "Базовая модель сменена",
+ "parameterNotSetDescWithMessage": "Не удалось задать {{parameter}}: {{message}}",
+ "parametersSet": "Параметры заданы",
+ "errorCopied": "Ошибка скопирована",
+ "sessionRef": "Сессия: {{sessionId}}",
+ "outOfMemoryError": "Ошибка нехватки памяти",
+ "outOfMemoryErrorDesc": "Ваши текущие настройки генерации превышают возможности системы. Пожалуйста, измените настройки и повторите попытку.",
+ "somethingWentWrong": "Что-то пошло не так"
},
"tooltip": {
"feature": {
@@ -739,7 +782,8 @@
"loadMore": "Загрузить больше",
"resetUI": "$t(accessibility.reset) интерфейс",
"createIssue": "Сообщить о проблеме",
- "about": "Об этом"
+ "about": "Об этом",
+ "submitSupportTicket": "Отправить тикет в службу поддержки"
},
"nodes": {
"zoomInNodes": "Увеличьте масштаб",
@@ -832,7 +876,7 @@
"workflowName": "Название",
"collection": "Коллекция",
"unknownErrorValidatingWorkflow": "Неизвестная ошибка при проверке рабочего процесса",
- "collectionFieldType": "Коллекция {{name}}",
+ "collectionFieldType": "{{name}} (Коллекция)",
"workflowNotes": "Примечания",
"string": "Строка",
"unknownNodeType": "Неизвестный тип узла",
@@ -848,7 +892,7 @@
"targetNodeDoesNotExist": "Недопустимое ребро: целевой/входной узел {{node}} не существует",
"mismatchedVersion": "Недопустимый узел: узел {{node}} типа {{type}} имеет несоответствующую версию (попробовать обновить?)",
"unknownFieldType": "$t(nodes.unknownField) тип: {{type}}",
- "collectionOrScalarFieldType": "Коллекция | Скаляр {{name}}",
+ "collectionOrScalarFieldType": "{{name}} (Один или коллекция)",
"betaDesc": "Этот вызов находится в бета-версии. Пока он не станет стабильным, в нем могут происходить изменения при обновлении приложений. Мы планируем поддерживать этот вызов в течение длительного времени.",
"nodeVersion": "Версия узла",
"loadingNodes": "Загрузка узлов...",
@@ -870,7 +914,16 @@
"noFieldsViewMode": "В этом рабочем процессе нет выбранных полей для отображения. Просмотрите полный рабочий процесс для настройки значений.",
"graph": "График",
"showEdgeLabels": "Показать метки на ребрах",
- "showEdgeLabelsHelp": "Показать метки на ребрах, указывающие на соединенные узлы"
+ "showEdgeLabelsHelp": "Показать метки на ребрах, указывающие на соединенные узлы",
+ "cannotMixAndMatchCollectionItemTypes": "Невозможно смешивать и сопоставлять типы элементов коллекции",
+ "missingNode": "Отсутствует узел вызова",
+ "missingInvocationTemplate": "Отсутствует шаблон вызова",
+ "missingFieldTemplate": "Отсутствующий шаблон поля",
+ "singleFieldType": "{{name}} (Один)",
+ "noGraph": "Нет графика",
+ "imageAccessError": "Невозможно найти изображение {{image_name}}, сбрасываем на значение по умолчанию",
+ "boardAccessError": "Невозможно найти доску {{board_id}}, сбрасываем на значение по умолчанию",
+ "modelAccessError": "Невозможно найти модель {{key}}, сброс на модель по умолчанию"
},
"controlnet": {
"amult": "a_mult",
@@ -1441,7 +1494,16 @@
"clearQueueAlertDialog2": "Вы уверены, что хотите очистить очередь?",
"item": "Элемент",
"graphFailedToQueue": "Не удалось поставить график в очередь",
- "openQueue": "Открыть очередь"
+ "openQueue": "Открыть очередь",
+ "prompts_one": "Запрос",
+ "prompts_few": "Запроса",
+ "prompts_many": "Запросов",
+ "iterations_one": "Итерация",
+ "iterations_few": "Итерации",
+ "iterations_many": "Итераций",
+ "generations_one": "Генерация",
+ "generations_few": "Генерации",
+ "generations_many": "Генераций"
},
"sdxl": {
"refinerStart": "Запуск доработчика",
diff --git a/invokeai/frontend/web/public/locales/zh_Hant.json b/invokeai/frontend/web/public/locales/zh_Hant.json
index 454ae4c983..7748947478 100644
--- a/invokeai/frontend/web/public/locales/zh_Hant.json
+++ b/invokeai/frontend/web/public/locales/zh_Hant.json
@@ -1,6 +1,6 @@
{
"common": {
- "nodes": "節點",
+ "nodes": "工作流程",
"img2img": "圖片轉圖片",
"statusDisconnected": "已中斷連線",
"back": "返回",
@@ -11,17 +11,239 @@
"reportBugLabel": "回報錯誤",
"githubLabel": "GitHub",
"hotkeysLabel": "快捷鍵",
- "languagePickerLabel": "切換語言",
+ "languagePickerLabel": "語言",
"unifiedCanvas": "統一畫布",
"cancel": "取消",
- "txt2img": "文字轉圖片"
+ "txt2img": "文字轉圖片",
+ "controlNet": "ControlNet",
+ "advanced": "進階",
+ "folder": "資料夾",
+ "installed": "已安裝",
+ "accept": "接受",
+ "goTo": "前往",
+ "input": "輸入",
+ "random": "隨機",
+ "selected": "已選擇",
+ "communityLabel": "社群",
+ "loading": "載入中",
+ "delete": "刪除",
+ "copy": "複製",
+ "error": "錯誤",
+ "file": "檔案",
+ "format": "格式",
+ "imageFailedToLoad": "無法載入圖片"
},
"accessibility": {
"invokeProgressBar": "Invoke 進度條",
"uploadImage": "上傳圖片",
- "reset": "重設",
+ "reset": "重置",
"nextImage": "下一張圖片",
"previousImage": "上一張圖片",
- "menu": "選單"
+ "menu": "選單",
+ "loadMore": "載入更多",
+ "about": "關於",
+ "createIssue": "建立問題",
+ "resetUI": "$t(accessibility.reset) 介面",
+ "submitSupportTicket": "提交支援工單",
+ "mode": "模式"
+ },
+ "boards": {
+ "loading": "載入中…",
+ "movingImagesToBoard_other": "正在移動 {{count}} 張圖片至板上:",
+ "move": "移動",
+ "uncategorized": "未分類",
+ "cancel": "取消"
+ },
+ "metadata": {
+ "workflow": "工作流程",
+ "steps": "步數",
+ "model": "模型",
+ "seed": "種子",
+ "vae": "VAE",
+ "seamless": "無縫",
+ "metadata": "元數據",
+ "width": "寬度",
+ "height": "高度"
+ },
+ "accordions": {
+ "control": {
+ "title": "控制"
+ },
+ "compositing": {
+ "title": "合成"
+ },
+ "advanced": {
+ "title": "進階",
+ "options": "$t(accordions.advanced.title) 選項"
+ }
+ },
+ "hotkeys": {
+ "nodesHotkeys": "節點",
+ "cancel": {
+ "title": "取消"
+ },
+ "generalHotkeys": "一般",
+ "keyboardShortcuts": "快捷鍵",
+ "appHotkeys": "應用程式"
+ },
+ "modelManager": {
+ "advanced": "進階",
+ "allModels": "全部模型",
+ "variant": "變體",
+ "config": "配置",
+ "model": "模型",
+ "selected": "已選擇",
+ "huggingFace": "HuggingFace",
+ "install": "安裝",
+ "metadata": "元數據",
+ "delete": "刪除",
+ "description": "描述",
+ "cancel": "取消",
+ "convert": "轉換",
+ "manual": "手動",
+ "none": "無",
+ "name": "名稱",
+ "load": "載入",
+ "height": "高度",
+ "width": "寬度",
+ "search": "搜尋",
+ "vae": "VAE",
+ "settings": "設定"
+ },
+ "controlnet": {
+ "mlsd": "M-LSD",
+ "canny": "Canny",
+ "duplicate": "重複",
+ "none": "無",
+ "pidi": "PIDI",
+ "h": "H",
+ "balanced": "平衡",
+ "crop": "裁切",
+ "processor": "處理器",
+ "control": "控制",
+ "f": "F",
+ "lineart": "線條藝術",
+ "w": "W",
+ "hed": "HED",
+ "delete": "刪除"
+ },
+ "queue": {
+ "queue": "佇列",
+ "canceled": "已取消",
+ "failed": "已失敗",
+ "completed": "已完成",
+ "cancel": "取消",
+ "session": "工作階段",
+ "batch": "批量",
+ "item": "項目",
+ "completedIn": "完成於",
+ "notReady": "無法排隊"
+ },
+ "parameters": {
+ "cancel": {
+ "cancel": "取消"
+ },
+ "height": "高度",
+ "type": "類型",
+ "symmetry": "對稱性",
+ "images": "圖片",
+ "width": "寬度",
+ "coherenceMode": "模式",
+ "seed": "種子",
+ "general": "一般",
+ "strength": "強度",
+ "steps": "步數",
+ "info": "資訊"
+ },
+ "settings": {
+ "beta": "Beta",
+ "developer": "開發者",
+ "general": "一般",
+ "models": "模型"
+ },
+ "popovers": {
+ "paramModel": {
+ "heading": "模型"
+ },
+ "compositingCoherenceMode": {
+ "heading": "模式"
+ },
+ "paramSteps": {
+ "heading": "步數"
+ },
+ "controlNetProcessor": {
+ "heading": "處理器"
+ },
+ "paramVAE": {
+ "heading": "VAE"
+ },
+ "paramHeight": {
+ "heading": "高度"
+ },
+ "paramSeed": {
+ "heading": "種子"
+ },
+ "paramWidth": {
+ "heading": "寬度"
+ },
+ "refinerSteps": {
+ "heading": "步數"
+ }
+ },
+ "unifiedCanvas": {
+ "undo": "復原",
+ "mask": "遮罩",
+ "eraser": "橡皮擦",
+ "antialiasing": "抗鋸齒",
+ "redo": "重做",
+ "layer": "圖層",
+ "accept": "接受",
+ "brush": "刷子",
+ "move": "移動",
+ "brushSize": "大小"
+ },
+ "nodes": {
+ "workflowName": "名稱",
+ "notes": "註釋",
+ "workflowVersion": "版本",
+ "workflowNotes": "註釋",
+ "executionStateError": "錯誤",
+ "unableToUpdateNodes_other": "無法更新 {{count}} 個節點",
+ "integer": "整數",
+ "workflow": "工作流程",
+ "enum": "枚舉",
+ "edit": "編輯",
+ "string": "字串",
+ "workflowTags": "標籤",
+ "node": "節點",
+ "boolean": "布林值",
+ "workflowAuthor": "作者",
+ "version": "版本",
+ "executionStateCompleted": "已完成",
+ "edge": "邊緣",
+ "versionUnknown": " 版本未知"
+ },
+ "sdxl": {
+ "steps": "步數",
+ "loading": "載入中…",
+ "refiner": "精煉器"
+ },
+ "gallery": {
+ "copy": "複製",
+ "download": "下載",
+ "loading": "載入中"
+ },
+ "ui": {
+ "tabs": {
+ "models": "模型",
+ "queueTab": "$t(ui.tabs.queue) $t(common.tab)",
+ "queue": "佇列"
+ }
+ },
+ "models": {
+ "loading": "載入中"
+ },
+ "workflows": {
+ "name": "名稱"
}
}
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts
index ba04947a2d..a1eb917ebb 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlAdapterPreprocessor.ts
@@ -22,7 +22,13 @@ import type { BatchConfig } from 'services/api/types';
import { socketInvocationComplete } from 'services/events/actions';
import { assert } from 'tsafe';
-const matcher = isAnyOf(caLayerImageChanged, caLayerProcessorConfigChanged, caLayerModelChanged, caLayerRecalled);
+const matcher = isAnyOf(
+ caLayerImageChanged,
+ caLayerProcessedImageChanged,
+ caLayerProcessorConfigChanged,
+ caLayerModelChanged,
+ caLayerRecalled
+);
const DEBOUNCE_MS = 300;
const log = logger('session');
@@ -73,9 +79,10 @@ export const addControlAdapterPreprocessor = (startAppListening: AppStartListeni
const originalConfig = originalLayer?.controlAdapter.processorConfig;
const image = layer.controlAdapter.image;
+ const processedImage = layer.controlAdapter.processedImage;
const config = layer.controlAdapter.processorConfig;
- if (isEqual(config, originalConfig) && isEqual(image, originalImage)) {
+ if (isEqual(config, originalConfig) && isEqual(image, originalImage) && processedImage) {
// Neither config nor image have changed, we can bail
return;
}
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx
index 8ff1f9711f..a44ae32c13 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerControlAdapterWrapper.tsx
@@ -4,6 +4,7 @@ import {
caLayerControlModeChanged,
caLayerImageChanged,
caLayerModelChanged,
+ caLayerProcessedImageChanged,
caLayerProcessorConfigChanged,
caOrIPALayerBeginEndStepPctChanged,
caOrIPALayerWeightChanged,
@@ -84,6 +85,14 @@ export const CALayerControlAdapterWrapper = memo(({ layerId }: Props) => {
[dispatch, layerId]
);
+ const onErrorLoadingImage = useCallback(() => {
+ dispatch(caLayerImageChanged({ layerId, imageDTO: null }));
+ }, [dispatch, layerId]);
+
+ const onErrorLoadingProcessedImage = useCallback(() => {
+ dispatch(caLayerProcessedImageChanged({ layerId, imageDTO: null }));
+ }, [dispatch, layerId]);
+
const droppableData = useMemo(
() => ({
actionType: 'SET_CA_LAYER_IMAGE',
@@ -114,6 +123,8 @@ export const CALayerControlAdapterWrapper = memo(({ layerId }: Props) => {
onChangeImage={onChangeImage}
droppableData={droppableData}
postUploadAction={postUploadAction}
+ onErrorLoadingImage={onErrorLoadingImage}
+ onErrorLoadingProcessedImage={onErrorLoadingProcessedImage}
/>
);
});
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapter.tsx
index c28c40ecc1..2a7b21352e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapter.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapter.tsx
@@ -28,6 +28,8 @@ type Props = {
onChangeProcessorConfig: (processorConfig: ProcessorConfig | null) => void;
onChangeModel: (modelConfig: ControlNetModelConfig | T2IAdapterModelConfig) => void;
onChangeImage: (imageDTO: ImageDTO | null) => void;
+ onErrorLoadingImage: () => void;
+ onErrorLoadingProcessedImage: () => void;
droppableData: TypesafeDroppableData;
postUploadAction: PostUploadAction;
};
@@ -41,6 +43,8 @@ export const ControlAdapter = memo(
onChangeProcessorConfig,
onChangeModel,
onChangeImage,
+ onErrorLoadingImage,
+ onErrorLoadingProcessedImage,
droppableData,
postUploadAction,
}: Props) => {
@@ -91,6 +95,8 @@ export const ControlAdapter = memo(
onChangeImage={onChangeImage}
droppableData={droppableData}
postUploadAction={postUploadAction}
+ onErrorLoadingImage={onErrorLoadingImage}
+ onErrorLoadingProcessedImage={onErrorLoadingProcessedImage}
/>
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
index 4d93eb12ec..c61cdda4a3 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterImagePreview.tsx
@@ -27,10 +27,19 @@ type Props = {
onChangeImage: (imageDTO: ImageDTO | null) => void;
droppableData: TypesafeDroppableData;
postUploadAction: PostUploadAction;
+ onErrorLoadingImage: () => void;
+ onErrorLoadingProcessedImage: () => void;
};
export const ControlAdapterImagePreview = memo(
- ({ controlAdapter, onChangeImage, droppableData, postUploadAction }: Props) => {
+ ({
+ controlAdapter,
+ onChangeImage,
+ droppableData,
+ postUploadAction,
+ onErrorLoadingImage,
+ onErrorLoadingProcessedImage,
+ }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const autoAddBoardId = useAppSelector((s) => s.gallery.autoAddBoardId);
@@ -128,10 +137,23 @@ export const ControlAdapterImagePreview = memo(
controlAdapter.processorConfig !== null;
useEffect(() => {
- if (isConnected && (isErrorControlImage || isErrorProcessedControlImage)) {
- handleResetControlImage();
+ if (!isConnected) {
+ return;
}
- }, [handleResetControlImage, isConnected, isErrorControlImage, isErrorProcessedControlImage]);
+ if (isErrorControlImage) {
+ onErrorLoadingImage();
+ }
+ if (isErrorProcessedControlImage) {
+ onErrorLoadingProcessedImage();
+ }
+ }, [
+ handleResetControlImage,
+ isConnected,
+ isErrorControlImage,
+ isErrorProcessedControlImage,
+ onErrorLoadingImage,
+ onErrorLoadingProcessedImage,
+ ]);
return (
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
index 08956e73dc..9226abf207 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
@@ -4,20 +4,35 @@ import { createSelector } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { useMouseEvents } from 'features/controlLayers/hooks/mouseEventHooks';
+import { BRUSH_SPACING_PCT, MAX_BRUSH_SPACING_PX, MIN_BRUSH_SPACING_PX } from 'features/controlLayers/konva/constants';
+import { setStageEventHandlers } from 'features/controlLayers/konva/events';
+import { debouncedRenderers, renderers as normalRenderers } from 'features/controlLayers/konva/renderers';
import {
+ $brushSize,
+ $brushSpacingPx,
+ $isDrawing,
+ $lastAddedPoint,
$lastCursorPos,
$lastMouseDownPos,
+ $selectedLayerId,
+ $selectedLayerType,
+ $shouldInvertBrushSizeScrollDirection,
$tool,
+ brushSizeChanged,
isRegionalGuidanceLayer,
layerBboxChanged,
layerTranslated,
+ rgLayerLineAdded,
+ rgLayerPointsAdded,
+ rgLayerRectAdded,
selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice';
-import { debouncedRenderers, renderers as normalRenderers } from 'features/controlLayers/util/renderers';
+import type { AddLineArg, AddPointToLineArg, AddRectArg } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { IRect } from 'konva/lib/types';
+import { clamp } from 'lodash-es';
import { memo, useCallback, useLayoutEffect, useMemo, useState } from 'react';
+import { getImageDTO } from 'services/api/endpoints/images';
import { useDevicePixelRatio } from 'use-device-pixel-ratio';
import { v4 as uuidv4 } from 'uuid';
@@ -47,7 +62,6 @@ const useStageRenderer = (
const dispatch = useAppDispatch();
const state = useAppSelector((s) => s.controlLayers.present);
const tool = useStore($tool);
- const mouseEventHandlers = useMouseEvents();
const lastCursorPos = useStore($lastCursorPos);
const lastMouseDownPos = useStore($lastMouseDownPos);
const selectedLayerIdColor = useAppSelector(selectSelectedLayerColor);
@@ -56,6 +70,26 @@ const useStageRenderer = (
const layerCount = useMemo(() => state.layers.length, [state.layers]);
const renderers = useMemo(() => (asPreview ? debouncedRenderers : normalRenderers), [asPreview]);
const dpr = useDevicePixelRatio({ round: false });
+ const shouldInvertBrushSizeScrollDirection = useAppSelector((s) => s.canvas.shouldInvertBrushSizeScrollDirection);
+ const brushSpacingPx = useMemo(
+ () => clamp(state.brushSize / BRUSH_SPACING_PCT, MIN_BRUSH_SPACING_PX, MAX_BRUSH_SPACING_PX),
+ [state.brushSize]
+ );
+
+ useLayoutEffect(() => {
+ $brushSize.set(state.brushSize);
+ $brushSpacingPx.set(brushSpacingPx);
+ $selectedLayerId.set(state.selectedLayerId);
+ $selectedLayerType.set(selectedLayerType);
+ $shouldInvertBrushSizeScrollDirection.set(shouldInvertBrushSizeScrollDirection);
+ }, [
+ brushSpacingPx,
+ selectedLayerIdColor,
+ selectedLayerType,
+ shouldInvertBrushSizeScrollDirection,
+ state.brushSize,
+ state.selectedLayerId,
+ ]);
const onLayerPosChanged = useCallback(
(layerId: string, x: number, y: number) => {
@@ -71,6 +105,31 @@ const useStageRenderer = (
[dispatch]
);
+ const onRGLayerLineAdded = useCallback(
+ (arg: AddLineArg) => {
+ dispatch(rgLayerLineAdded(arg));
+ },
+ [dispatch]
+ );
+ const onRGLayerPointAddedToLine = useCallback(
+ (arg: AddPointToLineArg) => {
+ dispatch(rgLayerPointsAdded(arg));
+ },
+ [dispatch]
+ );
+ const onRGLayerRectAdded = useCallback(
+ (arg: AddRectArg) => {
+ dispatch(rgLayerRectAdded(arg));
+ },
+ [dispatch]
+ );
+ const onBrushSizeChanged = useCallback(
+ (size: number) => {
+ dispatch(brushSizeChanged(size));
+ },
+ [dispatch]
+ );
+
useLayoutEffect(() => {
log.trace('Initializing stage');
if (!container) {
@@ -88,21 +147,29 @@ const useStageRenderer = (
if (asPreview) {
return;
}
- stage.on('mousedown', mouseEventHandlers.onMouseDown);
- stage.on('mouseup', mouseEventHandlers.onMouseUp);
- stage.on('mousemove', mouseEventHandlers.onMouseMove);
- stage.on('mouseleave', mouseEventHandlers.onMouseLeave);
- stage.on('wheel', mouseEventHandlers.onMouseWheel);
+ const cleanup = setStageEventHandlers({
+ stage,
+ $tool,
+ $isDrawing,
+ $lastMouseDownPos,
+ $lastCursorPos,
+ $lastAddedPoint,
+ $brushSize,
+ $brushSpacingPx,
+ $selectedLayerId,
+ $selectedLayerType,
+ $shouldInvertBrushSizeScrollDirection,
+ onRGLayerLineAdded,
+ onRGLayerPointAddedToLine,
+ onRGLayerRectAdded,
+ onBrushSizeChanged,
+ });
return () => {
- log.trace('Cleaning up stage listeners');
- stage.off('mousedown', mouseEventHandlers.onMouseDown);
- stage.off('mouseup', mouseEventHandlers.onMouseUp);
- stage.off('mousemove', mouseEventHandlers.onMouseMove);
- stage.off('mouseleave', mouseEventHandlers.onMouseLeave);
- stage.off('wheel', mouseEventHandlers.onMouseWheel);
+ log.trace('Removing stage listeners');
+ cleanup();
};
- }, [stage, asPreview, mouseEventHandlers]);
+ }, [asPreview, onBrushSizeChanged, onRGLayerLineAdded, onRGLayerPointAddedToLine, onRGLayerRectAdded, stage]);
useLayoutEffect(() => {
log.trace('Updating stage dimensions');
@@ -160,7 +227,7 @@ const useStageRenderer = (
useLayoutEffect(() => {
log.trace('Rendering layers');
- renderers.renderLayers(stage, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged);
+ renderers.renderLayers(stage, state.layers, state.globalMaskLayerOpacity, tool, getImageDTO, onLayerPosChanged);
}, [
stage,
state.layers,
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
deleted file mode 100644
index 514e8c35ff..0000000000
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import { $ctrl, $meta } from '@invoke-ai/ui-library';
-import { useStore } from '@nanostores/react';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { calculateNewBrushSize } from 'features/canvas/hooks/useCanvasZoom';
-import {
- $isDrawing,
- $lastCursorPos,
- $lastMouseDownPos,
- $tool,
- brushSizeChanged,
- rgLayerLineAdded,
- rgLayerPointsAdded,
- rgLayerRectAdded,
-} from 'features/controlLayers/store/controlLayersSlice';
-import type Konva from 'konva';
-import type { KonvaEventObject } from 'konva/lib/Node';
-import type { Vector2d } from 'konva/lib/types';
-import { clamp } from 'lodash-es';
-import { useCallback, useMemo, useRef } from 'react';
-
-const getIsFocused = (stage: Konva.Stage) => {
- return stage.container().contains(document.activeElement);
-};
-const getIsMouseDown = (e: KonvaEventObject) => e.evt.buttons === 1;
-
-const SNAP_PX = 10;
-
-export const snapPosToStage = (pos: Vector2d, stage: Konva.Stage) => {
- const snappedPos = { ...pos };
- // Get the normalized threshold for snapping to the edge of the stage
- const thresholdX = SNAP_PX / stage.scaleX();
- const thresholdY = SNAP_PX / stage.scaleY();
- const stageWidth = stage.width() / stage.scaleX();
- const stageHeight = stage.height() / stage.scaleY();
- // Snap to the edge of the stage if within threshold
- if (pos.x - thresholdX < 0) {
- snappedPos.x = 0;
- } else if (pos.x + thresholdX > stageWidth) {
- snappedPos.x = Math.floor(stageWidth);
- }
- if (pos.y - thresholdY < 0) {
- snappedPos.y = 0;
- } else if (pos.y + thresholdY > stageHeight) {
- snappedPos.y = Math.floor(stageHeight);
- }
- return snappedPos;
-};
-
-export const getScaledFlooredCursorPosition = (stage: Konva.Stage) => {
- const pointerPosition = stage.getPointerPosition();
- const stageTransform = stage.getAbsoluteTransform().copy();
- if (!pointerPosition) {
- return;
- }
- const scaledCursorPosition = stageTransform.invert().point(pointerPosition);
- return {
- x: Math.floor(scaledCursorPosition.x),
- y: Math.floor(scaledCursorPosition.y),
- };
-};
-
-const syncCursorPos = (stage: Konva.Stage): Vector2d | null => {
- const pos = getScaledFlooredCursorPosition(stage);
- if (!pos) {
- return null;
- }
- $lastCursorPos.set(pos);
- return pos;
-};
-
-const BRUSH_SPACING_PCT = 10;
-const MIN_BRUSH_SPACING_PX = 5;
-const MAX_BRUSH_SPACING_PX = 15;
-
-export const useMouseEvents = () => {
- const dispatch = useAppDispatch();
- const selectedLayerId = useAppSelector((s) => s.controlLayers.present.selectedLayerId);
- const selectedLayerType = useAppSelector((s) => {
- const selectedLayer = s.controlLayers.present.layers.find((l) => l.id === s.controlLayers.present.selectedLayerId);
- if (!selectedLayer) {
- return null;
- }
- return selectedLayer.type;
- });
- const tool = useStore($tool);
- const lastCursorPosRef = useRef<[number, number] | null>(null);
- const shouldInvertBrushSizeScrollDirection = useAppSelector((s) => s.canvas.shouldInvertBrushSizeScrollDirection);
- const brushSize = useAppSelector((s) => s.controlLayers.present.brushSize);
- const brushSpacingPx = useMemo(
- () => clamp(brushSize / BRUSH_SPACING_PCT, MIN_BRUSH_SPACING_PX, MAX_BRUSH_SPACING_PX),
- [brushSize]
- );
-
- const onMouseDown = useCallback(
- (e: KonvaEventObject) => {
- const stage = e.target.getStage();
- if (!stage) {
- return;
- }
- const pos = syncCursorPos(stage);
- if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
- return;
- }
- if (tool === 'brush' || tool === 'eraser') {
- dispatch(
- rgLayerLineAdded({
- layerId: selectedLayerId,
- points: [pos.x, pos.y, pos.x, pos.y],
- tool,
- })
- );
- $isDrawing.set(true);
- $lastMouseDownPos.set(pos);
- } else if (tool === 'rect') {
- $lastMouseDownPos.set(snapPosToStage(pos, stage));
- }
- },
- [dispatch, selectedLayerId, selectedLayerType, tool]
- );
-
- const onMouseUp = useCallback(
- (e: KonvaEventObject) => {
- const stage = e.target.getStage();
- if (!stage) {
- return;
- }
- const pos = $lastCursorPos.get();
- if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
- return;
- }
- const lastPos = $lastMouseDownPos.get();
- const tool = $tool.get();
- if (lastPos && selectedLayerId && tool === 'rect') {
- const snappedPos = snapPosToStage(pos, stage);
- dispatch(
- rgLayerRectAdded({
- layerId: selectedLayerId,
- rect: {
- x: Math.min(snappedPos.x, lastPos.x),
- y: Math.min(snappedPos.y, lastPos.y),
- width: Math.abs(snappedPos.x - lastPos.x),
- height: Math.abs(snappedPos.y - lastPos.y),
- },
- })
- );
- }
- $isDrawing.set(false);
- $lastMouseDownPos.set(null);
- },
- [dispatch, selectedLayerId, selectedLayerType]
- );
-
- const onMouseMove = useCallback(
- (e: KonvaEventObject) => {
- const stage = e.target.getStage();
- if (!stage) {
- return;
- }
- const pos = syncCursorPos(stage);
- if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
- return;
- }
- if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) {
- if ($isDrawing.get()) {
- // Continue the last line
- if (lastCursorPosRef.current) {
- // Dispatching redux events impacts perf substantially - using brush spacing keeps dispatches to a reasonable number
- if (Math.hypot(lastCursorPosRef.current[0] - pos.x, lastCursorPosRef.current[1] - pos.y) < brushSpacingPx) {
- return;
- }
- }
- lastCursorPosRef.current = [pos.x, pos.y];
- dispatch(rgLayerPointsAdded({ layerId: selectedLayerId, point: lastCursorPosRef.current }));
- } else {
- // Start a new line
- dispatch(rgLayerLineAdded({ layerId: selectedLayerId, points: [pos.x, pos.y, pos.x, pos.y], tool }));
- }
- $isDrawing.set(true);
- }
- },
- [brushSpacingPx, dispatch, selectedLayerId, selectedLayerType, tool]
- );
-
- const onMouseLeave = useCallback(
- (e: KonvaEventObject) => {
- const stage = e.target.getStage();
- if (!stage) {
- return;
- }
- const pos = syncCursorPos(stage);
- $isDrawing.set(false);
- $lastCursorPos.set(null);
- $lastMouseDownPos.set(null);
- if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
- return;
- }
- if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) {
- dispatch(rgLayerPointsAdded({ layerId: selectedLayerId, point: [pos.x, pos.y] }));
- }
- },
- [selectedLayerId, selectedLayerType, tool, dispatch]
- );
-
- const onMouseWheel = useCallback(
- (e: KonvaEventObject) => {
- e.evt.preventDefault();
-
- if (selectedLayerType !== 'regional_guidance_layer' || (tool !== 'brush' && tool !== 'eraser')) {
- return;
- }
- // checking for ctrl key is pressed or not,
- // so that brush size can be controlled using ctrl + scroll up/down
-
- // Invert the delta if the property is set to true
- let delta = e.evt.deltaY;
- if (shouldInvertBrushSizeScrollDirection) {
- delta = -delta;
- }
-
- if ($ctrl.get() || $meta.get()) {
- dispatch(brushSizeChanged(calculateNewBrushSize(brushSize, delta)));
- }
- },
- [selectedLayerType, tool, shouldInvertBrushSizeScrollDirection, dispatch, brushSize]
- );
-
- const handlers = useMemo(
- () => ({ onMouseDown, onMouseUp, onMouseMove, onMouseLeave, onMouseWheel }),
- [onMouseDown, onMouseUp, onMouseMove, onMouseLeave, onMouseWheel]
- );
-
- return handlers;
-};
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts b/invokeai/frontend/web/src/features/controlLayers/konva/bbox.ts
similarity index 94%
rename from invokeai/frontend/web/src/features/controlLayers/util/bbox.ts
rename to invokeai/frontend/web/src/features/controlLayers/konva/bbox.ts
index 3b037863c9..505998cb39 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/bbox.ts
@@ -1,11 +1,10 @@
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { imageDataToDataURL } from 'features/canvas/util/blobToDataURL';
-import { RG_LAYER_OBJECT_GROUP_NAME } from 'features/controlLayers/store/controlLayersSlice';
import Konva from 'konva';
import type { IRect } from 'konva/lib/types';
import { assert } from 'tsafe';
-const GET_CLIENT_RECT_CONFIG = { skipTransform: true };
+import { RG_LAYER_OBJECT_GROUP_NAME } from './naming';
type Extents = {
minX: number;
@@ -14,10 +13,13 @@ type Extents = {
maxY: number;
};
+const GET_CLIENT_RECT_CONFIG = { skipTransform: true };
+
+//#region getImageDataBbox
/**
* Get the bounding box of an image.
* @param imageData The ImageData object to get the bounding box of.
- * @returns The minimum and maximum x and y values of the image's bounding box.
+ * @returns The minimum and maximum x and y values of the image's bounding box, or null if the image has no pixels.
*/
const getImageDataBbox = (imageData: ImageData): Extents | null => {
const { data, width, height } = imageData;
@@ -51,7 +53,9 @@ const getImageDataBbox = (imageData: ImageData): Extents | null => {
return isEmpty ? null : { minX, minY, maxX, maxY };
};
+//#endregion
+//#region getIsolatedRGLayerClone
/**
* Clones a regional guidance konva layer onto an offscreen stage/canvas. This allows the pixel data for a given layer
* to be captured, manipulated or analyzed without interference from other layers.
@@ -88,7 +92,9 @@ const getIsolatedRGLayerClone = (layer: Konva.Layer): { stageClone: Konva.Stage;
return { stageClone, layerClone };
};
+//#endregion
+//#region getLayerBboxPixels
/**
* 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.
@@ -137,7 +143,9 @@ export const getLayerBboxPixels = (layer: Konva.Layer, preview: boolean = false)
return correctedLayerBbox;
};
+//#endregion
+//#region getLayerBboxFast
/**
* 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.
@@ -153,3 +161,4 @@ export const getLayerBboxFast = (layer: Konva.Layer): IRect => {
height: Math.floor(bbox.height),
};
};
+//#endregion
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/constants.ts b/invokeai/frontend/web/src/features/controlLayers/konva/constants.ts
new file mode 100644
index 0000000000..27bfc8b731
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/constants.ts
@@ -0,0 +1,36 @@
+/**
+ * A transparency checker pattern image.
+ * This is invokeai/frontend/web/public/assets/images/transparent_bg.png as a dataURL
+ */
+export const TRANSPARENCY_CHECKER_PATTERN =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64paAAAEsmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjIwIgogICBleGlmOlBpeGVsWURpbWVuc2lvbj0iMjAiCiAgIGV4aWY6Q29sb3JTcGFjZT0iMSIKICAgdGlmZjpJbWFnZVdpZHRoPSIyMCIKICAgdGlmZjpJbWFnZUxlbmd0aD0iMjAiCiAgIHRpZmY6UmVzb2x1dGlvblVuaXQ9IjIiCiAgIHRpZmY6WFJlc29sdXRpb249IjMwMC8xIgogICB0aWZmOllSZXNvbHV0aW9uPSIzMDAvMSIKICAgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIKICAgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIgogICB4bXA6TW9kaWZ5RGF0ZT0iMjAyNC0wNC0yM1QwODoyMDo0NysxMDowMCIKICAgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyNC0wNC0yM1QwODoyMDo0NysxMDowMCI+CiAgIDx4bXBNTTpIaXN0b3J5PgogICAgPHJkZjpTZXE+CiAgICAgPHJkZjpsaQogICAgICBzdEV2dDphY3Rpb249InByb2R1Y2VkIgogICAgICBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZmZpbml0eSBQaG90byAxLjEwLjgiCiAgICAgIHN0RXZ0OndoZW49IjIwMjQtMDQtMjNUMDg6MjA6NDcrMTA6MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/Pn9pdVgAAAGBaUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWR3yuDURjHP5uJmKghFy6WxpVpqMWNMgm1tGbKr5vt3S+1d3t73y3JrXKrKHHj1wV/AbfKtVJESq53TdywXs9rakv2nJ7zfM73nOfpnOeAPZJRVMPhAzWb18NTAffC4pK7oYiDTjpw4YgqhjYeCgWpaR8P2Kx457Vq1T73rzXHE4YCtkbhMUXT88LTwsG1vGbxrnC7ko7Ghc+F+3W5oPC9pcfKXLQ4VeYvi/VIeALsbcLuVBXHqlhJ66qwvByPmikov/exXuJMZOfnJPaId2MQZooAbmaYZAI/g4zK7MfLEAOyoka+7yd/lpzkKjJrrKOzSoo0efpFLUj1hMSk6AkZGdat/v/tq5EcHipXdwag/sU033qhYQdK26b5eWyapROoe4arbCU/dwQj76JvVzTPIbRuwsV1RYvtweUWdD1pUT36I9WJ25NJeD2DlkVw3ULTcrlnv/ucPkJkQ77qBvYPoE/Ot658AxagZ8FoS/a7AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAL0lEQVQ4jWM8ffo0A25gYmKCR5YJjxxBMKp5ZGhm/P//Px7pM2fO0MrmUc0jQzMAB2EIhZC3pUYAAAAASUVORK5CYII=';
+
+/**
+ * The color of a bounding box stroke when its object is selected.
+ */
+export const BBOX_SELECTED_STROKE = 'rgba(78, 190, 255, 1)';
+
+/**
+ * The inner border color for the brush preview.
+ */
+export const BRUSH_BORDER_INNER_COLOR = 'rgba(0,0,0,1)';
+
+/**
+ * The outer border color for the brush preview.
+ */
+export const BRUSH_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)';
+
+/**
+ * The target spacing of individual points of brush strokes, as a percentage of the brush size.
+ */
+export const BRUSH_SPACING_PCT = 10;
+
+/**
+ * The minimum brush spacing in pixels.
+ */
+export const MIN_BRUSH_SPACING_PX = 5;
+
+/**
+ * The maximum brush spacing in pixels.
+ */
+export const MAX_BRUSH_SPACING_PX = 15;
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts
new file mode 100644
index 0000000000..8b130e940f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts
@@ -0,0 +1,201 @@
+import { calculateNewBrushSize } from 'features/canvas/hooks/useCanvasZoom';
+import {
+ getIsFocused,
+ getIsMouseDown,
+ getScaledFlooredCursorPosition,
+ snapPosToStage,
+} from 'features/controlLayers/konva/util';
+import type { AddLineArg, AddPointToLineArg, AddRectArg, Layer, Tool } from 'features/controlLayers/store/types';
+import type Konva from 'konva';
+import type { Vector2d } from 'konva/lib/types';
+import type { WritableAtom } from 'nanostores';
+
+import { TOOL_PREVIEW_LAYER_ID } from './naming';
+
+type SetStageEventHandlersArg = {
+ stage: Konva.Stage;
+ $tool: WritableAtom;
+ $isDrawing: WritableAtom;
+ $lastMouseDownPos: WritableAtom;
+ $lastCursorPos: WritableAtom;
+ $lastAddedPoint: WritableAtom;
+ $brushSize: WritableAtom;
+ $brushSpacingPx: WritableAtom;
+ $selectedLayerId: WritableAtom;
+ $selectedLayerType: WritableAtom;
+ $shouldInvertBrushSizeScrollDirection: WritableAtom;
+ onRGLayerLineAdded: (arg: AddLineArg) => void;
+ onRGLayerPointAddedToLine: (arg: AddPointToLineArg) => void;
+ onRGLayerRectAdded: (arg: AddRectArg) => void;
+ onBrushSizeChanged: (size: number) => void;
+};
+
+const syncCursorPos = (stage: Konva.Stage, $lastCursorPos: WritableAtom) => {
+ const pos = getScaledFlooredCursorPosition(stage);
+ if (!pos) {
+ return null;
+ }
+ $lastCursorPos.set(pos);
+ return pos;
+};
+
+export const setStageEventHandlers = ({
+ stage,
+ $tool,
+ $isDrawing,
+ $lastMouseDownPos,
+ $lastCursorPos,
+ $lastAddedPoint,
+ $brushSize,
+ $brushSpacingPx,
+ $selectedLayerId,
+ $selectedLayerType,
+ $shouldInvertBrushSizeScrollDirection,
+ onRGLayerLineAdded,
+ onRGLayerPointAddedToLine,
+ onRGLayerRectAdded,
+ onBrushSizeChanged,
+}: SetStageEventHandlersArg): (() => void) => {
+ stage.on('mouseenter', (e) => {
+ const stage = e.target.getStage();
+ if (!stage) {
+ return;
+ }
+ const tool = $tool.get();
+ stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(tool === 'brush' || tool === 'eraser');
+ });
+
+ stage.on('mousedown', (e) => {
+ const stage = e.target.getStage();
+ if (!stage) {
+ return;
+ }
+ const tool = $tool.get();
+ const pos = syncCursorPos(stage, $lastCursorPos);
+ const selectedLayerId = $selectedLayerId.get();
+ const selectedLayerType = $selectedLayerType.get();
+ if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
+ return;
+ }
+ if (tool === 'brush' || tool === 'eraser') {
+ onRGLayerLineAdded({
+ layerId: selectedLayerId,
+ points: [pos.x, pos.y, pos.x, pos.y],
+ tool,
+ });
+ $isDrawing.set(true);
+ $lastMouseDownPos.set(pos);
+ } else if (tool === 'rect') {
+ $lastMouseDownPos.set(snapPosToStage(pos, stage));
+ }
+ });
+
+ stage.on('mouseup', (e) => {
+ const stage = e.target.getStage();
+ if (!stage) {
+ return;
+ }
+ const pos = $lastCursorPos.get();
+ const selectedLayerId = $selectedLayerId.get();
+ const selectedLayerType = $selectedLayerType.get();
+
+ if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
+ return;
+ }
+ const lastPos = $lastMouseDownPos.get();
+ const tool = $tool.get();
+ if (lastPos && selectedLayerId && tool === 'rect') {
+ const snappedPos = snapPosToStage(pos, stage);
+ onRGLayerRectAdded({
+ layerId: selectedLayerId,
+ rect: {
+ x: Math.min(snappedPos.x, lastPos.x),
+ y: Math.min(snappedPos.y, lastPos.y),
+ width: Math.abs(snappedPos.x - lastPos.x),
+ height: Math.abs(snappedPos.y - lastPos.y),
+ },
+ });
+ }
+ $isDrawing.set(false);
+ $lastMouseDownPos.set(null);
+ });
+
+ stage.on('mousemove', (e) => {
+ const stage = e.target.getStage();
+ if (!stage) {
+ return;
+ }
+ const tool = $tool.get();
+ const pos = syncCursorPos(stage, $lastCursorPos);
+ const selectedLayerId = $selectedLayerId.get();
+ const selectedLayerType = $selectedLayerType.get();
+
+ stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(tool === 'brush' || tool === 'eraser');
+
+ if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
+ return;
+ }
+ if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) {
+ if ($isDrawing.get()) {
+ // Continue the last line
+ const lastAddedPoint = $lastAddedPoint.get();
+ if (lastAddedPoint) {
+ // Dispatching redux events impacts perf substantially - using brush spacing keeps dispatches to a reasonable number
+ if (Math.hypot(lastAddedPoint.x - pos.x, lastAddedPoint.y - pos.y) < $brushSpacingPx.get()) {
+ return;
+ }
+ }
+ $lastAddedPoint.set({ x: pos.x, y: pos.y });
+ onRGLayerPointAddedToLine({ layerId: selectedLayerId, point: [pos.x, pos.y] });
+ } else {
+ // Start a new line
+ onRGLayerLineAdded({ layerId: selectedLayerId, points: [pos.x, pos.y, pos.x, pos.y], tool });
+ }
+ $isDrawing.set(true);
+ }
+ });
+
+ stage.on('mouseleave', (e) => {
+ const stage = e.target.getStage();
+ if (!stage) {
+ return;
+ }
+ const pos = syncCursorPos(stage, $lastCursorPos);
+ $isDrawing.set(false);
+ $lastCursorPos.set(null);
+ $lastMouseDownPos.set(null);
+ const selectedLayerId = $selectedLayerId.get();
+ const selectedLayerType = $selectedLayerType.get();
+ const tool = $tool.get();
+
+ stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false);
+
+ if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
+ return;
+ }
+ if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) {
+ onRGLayerPointAddedToLine({ layerId: selectedLayerId, point: [pos.x, pos.y] });
+ }
+ });
+
+ stage.on('wheel', (e) => {
+ e.evt.preventDefault();
+ const selectedLayerType = $selectedLayerType.get();
+ const tool = $tool.get();
+ if (selectedLayerType !== 'regional_guidance_layer' || (tool !== 'brush' && tool !== 'eraser')) {
+ return;
+ }
+
+ // Invert the delta if the property is set to true
+ let delta = e.evt.deltaY;
+ if ($shouldInvertBrushSizeScrollDirection.get()) {
+ delta = -delta;
+ }
+
+ if (e.evt.ctrlKey || e.evt.metaKey) {
+ onBrushSizeChanged(calculateNewBrushSize($brushSize.get(), delta));
+ }
+ });
+
+ return () => stage.off('mousedown mouseup mousemove mouseenter mouseleave wheel');
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts b/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts
new file mode 100644
index 0000000000..2fcdf4ce60
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts
@@ -0,0 +1,21 @@
+/**
+ * Konva filters
+ * https://konvajs.org/docs/filters/Custom_Filter.html
+ */
+
+/**
+ * Calculates the lightness (HSL) of a given pixel and sets the alpha channel to that value.
+ * This is useful for edge maps and other masks, to make the black areas transparent.
+ * @param imageData The image data to apply the filter to
+ */
+export const LightnessToAlphaFilter = (imageData: ImageData): void => {
+ const len = imageData.data.length / 4;
+ for (let i = 0; i < len; i++) {
+ const r = imageData.data[i * 4 + 0] as number;
+ const g = imageData.data[i * 4 + 1] as number;
+ const b = imageData.data[i * 4 + 2] as number;
+ const cMin = Math.min(r, g, b);
+ const cMax = Math.max(r, g, b);
+ imageData.data[i * 4 + 3] = (cMin + cMax) / 2;
+ }
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/naming.ts b/invokeai/frontend/web/src/features/controlLayers/konva/naming.ts
new file mode 100644
index 0000000000..354719c836
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/naming.ts
@@ -0,0 +1,38 @@
+/**
+ * This file contains IDs, names, and ID getters for konva layers and objects.
+ */
+
+// IDs for singleton Konva layers and objects
+export const TOOL_PREVIEW_LAYER_ID = 'tool_preview_layer';
+export const TOOL_PREVIEW_BRUSH_GROUP_ID = 'tool_preview_layer.brush_group';
+export const TOOL_PREVIEW_BRUSH_FILL_ID = 'tool_preview_layer.brush_fill';
+export const TOOL_PREVIEW_BRUSH_BORDER_INNER_ID = 'tool_preview_layer.brush_border_inner';
+export const TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID = 'tool_preview_layer.brush_border_outer';
+export const TOOL_PREVIEW_RECT_ID = 'tool_preview_layer.rect';
+export const BACKGROUND_LAYER_ID = 'background_layer';
+export const BACKGROUND_RECT_ID = 'background_layer.rect';
+export const NO_LAYERS_MESSAGE_LAYER_ID = 'no_layers_message';
+
+// Names for Konva layers and objects (comparable to CSS classes)
+export const CA_LAYER_NAME = 'control_adapter_layer';
+export const CA_LAYER_IMAGE_NAME = 'control_adapter_layer.image';
+export const RG_LAYER_NAME = 'regional_guidance_layer';
+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_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_IMAGE_NAME = 'initial_image_layer.image';
+export const LAYER_BBOX_NAME = 'layer.bbox';
+export const COMPOSITING_RECT_NAME = 'compositing-rect';
+
+// Getters for non-singleton layer and object IDs
+export const getRGLayerId = (layerId: string) => `${RG_LAYER_NAME}_${layerId}`;
+export const getRGLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`;
+export const getRGLayerRectId = (layerId: string, lineId: string) => `${layerId}.rect_${lineId}`;
+export const getRGLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`;
+export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`;
+export const getCALayerId = (layerId: string) => `control_adapter_layer_${layerId}`;
+export const getCALayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`;
+export const getIILayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`;
+export const getIPALayerId = (layerId: string) => `ip_adapter_layer_${layerId}`;
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers.ts
similarity index 63%
rename from invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
rename to invokeai/frontend/web/src/features/controlLayers/konva/renderers.ts
index 79933e6b00..f521c77ed4 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers.ts
@@ -1,8 +1,7 @@
-import { getStore } from 'app/store/nanostores/store';
import { rgbaColorToString, rgbColorToString } from 'features/canvas/util/colorToString';
-import { getScaledFlooredCursorPosition, snapPosToStage } from 'features/controlLayers/hooks/mouseEventHooks';
+import { getLayerBboxFast, getLayerBboxPixels } from 'features/controlLayers/konva/bbox';
+import { LightnessToAlphaFilter } from 'features/controlLayers/konva/filters';
import {
- $tool,
BACKGROUND_LAYER_ID,
BACKGROUND_RECT_ID,
CA_LAYER_IMAGE_NAME,
@@ -14,10 +13,6 @@ import {
getRGLayerObjectGroupId,
INITIAL_IMAGE_LAYER_IMAGE_NAME,
INITIAL_IMAGE_LAYER_NAME,
- isControlAdapterLayer,
- isInitialImageLayer,
- isRegionalGuidanceLayer,
- isRenderableLayer,
LAYER_BBOX_NAME,
NO_LAYERS_MESSAGE_LAYER_ID,
RG_LAYER_LINE_NAME,
@@ -30,6 +25,13 @@ import {
TOOL_PREVIEW_BRUSH_GROUP_ID,
TOOL_PREVIEW_LAYER_ID,
TOOL_PREVIEW_RECT_ID,
+} from 'features/controlLayers/konva/naming';
+import { getScaledFlooredCursorPosition, snapPosToStage } from 'features/controlLayers/konva/util';
+import {
+ isControlAdapterLayer,
+ isInitialImageLayer,
+ isRegionalGuidanceLayer,
+ isRenderableLayer,
} from 'features/controlLayers/store/controlLayersSlice';
import type {
ControlAdapterLayer,
@@ -40,61 +42,46 @@ import type {
VectorMaskLine,
VectorMaskRect,
} from 'features/controlLayers/store/types';
-import { getLayerBboxFast, getLayerBboxPixels } from 'features/controlLayers/util/bbox';
import { t } from 'i18next';
import Konva from 'konva';
import type { IRect, Vector2d } from 'konva/lib/types';
import { debounce } from 'lodash-es';
import type { RgbColor } from 'react-colorful';
-import { imagesApi } from 'services/api/endpoints/images';
+import type { ImageDTO } from 'services/api/types';
import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid';
-const BBOX_SELECTED_STROKE = 'rgba(78, 190, 255, 1)';
-const BRUSH_BORDER_INNER_COLOR = 'rgba(0,0,0,1)';
-const BRUSH_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)';
-// This is invokeai/frontend/web/public/assets/images/transparent_bg.png as a dataURL
-export const STAGE_BG_DATAURL =
- 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64paAAAEsmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjIwIgogICBleGlmOlBpeGVsWURpbWVuc2lvbj0iMjAiCiAgIGV4aWY6Q29sb3JTcGFjZT0iMSIKICAgdGlmZjpJbWFnZVdpZHRoPSIyMCIKICAgdGlmZjpJbWFnZUxlbmd0aD0iMjAiCiAgIHRpZmY6UmVzb2x1dGlvblVuaXQ9IjIiCiAgIHRpZmY6WFJlc29sdXRpb249IjMwMC8xIgogICB0aWZmOllSZXNvbHV0aW9uPSIzMDAvMSIKICAgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIKICAgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIgogICB4bXA6TW9kaWZ5RGF0ZT0iMjAyNC0wNC0yM1QwODoyMDo0NysxMDowMCIKICAgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyNC0wNC0yM1QwODoyMDo0NysxMDowMCI+CiAgIDx4bXBNTTpIaXN0b3J5PgogICAgPHJkZjpTZXE+CiAgICAgPHJkZjpsaQogICAgICBzdEV2dDphY3Rpb249InByb2R1Y2VkIgogICAgICBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZmZpbml0eSBQaG90byAxLjEwLjgiCiAgICAgIHN0RXZ0OndoZW49IjIwMjQtMDQtMjNUMDg6MjA6NDcrMTA6MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/Pn9pdVgAAAGBaUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWR3yuDURjHP5uJmKghFy6WxpVpqMWNMgm1tGbKr5vt3S+1d3t73y3JrXKrKHHj1wV/AbfKtVJESq53TdywXs9rakv2nJ7zfM73nOfpnOeAPZJRVMPhAzWb18NTAffC4pK7oYiDTjpw4YgqhjYeCgWpaR8P2Kx457Vq1T73rzXHE4YCtkbhMUXT88LTwsG1vGbxrnC7ko7Ghc+F+3W5oPC9pcfKXLQ4VeYvi/VIeALsbcLuVBXHqlhJ66qwvByPmikov/exXuJMZOfnJPaId2MQZooAbmaYZAI/g4zK7MfLEAOyoka+7yd/lpzkKjJrrKOzSoo0efpFLUj1hMSk6AkZGdat/v/tq5EcHipXdwag/sU033qhYQdK26b5eWyapROoe4arbCU/dwQj76JvVzTPIbRuwsV1RYvtweUWdD1pUT36I9WJ25NJeD2DlkVw3ULTcrlnv/ucPkJkQ77qBvYPoE/Ot658AxagZ8FoS/a7AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAL0lEQVQ4jWM8ffo0A25gYmKCR5YJjxxBMKp5ZGhm/P//Px7pM2fO0MrmUc0jQzMAB2EIhZC3pUYAAAAASUVORK5CYII=';
+import {
+ BBOX_SELECTED_STROKE,
+ BRUSH_BORDER_INNER_COLOR,
+ BRUSH_BORDER_OUTER_COLOR,
+ TRANSPARENCY_CHECKER_PATTERN,
+} from './constants';
-const mapId = (object: { id: string }) => object.id;
+const mapId = (object: { id: string }): string => object.id;
-const selectRenderableLayers = (n: Konva.Node) =>
+/**
+ * Konva selection callback to select all renderable layers. This includes RG, CA and II layers.
+ */
+const selectRenderableLayers = (n: Konva.Node): boolean =>
n.name() === RG_LAYER_NAME || n.name() === CA_LAYER_NAME || n.name() === INITIAL_IMAGE_LAYER_NAME;
-const selectVectorMaskObjects = (node: Konva.Node) => {
+/**
+ * Konva selection callback to select RG mask objects. This includes lines and rects.
+ */
+const selectVectorMaskObjects = (node: Konva.Node): boolean => {
return node.name() === RG_LAYER_LINE_NAME || node.name() === RG_LAYER_RECT_NAME;
};
/**
- * Creates the brush preview layer.
- * @param stage The konva stage to render on.
- * @returns The brush preview layer.
+ * Creates the singleton tool preview layer and all its objects.
+ * @param stage The konva stage
*/
-const createToolPreviewLayer = (stage: Konva.Stage) => {
+const createToolPreviewLayer = (stage: Konva.Stage): Konva.Layer => {
// Initialize the brush preview layer & add to the stage
const toolPreviewLayer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, visible: false, listening: false });
stage.add(toolPreviewLayer);
- // Add handlers to show/hide the brush preview layer
- stage.on('mousemove', (e) => {
- const tool = $tool.get();
- e.target
- .getStage()
- ?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)
- ?.visible(tool === 'brush' || tool === 'eraser');
- });
- stage.on('mouseleave', (e) => {
- e.target.getStage()?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false);
- });
- stage.on('mouseenter', (e) => {
- const tool = $tool.get();
- e.target
- .getStage()
- ?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)
- ?.visible(tool === 'brush' || tool === 'eraser');
- });
-
// Create the brush preview group & circles
const brushPreviewGroup = new Konva.Group({ id: TOOL_PREVIEW_BRUSH_GROUP_ID });
const brushPreviewFill = new Konva.Circle({
@@ -121,7 +108,7 @@ const createToolPreviewLayer = (stage: Konva.Stage) => {
brushPreviewGroup.add(brushPreviewBorderOuter);
toolPreviewLayer.add(brushPreviewGroup);
- // Create the rect preview
+ // Create the rect preview - this is a rectangle drawn from the last mouse down position to the current cursor position
const rectPreview = new Konva.Rect({ id: TOOL_PREVIEW_RECT_ID, listening: false, stroke: 'white', strokeWidth: 1 });
toolPreviewLayer.add(rectPreview);
@@ -130,12 +117,14 @@ const createToolPreviewLayer = (stage: Konva.Stage) => {
/**
* Renders the brush preview for the selected tool.
- * @param stage The konva stage to render on.
- * @param tool The selected tool.
- * @param color The selected layer's color.
- * @param cursorPos The cursor position.
- * @param lastMouseDownPos The position of the last mouse down event - used for the rect tool.
- * @param brushSize The brush size.
+ * @param stage The konva stage
+ * @param tool The selected tool
+ * @param color The selected layer's color
+ * @param selectedLayerType The selected layer's type
+ * @param globalMaskLayerOpacity The global mask layer opacity
+ * @param cursorPos The cursor position
+ * @param lastMouseDownPos The position of the last mouse down event - used for the rect tool
+ * @param brushSize The brush size
*/
const renderToolPreview = (
stage: Konva.Stage,
@@ -146,7 +135,7 @@ const renderToolPreview = (
cursorPos: Vector2d | null,
lastMouseDownPos: Vector2d | null,
brushSize: number
-) => {
+): void => {
const layerCount = stage.find(selectRenderableLayers).length;
// Update the stage's pointer style
if (layerCount === 0) {
@@ -162,7 +151,7 @@ const renderToolPreview = (
// Move rect gets a crosshair
stage.container().style.cursor = 'crosshair';
} else {
- // Else we use the brush preview
+ // Else we hide the native cursor and use the konva-rendered brush preview
stage.container().style.cursor = 'none';
}
@@ -227,28 +216,29 @@ const renderToolPreview = (
};
/**
- * Creates a vector mask layer.
- * @param stage The konva stage to attach the layer to.
- * @param reduxLayer The redux layer to create the konva layer from.
- * @param onLayerPosChanged Callback for when the layer's position changes.
+ * Creates a regional guidance layer.
+ * @param stage The konva stage
+ * @param layerState The regional guidance layer state
+ * @param onLayerPosChanged Callback for when the layer's position changes
*/
-const createRegionalGuidanceLayer = (
+const createRGLayer = (
stage: Konva.Stage,
- reduxLayer: RegionalGuidanceLayer,
+ layerState: RegionalGuidanceLayer,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
-) => {
+): Konva.Layer => {
// This layer hasn't been added to the konva state yet
const konvaLayer = new Konva.Layer({
- id: reduxLayer.id,
+ id: layerState.id,
name: RG_LAYER_NAME,
draggable: true,
dragDistance: 0,
});
- // Create a `dragmove` listener for this layer
+ // When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing
+ // the position - we do not need to call this on the `dragmove` event.
if (onLayerPosChanged) {
konvaLayer.on('dragend', function (e) {
- onLayerPosChanged(reduxLayer.id, Math.floor(e.target.x()), Math.floor(e.target.y()));
+ onLayerPosChanged(layerState.id, Math.floor(e.target.x()), Math.floor(e.target.y()));
});
}
@@ -258,7 +248,7 @@ const createRegionalGuidanceLayer = (
if (!cursorPos) {
return this.getAbsolutePosition();
}
- // Prevent the user from dragging the layer out of the stage bounds.
+ // Prevent the user from dragging the layer out of the stage bounds by constaining the cursor position to the stage bounds
if (
cursorPos.x < 0 ||
cursorPos.x > stage.width() / stage.scaleX() ||
@@ -272,7 +262,7 @@ const createRegionalGuidanceLayer = (
// The object group holds all of the layer's objects (e.g. lines and rects)
const konvaObjectGroup = new Konva.Group({
- id: getRGLayerObjectGroupId(reduxLayer.id, uuidv4()),
+ id: getRGLayerObjectGroupId(layerState.id, uuidv4()),
name: RG_LAYER_OBJECT_GROUP_NAME,
listening: false,
});
@@ -284,47 +274,51 @@ const createRegionalGuidanceLayer = (
};
/**
- * Creates a konva line from a redux vector mask line.
- * @param reduxObject The redux object to create the konva line from.
- * @param konvaGroup The konva group to add the line to.
+ * Creates a konva line from a vector mask line.
+ * @param vectorMaskLine The vector mask line state
+ * @param layerObjectGroup The konva layer's object group to add the line to
*/
-const createVectorMaskLine = (reduxObject: VectorMaskLine, konvaGroup: Konva.Group): Konva.Line => {
- const vectorMaskLine = new Konva.Line({
- id: reduxObject.id,
- key: reduxObject.id,
+const createVectorMaskLine = (vectorMaskLine: VectorMaskLine, layerObjectGroup: Konva.Group): Konva.Line => {
+ const konvaLine = new Konva.Line({
+ id: vectorMaskLine.id,
+ key: vectorMaskLine.id,
name: RG_LAYER_LINE_NAME,
- strokeWidth: reduxObject.strokeWidth,
+ strokeWidth: vectorMaskLine.strokeWidth,
tension: 0,
lineCap: 'round',
lineJoin: 'round',
shadowForStrokeEnabled: false,
- globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out',
+ globalCompositeOperation: vectorMaskLine.tool === 'brush' ? 'source-over' : 'destination-out',
listening: false,
});
- konvaGroup.add(vectorMaskLine);
- return vectorMaskLine;
+ layerObjectGroup.add(konvaLine);
+ return konvaLine;
};
/**
- * Creates a konva rect from a redux vector mask rect.
- * @param reduxObject The redux object to create the konva rect from.
- * @param konvaGroup The konva group to add the rect to.
+ * Creates a konva rect from a vector mask rect.
+ * @param vectorMaskRect The vector mask rect state
+ * @param layerObjectGroup The konva layer's object group to add the line to
*/
-const createVectorMaskRect = (reduxObject: VectorMaskRect, konvaGroup: Konva.Group): Konva.Rect => {
- const vectorMaskRect = new Konva.Rect({
- id: reduxObject.id,
- key: reduxObject.id,
+const createVectorMaskRect = (vectorMaskRect: VectorMaskRect, layerObjectGroup: Konva.Group): Konva.Rect => {
+ const konvaRect = new Konva.Rect({
+ id: vectorMaskRect.id,
+ key: vectorMaskRect.id,
name: RG_LAYER_RECT_NAME,
- x: reduxObject.x,
- y: reduxObject.y,
- width: reduxObject.width,
- height: reduxObject.height,
+ x: vectorMaskRect.x,
+ y: vectorMaskRect.y,
+ width: vectorMaskRect.width,
+ height: vectorMaskRect.height,
listening: false,
});
- konvaGroup.add(vectorMaskRect);
- return vectorMaskRect;
+ layerObjectGroup.add(konvaRect);
+ return konvaRect;
};
+/**
+ * Creates the "compositing rect" for a layer.
+ * @param konvaLayer The konva layer
+ */
const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => {
const compositingRect = new Konva.Rect({ name: COMPOSITING_RECT_NAME, listening: false });
konvaLayer.add(compositingRect);
@@ -332,41 +326,41 @@ const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => {
};
/**
- * Renders a vector mask layer.
- * @param stage The konva stage to render on.
- * @param reduxLayer The redux vector mask layer to render.
- * @param reduxLayerIndex The index of the layer in the redux store.
- * @param globalMaskLayerOpacity The opacity of the global mask layer.
- * @param tool The current tool.
+ * Renders a regional guidance layer.
+ * @param stage The konva stage
+ * @param layerState The regional guidance layer state
+ * @param globalMaskLayerOpacity The global mask layer opacity
+ * @param tool The current tool
+ * @param onLayerPosChanged Callback for when the layer's position changes
*/
-const renderRegionalGuidanceLayer = (
+const renderRGLayer = (
stage: Konva.Stage,
- reduxLayer: RegionalGuidanceLayer,
+ layerState: RegionalGuidanceLayer,
globalMaskLayerOpacity: number,
tool: Tool,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
): void => {
const konvaLayer =
- stage.findOne(`#${reduxLayer.id}`) ??
- createRegionalGuidanceLayer(stage, reduxLayer, onLayerPosChanged);
+ stage.findOne(`#${layerState.id}`) ?? createRGLayer(stage, layerState, onLayerPosChanged);
// Update the layer's position and listening state
konvaLayer.setAttrs({
listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events
- x: Math.floor(reduxLayer.x),
- y: Math.floor(reduxLayer.y),
+ x: Math.floor(layerState.x),
+ y: Math.floor(layerState.y),
});
// Convert the color to a string, stripping the alpha - the object group will handle opacity.
- const rgbColor = rgbColorToString(reduxLayer.previewColor);
+ const rgbColor = rgbColorToString(layerState.previewColor);
const konvaObjectGroup = konvaLayer.findOne(`.${RG_LAYER_OBJECT_GROUP_NAME}`);
- assert(konvaObjectGroup, `Object group not found for layer ${reduxLayer.id}`);
+ assert(konvaObjectGroup, `Object group not found for layer ${layerState.id}`);
// We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required.
let groupNeedsCache = false;
- const objectIds = reduxLayer.maskObjects.map(mapId);
+ const objectIds = layerState.maskObjects.map(mapId);
+ // Destroy any objects that are no longer in the redux state
for (const objectNode of konvaObjectGroup.find(selectVectorMaskObjects)) {
if (!objectIds.includes(objectNode.id())) {
objectNode.destroy();
@@ -374,15 +368,15 @@ const renderRegionalGuidanceLayer = (
}
}
- for (const reduxObject of reduxLayer.maskObjects) {
- if (reduxObject.type === 'vector_mask_line') {
+ for (const maskObject of layerState.maskObjects) {
+ if (maskObject.type === 'vector_mask_line') {
const vectorMaskLine =
- stage.findOne(`#${reduxObject.id}`) ?? createVectorMaskLine(reduxObject, konvaObjectGroup);
+ stage.findOne(`#${maskObject.id}`) ?? createVectorMaskLine(maskObject, konvaObjectGroup);
// Only update the points if they have changed. The point values are never mutated, they are only added to the
// array, so checking the length is sufficient to determine if we need to re-cache.
- if (vectorMaskLine.points().length !== reduxObject.points.length) {
- vectorMaskLine.points(reduxObject.points);
+ if (vectorMaskLine.points().length !== maskObject.points.length) {
+ vectorMaskLine.points(maskObject.points);
groupNeedsCache = true;
}
// Only update the color if it has changed.
@@ -390,9 +384,9 @@ const renderRegionalGuidanceLayer = (
vectorMaskLine.stroke(rgbColor);
groupNeedsCache = true;
}
- } else if (reduxObject.type === 'vector_mask_rect') {
+ } else if (maskObject.type === 'vector_mask_rect') {
const konvaObject =
- stage.findOne(`#${reduxObject.id}`) ?? createVectorMaskRect(reduxObject, konvaObjectGroup);
+ stage.findOne(`#${maskObject.id}`) ?? createVectorMaskRect(maskObject, konvaObjectGroup);
// Only update the color if it has changed.
if (konvaObject.fill() !== rgbColor) {
@@ -403,8 +397,8 @@ const renderRegionalGuidanceLayer = (
}
// Only update layer visibility if it has changed.
- if (konvaLayer.visible() !== reduxLayer.isEnabled) {
- konvaLayer.visible(reduxLayer.isEnabled);
+ if (konvaLayer.visible() !== layerState.isEnabled) {
+ konvaLayer.visible(layerState.isEnabled);
groupNeedsCache = true;
}
@@ -428,7 +422,7 @@ const renderRegionalGuidanceLayer = (
* Instead, with the special handling, the effect is as if you drew all the shapes at 100% opacity, flattened them to
* a single raster image, and _then_ applied the 50% opacity.
*/
- if (reduxLayer.isSelected && tool !== 'move') {
+ if (layerState.isSelected && tool !== 'move') {
// We must clear the cache first so Konva will re-draw the group with the new compositing rect
if (konvaObjectGroup.isCached()) {
konvaObjectGroup.clearCache();
@@ -438,7 +432,7 @@ const renderRegionalGuidanceLayer = (
compositingRect.setAttrs({
// The rect should be the size of the layer - use the fast method if we don't have a pixel-perfect bbox already
- ...(!reduxLayer.bboxNeedsUpdate && reduxLayer.bbox ? reduxLayer.bbox : getLayerBboxFast(konvaLayer)),
+ ...(!layerState.bboxNeedsUpdate && layerState.bbox ? layerState.bbox : getLayerBboxFast(konvaLayer)),
fill: rgbColor,
opacity: globalMaskLayerOpacity,
// Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes)
@@ -459,9 +453,14 @@ const renderRegionalGuidanceLayer = (
}
};
-const createInitialImageLayer = (stage: Konva.Stage, reduxLayer: InitialImageLayer): Konva.Layer => {
+/**
+ * Creates an initial image konva layer.
+ * @param stage The konva stage
+ * @param layerState The initial image layer state
+ */
+const createIILayer = (stage: Konva.Stage, layerState: InitialImageLayer): Konva.Layer => {
const konvaLayer = new Konva.Layer({
- id: reduxLayer.id,
+ id: layerState.id,
name: INITIAL_IMAGE_LAYER_NAME,
imageSmoothingEnabled: true,
listening: false,
@@ -470,20 +469,27 @@ const createInitialImageLayer = (stage: Konva.Stage, reduxLayer: InitialImageLay
return konvaLayer;
};
-const createInitialImageLayerImage = (konvaLayer: Konva.Layer, image: HTMLImageElement): Konva.Image => {
+/**
+ * Creates the konva image for an initial image layer.
+ * @param konvaLayer The konva layer
+ * @param imageEl The image element
+ */
+const createIILayerImage = (konvaLayer: Konva.Layer, imageEl: HTMLImageElement): Konva.Image => {
const konvaImage = new Konva.Image({
name: INITIAL_IMAGE_LAYER_IMAGE_NAME,
- image,
+ image: imageEl,
});
konvaLayer.add(konvaImage);
return konvaImage;
};
-const updateInitialImageLayerImageAttrs = (
- stage: Konva.Stage,
- konvaImage: Konva.Image,
- reduxLayer: InitialImageLayer
-) => {
+/**
+ * Updates an initial image layer's attributes (width, height, opacity, visibility).
+ * @param stage The konva stage
+ * @param konvaImage The konva image
+ * @param layerState The initial image layer state
+ */
+const updateIILayerImageAttrs = (stage: Konva.Stage, konvaImage: Konva.Image, layerState: InitialImageLayer): void => {
// 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.
@@ -492,46 +498,55 @@ const updateInitialImageLayerImageAttrs = (
if (
konvaImage.width() !== newWidth ||
konvaImage.height() !== newHeight ||
- konvaImage.visible() !== reduxLayer.isEnabled
+ konvaImage.visible() !== layerState.isEnabled
) {
konvaImage.setAttrs({
- opacity: reduxLayer.opacity,
+ opacity: layerState.opacity,
scaleX: 1,
scaleY: 1,
width: stage.width() / stage.scaleX(),
height: stage.height() / stage.scaleY(),
- visible: reduxLayer.isEnabled,
+ visible: layerState.isEnabled,
});
}
- if (konvaImage.opacity() !== reduxLayer.opacity) {
- konvaImage.opacity(reduxLayer.opacity);
+ if (konvaImage.opacity() !== layerState.opacity) {
+ konvaImage.opacity(layerState.opacity);
}
};
-const updateInitialImageLayerImageSource = async (
+/**
+ * Update an initial image layer's image source when the image changes.
+ * @param stage The konva stage
+ * @param konvaLayer The konva layer
+ * @param layerState The initial image layer state
+ * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
+ */
+const updateIILayerImageSource = async (
stage: Konva.Stage,
konvaLayer: Konva.Layer,
- reduxLayer: InitialImageLayer
-) => {
- if (reduxLayer.image) {
- const imageName = reduxLayer.image.name;
- const req = getStore().dispatch(imagesApi.endpoints.getImageDTO.initiate(imageName));
- const imageDTO = await req.unwrap();
- req.unsubscribe();
+ layerState: InitialImageLayer,
+ getImageDTO: (imageName: string) => Promise
+): Promise => {
+ if (layerState.image) {
+ const imageName = layerState.image.name;
+ const imageDTO = await getImageDTO(imageName);
+ if (!imageDTO) {
+ return;
+ }
const imageEl = new Image();
- const imageId = getIILayerImageId(reduxLayer.id, imageName);
+ const imageId = getIILayerImageId(layerState.id, imageName);
imageEl.onload = () => {
// Find the existing image or create a new one - must find using the name, bc the id may have just changed
const konvaImage =
konvaLayer.findOne(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`) ??
- createInitialImageLayerImage(konvaLayer, imageEl);
+ createIILayerImage(konvaLayer, imageEl);
// Update the image's attributes
konvaImage.setAttrs({
id: imageId,
image: imageEl,
});
- updateInitialImageLayerImageAttrs(stage, konvaImage, reduxLayer);
+ updateIILayerImageAttrs(stage, konvaImage, layerState);
imageEl.id = imageId;
};
imageEl.src = imageDTO.image_url;
@@ -540,14 +555,24 @@ const updateInitialImageLayerImageSource = async (
}
};
-const renderInitialImageLayer = (stage: Konva.Stage, reduxLayer: InitialImageLayer) => {
- const konvaLayer = stage.findOne(`#${reduxLayer.id}`) ?? createInitialImageLayer(stage, reduxLayer);
+/**
+ * Renders an initial image layer.
+ * @param stage The konva stage
+ * @param layerState The initial image layer state
+ * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
+ */
+const renderIILayer = (
+ stage: Konva.Stage,
+ layerState: InitialImageLayer,
+ getImageDTO: (imageName: string) => Promise
+): void => {
+ const konvaLayer = stage.findOne(`#${layerState.id}`) ?? createIILayer(stage, layerState);
const konvaImage = konvaLayer.findOne(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`);
const canvasImageSource = konvaImage?.image();
let imageSourceNeedsUpdate = false;
if (canvasImageSource instanceof HTMLImageElement) {
- const image = reduxLayer.image;
- if (image && canvasImageSource.id !== getCALayerImageId(reduxLayer.id, image.name)) {
+ const image = layerState.image;
+ if (image && canvasImageSource.id !== getCALayerImageId(layerState.id, image.name)) {
imageSourceNeedsUpdate = true;
} else if (!image) {
imageSourceNeedsUpdate = true;
@@ -557,15 +582,20 @@ const renderInitialImageLayer = (stage: Konva.Stage, reduxLayer: InitialImageLay
}
if (imageSourceNeedsUpdate) {
- updateInitialImageLayerImageSource(stage, konvaLayer, reduxLayer);
+ updateIILayerImageSource(stage, konvaLayer, layerState, getImageDTO);
} else if (konvaImage) {
- updateInitialImageLayerImageAttrs(stage, konvaImage, reduxLayer);
+ updateIILayerImageAttrs(stage, konvaImage, layerState);
}
};
-const createControlNetLayer = (stage: Konva.Stage, reduxLayer: ControlAdapterLayer): Konva.Layer => {
+/**
+ * Creates a control adapter layer.
+ * @param stage The konva stage
+ * @param layerState The control adapter layer state
+ */
+const createCALayer = (stage: Konva.Stage, layerState: ControlAdapterLayer): Konva.Layer => {
const konvaLayer = new Konva.Layer({
- id: reduxLayer.id,
+ id: layerState.id,
name: CA_LAYER_NAME,
imageSmoothingEnabled: true,
listening: false,
@@ -574,39 +604,53 @@ const createControlNetLayer = (stage: Konva.Stage, reduxLayer: ControlAdapterLay
return konvaLayer;
};
-const createControlNetLayerImage = (konvaLayer: Konva.Layer, image: HTMLImageElement): Konva.Image => {
+/**
+ * Creates a control adapter layer image.
+ * @param konvaLayer The konva layer
+ * @param imageEl The image element
+ */
+const createCALayerImage = (konvaLayer: Konva.Layer, imageEl: HTMLImageElement): Konva.Image => {
const konvaImage = new Konva.Image({
name: CA_LAYER_IMAGE_NAME,
- image,
+ image: imageEl,
});
konvaLayer.add(konvaImage);
return konvaImage;
};
-const updateControlNetLayerImageSource = async (
+/**
+ * Updates the image source for a control adapter layer. This includes loading the image from the server and updating the konva image.
+ * @param stage The konva stage
+ * @param konvaLayer The konva layer
+ * @param layerState The control adapter layer state
+ * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
+ */
+const updateCALayerImageSource = async (
stage: Konva.Stage,
konvaLayer: Konva.Layer,
- reduxLayer: ControlAdapterLayer
-) => {
- const image = reduxLayer.controlAdapter.processedImage ?? reduxLayer.controlAdapter.image;
+ layerState: ControlAdapterLayer,
+ getImageDTO: (imageName: string) => Promise
+): Promise => {
+ const image = layerState.controlAdapter.processedImage ?? layerState.controlAdapter.image;
if (image) {
const imageName = image.name;
- const req = getStore().dispatch(imagesApi.endpoints.getImageDTO.initiate(imageName));
- const imageDTO = await req.unwrap();
- req.unsubscribe();
+ const imageDTO = await getImageDTO(imageName);
+ if (!imageDTO) {
+ return;
+ }
const imageEl = new Image();
- const imageId = getCALayerImageId(reduxLayer.id, imageName);
+ const imageId = getCALayerImageId(layerState.id, imageName);
imageEl.onload = () => {
// Find the existing image or create a new one - must find using the name, bc the id may have just changed
const konvaImage =
- konvaLayer.findOne(`.${CA_LAYER_IMAGE_NAME}`) ?? createControlNetLayerImage(konvaLayer, imageEl);
+ konvaLayer.findOne(`.${CA_LAYER_IMAGE_NAME}`) ?? createCALayerImage(konvaLayer, imageEl);
// Update the image's attributes
konvaImage.setAttrs({
id: imageId,
image: imageEl,
});
- updateControlNetLayerImageAttrs(stage, konvaImage, reduxLayer);
+ updateCALayerImageAttrs(stage, konvaImage, layerState);
// Must cache after this to apply the filters
konvaImage.cache();
imageEl.id = imageId;
@@ -617,11 +661,17 @@ const updateControlNetLayerImageSource = async (
}
};
-const updateControlNetLayerImageAttrs = (
+/**
+ * Updates the image attributes for a control adapter layer's image (width, height, visibility, opacity, filters).
+ * @param stage The konva stage
+ * @param konvaImage The konva image
+ * @param layerState The control adapter layer state
+ */
+const updateCALayerImageAttrs = (
stage: Konva.Stage,
konvaImage: Konva.Image,
- reduxLayer: ControlAdapterLayer
-) => {
+ layerState: ControlAdapterLayer
+): void => {
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.
@@ -632,36 +682,47 @@ const updateControlNetLayerImageAttrs = (
if (
konvaImage.width() !== newWidth ||
konvaImage.height() !== newHeight ||
- konvaImage.visible() !== reduxLayer.isEnabled ||
- hasFilter !== reduxLayer.isFilterEnabled
+ konvaImage.visible() !== layerState.isEnabled ||
+ hasFilter !== layerState.isFilterEnabled
) {
konvaImage.setAttrs({
- opacity: reduxLayer.opacity,
+ opacity: layerState.opacity,
scaleX: 1,
scaleY: 1,
width: stage.width() / stage.scaleX(),
height: stage.height() / stage.scaleY(),
- visible: reduxLayer.isEnabled,
- filters: reduxLayer.isFilterEnabled ? [LightnessToAlphaFilter] : [],
+ visible: layerState.isEnabled,
+ filters: layerState.isFilterEnabled ? [LightnessToAlphaFilter] : [],
});
needsCache = true;
}
- if (konvaImage.opacity() !== reduxLayer.opacity) {
- konvaImage.opacity(reduxLayer.opacity);
+ if (konvaImage.opacity() !== layerState.opacity) {
+ konvaImage.opacity(layerState.opacity);
}
if (needsCache) {
konvaImage.cache();
}
};
-const renderControlNetLayer = (stage: Konva.Stage, reduxLayer: ControlAdapterLayer) => {
- const konvaLayer = stage.findOne(`#${reduxLayer.id}`) ?? createControlNetLayer(stage, reduxLayer);
+/**
+ * Renders a control adapter layer. If the layer doesn't already exist, it is created. Otherwise, the layer is updated
+ * with the current image source and attributes.
+ * @param stage The konva stage
+ * @param layerState The control adapter layer state
+ * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
+ */
+const renderCALayer = (
+ stage: Konva.Stage,
+ layerState: ControlAdapterLayer,
+ getImageDTO: (imageName: string) => Promise
+): void => {
+ const konvaLayer = stage.findOne(`#${layerState.id}`) ?? createCALayer(stage, layerState);
const konvaImage = konvaLayer.findOne(`.${CA_LAYER_IMAGE_NAME}`);
const canvasImageSource = konvaImage?.image();
let imageSourceNeedsUpdate = false;
if (canvasImageSource instanceof HTMLImageElement) {
- const image = reduxLayer.controlAdapter.processedImage ?? reduxLayer.controlAdapter.image;
- if (image && canvasImageSource.id !== getCALayerImageId(reduxLayer.id, image.name)) {
+ const image = layerState.controlAdapter.processedImage ?? layerState.controlAdapter.image;
+ if (image && canvasImageSource.id !== getCALayerImageId(layerState.id, image.name)) {
imageSourceNeedsUpdate = true;
} else if (!image) {
imageSourceNeedsUpdate = true;
@@ -671,44 +732,46 @@ const renderControlNetLayer = (stage: Konva.Stage, reduxLayer: ControlAdapterLay
}
if (imageSourceNeedsUpdate) {
- updateControlNetLayerImageSource(stage, konvaLayer, reduxLayer);
+ updateCALayerImageSource(stage, konvaLayer, layerState, getImageDTO);
} else if (konvaImage) {
- updateControlNetLayerImageAttrs(stage, konvaImage, reduxLayer);
+ updateCALayerImageAttrs(stage, konvaImage, layerState);
}
};
/**
* Renders the layers on the stage.
- * @param stage The konva stage to render on.
- * @param reduxLayers Array of the layers from the redux store.
- * @param layerOpacity The opacity of the layer.
- * @param onLayerPosChanged Callback for when the layer's position changes. This is optional to allow for offscreen rendering.
- * @returns
+ * @param stage The konva stage
+ * @param layerStates Array of all layer states
+ * @param globalMaskLayerOpacity The global mask layer opacity
+ * @param tool The current tool
+ * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source
+ * @param onLayerPosChanged Callback for when the layer's position changes
*/
const renderLayers = (
stage: Konva.Stage,
- reduxLayers: Layer[],
+ layerStates: Layer[],
globalMaskLayerOpacity: number,
tool: Tool,
+ getImageDTO: (imageName: string) => Promise,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
-) => {
- const reduxLayerIds = reduxLayers.filter(isRenderableLayer).map(mapId);
+): void => {
+ const layerIds = layerStates.filter(isRenderableLayer).map(mapId);
// Remove un-rendered layers
for (const konvaLayer of stage.find(selectRenderableLayers)) {
- if (!reduxLayerIds.includes(konvaLayer.id())) {
+ if (!layerIds.includes(konvaLayer.id())) {
konvaLayer.destroy();
}
}
- for (const reduxLayer of reduxLayers) {
- if (isRegionalGuidanceLayer(reduxLayer)) {
- renderRegionalGuidanceLayer(stage, reduxLayer, globalMaskLayerOpacity, tool, onLayerPosChanged);
+ for (const layer of layerStates) {
+ if (isRegionalGuidanceLayer(layer)) {
+ renderRGLayer(stage, layer, globalMaskLayerOpacity, tool, onLayerPosChanged);
}
- if (isControlAdapterLayer(reduxLayer)) {
- renderControlNetLayer(stage, reduxLayer);
+ if (isControlAdapterLayer(layer)) {
+ renderCALayer(stage, layer, getImageDTO);
}
- if (isInitialImageLayer(reduxLayer)) {
- renderInitialImageLayer(stage, reduxLayer);
+ if (isInitialImageLayer(layer)) {
+ renderIILayer(stage, layer, getImageDTO);
}
// IP Adapter layers are not rendered
}
@@ -716,13 +779,12 @@ const renderLayers = (
/**
* Creates a bounding box rect for a layer.
- * @param reduxLayer The redux layer to create the bounding box for.
- * @param konvaLayer The konva layer to attach the bounding box to.
- * @param onBboxMouseDown Callback for when the bounding box is clicked.
+ * @param layerState The layer state for the layer to create the bounding box for
+ * @param konvaLayer The konva layer to attach the bounding box to
*/
-const createBboxRect = (reduxLayer: Layer, konvaLayer: Konva.Layer) => {
+const createBboxRect = (layerState: Layer, konvaLayer: Konva.Layer): Konva.Rect => {
const rect = new Konva.Rect({
- id: getLayerBboxId(reduxLayer.id),
+ id: getLayerBboxId(layerState.id),
name: LAYER_BBOX_NAME,
strokeWidth: 1,
visible: false,
@@ -733,12 +795,12 @@ const createBboxRect = (reduxLayer: Layer, konvaLayer: Konva.Layer) => {
/**
* Renders the bounding boxes for the layers.
- * @param stage The konva stage to render on
- * @param reduxLayers An array of all redux layers to draw bboxes for
+ * @param stage The konva stage
+ * @param layerStates An array of layers to draw bboxes for
* @param tool The current tool
* @returns
*/
-const renderBboxes = (stage: Konva.Stage, reduxLayers: Layer[], tool: Tool) => {
+const renderBboxes = (stage: Konva.Stage, layerStates: Layer[], tool: Tool): void => {
// Hide all bboxes so they don't interfere with getClientRect
for (const bboxRect of stage.find(`.${LAYER_BBOX_NAME}`)) {
bboxRect.visible(false);
@@ -749,39 +811,39 @@ const renderBboxes = (stage: Konva.Stage, reduxLayers: Layer[], tool: Tool) => {
return;
}
- for (const reduxLayer of reduxLayers.filter(isRegionalGuidanceLayer)) {
- if (!reduxLayer.bbox) {
+ for (const layer of layerStates.filter(isRegionalGuidanceLayer)) {
+ if (!layer.bbox) {
continue;
}
- const konvaLayer = stage.findOne(`#${reduxLayer.id}`);
- assert(konvaLayer, `Layer ${reduxLayer.id} not found in stage`);
+ const konvaLayer = stage.findOne(`#${layer.id}`);
+ assert(konvaLayer, `Layer ${layer.id} not found in stage`);
- const bboxRect = konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(reduxLayer, konvaLayer);
+ const bboxRect = konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(layer, konvaLayer);
bboxRect.setAttrs({
- visible: !reduxLayer.bboxNeedsUpdate,
- listening: reduxLayer.isSelected,
- x: reduxLayer.bbox.x,
- y: reduxLayer.bbox.y,
- width: reduxLayer.bbox.width,
- height: reduxLayer.bbox.height,
- stroke: reduxLayer.isSelected ? BBOX_SELECTED_STROKE : '',
+ visible: !layer.bboxNeedsUpdate,
+ listening: layer.isSelected,
+ x: layer.bbox.x,
+ y: layer.bbox.y,
+ width: layer.bbox.width,
+ height: layer.bbox.height,
+ stroke: layer.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 stage The konva stage
+ * @param layerStates An array of layers to calculate bboxes for
* @param onBboxChanged Callback for when the bounding box changes
*/
const updateBboxes = (
stage: Konva.Stage,
- reduxLayers: Layer[],
+ layerStates: Layer[],
onBboxChanged: (layerId: string, bbox: IRect | null) => void
-) => {
- for (const rgLayer of reduxLayers.filter(isRegionalGuidanceLayer)) {
+): void => {
+ for (const rgLayer of layerStates.filter(isRegionalGuidanceLayer)) {
const konvaLayer = stage.findOne(`#${rgLayer.id}`);
assert(konvaLayer, `Layer ${rgLayer.id} not found in stage`);
// We only need to recalculate the bbox if the layer has changed
@@ -808,7 +870,7 @@ const updateBboxes = (
/**
* Creates the background layer for the stage.
- * @param stage The konva stage to render on
+ * @param stage The konva stage
*/
const createBackgroundLayer = (stage: Konva.Stage): Konva.Layer => {
const layer = new Konva.Layer({
@@ -829,17 +891,17 @@ const createBackgroundLayer = (stage: Konva.Stage): Konva.Layer => {
image.onload = () => {
background.fillPatternImage(image);
};
- image.src = STAGE_BG_DATAURL;
+ image.src = TRANSPARENCY_CHECKER_PATTERN;
return layer;
};
/**
* Renders the background layer for the stage.
- * @param stage The konva stage to render on
+ * @param stage The konva stage
* @param width The unscaled width of the canvas
* @param height The unscaled height of the canvas
*/
-const renderBackground = (stage: Konva.Stage, width: number, height: number) => {
+const renderBackground = (stage: Konva.Stage, width: number, height: number): void => {
const layer = stage.findOne(`#${BACKGROUND_LAYER_ID}`) ?? createBackgroundLayer(stage);
const background = layer.findOne(`#${BACKGROUND_RECT_ID}`);
@@ -880,6 +942,10 @@ const arrangeLayers = (stage: Konva.Stage, layerIds: string[]): void => {
stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.zIndex(nextZIndex++);
};
+/**
+ * Creates the "no layers" fallback layer
+ * @param stage The konva stage
+ */
const createNoLayersMessageLayer = (stage: Konva.Stage): Konva.Layer => {
const noLayersMessageLayer = new Konva.Layer({
id: NO_LAYERS_MESSAGE_LAYER_ID,
@@ -891,7 +957,7 @@ const createNoLayersMessageLayer = (stage: Konva.Stage): Konva.Layer => {
y: 0,
align: 'center',
verticalAlign: 'middle',
- text: t('controlLayers.noLayersAdded'),
+ text: t('controlLayers.noLayersAdded', 'No Layers Added'),
fontFamily: '"Inter Variable", sans-serif',
fontStyle: '600',
fill: 'white',
@@ -901,7 +967,14 @@ const createNoLayersMessageLayer = (stage: Konva.Stage): Konva.Layer => {
return noLayersMessageLayer;
};
-const renderNoLayersMessage = (stage: Konva.Stage, layerCount: number, width: number, height: number) => {
+/**
+ * Renders the "no layers" message when there are no layers to render
+ * @param stage The konva stage
+ * @param layerCount The current number of layers
+ * @param width The target width of the text
+ * @param height The target height of the text
+ */
+const renderNoLayersMessage = (stage: Konva.Stage, layerCount: number, width: number, height: number): void => {
const noLayersMessageLayer =
stage.findOne(`#${NO_LAYERS_MESSAGE_LAYER_ID}`) ?? createNoLayersMessageLayer(stage);
if (layerCount === 0) {
@@ -936,20 +1009,3 @@ export const debouncedRenderers = {
arrangeLayers: debounce(arrangeLayers, DEBOUNCE_MS),
updateBboxes: debounce(updateBboxes, DEBOUNCE_MS),
};
-
-/**
- * Calculates the lightness (HSL) of a given pixel and sets the alpha channel to that value.
- * This is useful for edge maps and other masks, to make the black areas transparent.
- * @param imageData The image data to apply the filter to
- */
-const LightnessToAlphaFilter = (imageData: ImageData) => {
- const len = imageData.data.length / 4;
- for (let i = 0; i < len; i++) {
- const r = imageData.data[i * 4 + 0] as number;
- const g = imageData.data[i * 4 + 1] as number;
- const b = imageData.data[i * 4 + 2] as number;
- const cMin = Math.min(r, g, b);
- const cMax = Math.max(r, g, b);
- imageData.data[i * 4 + 3] = (cMin + cMax) / 2;
- }
-};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts
new file mode 100644
index 0000000000..29f81fb799
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts
@@ -0,0 +1,67 @@
+import type Konva from 'konva';
+import type { KonvaEventObject } from 'konva/lib/Node';
+import type { Vector2d } from 'konva/lib/types';
+
+//#region getScaledFlooredCursorPosition
+/**
+ * Gets the scaled and floored cursor position on the stage. If the cursor is not currently over the stage, returns null.
+ * @param stage The konva stage
+ */
+export const getScaledFlooredCursorPosition = (stage: Konva.Stage): Vector2d | null => {
+ const pointerPosition = stage.getPointerPosition();
+ const stageTransform = stage.getAbsoluteTransform().copy();
+ if (!pointerPosition) {
+ return null;
+ }
+ const scaledCursorPosition = stageTransform.invert().point(pointerPosition);
+ return {
+ x: Math.floor(scaledCursorPosition.x),
+ y: Math.floor(scaledCursorPosition.y),
+ };
+};
+//#endregion
+
+//#region snapPosToStage
+/**
+ * Snaps a position to the edge of the stage if within a threshold of the edge
+ * @param pos The position to snap
+ * @param stage The konva stage
+ * @param snapPx The snap threshold in pixels
+ */
+export const snapPosToStage = (pos: Vector2d, stage: Konva.Stage, snapPx = 10): Vector2d => {
+ const snappedPos = { ...pos };
+ // Get the normalized threshold for snapping to the edge of the stage
+ const thresholdX = snapPx / stage.scaleX();
+ const thresholdY = snapPx / stage.scaleY();
+ const stageWidth = stage.width() / stage.scaleX();
+ const stageHeight = stage.height() / stage.scaleY();
+ // Snap to the edge of the stage if within threshold
+ if (pos.x - thresholdX < 0) {
+ snappedPos.x = 0;
+ } else if (pos.x + thresholdX > stageWidth) {
+ snappedPos.x = Math.floor(stageWidth);
+ }
+ if (pos.y - thresholdY < 0) {
+ snappedPos.y = 0;
+ } else if (pos.y + thresholdY > stageHeight) {
+ snappedPos.y = Math.floor(stageHeight);
+ }
+ return snappedPos;
+};
+//#endregion
+
+//#region getIsMouseDown
+/**
+ * Checks if the left mouse button is currently pressed
+ * @param e The konva event
+ */
+export const getIsMouseDown = (e: KonvaEventObject): boolean => e.evt.buttons === 1;
+//#endregion
+
+//#region getIsFocused
+/**
+ * Checks if the stage is currently focused
+ * @param stage The konva stage
+ */
+export const getIsFocused = (stage: Konva.Stage): boolean => stage.container().contains(document.activeElement);
+//#endregion
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
index 5fa8cc3dfb..8d6a6ecfd9 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts
@@ -4,6 +4,14 @@ import type { PersistConfig, RootState } from 'app/store/store';
import { moveBackward, moveForward, moveToBack, moveToFront } from 'common/util/arrayUtils';
import { deepClone } from 'common/util/deepClone';
import { roundDownToMultiple } from 'common/util/roundDownToMultiple';
+import {
+ getCALayerId,
+ getIPALayerId,
+ getRGLayerId,
+ getRGLayerLineId,
+ getRGLayerRectId,
+ INITIAL_IMAGE_LAYER_ID,
+} from 'features/controlLayers/konva/naming';
import type {
CLIPVisionModelV2,
ControlModeV2,
@@ -36,6 +44,9 @@ import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid';
import type {
+ AddLineArg,
+ AddPointToLineArg,
+ AddRectArg,
ControlAdapterLayer,
ControlLayersState,
DrawingTool,
@@ -492,11 +503,11 @@ export const controlLayersSlice = createSlice({
layer.bboxNeedsUpdate = true;
layer.uploadedMaskImage = null;
},
- prepare: (payload: { layerId: string; points: [number, number, number, number]; tool: DrawingTool }) => ({
+ prepare: (payload: AddLineArg) => ({
payload: { ...payload, lineUuid: uuidv4() },
}),
},
- rgLayerPointsAdded: (state, action: PayloadAction<{ layerId: string; point: [number, number] }>) => {
+ rgLayerPointsAdded: (state, action: PayloadAction) => {
const { layerId, point } = action.payload;
const layer = selectRGLayerOrThrow(state, layerId);
const lastLine = layer.maskObjects.findLast(isLine);
@@ -529,7 +540,7 @@ export const controlLayersSlice = createSlice({
layer.bboxNeedsUpdate = true;
layer.uploadedMaskImage = null;
},
- prepare: (payload: { layerId: string; rect: IRect }) => ({ payload: { ...payload, rectUuid: uuidv4() } }),
+ prepare: (payload: AddRectArg) => ({ payload: { ...payload, rectUuid: uuidv4() } }),
},
rgLayerMaskImageUploaded: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO }>) => {
const { layerId, imageDTO } = action.payload;
@@ -883,45 +894,21 @@ const migrateControlLayersState = (state: any): any => {
return state;
};
+// Ephemeral interaction state
export const $isDrawing = atom(false);
export const $lastMouseDownPos = atom(null);
export const $tool = atom('brush');
export const $lastCursorPos = atom(null);
+export const $isPreviewVisible = atom(true);
+export const $lastAddedPoint = atom(null);
-// IDs for singleton Konva layers and objects
-export const TOOL_PREVIEW_LAYER_ID = 'tool_preview_layer';
-export const TOOL_PREVIEW_BRUSH_GROUP_ID = 'tool_preview_layer.brush_group';
-export const TOOL_PREVIEW_BRUSH_FILL_ID = 'tool_preview_layer.brush_fill';
-export const TOOL_PREVIEW_BRUSH_BORDER_INNER_ID = 'tool_preview_layer.brush_border_inner';
-export const TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID = 'tool_preview_layer.brush_border_outer';
-export const TOOL_PREVIEW_RECT_ID = 'tool_preview_layer.rect';
-export const BACKGROUND_LAYER_ID = 'background_layer';
-export const BACKGROUND_RECT_ID = 'background_layer.rect';
-export const NO_LAYERS_MESSAGE_LAYER_ID = 'no_layers_message';
-
-// Names (aka classes) for Konva layers and objects
-export const CA_LAYER_NAME = 'control_adapter_layer';
-export const CA_LAYER_IMAGE_NAME = 'control_adapter_layer.image';
-export const RG_LAYER_NAME = 'regional_guidance_layer';
-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_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_IMAGE_NAME = 'initial_image_layer.image';
-export const LAYER_BBOX_NAME = 'layer.bbox';
-export const COMPOSITING_RECT_NAME = 'compositing-rect';
-
-// Getters for non-singleton layer and object IDs
-export const getRGLayerId = (layerId: string) => `${RG_LAYER_NAME}_${layerId}`;
-const getRGLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`;
-const getRGLayerRectId = (layerId: string, lineId: string) => `${layerId}.rect_${lineId}`;
-export const getRGLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`;
-export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`;
-export const getCALayerId = (layerId: string) => `control_adapter_layer_${layerId}`;
-export const getCALayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`;
-export const getIILayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`;
-export const getIPALayerId = (layerId: string) => `ip_adapter_layer_${layerId}`;
+// Some nanostores that are manually synced to redux state to provide imperative access
+// TODO(psyche): This is a hack, figure out another way to handle this...
+export const $brushSize = atom(0);
+export const $brushSpacingPx = atom(0);
+export const $selectedLayerId = atom(null);
+export const $selectedLayerType = atom(null);
+export const $shouldInvertBrushSizeScrollDirection = atom(false);
export const controlLayersPersistConfig: PersistConfig = {
name: controlLayersSlice.name,
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index 771e5060e1..bd86a8aa20 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -17,6 +17,7 @@ import {
zParameterPositivePrompt,
zParameterStrength,
} from 'features/parameters/types/parameterSchemas';
+import type { IRect } from 'konva/lib/types';
import { z } from 'zod';
const zTool = z.enum(['brush', 'eraser', 'move', 'rect']);
@@ -129,3 +130,7 @@ export type ControlLayersState = {
aspectRatio: AspectRatioState;
};
};
+
+export type AddLineArg = { layerId: string; points: [number, number, number, number]; tool: DrawingTool };
+export type AddPointToLineArg = { layerId: string; point: [number, number] };
+export type AddRectArg = { layerId: string; rect: IRect };
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/getLayerBlobs.ts b/invokeai/frontend/web/src/features/controlLayers/util/getLayerBlobs.ts
deleted file mode 100644
index 2ad3e0c90c..0000000000
--- a/invokeai/frontend/web/src/features/controlLayers/util/getLayerBlobs.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { getStore } from 'app/store/nanostores/store';
-import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
-import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
-import { isRegionalGuidanceLayer, RG_LAYER_NAME } from 'features/controlLayers/store/controlLayersSlice';
-import { renderers } from 'features/controlLayers/util/renderers';
-import Konva from 'konva';
-import { assert } from 'tsafe';
-
-/**
- * Get the blobs of all regional prompt layers. Only visible layers are returned.
- * @param layerIds The IDs of the layers to get blobs for. If not provided, all regional prompt layers are used.
- * @param preview Whether to open a new tab displaying each layer.
- * @returns A map of layer IDs to blobs.
- */
-export const getRegionalPromptLayerBlobs = async (
- layerIds?: string[],
- preview: boolean = false
-): Promise> => {
- const state = getStore().getState();
- const { layers } = state.controlLayers.present;
- const { width, height } = state.controlLayers.present.size;
- const reduxLayers = layers.filter(isRegionalGuidanceLayer);
- const container = document.createElement('div');
- const stage = new Konva.Stage({ container, width, height });
- renderers.renderLayers(stage, reduxLayers, 1, 'brush');
-
- const konvaLayers = stage.find(`.${RG_LAYER_NAME}`);
- const blobs: Record = {};
-
- // First remove all layers
- for (const layer of konvaLayers) {
- layer.remove();
- }
-
- // Next render each layer to a blob
- for (const layer of konvaLayers) {
- if (layerIds && !layerIds.includes(layer.id())) {
- continue;
- }
- const reduxLayer = reduxLayers.find((l) => l.id === layer.id());
- assert(reduxLayer, `Redux layer ${layer.id()} not found`);
- stage.add(layer);
- const blob = await new Promise((resolve) => {
- stage.toBlob({
- callback: (blob) => {
- assert(blob, 'Blob is null');
- resolve(blob);
- },
- });
- });
-
- if (preview) {
- const base64 = await blobToDataURL(blob);
- openBase64ImageInTab([
- {
- base64,
- caption: `${reduxLayer.id}: ${reduxLayer.positivePrompt} / ${reduxLayer.negativePrompt}`,
- },
- ]);
- }
- layer.remove();
- blobs[layer.id()] = blob;
- }
-
- return blobs;
-};
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataGraphTabContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataGraphTabContent.tsx
index 9f7cac4a3e..b5b81d1e6f 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataGraphTabContent.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataGraphTabContent.tsx
@@ -28,7 +28,9 @@ const ImageMetadataGraphTabContent = ({ image }: Props) => {
return ;
}
- return ;
+ return (
+
+ );
};
export default memo(ImageMetadataGraphTabContent);
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataViewer.tsx
index 46121f9724..aa50498848 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataViewer.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataViewer.tsx
@@ -68,14 +68,22 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
{metadata ? (
-
+
) : (
)}
{image ? (
-
+
) : (
)}
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataWorkflowTabContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataWorkflowTabContent.tsx
index fe4ce3e701..9c224d6190 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataWorkflowTabContent.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataWorkflowTabContent.tsx
@@ -28,7 +28,13 @@ const ImageMetadataWorkflowTabContent = ({ image }: Props) => {
return ;
}
- return ;
+ return (
+
+ );
};
export default memo(ImageMetadataWorkflowTabContent);
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonHover.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonHover.tsx
index a02e94b547..9bff769cf0 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonHover.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonHover.tsx
@@ -3,7 +3,7 @@ import { useAppSelector } from 'app/store/storeHooks';
import { useBoolean } from 'common/hooks/useBoolean';
import { preventDefault } from 'common/util/stopPropagation';
import type { Dimensions } from 'features/canvas/store/canvasTypes';
-import { STAGE_BG_DATAURL } from 'features/controlLayers/util/renderers';
+import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
import { memo, useMemo, useRef } from 'react';
@@ -78,7 +78,7 @@ export const ImageComparisonHover = memo(({ firstImage, secondImage, containerDi
left={0}
right={0}
bottom={0}
- backgroundImage={STAGE_BG_DATAURL}
+ backgroundImage={TRANSPARENCY_CHECKER_PATTERN}
backgroundRepeat="repeat"
opacity={0.2}
/>
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx
index 8972af7d4f..3cdf7c48d5 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx
@@ -2,7 +2,7 @@ import { Box, Flex, Icon, Image } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { preventDefault } from 'common/util/stopPropagation';
import type { Dimensions } from 'features/canvas/store/canvasTypes';
-import { STAGE_BG_DATAURL } from 'features/controlLayers/util/renderers';
+import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
@@ -120,7 +120,7 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage, containerD
left={0}
right={0}
bottom={0}
- backgroundImage={STAGE_BG_DATAURL}
+ backgroundImage={TRANSPARENCY_CHECKER_PATTERN}
backgroundRepeat="repeat"
opacity={0.2}
/>
diff --git a/invokeai/frontend/web/src/features/metadata/util/handlers.ts b/invokeai/frontend/web/src/features/metadata/util/handlers.ts
index 2829507dcd..33715cbbe1 100644
--- a/invokeai/frontend/web/src/features/metadata/util/handlers.ts
+++ b/invokeai/frontend/web/src/features/metadata/util/handlers.ts
@@ -1,4 +1,7 @@
+import { getStore } from 'app/store/nanostores/store';
+import { deepClone } from 'common/util/deepClone';
import { objectKeys } from 'common/util/objectKeys';
+import { shouldConcatPromptsChanged } from 'features/controlLayers/store/controlLayersSlice';
import type { Layer } from 'features/controlLayers/store/types';
import type { LoRA } from 'features/lora/store/loraSlice';
import type {
@@ -16,6 +19,7 @@ import { validators } from 'features/metadata/util/validators';
import type { ModelIdentifierField } from 'features/nodes/types/common';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
+import { size } from 'lodash-es';
import { assert } from 'tsafe';
import { parsers } from './parsers';
@@ -376,54 +380,25 @@ export const handlers = {
}),
} as const;
+type ParsedValue = Awaited>;
+type RecallResults = Partial>;
+
export const parseAndRecallPrompts = async (metadata: unknown) => {
- const results = await Promise.allSettled([
- handlers.positivePrompt.parse(metadata).then((positivePrompt) => {
- if (!handlers.positivePrompt.recall) {
- return;
- }
- handlers.positivePrompt?.recall(positivePrompt);
- }),
- handlers.negativePrompt.parse(metadata).then((negativePrompt) => {
- if (!handlers.negativePrompt.recall) {
- return;
- }
- handlers.negativePrompt?.recall(negativePrompt);
- }),
- handlers.sdxlPositiveStylePrompt.parse(metadata).then((sdxlPositiveStylePrompt) => {
- if (!handlers.sdxlPositiveStylePrompt.recall) {
- return;
- }
- handlers.sdxlPositiveStylePrompt?.recall(sdxlPositiveStylePrompt);
- }),
- handlers.sdxlNegativeStylePrompt.parse(metadata).then((sdxlNegativeStylePrompt) => {
- if (!handlers.sdxlNegativeStylePrompt.recall) {
- return;
- }
- handlers.sdxlNegativeStylePrompt?.recall(sdxlNegativeStylePrompt);
- }),
- ]);
- if (results.some((result) => result.status === 'fulfilled')) {
+ const keysToRecall: (keyof typeof handlers)[] = [
+ 'positivePrompt',
+ 'negativePrompt',
+ 'sdxlPositiveStylePrompt',
+ 'sdxlNegativeStylePrompt',
+ ];
+ const recalled = await recallKeys(keysToRecall, metadata);
+ if (size(recalled) > 0) {
parameterSetToast(t('metadata.allPrompts'));
}
};
export const parseAndRecallImageDimensions = async (metadata: unknown) => {
- const results = await Promise.allSettled([
- handlers.width.parse(metadata).then((width) => {
- if (!handlers.width.recall) {
- return;
- }
- handlers.width?.recall(width);
- }),
- handlers.height.parse(metadata).then((height) => {
- if (!handlers.height.recall) {
- return;
- }
- handlers.height?.recall(height);
- }),
- ]);
- if (results.some((result) => result.status === 'fulfilled')) {
+ const recalled = recallKeys(['width', 'height'], metadata);
+ if (size(recalled) > 0) {
parameterSetToast(t('metadata.imageDimensions'));
}
};
@@ -438,28 +413,20 @@ export const parseAndRecallAllMetadata = async (
toControlLayers: boolean,
skip: (keyof typeof handlers)[] = []
) => {
- const skipKeys = skip ?? [];
+ const skipKeys = deepClone(skip);
if (toControlLayers) {
skipKeys.push(...TO_CONTROL_LAYERS_SKIP_KEYS);
} else {
skipKeys.push(...NOT_TO_CONTROL_LAYERS_SKIP_KEYS);
}
- const results = await Promise.allSettled(
- objectKeys(handlers)
- .filter((key) => !skipKeys.includes(key))
- .map((key) => {
- const { parse, recall } = handlers[key];
- return parse(metadata).then((value) => {
- if (!recall) {
- return;
- }
- /* @ts-expect-error The return type of parse and the input type of recall are guaranteed to be compatible. */
- recall(value);
- });
- })
- );
- if (results.some((result) => result.status === 'fulfilled')) {
+ // We may need to take some further action depending on what was recalled. For example, we need to disable SDXL prompt
+ // concat if the negative or positive style prompt was set. Because the recalling is all async, we need to collect all
+ // results
+ const keysToRecall = objectKeys(handlers).filter((key) => !skipKeys.includes(key));
+ const recalled = await recallKeys(keysToRecall, metadata);
+
+ if (size(recalled) > 0) {
toast({
id: 'PARAMETER_SET',
title: t('toast.parametersSet'),
@@ -473,3 +440,43 @@ export const parseAndRecallAllMetadata = async (
});
}
};
+
+/**
+ * Recalls a set of keys from metadata.
+ * Includes special handling for some metadata where recalling may have side effects. For example, recalling a "style"
+ * prompt that is different from the "positive" or "negative" prompt should disable prompt concatenation.
+ * @param keysToRecall An array of keys to recall.
+ * @param metadata The metadata to recall from
+ * @returns A promise that resolves to an object containing the recalled values.
+ */
+const recallKeys = async (keysToRecall: (keyof typeof handlers)[], metadata: unknown): Promise => {
+ const { dispatch } = getStore();
+ const recalled: RecallResults = {};
+ for (const key of keysToRecall) {
+ const { parse, recall } = handlers[key];
+ if (!recall) {
+ continue;
+ }
+ try {
+ const value = await parse(metadata);
+ /* @ts-expect-error The return type of parse and the input type of recall are guaranteed to be compatible. */
+ await recall(value);
+ recalled[key] = value;
+ } catch {
+ // no-op
+ }
+ }
+
+ if (
+ (recalled['sdxlPositiveStylePrompt'] && recalled['sdxlPositiveStylePrompt'] !== recalled['positivePrompt']) ||
+ (recalled['sdxlNegativeStylePrompt'] && recalled['sdxlNegativeStylePrompt'] !== recalled['negativePrompt'])
+ ) {
+ // If we set the negative style prompt or positive style prompt, we should disable prompt concat
+ dispatch(shouldConcatPromptsChanged(false));
+ } else {
+ // Otherwise, we should enable prompt concat
+ dispatch(shouldConcatPromptsChanged(true));
+ }
+
+ return recalled;
+};
diff --git a/invokeai/frontend/web/src/features/metadata/util/modelFetchingHelpers.ts b/invokeai/frontend/web/src/features/metadata/util/modelFetchingHelpers.ts
index a2db414937..4bd2436c0b 100644
--- a/invokeai/frontend/web/src/features/metadata/util/modelFetchingHelpers.ts
+++ b/invokeai/frontend/web/src/features/metadata/util/modelFetchingHelpers.ts
@@ -1,6 +1,7 @@
import { getStore } from 'app/store/nanostores/store';
import type { ModelIdentifierField } from 'features/nodes/types/common';
import { isModelIdentifier, isModelIdentifierV2 } from 'features/nodes/types/common';
+import type { ModelIdentifier } from 'features/nodes/types/v2/common';
import { modelsApi } from 'services/api/endpoints/models';
import type { AnyModelConfig, BaseModelType, ModelType } from 'services/api/types';
@@ -107,19 +108,30 @@ export const fetchModelConfigWithTypeGuard = async (
/**
* Fetches the model key from a model identifier. This includes fetching the key for MM1 format model identifiers.
- * @param modelIdentifier The model identifier. The MM2 format `{key: string}` simply extracts the key. The MM1 format
- * `{model_name: string, base_model: BaseModelType}` must do a network request to fetch the key.
+ * @param modelIdentifier The model identifier. This can be a MM1 or MM2 identifier. In every case, we attempt to fetch
+ * the model config from the server to ensure that the model identifier is valid and represents an installed model.
* @param type The type of model to fetch. This is used to fetch the key for MM1 format model identifiers.
* @param message An optional custom message to include in the error if the model identifier is invalid.
* @returns A promise that resolves to the model key.
* @throws {InvalidModelConfigError} If the model identifier is invalid.
*/
-export const getModelKey = async (modelIdentifier: unknown, type: ModelType, message?: string): Promise => {
+export const getModelKey = async (
+ modelIdentifier: unknown | ModelIdentifierField | ModelIdentifier,
+ type: ModelType,
+ message?: string
+): Promise => {
if (isModelIdentifier(modelIdentifier)) {
- return modelIdentifier.key;
- }
- if (isModelIdentifierV2(modelIdentifier)) {
+ try {
+ // Check if the model exists by key
+ return (await fetchModelConfig(modelIdentifier.key)).key;
+ } catch {
+ // If not, fetch the model key by name and base model
+ return (await fetchModelConfigByAttrs(modelIdentifier.name, modelIdentifier.base, type)).key;
+ }
+ } else if (isModelIdentifierV2(modelIdentifier)) {
+ // Try by old-format model identifier
return (await fetchModelConfigByAttrs(modelIdentifier.model_name, modelIdentifier.base_model, type)).key;
}
+ // Nope, couldn't find it
throw new InvalidModelConfigError(message || `Invalid model identifier: ${modelIdentifier}`);
};
diff --git a/invokeai/frontend/web/src/features/metadata/util/parsers.ts b/invokeai/frontend/web/src/features/metadata/util/parsers.ts
index 0757d2e8db..78d569f987 100644
--- a/invokeai/frontend/web/src/features/metadata/util/parsers.ts
+++ b/invokeai/frontend/web/src/features/metadata/util/parsers.ts
@@ -4,7 +4,7 @@ import {
initialT2IAdapter,
} from 'features/controlAdapters/util/buildControlAdapter';
import { buildControlAdapterProcessor } from 'features/controlAdapters/util/buildControlAdapterProcessor';
-import { getCALayerId, getIPALayerId, INITIAL_IMAGE_LAYER_ID } from 'features/controlLayers/store/controlLayersSlice';
+import { getCALayerId, getIPALayerId, INITIAL_IMAGE_LAYER_ID } from 'features/controlLayers/konva/naming';
import type { ControlAdapterLayer, InitialImageLayer, IPAdapterLayer, Layer } from 'features/controlLayers/store/types';
import { zLayer } from 'features/controlLayers/store/types';
import {
diff --git a/invokeai/frontend/web/src/features/metadata/util/recallers.ts b/invokeai/frontend/web/src/features/metadata/util/recallers.ts
index 5a17fd4b5d..b69a14810d 100644
--- a/invokeai/frontend/web/src/features/metadata/util/recallers.ts
+++ b/invokeai/frontend/web/src/features/metadata/util/recallers.ts
@@ -6,12 +6,10 @@ import {
ipAdaptersReset,
t2iAdaptersReset,
} from 'features/controlAdapters/store/controlAdaptersSlice';
+import { getCALayerId, getIPALayerId, getRGLayerId } from 'features/controlLayers/konva/naming';
import {
allLayersDeleted,
caLayerRecalled,
- getCALayerId,
- getIPALayerId,
- getRGLayerId,
heightChanged,
iiLayerRecalled,
ipaLayerRecalled,
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addControlLayers.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addControlLayers.ts
index 2f254fb120..4261318479 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addControlLayers.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addControlLayers.ts
@@ -1,6 +1,10 @@
import { getStore } from 'app/store/nanostores/store';
import type { RootState } from 'app/store/store';
import { deepClone } from 'common/util/deepClone';
+import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
+import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
+import { RG_LAYER_NAME } from 'features/controlLayers/konva/naming';
+import { renderers } from 'features/controlLayers/konva/renderers';
import {
isControlAdapterLayer,
isInitialImageLayer,
@@ -16,7 +20,6 @@ import type {
ProcessorConfig,
T2IAdapterConfigV2,
} from 'features/controlLayers/util/controlAdapters';
-import { getRegionalPromptLayerBlobs } from 'features/controlLayers/util/getLayerBlobs';
import type { ImageField } from 'features/nodes/types/common';
import {
CONTROL_NET_COLLECT,
@@ -31,11 +34,13 @@ import {
T2I_ADAPTER_COLLECT,
} from 'features/nodes/util/graph/constants';
import type { Graph } from 'features/nodes/util/graph/generation/Graph';
+import Konva from 'konva';
import { size } from 'lodash-es';
import { getImageDTO, imagesApi } from 'services/api/endpoints/images';
import type { BaseModelType, ImageDTO, Invocation } from 'services/api/types';
import { assert } from 'tsafe';
+//#region addControlLayers
/**
* Adds the control layers to the graph
* @param state The app root state
@@ -90,7 +95,7 @@ export const addControlLayers = async (
const validRGLayers = validLayers.filter(isRegionalGuidanceLayer);
const layerIds = validRGLayers.map((l) => l.id);
- const blobs = await getRegionalPromptLayerBlobs(layerIds);
+ const blobs = await getRGLayerBlobs(layerIds);
assert(size(blobs) === size(layerIds), 'Mismatch between layer IDs and blobs');
for (const layer of validRGLayers) {
@@ -257,6 +262,7 @@ export const addControlLayers = async (
g.upsertMetadata({ control_layers: { layers: validLayers, version: state.controlLayers.present._version } });
return validLayers;
};
+//#endregion
//#region Control Adapters
const addGlobalControlAdapterToGraph = (
@@ -509,7 +515,7 @@ const isValidLayer = (layer: Layer, base: BaseModelType) => {
};
//#endregion
-//#region Helpers
+//#region getMaskImage
const getMaskImage = async (layer: RegionalGuidanceLayer, blob: Blob): Promise => {
if (layer.uploadedMaskImage) {
const imageDTO = await getImageDTO(layer.uploadedMaskImage.name);
@@ -529,7 +535,9 @@ const getMaskImage = async (layer: RegionalGuidanceLayer, blob: Blob): Promise> => {
+ const state = getStore().getState();
+ const { layers } = state.controlLayers.present;
+ const { width, height } = state.controlLayers.present.size;
+ const reduxLayers = layers.filter(isRegionalGuidanceLayer);
+ const container = document.createElement('div');
+ const stage = new Konva.Stage({ container, width, height });
+ renderers.renderLayers(stage, reduxLayers, 1, 'brush', getImageDTO);
+
+ const konvaLayers = stage.find(`.${RG_LAYER_NAME}`);
+ const blobs: Record = {};
+
+ // First remove all layers
+ for (const layer of konvaLayers) {
+ layer.remove();
+ }
+
+ // Next render each layer to a blob
+ for (const layer of konvaLayers) {
+ if (layerIds && !layerIds.includes(layer.id())) {
+ continue;
+ }
+ const reduxLayer = reduxLayers.find((l) => l.id === layer.id());
+ assert(reduxLayer, `Redux layer ${layer.id()} not found`);
+ stage.add(layer);
+ const blob = await new Promise((resolve) => {
+ stage.toBlob({
+ callback: (blob) => {
+ assert(blob, 'Blob is null');
+ resolve(blob);
+ },
+ });
+ });
+
+ if (preview) {
+ const base64 = await blobToDataURL(blob);
+ openBase64ImageInTab([
+ {
+ base64,
+ caption: `${reduxLayer.id}: ${reduxLayer.positivePrompt} / ${reduxLayer.negativePrompt}`,
+ },
+ ]);
+ }
+ layer.remove();
+ blobs[layer.id()] = blob;
+ }
+
+ return blobs;
+};
+//#endregion
diff --git a/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioCanvasPreview.tsx b/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioCanvasPreview.tsx
index 00fa10c0c5..08b591f9b1 100644
--- a/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioCanvasPreview.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioCanvasPreview.tsx
@@ -1,8 +1,17 @@
import { Flex } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
import { StageComponent } from 'features/controlLayers/components/StageComponent';
+import { $isPreviewVisible } from 'features/controlLayers/store/controlLayersSlice';
+import { AspectRatioIconPreview } from 'features/parameters/components/ImageSize/AspectRatioIconPreview';
import { memo } from 'react';
export const AspectRatioCanvasPreview = memo(() => {
+ const isPreviewVisible = useStore($isPreviewVisible);
+
+ if (!isPreviewVisible) {
+ return ;
+ }
+
return (
diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx
index ddf4997a16..3c8f274ecb 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx
@@ -3,15 +3,12 @@ import { aspectRatioChanged, heightChanged, widthChanged } from 'features/contro
import { ParamHeight } from 'features/parameters/components/Core/ParamHeight';
import { ParamWidth } from 'features/parameters/components/Core/ParamWidth';
import { AspectRatioCanvasPreview } from 'features/parameters/components/ImageSize/AspectRatioCanvasPreview';
-import { AspectRatioIconPreview } from 'features/parameters/components/ImageSize/AspectRatioIconPreview';
import { ImageSize } from 'features/parameters/components/ImageSize/ImageSize';
import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
-import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react';
export const ImageSizeLinear = memo(() => {
const dispatch = useAppDispatch();
- const tab = useAppSelector(activeTabNameSelector);
const width = useAppSelector((s) => s.controlLayers.present.size.width);
const height = useAppSelector((s) => s.controlLayers.present.size.height);
const aspectRatioState = useAppSelector((s) => s.controlLayers.present.size.aspectRatio);
@@ -50,7 +47,7 @@ export const ImageSizeLinear = memo(() => {
aspectRatioState={aspectRatioState}
heightComponent={}
widthComponent={}
- previewComponent={tab === 'generation' ? : }
+ previewComponent={}
onChangeAspectRatioState={onChangeAspectRatioState}
onChangeWidth={onChangeWidth}
onChangeHeight={onChangeHeight}
diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
index b78d5dce9a..3c58a08e4c 100644
--- a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx
@@ -3,6 +3,7 @@ import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/u
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { ControlLayersPanelContent } from 'features/controlLayers/components/ControlLayersPanelContent';
+import { $isPreviewVisible } from 'features/controlLayers/store/controlLayersSlice';
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
import { Prompts } from 'features/parameters/components/Prompts/Prompts';
import QueueControls from 'features/queue/components/QueueControls';
@@ -53,6 +54,7 @@ const ParametersPanelTextToImage = () => {
if (i === 1) {
dispatch(isImageViewerOpenChanged(false));
}
+ $isPreviewVisible.set(i === 0);
},
[dispatch]
);
@@ -66,6 +68,7 @@ const ParametersPanelTextToImage = () => {
{isSDXL ? : }