diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000000..dc0bb0f4398 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v22.12.0 diff --git a/docs/features/low-vram.md b/docs/features/low-vram.md index c06a080f36c..6f91039f994 100644 --- a/docs/features/low-vram.md +++ b/docs/features/low-vram.md @@ -28,11 +28,12 @@ It is possible to fine-tune the settings for best performance or if you still ge ## Details and fine-tuning -Low-VRAM mode involves 3 features, each of which can be configured or fine-tuned: +Low-VRAM mode involves 4 features, each of which can be configured or fine-tuned: -- Partial model loading -- Dynamic RAM and VRAM cache sizes -- Working memory +- Partial model loading (`enable_partial_loading`) +- Dynamic RAM and VRAM cache sizes (`max_cache_ram_gb`, `max_cache_vram_gb`) +- Working memory (`device_working_mem_gb`) +- Keeping a RAM weight copy (`keep_ram_copy_of_weights`) Read on to learn about these features and understand how to fine-tune them for your system and use-cases. @@ -67,12 +68,20 @@ As of v5.6.0, the caches are dynamically sized. The `ram` and `vram` settings ar But, if your GPU has enough VRAM to hold models fully, you might get a perf boost by manually setting the cache sizes in `invokeai.yaml`: ```yaml -# Set the RAM cache size to as large as possible, leaving a few GB free for the rest of your system and Invoke. -# For example, if your system has 32GB RAM, 28GB is a good value. +# The default max cache RAM size is logged on InvokeAI startup. It is determined based on your system RAM / VRAM. +# You can override the default value by setting `max_cache_ram_gb`. +# Increasing `max_cache_ram_gb` will increase the amount of RAM used to cache inactive models, resulting in faster model +# reloads for the cached models. +# As an example, if your system has 32GB of RAM and no other heavy processes, setting the `max_cache_ram_gb` to 28GB +# might be a good value to achieve aggressive model caching. max_cache_ram_gb: 28 -# Set the VRAM cache size to be as large as possible while leaving enough room for the working memory of the tasks you will be doing. -# For example, on a 24GB GPU that will be running unquantized FLUX without any auxiliary models, -# 18GB is a good value. +# The default max cache VRAM size is adjusted dynamically based on the amount of available VRAM (taking into +# consideration the VRAM used by other processes). +# You can override the default value by setting `max_cache_vram_gb`. Note that this value takes precedence over the +# `device_working_mem_gb`. +# It is recommended to set the VRAM cache size to be as large as possible while leaving enough room for the working +# memory of the tasks you will be doing. For example, on a 24GB GPU that will be running unquantized FLUX without any +# auxiliary models, 18GB might be a good value. max_cache_vram_gb: 18 ``` @@ -109,6 +118,15 @@ device_working_mem_gb: 4 Once decoding completes, the model manager "reclaims" the extra VRAM allocated as working memory for future model loading operations. +### Keeping a RAM weight copy + +Invoke has the option of keeping a RAM copy of all model weights, even when they are loaded onto the GPU. This optimization is _on_ by default, and enables faster model switching and LoRA patching. Disabling this feature will reduce the average RAM load while running Invoke (peak RAM likely won't change), at the cost of slower model switching and LoRA patching. If you have limited RAM, you can disable this optimization: + +```yaml +# Set to false to reduce the average RAM usage at the cost of slower model switching and LoRA patching. +keep_ram_copy_of_weights: false +``` + ### Disabling Nvidia sysmem fallback (Windows only) On Windows, Nvidia GPUs are able to use system RAM when their VRAM fills up via **sysmem fallback**. While it sounds like a good idea on the surface, in practice it causes massive slowdowns during generation. diff --git a/invokeai/app/invocations/batch.py b/invokeai/app/invocations/batch.py new file mode 100644 index 00000000000..8f4d85a6fe3 --- /dev/null +++ b/invokeai/app/invocations/batch.py @@ -0,0 +1,200 @@ +from typing import Literal + +from pydantic import BaseModel + +from invokeai.app.invocations.baseinvocation import ( + BaseInvocation, + BaseInvocationOutput, + Classification, + invocation, + invocation_output, +) +from invokeai.app.invocations.fields import ( + ImageField, + Input, + InputField, + OutputField, +) +from invokeai.app.invocations.primitives import ( + FloatOutput, + ImageOutput, + IntegerOutput, + StringOutput, +) +from invokeai.app.services.shared.invocation_context import InvocationContext + +BATCH_GROUP_IDS = Literal[ + "None", + "Group 1", + "Group 2", + "Group 3", + "Group 4", + "Group 5", +] + + +class NotExecutableNodeError(Exception): + def __init__(self, message: str = "This class should never be executed or instantiated directly."): + super().__init__(message) + + pass + + +class BaseBatchInvocation(BaseInvocation): + batch_group_id: BATCH_GROUP_IDS = InputField( + default="None", + description="The ID of this batch node's group. If provided, all batch nodes in with the same ID will be 'zipped' before execution, and all nodes' collections must be of the same size.", + input=Input.Direct, + title="Batch Group", + ) + + def __init__(self): + raise NotExecutableNodeError() + + +@invocation( + "image_batch", + title="Image Batch", + tags=["primitives", "image", "batch", "special"], + category="primitives", + version="1.0.0", + classification=Classification.Special, +) +class ImageBatchInvocation(BaseBatchInvocation): + """Create a batched generation, where the workflow is executed once for each image in the batch.""" + + images: list[ImageField] = InputField( + default=[], min_length=1, description="The images to batch over", input=Input.Direct + ) + + def invoke(self, context: InvocationContext) -> ImageOutput: + raise NotExecutableNodeError() + + +@invocation( + "string_batch", + title="String Batch", + tags=["primitives", "string", "batch", "special"], + category="primitives", + version="1.0.0", + classification=Classification.Special, +) +class StringBatchInvocation(BaseBatchInvocation): + """Create a batched generation, where the workflow is executed once for each string in the batch.""" + + strings: list[str] = InputField( + default=[], min_length=1, description="The strings to batch over", input=Input.Direct + ) + + def invoke(self, context: InvocationContext) -> StringOutput: + raise NotExecutableNodeError() + + +@invocation( + "integer_batch", + title="Integer Batch", + tags=["primitives", "integer", "number", "batch", "special"], + category="primitives", + version="1.0.0", + classification=Classification.Special, +) +class IntegerBatchInvocation(BaseBatchInvocation): + """Create a batched generation, where the workflow is executed once for each integer in the batch.""" + + integers: list[int] = InputField( + default=[], + min_length=1, + description="The integers to batch over", + ) + + def invoke(self, context: InvocationContext) -> IntegerOutput: + raise NotExecutableNodeError() + + +@invocation_output("integer_generator_output") +class IntegerGeneratorOutput(BaseInvocationOutput): + integers: list[int] = OutputField(description="The generated integers") + + +class IntegerGeneratorField(BaseModel): + pass + + +@invocation( + "integer_generator", + title="Integer Generator", + tags=["primitives", "int", "number", "batch", "special"], + category="primitives", + version="1.0.0", + classification=Classification.Special, +) +class IntegerGenerator(BaseInvocation): + """Generated a range of integers for use in a batched generation""" + + generator: IntegerGeneratorField = InputField( + description="The integer generator.", + input=Input.Direct, + title="Generator Type", + ) + + def __init__(self): + raise NotExecutableNodeError() + + def invoke(self, context: InvocationContext) -> IntegerGeneratorOutput: + raise NotExecutableNodeError() + + +@invocation( + "float_batch", + title="Float Batch", + tags=["primitives", "float", "number", "batch", "special"], + category="primitives", + version="1.0.0", + classification=Classification.Special, +) +class FloatBatchInvocation(BaseBatchInvocation): + """Create a batched generation, where the workflow is executed once for each float in the batch.""" + + floats: list[float] = InputField( + default=[], + min_length=1, + description="The floats to batch over", + ) + + def invoke(self, context: InvocationContext) -> FloatOutput: + raise NotExecutableNodeError() + + +@invocation_output("float_generator_output") +class FloatGeneratorOutput(BaseInvocationOutput): + """Base class for nodes that output a collection of floats""" + + floats: list[float] = OutputField(description="The generated floats") + + +class FloatGeneratorField(BaseModel): + pass + + +@invocation( + "float_generator", + title="Float Generator", + tags=["primitives", "float", "number", "batch", "special"], + category="primitives", + version="1.0.0", + classification=Classification.Special, +) +class FloatGenerator(BaseInvocation): + """Generated a range of floats for use in a batched generation""" + + generator: FloatGeneratorField = InputField( + description="The float generator.", + input=Input.Direct, + title="Generator Type", + ) + + def __init__(self): + raise NotExecutableNodeError() + + def invoke(self, context: InvocationContext) -> FloatGeneratorOutput: + raise NotExecutableNodeError() diff --git a/invokeai/app/invocations/denoise_latents.py b/invokeai/app/invocations/denoise_latents.py index 3949641a91d..f081e000c02 100644 --- a/invokeai/app/invocations/denoise_latents.py +++ b/invokeai/app/invocations/denoise_latents.py @@ -40,6 +40,7 @@ from invokeai.app.util.controlnet_utils import prepare_control_image from invokeai.backend.ip_adapter.ip_adapter import IPAdapter from invokeai.backend.model_manager import BaseModelType, ModelVariantType +from invokeai.backend.model_manager.config import AnyModelConfig from invokeai.backend.model_patcher import ModelPatcher from invokeai.backend.patches.layer_patcher import LayerPatcher from invokeai.backend.patches.model_patch_raw import ModelPatchRaw @@ -85,6 +86,7 @@ def get_scheduler( scheduler_info: ModelIdentifierField, scheduler_name: str, seed: int, + unet_config: AnyModelConfig, ) -> Scheduler: """Load a scheduler and apply some scheduler-specific overrides.""" # TODO(ryand): Silently falling back to ddim seems like a bad idea. Look into why this was added and remove if @@ -103,6 +105,9 @@ def get_scheduler( "_backup": scheduler_config, } + if hasattr(unet_config, "prediction_type"): + scheduler_config["prediction_type"] = unet_config.prediction_type + # make dpmpp_sde reproducable(seed can be passed only in initializer) if scheduler_class is DPMSolverSDEScheduler: scheduler_config["noise_sampler_seed"] = seed @@ -829,6 +834,9 @@ def _new_invoke(self, context: InvocationContext) -> LatentsOutput: seed, noise, latents = self.prepare_noise_and_latents(context, self.noise, self.latents) _, _, latent_height, latent_width = latents.shape + # get the unet's config so that we can pass the base to sd_step_callback() + unet_config = context.models.get_config(self.unet.unet.key) + conditioning_data = self.get_conditioning_data( context=context, positive_conditioning_field=self.positive_conditioning, @@ -848,6 +856,7 @@ def _new_invoke(self, context: InvocationContext) -> LatentsOutput: scheduler_info=self.unet.scheduler, scheduler_name=self.scheduler, seed=seed, + unet_config=unet_config, ) timesteps, init_timestep, scheduler_step_kwargs = self.init_scheduler( @@ -859,9 +868,6 @@ def _new_invoke(self, context: InvocationContext) -> LatentsOutput: denoising_end=self.denoising_end, ) - # get the unet's config so that we can pass the base to sd_step_callback() - unet_config = context.models.get_config(self.unet.unet.key) - ### preview def step_callback(state: PipelineIntermediateState) -> None: context.util.sd_step_callback(state, unet_config.base) @@ -1030,6 +1036,7 @@ def _lora_loader() -> Iterator[Tuple[ModelPatchRaw, float]]: scheduler_info=self.unet.scheduler, scheduler_name=self.scheduler, seed=seed, + unet_config=unet_config, ) pipeline = self.create_pipeline(unet, scheduler) diff --git a/invokeai/app/invocations/flux_model_loader.py b/invokeai/app/invocations/flux_model_loader.py index ab2d69aa02b..884b01a9805 100644 --- a/invokeai/app/invocations/flux_model_loader.py +++ b/invokeai/app/invocations/flux_model_loader.py @@ -10,6 +10,10 @@ from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField, UIType from invokeai.app.invocations.model import CLIPField, ModelIdentifierField, T5EncoderField, TransformerField, VAEField from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.app.util.t5_model_identifier import ( + preprocess_t5_encoder_model_identifier, + preprocess_t5_tokenizer_model_identifier, +) from invokeai.backend.flux.util import max_seq_lengths from invokeai.backend.model_manager.config import ( CheckpointConfigBase, @@ -74,8 +78,8 @@ def invoke(self, context: InvocationContext) -> FluxModelLoaderOutput: tokenizer = self.clip_embed_model.model_copy(update={"submodel_type": SubModelType.Tokenizer}) clip_encoder = self.clip_embed_model.model_copy(update={"submodel_type": SubModelType.TextEncoder}) - tokenizer2 = self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.Tokenizer2}) - t5_encoder = self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.TextEncoder2}) + tokenizer2 = preprocess_t5_tokenizer_model_identifier(self.t5_encoder_model) + t5_encoder = preprocess_t5_encoder_model_identifier(self.t5_encoder_model) transformer_config = context.models.get_config(transformer) assert isinstance(transformer_config, CheckpointConfigBase) diff --git a/invokeai/app/invocations/flux_text_encoder.py b/invokeai/app/invocations/flux_text_encoder.py index 3c49b6287b1..74c293d0c09 100644 --- a/invokeai/app/invocations/flux_text_encoder.py +++ b/invokeai/app/invocations/flux_text_encoder.py @@ -2,7 +2,7 @@ from typing import Iterator, Literal, Optional, Tuple import torch -from transformers import CLIPTextModel, CLIPTokenizer, T5EncoderModel, T5Tokenizer +from transformers import CLIPTextModel, CLIPTokenizer, T5EncoderModel, T5Tokenizer, T5TokenizerFast from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation from invokeai.app.invocations.fields import ( @@ -76,7 +76,7 @@ def _t5_encode(self, context: InvocationContext) -> torch.Tensor: context.models.load(self.t5_encoder.tokenizer) as t5_tokenizer, ): assert isinstance(t5_text_encoder, T5EncoderModel) - assert isinstance(t5_tokenizer, T5Tokenizer) + assert isinstance(t5_tokenizer, (T5Tokenizer, T5TokenizerFast)) t5_encoder = HFEncoder(t5_text_encoder, t5_tokenizer, False, self.t5_max_seq_len) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index 7f7a060c557..12835edcd7c 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -23,6 +23,7 @@ from invokeai.app.invocations.primitives import ImageOutput from invokeai.app.services.image_records.image_records_common import ImageCategory from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.app.util.misc import SEED_MAX from invokeai.backend.image_util.invisible_watermark import InvisibleWatermark from invokeai.backend.image_util.safety_checker import SafetyChecker @@ -161,12 +162,12 @@ class ImagePasteInvocation(BaseInvocation, WithMetadata, WithBoard): crop: bool = InputField(default=False, description="Crop to base image dimensions") def invoke(self, context: InvocationContext) -> ImageOutput: - base_image = context.images.get_pil(self.base_image.image_name) - image = context.images.get_pil(self.image.image_name) + base_image = context.images.get_pil(self.base_image.image_name, mode="RGBA") + image = context.images.get_pil(self.image.image_name, mode="RGBA") mask = None if self.mask is not None: - mask = context.images.get_pil(self.mask.image_name) - mask = ImageOps.invert(mask.convert("L")) + mask = context.images.get_pil(self.mask.image_name, mode="L") + mask = ImageOps.invert(mask) # TODO: probably shouldn't invert mask here... should user be required to do it? min_x = min(0, self.x) @@ -176,7 +177,11 @@ def invoke(self, context: InvocationContext) -> ImageOutput: new_image = Image.new(mode="RGBA", size=(max_x - min_x, max_y - min_y), color=(0, 0, 0, 0)) new_image.paste(base_image, (abs(min_x), abs(min_y))) - new_image.paste(image, (max(0, self.x), max(0, self.y)), mask=mask) + + # Create a temporary image to paste the image with transparency + temp_image = Image.new("RGBA", new_image.size) + temp_image.paste(image, (max(0, self.x), max(0, self.y)), mask=mask) + new_image = Image.alpha_composite(new_image, temp_image) if self.crop: base_w, base_h = base_image.size @@ -301,14 +306,44 @@ class ImageBlurInvocation(BaseInvocation, WithMetadata, WithBoard): blur_type: Literal["gaussian", "box"] = InputField(default="gaussian", description="The type of blur") def invoke(self, context: InvocationContext) -> ImageOutput: - image = context.images.get_pil(self.image.image_name) + image = context.images.get_pil(self.image.image_name, mode="RGBA") + # Split the image into RGBA channels + r, g, b, a = image.split() + + # Premultiply RGB channels by alpha + premultiplied_image = ImageChops.multiply(image, a.convert("RGBA")) + premultiplied_image.putalpha(a) + + # Apply the blur blur = ( ImageFilter.GaussianBlur(self.radius) if self.blur_type == "gaussian" else ImageFilter.BoxBlur(self.radius) ) - blur_image = image.filter(blur) + blurred_image = premultiplied_image.filter(blur) + + # Split the blurred image into RGBA channels + r, g, b, a_orig = blurred_image.split() + + # Convert to float using NumPy. float 32/64 division are much faster than float 16 + r = numpy.array(r, dtype=numpy.float32) + g = numpy.array(g, dtype=numpy.float32) + b = numpy.array(b, dtype=numpy.float32) + a = numpy.array(a_orig, dtype=numpy.float32) / 255.0 # Normalize alpha to [0, 1] - image_dto = context.images.save(image=blur_image) + # Unpremultiply RGB channels by alpha + r /= a + 1e-6 # Add a small epsilon to avoid division by zero + g /= a + 1e-6 + b /= a + 1e-6 + + # Convert back to PIL images + r = Image.fromarray(numpy.uint8(numpy.clip(r, 0, 255))) + g = Image.fromarray(numpy.uint8(numpy.clip(g, 0, 255))) + b = Image.fromarray(numpy.uint8(numpy.clip(b, 0, 255))) + + # Merge back into a single image + result_image = Image.merge("RGBA", (r, g, b, a_orig)) + + image_dto = context.images.save(image=result_image) return ImageOutput.build(image_dto) @@ -1055,3 +1090,67 @@ def invoke(self, context: InvocationContext) -> ImageOutput: image_dto = context.images.save(image=generated_image) return ImageOutput.build(image_dto) + + +@invocation( + "img_noise", + title="Add Image Noise", + tags=["image", "noise"], + category="image", + version="1.0.1", +) +class ImageNoiseInvocation(BaseInvocation, WithMetadata, WithBoard): + """Add noise to an image""" + + image: ImageField = InputField(description="The image to add noise to") + seed: int = InputField( + default=0, + ge=0, + le=SEED_MAX, + description=FieldDescriptions.seed, + ) + noise_type: Literal["gaussian", "salt_and_pepper"] = InputField( + default="gaussian", + description="The type of noise to add", + ) + amount: float = InputField(default=0.1, ge=0, le=1, description="The amount of noise to add") + noise_color: bool = InputField(default=True, description="Whether to add colored noise") + size: int = InputField(default=1, ge=1, description="The size of the noise points") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name, mode="RGBA") + + # Save out the alpha channel + alpha = image.getchannel("A") + + # Set the seed for numpy random + rs = numpy.random.RandomState(numpy.random.MT19937(numpy.random.SeedSequence(self.seed))) + + if self.noise_type == "gaussian": + if self.noise_color: + noise = rs.normal(0, 1, (image.height // self.size, image.width // self.size, 3)) * 255 + else: + noise = rs.normal(0, 1, (image.height // self.size, image.width // self.size)) * 255 + noise = numpy.stack([noise] * 3, axis=-1) + elif self.noise_type == "salt_and_pepper": + if self.noise_color: + noise = rs.choice( + [0, 255], (image.height // self.size, image.width // self.size, 3), p=[1 - self.amount, self.amount] + ) + else: + noise = rs.choice( + [0, 255], (image.height // self.size, image.width // self.size), p=[1 - self.amount, self.amount] + ) + noise = numpy.stack([noise] * 3, axis=-1) + + noise = Image.fromarray(noise.astype(numpy.uint8), mode="RGB").resize( + (image.width, image.height), Image.Resampling.NEAREST + ) + noisy_image = Image.blend(image.convert("RGB"), noise, self.amount).convert("RGBA") + + # Paste back the alpha channel + noisy_image.putalpha(alpha) + + image_dto = context.images.save(image=noisy_image) + + return ImageOutput.build(image_dto) diff --git a/invokeai/app/invocations/primitives.py b/invokeai/app/invocations/primitives.py index e58e20448bd..97de3eb8981 100644 --- a/invokeai/app/invocations/primitives.py +++ b/invokeai/app/invocations/primitives.py @@ -7,7 +7,6 @@ from invokeai.app.invocations.baseinvocation import ( BaseInvocation, BaseInvocationOutput, - Classification, invocation, invocation_output, ) @@ -539,23 +538,3 @@ def invoke(self, context: InvocationContext) -> BoundingBoxOutput: # endregion - - -@invocation( - "image_batch", - title="Image Batch", - tags=["primitives", "image", "batch", "internal"], - category="primitives", - version="1.0.0", - classification=Classification.Special, -) -class ImageBatchInvocation(BaseInvocation): - """Create a batched generation, where the workflow is executed once for each image in the batch.""" - - images: list[ImageField] = InputField(min_length=1, description="The images to batch over", input=Input.Direct) - - def __init__(self): - raise NotImplementedError("This class should never be executed or instantiated directly.") - - def invoke(self, context: InvocationContext) -> ImageOutput: - raise NotImplementedError("This class should never be executed or instantiated directly.") diff --git a/invokeai/app/invocations/sd3_model_loader.py b/invokeai/app/invocations/sd3_model_loader.py index 6b2d03ef3d9..b7e23b5750f 100644 --- a/invokeai/app/invocations/sd3_model_loader.py +++ b/invokeai/app/invocations/sd3_model_loader.py @@ -10,6 +10,10 @@ from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField, UIType from invokeai.app.invocations.model import CLIPField, ModelIdentifierField, T5EncoderField, TransformerField, VAEField from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.app.util.t5_model_identifier import ( + preprocess_t5_encoder_model_identifier, + preprocess_t5_tokenizer_model_identifier, +) from invokeai.backend.model_manager.config import SubModelType @@ -88,16 +92,8 @@ def invoke(self, context: InvocationContext) -> Sd3ModelLoaderOutput: if self.clip_g_model else self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder2}) ) - tokenizer_t5 = ( - self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.Tokenizer3}) - if self.t5_encoder_model - else self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer3}) - ) - t5_encoder = ( - self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.TextEncoder3}) - if self.t5_encoder_model - else self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder3}) - ) + tokenizer_t5 = preprocess_t5_tokenizer_model_identifier(self.t5_encoder_model or self.model) + t5_encoder = preprocess_t5_encoder_model_identifier(self.t5_encoder_model or self.model) return Sd3ModelLoaderOutput( transformer=TransformerField(transformer=transformer, loras=[]), diff --git a/invokeai/app/invocations/tiled_multi_diffusion_denoise_latents.py b/invokeai/app/invocations/tiled_multi_diffusion_denoise_latents.py index c8e4d3b7dba..208bc56fadf 100644 --- a/invokeai/app/invocations/tiled_multi_diffusion_denoise_latents.py +++ b/invokeai/app/invocations/tiled_multi_diffusion_denoise_latents.py @@ -218,6 +218,7 @@ def _lora_loader() -> Iterator[Tuple[ModelPatchRaw, float]]: scheduler_info=self.unet.scheduler, scheduler_name=self.scheduler, seed=seed, + unet_config=unet_config, ) pipeline = self.create_pipeline(unet=unet, scheduler=scheduler) diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 6d95f2f7fa9..4cc6aa720f6 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -87,6 +87,7 @@ class InvokeAIAppConfig(BaseSettings): log_memory_usage: If True, a memory snapshot will be captured before and after every model cache operation, and the result will be logged (at debug level). There is a time cost to capturing the memory snapshots, so it is recommended to only enable this feature if you are actively inspecting the model cache's behaviour. device_working_mem_gb: The amount of working memory to keep available on the compute device (in GB). Has no effect if running on CPU. If you are experiencing OOM errors, try increasing this value. enable_partial_loading: Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM. + keep_ram_copy_of_weights: Whether to keep a full RAM copy of a model's weights when the model is loaded in VRAM. Keeping a RAM copy increases average RAM usage, but speeds up model switching and LoRA patching (assuming there is sufficient RAM). Set this to False if RAM pressure is consistently high. ram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable. vram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable. lazy_offload: DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable. @@ -162,6 +163,7 @@ class InvokeAIAppConfig(BaseSettings): log_memory_usage: bool = Field(default=False, description="If True, a memory snapshot will be captured before and after every model cache operation, and the result will be logged (at debug level). There is a time cost to capturing the memory snapshots, so it is recommended to only enable this feature if you are actively inspecting the model cache's behaviour.") device_working_mem_gb: float = Field(default=3, description="The amount of working memory to keep available on the compute device (in GB). Has no effect if running on CPU. If you are experiencing OOM errors, try increasing this value.") enable_partial_loading: bool = Field(default=False, description="Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM.") + keep_ram_copy_of_weights: bool = Field(default=True, description="Whether to keep a full RAM copy of a model's weights when the model is loaded in VRAM. Keeping a RAM copy increases average RAM usage, but speeds up model switching and LoRA patching (assuming there is sufficient RAM). Set this to False if RAM pressure is consistently high.") # Deprecated CACHE configs ram: Optional[float] = Field(default=None, gt=0, description="DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.") vram: Optional[float] = Field(default=None, ge=0, description="DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.") diff --git a/invokeai/app/services/model_manager/model_manager_default.py b/invokeai/app/services/model_manager/model_manager_default.py index cec3b0bc18b..9ad10c5e737 100644 --- a/invokeai/app/services/model_manager/model_manager_default.py +++ b/invokeai/app/services/model_manager/model_manager_default.py @@ -84,6 +84,7 @@ def build_model_manager( ram_cache = ModelCache( execution_device_working_mem_gb=app_config.device_working_mem_gb, enable_partial_loading=app_config.enable_partial_loading, + keep_ram_copy_of_weights=app_config.keep_ram_copy_of_weights, max_ram_cache_size_gb=app_config.max_cache_ram_gb, max_vram_cache_size_gb=app_config.max_cache_vram_gb, execution_device=execution_device or TorchDevice.choose_torch_device(), diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index d43688efad4..01053cd07ee 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -108,8 +108,16 @@ def validate_types(cls, v: Optional[BatchDataCollection]): return v for batch_data_list in v: for datum in batch_data_list: + if not datum.items: + continue + + # Special handling for numbers - they can be mixed + # TODO(psyche): Update BatchDatum to have a `type` field to specify the type of the items, then we can have strict float and int fields + if all(isinstance(item, (int, float)) for item in datum.items): + continue + # Get the type of the first item in the list - first_item_type = type(datum.items[0]) if datum.items else None + first_item_type = type(datum.items[0]) for item in datum.items: if type(item) is not first_item_type: raise BatchItemsTypeError("All items in a batch must have the same type") diff --git a/invokeai/app/util/t5_model_identifier.py b/invokeai/app/util/t5_model_identifier.py new file mode 100644 index 00000000000..eb3a84aee52 --- /dev/null +++ b/invokeai/app/util/t5_model_identifier.py @@ -0,0 +1,26 @@ +from invokeai.app.invocations.model import ModelIdentifierField +from invokeai.backend.model_manager.config import BaseModelType, SubModelType + + +def preprocess_t5_encoder_model_identifier(model_identifier: ModelIdentifierField) -> ModelIdentifierField: + """A helper function to normalize a T5 encoder model identifier so that T5 models associated with FLUX + or SD3 models can be used interchangeably. + """ + if model_identifier.base == BaseModelType.Any: + return model_identifier.model_copy(update={"submodel_type": SubModelType.TextEncoder2}) + elif model_identifier.base == BaseModelType.StableDiffusion3: + return model_identifier.model_copy(update={"submodel_type": SubModelType.TextEncoder3}) + else: + raise ValueError(f"Unsupported model base: {model_identifier.base}") + + +def preprocess_t5_tokenizer_model_identifier(model_identifier: ModelIdentifierField) -> ModelIdentifierField: + """A helper function to normalize a T5 tokenizer model identifier so that T5 models associated with FLUX + or SD3 models can be used interchangeably. + """ + if model_identifier.base == BaseModelType.Any: + return model_identifier.model_copy(update={"submodel_type": SubModelType.Tokenizer2}) + elif model_identifier.base == BaseModelType.StableDiffusion3: + return model_identifier.model_copy(update={"submodel_type": SubModelType.Tokenizer3}) + else: + raise ValueError(f"Unsupported model base: {model_identifier.base}") diff --git a/invokeai/backend/flux/modules/conditioner.py b/invokeai/backend/flux/modules/conditioner.py index c03e877e2db..ffbbbf20dd7 100644 --- a/invokeai/backend/flux/modules/conditioner.py +++ b/invokeai/backend/flux/modules/conditioner.py @@ -1,13 +1,19 @@ # Initially pulled from https://github.com/black-forest-labs/flux from torch import Tensor, nn -from transformers import PreTrainedModel, PreTrainedTokenizer +from transformers import PreTrainedModel, PreTrainedTokenizer, PreTrainedTokenizerFast from invokeai.backend.util.devices import TorchDevice class HFEncoder(nn.Module): - def __init__(self, encoder: PreTrainedModel, tokenizer: PreTrainedTokenizer, is_clip: bool, max_length: int): + def __init__( + self, + encoder: PreTrainedModel, + tokenizer: PreTrainedTokenizer | PreTrainedTokenizerFast, + is_clip: bool, + max_length: int, + ): super().__init__() self.max_length = max_length self.is_clip = is_clip diff --git a/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_only_full_load.py b/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_only_full_load.py index 719a559dd02..be398fa1295 100644 --- a/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_only_full_load.py +++ b/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_only_full_load.py @@ -9,12 +9,17 @@ class CachedModelOnlyFullLoad: MPS memory, etc. """ - def __init__(self, model: torch.nn.Module | Any, compute_device: torch.device, total_bytes: int): + def __init__( + self, model: torch.nn.Module | Any, compute_device: torch.device, total_bytes: int, keep_ram_copy: bool = False + ): """Initialize a CachedModelOnlyFullLoad. Args: model (torch.nn.Module | Any): The model to wrap. Should be on the CPU. compute_device (torch.device): The compute device to move the model to. total_bytes (int): The total size (in bytes) of all the weights in the model. + keep_ram_copy (bool): Whether to keep a read-only copy of the model's state dict in RAM. Keeping a RAM copy + increases RAM usage, but speeds up model offload from VRAM and LoRA patching (assuming there is + sufficient RAM). """ # model is often a torch.nn.Module, but could be any model type. Throughout this class, we handle both cases. self._model = model @@ -23,7 +28,7 @@ def __init__(self, model: torch.nn.Module | Any, compute_device: torch.device, t # A CPU read-only copy of the model's state dict. self._cpu_state_dict: dict[str, torch.Tensor] | None = None - if isinstance(model, torch.nn.Module): + if isinstance(model, torch.nn.Module) and keep_ram_copy: self._cpu_state_dict = model.state_dict() self._total_bytes = total_bytes diff --git a/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_with_partial_load.py b/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_with_partial_load.py index 3c069c975d9..004943c0174 100644 --- a/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_with_partial_load.py +++ b/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_with_partial_load.py @@ -14,33 +14,38 @@ class CachedModelWithPartialLoad: MPS memory, etc. """ - def __init__(self, model: torch.nn.Module, compute_device: torch.device): + def __init__(self, model: torch.nn.Module, compute_device: torch.device, keep_ram_copy: bool = False): self._model = model self._compute_device = compute_device - # A CPU read-only copy of the model's state dict. - self._cpu_state_dict: dict[str, torch.Tensor] = model.state_dict() + model_state_dict = model.state_dict() + # A CPU read-only copy of the model's state dict. Used for faster model unloads from VRAM, and to speed up LoRA + # patching. Set to `None` if keep_ram_copy is False. + self._cpu_state_dict: dict[str, torch.Tensor] | None = model_state_dict if keep_ram_copy else None # A dictionary of the size of each tensor in the state dict. # HACK(ryand): We use this dictionary any time we are doing byte tracking calculations. We do this for # consistency in case the application code has modified the model's size (e.g. by casting to a different # precision). Of course, this means that we are making model cache load/unload decisions based on model size # data that may not be fully accurate. - self._state_dict_bytes = {k: calc_tensor_size(v) for k, v in self._cpu_state_dict.items()} + self._state_dict_bytes = {k: calc_tensor_size(v) for k, v in model_state_dict.items()} self._total_bytes = sum(self._state_dict_bytes.values()) self._cur_vram_bytes: int | None = None self._modules_that_support_autocast = self._find_modules_that_support_autocast() - self._keys_in_modules_that_do_not_support_autocast = self._find_keys_in_modules_that_do_not_support_autocast() + self._keys_in_modules_that_do_not_support_autocast = self._find_keys_in_modules_that_do_not_support_autocast( + model_state_dict + ) + self._state_dict_keys_by_module_prefix = self._group_state_dict_keys_by_module_prefix(model_state_dict) def _find_modules_that_support_autocast(self) -> dict[str, torch.nn.Module]: """Find all modules that support autocasting.""" return {n: m for n, m in self._model.named_modules() if isinstance(m, CustomModuleMixin)} # type: ignore - def _find_keys_in_modules_that_do_not_support_autocast(self) -> set[str]: + def _find_keys_in_modules_that_do_not_support_autocast(self, state_dict: dict[str, torch.Tensor]) -> set[str]: keys_in_modules_that_do_not_support_autocast: set[str] = set() - for key in self._cpu_state_dict.keys(): + for key in state_dict.keys(): for module_name in self._modules_that_support_autocast.keys(): if key.startswith(module_name): break @@ -48,6 +53,47 @@ def _find_keys_in_modules_that_do_not_support_autocast(self) -> set[str]: keys_in_modules_that_do_not_support_autocast.add(key) return keys_in_modules_that_do_not_support_autocast + def _group_state_dict_keys_by_module_prefix(self, state_dict: dict[str, torch.Tensor]) -> dict[str, list[str]]: + """A helper function that groups state dict keys by module prefix. + + Example: + ``` + state_dict = { + "weight": ..., + "module.submodule.weight": ..., + "module.submodule.bias": ..., + "module.other_submodule.weight": ..., + "module.other_submodule.bias": ..., + } + + output = group_state_dict_keys_by_module_prefix(state_dict) + + # The output will be: + output = { + "": [ + "weight", + ], + "module.submodule": [ + "module.submodule.weight", + "module.submodule.bias", + ], + "module.other_submodule": [ + "module.other_submodule.weight", + "module.other_submodule.bias", + ], + } + ``` + """ + state_dict_keys_by_module_prefix: dict[str, list[str]] = {} + for key in state_dict.keys(): + split = key.rsplit(".", 1) + # `split` will have length 1 if the root module has parameters. + module_name = split[0] if len(split) > 1 else "" + if module_name not in state_dict_keys_by_module_prefix: + state_dict_keys_by_module_prefix[module_name] = [] + state_dict_keys_by_module_prefix[module_name].append(key) + return state_dict_keys_by_module_prefix + def _move_non_persistent_buffers_to_device(self, device: torch.device): """Move the non-persistent buffers to the target device. These buffers are not included in the state dict, so we need to move them manually. @@ -98,6 +144,82 @@ def full_unload_from_vram(self) -> int: """Unload all weights from VRAM.""" return self.partial_unload_from_vram(self.total_bytes()) + def _load_state_dict_with_device_conversion( + self, state_dict: dict[str, torch.Tensor], keys_to_convert: set[str], target_device: torch.device + ): + if self._cpu_state_dict is not None: + # Run the fast version. + self._load_state_dict_with_fast_device_conversion( + state_dict=state_dict, + keys_to_convert=keys_to_convert, + target_device=target_device, + cpu_state_dict=self._cpu_state_dict, + ) + else: + # Run the low-virtual-memory version. + self._load_state_dict_with_jit_device_conversion( + state_dict=state_dict, + keys_to_convert=keys_to_convert, + target_device=target_device, + ) + + def _load_state_dict_with_jit_device_conversion( + self, + state_dict: dict[str, torch.Tensor], + keys_to_convert: set[str], + target_device: torch.device, + ): + """A custom state dict loading implementation with good peak memory properties. + + This implementation has the important property that it copies parameters to the target device one module at a time + rather than applying all of the device conversions and then calling load_state_dict(). This is done to minimize the + peak virtual memory usage. Specifically, we want to avoid a case where we hold references to all of the CPU weights + and CUDA weights simultaneously, because Windows will reserve virtual memory for both. + """ + for module_name, module in self._model.named_modules(): + module_keys = self._state_dict_keys_by_module_prefix.get(module_name, []) + # Calculate the length of the module name prefix. + prefix_len = len(module_name) + if prefix_len > 0: + prefix_len += 1 + + module_state_dict = {} + for key in module_keys: + if key in keys_to_convert: + # It is important that we overwrite `state_dict[key]` to avoid keeping two copies of the same + # parameter. + state_dict[key] = state_dict[key].to(target_device) + # Note that we keep parameters that have not been moved to a new device in case the module implements + # weird custom state dict loading logic that requires all parameters to be present. + module_state_dict[key[prefix_len:]] = state_dict[key] + + if len(module_state_dict) > 0: + # We set strict=False, because if `module` has both parameters and child modules, then we are loading a + # state dict that only contains the parameters of `module` (not its children). + # We assume that it is rare for non-leaf modules to have parameters. Calling load_state_dict() on non-leaf + # modules will recurse through all of the children, so is a bit wasteful. + incompatible_keys = module.load_state_dict(module_state_dict, strict=False, assign=True) + # Missing keys are ok, unexpected keys are not. + assert len(incompatible_keys.unexpected_keys) == 0 + + def _load_state_dict_with_fast_device_conversion( + self, + state_dict: dict[str, torch.Tensor], + keys_to_convert: set[str], + target_device: torch.device, + cpu_state_dict: dict[str, torch.Tensor], + ): + """Convert parameters to the target device and load them into the model. Leverages the `cpu_state_dict` to speed + up transfers of weights to the CPU. + """ + for key in keys_to_convert: + if target_device.type == "cpu": + state_dict[key] = cpu_state_dict[key] + else: + state_dict[key] = state_dict[key].to(target_device) + + self._model.load_state_dict(state_dict, assign=True) + @torch.no_grad() def partial_load_to_vram(self, vram_bytes_to_load: int) -> int: """Load more weights into VRAM without exceeding vram_bytes_to_load. @@ -112,26 +234,33 @@ def partial_load_to_vram(self, vram_bytes_to_load: int) -> int: cur_state_dict = self._model.state_dict() + # Identify the keys that will be loaded into VRAM. + keys_to_load: set[str] = set() + # First, process the keys that *must* be loaded into VRAM. for key in self._keys_in_modules_that_do_not_support_autocast: param = cur_state_dict[key] if param.device.type == self._compute_device.type: continue + keys_to_load.add(key) param_size = self._state_dict_bytes[key] - cur_state_dict[key] = param.to(self._compute_device, copy=True) vram_bytes_loaded += param_size if vram_bytes_loaded > vram_bytes_to_load: logger = InvokeAILogger.get_logger() logger.warning( - f"Loaded {vram_bytes_loaded / 2**20} MB into VRAM, but only {vram_bytes_to_load / 2**20} MB were " + f"Loading {vram_bytes_loaded / 2**20} MB into VRAM, but only {vram_bytes_to_load / 2**20} MB were " "requested. This is the minimum set of weights in VRAM required to run the model." ) # Next, process the keys that can optionally be loaded into VRAM. fully_loaded = True for key, param in cur_state_dict.items(): + # Skip the keys that have already been processed above. + if key in keys_to_load: + continue + if param.device.type == self._compute_device.type: continue @@ -142,14 +271,14 @@ def partial_load_to_vram(self, vram_bytes_to_load: int) -> int: fully_loaded = False continue - cur_state_dict[key] = param.to(self._compute_device, copy=True) + keys_to_load.add(key) vram_bytes_loaded += param_size - if vram_bytes_loaded > 0: + if len(keys_to_load) > 0: # We load the entire state dict, not just the parameters that changed, in case there are modules that # override _load_from_state_dict() and do some funky stuff that requires the entire state dict. # Alternatively, in the future, grouping parameters by module could probably solve this problem. - self._model.load_state_dict(cur_state_dict, assign=True) + self._load_state_dict_with_device_conversion(cur_state_dict, keys_to_load, self._compute_device) if self._cur_vram_bytes is not None: self._cur_vram_bytes += vram_bytes_loaded @@ -180,6 +309,10 @@ def partial_unload_from_vram(self, vram_bytes_to_free: int, keep_required_weight offload_device = "cpu" cur_state_dict = self._model.state_dict() + + # Identify the keys that will be offloaded to CPU. + keys_to_offload: set[str] = set() + for key, param in cur_state_dict.items(): if vram_bytes_freed >= vram_bytes_to_free: break @@ -191,11 +324,11 @@ def partial_unload_from_vram(self, vram_bytes_to_free: int, keep_required_weight required_weights_in_vram += self._state_dict_bytes[key] continue - cur_state_dict[key] = self._cpu_state_dict[key] + keys_to_offload.add(key) vram_bytes_freed += self._state_dict_bytes[key] - if vram_bytes_freed > 0: - self._model.load_state_dict(cur_state_dict, assign=True) + if len(keys_to_offload) > 0: + self._load_state_dict_with_device_conversion(cur_state_dict, keys_to_offload, torch.device("cpu")) if self._cur_vram_bytes is not None: self._cur_vram_bytes -= vram_bytes_freed diff --git a/invokeai/backend/model_manager/load/model_cache/model_cache.py b/invokeai/backend/model_manager/load/model_cache/model_cache.py index bf51b974ce3..237f5324fff 100644 --- a/invokeai/backend/model_manager/load/model_cache/model_cache.py +++ b/invokeai/backend/model_manager/load/model_cache/model_cache.py @@ -78,6 +78,7 @@ def __init__( self, execution_device_working_mem_gb: float, enable_partial_loading: bool, + keep_ram_copy_of_weights: bool, max_ram_cache_size_gb: float | None = None, max_vram_cache_size_gb: float | None = None, execution_device: torch.device | str = "cuda", @@ -105,6 +106,7 @@ def __init__( :param logger: InvokeAILogger to use (otherwise creates one) """ self._enable_partial_loading = enable_partial_loading + self._keep_ram_copy_of_weights = keep_ram_copy_of_weights self._execution_device_working_mem_gb = execution_device_working_mem_gb self._execution_device: torch.device = torch.device(execution_device) self._storage_device: torch.device = torch.device(storage_device) @@ -121,6 +123,8 @@ def __init__( self._cached_models: Dict[str, CacheRecord] = {} self._cache_stack: List[str] = [] + self._ram_cache_size_bytes = self._calc_ram_available_to_model_cache() + @property def stats(self) -> Optional[CacheStats]: """Return collected CacheStats object.""" @@ -154,9 +158,13 @@ def put(self, key: str, model: AnyModel) -> None: # Wrap model. if isinstance(model, torch.nn.Module) and running_with_cuda and self._enable_partial_loading: - wrapped_model = CachedModelWithPartialLoad(model, self._execution_device) + wrapped_model = CachedModelWithPartialLoad( + model, self._execution_device, keep_ram_copy=self._keep_ram_copy_of_weights + ) else: - wrapped_model = CachedModelOnlyFullLoad(model, self._execution_device, size) + wrapped_model = CachedModelOnlyFullLoad( + model, self._execution_device, size, keep_ram_copy=self._keep_ram_copy_of_weights + ) cache_record = CacheRecord(key=key, cached_model=wrapped_model) self._cached_models[key] = cache_record @@ -382,41 +390,89 @@ def _get_vram_in_use(self) -> int: # Alternative definition of VRAM in use: # return sum(ce.cached_model.cur_vram_bytes() for ce in self._cached_models.values()) - def _get_ram_available(self) -> int: - """Get the amount of RAM available for the cache to use, while keeping memory pressure under control.""" + def _calc_ram_available_to_model_cache(self) -> int: + """Calculate the amount of RAM available for the cache to use.""" # If self._max_ram_cache_size_gb is set, then it overrides the default logic. if self._max_ram_cache_size_gb is not None: - ram_total_available_to_cache = int(self._max_ram_cache_size_gb * GB) - return ram_total_available_to_cache - self._get_ram_in_use() - - virtual_memory = psutil.virtual_memory() - ram_total = virtual_memory.total - ram_available = virtual_memory.available - ram_used = ram_total - ram_available - - # The total size of all the models in the cache will often be larger than the amount of RAM reported by psutil - # (due to lazy-loading and OS RAM caching behaviour). We could just rely on the psutil values, but it feels - # like a bad idea to over-fill the model cache. So, for now, we'll try to keep the total size of models in the - # cache under the total amount of system RAM. - cache_ram_used = self._get_ram_in_use() - ram_used = max(cache_ram_used, ram_used) - - # Aim to keep 10% of RAM free. - ram_available_based_on_memory_usage = int(ram_total * 0.9) - ram_used + self._logger.info(f"Using user-defined RAM cache size: {self._max_ram_cache_size_gb} GB.") + return int(self._max_ram_cache_size_gb * GB) + + # Heuristics for dynamically calculating the RAM cache size, **in order of increasing priority**: + # 1. As an initial default, use 50% of the total RAM for InvokeAI. + # - Assume a 2GB baseline for InvokeAI's non-model RAM usage, and use the rest of the RAM for the model cache. + # 2. On a system with a lot of RAM (e.g. 64GB+), users probably don't want InvokeAI to eat up too much RAM. + # There are diminishing returns to storing more and more models. So, we apply an upper bound. + # - On systems without a CUDA device, the upper bound is 32GB. + # - On systems with a CUDA device, the upper bound is 2x the amount of VRAM. + # 3. On systems with a CUDA device, the minimum should be the VRAM size (less the working memory). + # - Setting lower than this would mean that we sometimes kick models out of the cache when there is room for + # all models in VRAM. + # - Consider an extreme case of a system with 8GB RAM / 24GB VRAM. I haven't tested this, but I think + # you'd still want the RAM cache size to be ~24GB (less the working memory). (Though you'd probably want to + # set `keep_ram_copy_of_weights: false` in this case.) + # 4. Absolute minimum of 4GB. + + # NOTE(ryand): We explored dynamically adjusting the RAM cache size based on memory pressure (using psutil), but + # decided against it for now, for the following reasons: + # - It was surprisingly difficult to get memory metrics with consistent definitions across OSes. (If you go + # down this path again, don't underestimate the amount of complexity here and be sure to test rigorously on all + # OSes.) + # - Making the RAM cache size dynamic opens the door for performance regressions that are hard to diagnose and + # hard for users to understand. It is better for users to see that their RAM is maxed out, and then override + # the default value if desired. + + # Lookup the total VRAM size for the CUDA execution device. + total_cuda_vram_bytes: int | None = None + if self._execution_device.type == "cuda": + _, total_cuda_vram_bytes = torch.cuda.mem_get_info(self._execution_device) + + # Apply heuristic 1. + # ------------------ + heuristics_applied = [1] + total_system_ram_bytes = psutil.virtual_memory().total + # Assumed baseline RAM used by InvokeAI for non-model stuff. + baseline_ram_used_by_invokeai = 2 * GB + ram_available_to_model_cache = int(total_system_ram_bytes * 0.5 - baseline_ram_used_by_invokeai) + + # Apply heuristic 2. + # ------------------ + max_ram_cache_size_bytes = 32 * GB + if total_cuda_vram_bytes is not None: + max_ram_cache_size_bytes = 2 * total_cuda_vram_bytes + if ram_available_to_model_cache > max_ram_cache_size_bytes: + heuristics_applied.append(2) + ram_available_to_model_cache = max_ram_cache_size_bytes + + # Apply heuristic 3. + # ------------------ + if total_cuda_vram_bytes is not None: + if self._max_vram_cache_size_gb is not None: + min_ram_cache_size_bytes = int(self._max_vram_cache_size_gb * GB) + else: + min_ram_cache_size_bytes = total_cuda_vram_bytes - int(self._execution_device_working_mem_gb * GB) + if ram_available_to_model_cache < min_ram_cache_size_bytes: + heuristics_applied.append(3) + ram_available_to_model_cache = min_ram_cache_size_bytes - # If we are running out of RAM, then there's an increased likelihood that we will run into this issue: - # https://github.com/invoke-ai/InvokeAI/issues/7513 - # To keep things running smoothly, there's a minimum RAM cache size that we always allow (even if this means - # using swap). - min_ram_cache_size_bytes = 4 * GB - ram_available_based_on_min_cache_size = min_ram_cache_size_bytes - cache_ram_used + # Apply heuristic 4. + # ------------------ + if ram_available_to_model_cache < 4 * GB: + heuristics_applied.append(4) + ram_available_to_model_cache = 4 * GB - return max(ram_available_based_on_memory_usage, ram_available_based_on_min_cache_size) + self._logger.info( + f"Calculated model RAM cache size: {ram_available_to_model_cache / MB:.2f} MB. Heuristics applied: {heuristics_applied}." + ) + return ram_available_to_model_cache def _get_ram_in_use(self) -> int: """Get the amount of RAM currently in use.""" return sum(ce.cached_model.total_bytes() for ce in self._cached_models.values()) + def _get_ram_available(self) -> int: + """Get the amount of RAM available for the cache to use.""" + return self._ram_cache_size_bytes - self._get_ram_in_use() + def _capture_memory_snapshot(self) -> Optional[MemorySnapshot]: if self._log_memory_usage: return MemorySnapshot.capture() diff --git a/invokeai/backend/model_manager/load/model_loaders/flux.py b/invokeai/backend/model_manager/load/model_loaders/flux.py index edf14ec48cc..d44cc014431 100644 --- a/invokeai/backend/model_manager/load/model_loaders/flux.py +++ b/invokeai/backend/model_manager/load/model_loaders/flux.py @@ -80,19 +80,19 @@ def _load_model( raise ValueError("Only VAECheckpointConfig models are currently supported here.") model_path = Path(config.path) - with SilenceWarnings(): + with accelerate.init_empty_weights(): model = AutoEncoder(ae_params[config.config_path]) - sd = load_file(model_path) - model.load_state_dict(sd, assign=True) - # VAE is broken in float16, which mps defaults to - if self._torch_dtype == torch.float16: - try: - vae_dtype = torch.tensor([1.0], dtype=torch.bfloat16, device=self._torch_device).dtype - except TypeError: - vae_dtype = torch.float32 - else: - vae_dtype = self._torch_dtype - model.to(vae_dtype) + sd = load_file(model_path) + model.load_state_dict(sd, assign=True) + # VAE is broken in float16, which mps defaults to + if self._torch_dtype == torch.float16: + try: + vae_dtype = torch.tensor([1.0], dtype=torch.bfloat16, device=self._torch_device).dtype + except TypeError: + vae_dtype = torch.float32 + else: + vae_dtype = self._torch_dtype + model.to(vae_dtype) return model @@ -183,7 +183,9 @@ def _load_model( case SubModelType.Tokenizer2 | SubModelType.Tokenizer3: return T5Tokenizer.from_pretrained(Path(config.path) / "tokenizer_2", max_length=512) case SubModelType.TextEncoder2 | SubModelType.TextEncoder3: - return T5EncoderModel.from_pretrained(Path(config.path) / "text_encoder_2", torch_dtype="auto") + return T5EncoderModel.from_pretrained( + Path(config.path) / "text_encoder_2", torch_dtype="auto", low_cpu_mem_usage=True + ) raise ValueError( f"Only Tokenizer and TextEncoder submodels are currently supported. Received: {submodel_type.value if submodel_type else 'None'}" @@ -217,17 +219,18 @@ def _load_from_singlefile( assert isinstance(config, MainCheckpointConfig) model_path = Path(config.path) - with SilenceWarnings(): + with accelerate.init_empty_weights(): model = Flux(params[config.config_path]) - sd = load_file(model_path) - if "model.diffusion_model.double_blocks.0.img_attn.norm.key_norm.scale" in sd: - sd = convert_bundle_to_flux_transformer_checkpoint(sd) - new_sd_size = sum([ten.nelement() * torch.bfloat16.itemsize for ten in sd.values()]) - self._ram_cache.make_room(new_sd_size) - for k in sd.keys(): - # We need to cast to bfloat16 due to it being the only currently supported dtype for inference - sd[k] = sd[k].to(torch.bfloat16) - model.load_state_dict(sd, assign=True) + + sd = load_file(model_path) + if "model.diffusion_model.double_blocks.0.img_attn.norm.key_norm.scale" in sd: + sd = convert_bundle_to_flux_transformer_checkpoint(sd) + new_sd_size = sum([ten.nelement() * torch.bfloat16.itemsize for ten in sd.values()]) + self._ram_cache.make_room(new_sd_size) + for k in sd.keys(): + # We need to cast to bfloat16 due to it being the only currently supported dtype for inference + sd[k] = sd[k].to(torch.bfloat16) + model.load_state_dict(sd, assign=True) return model @@ -258,11 +261,11 @@ def _load_from_singlefile( assert isinstance(config, MainGGUFCheckpointConfig) model_path = Path(config.path) - with SilenceWarnings(): + with accelerate.init_empty_weights(): model = Flux(params[config.config_path]) - # HACK(ryand): We shouldn't be hard-coding the compute_dtype here. - sd = gguf_sd_loader(model_path, compute_dtype=torch.bfloat16) + # HACK(ryand): We shouldn't be hard-coding the compute_dtype here. + sd = gguf_sd_loader(model_path, compute_dtype=torch.bfloat16) # HACK(ryand): There are some broken GGUF models in circulation that have the wrong shape for img_in.weight. # We override the shape here to fix the issue. diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 285356cb6e2..ef1bbb04503 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -76,6 +76,7 @@ "konva": "^9.3.15", "lodash-es": "^4.17.21", "lru-cache": "^11.0.1", + "mtwist": "^1.0.2", "nanoid": "^5.0.7", "nanostores": "^0.11.3", "new-github-issue-url": "^1.0.0", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 2251219af39..4d7a0830d24 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -77,6 +77,9 @@ dependencies: lru-cache: specifier: ^11.0.1 version: 11.0.1 + mtwist: + specifier: ^1.0.2 + version: 1.0.2 nanoid: specifier: ^5.0.7 version: 5.0.7 @@ -7016,6 +7019,10 @@ packages: /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + /mtwist@1.0.2: + resolution: {integrity: sha512-eRsSga5jkLg7nNERPOV8vDNxgSwuEcj5upQfJcT0gXfJwXo3pMc7xOga0fu8rXHyrxzl7GFVWWDuaPQgpKDvgw==} + dev: false + /muggle-string@0.3.1: resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} dev: true diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index f3dcc8097d0..1aab7a2ad95 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -177,7 +177,16 @@ "none": "None", "new": "New", "generating": "Generating", - "warnings": "Warnings" + "warnings": "Warnings", + "start": "Start", + "count": "Count", + "step": "Step", + "end": "End", + "min": "Min", + "max": "Max", + "values": "Values", + "resetToDefaults": "Reset to Defaults", + "seed": "Seed" }, "hrf": { "hrf": "High Resolution Fix", @@ -850,6 +859,15 @@ "defaultVAE": "Default VAE" }, "nodes": { + "arithmeticSequence": "Arithmetic Sequence", + "linearDistribution": "Linear Distribution", + "uniformRandomDistribution": "Uniform Random Distribution", + "parseString": "Parse String", + "splitOn": "Split On", + "noBatchGroup": "no group", + "generatorNRandomValues_one": "{{count}} random value", + "generatorNRandomValues_other": "{{count}} random values", + "generatorNoValues": "empty", "addNode": "Add Node", "addNodeToolTip": "Add Node (Shift+A, Space)", "addLinearView": "Add to Linear View", @@ -989,7 +1007,11 @@ "imageAccessError": "Unable to find image {{image_name}}, resetting to default", "boardAccessError": "Unable to find board {{board_id}}, resetting to default", "modelAccessError": "Unable to find model {{key}}, resetting to default", - "saveToGallery": "Save To Gallery" + "saveToGallery": "Save To Gallery", + "addItem": "Add Item", + "generateValues": "Generate Values", + "floatRangeGenerator": "Float Range Generator", + "integerRangeGenerator": "Integer Range Generator" }, "parameters": { "aspect": "Aspect", @@ -1024,11 +1046,22 @@ "addingImagesTo": "Adding images to", "invoke": "Invoke", "missingFieldTemplate": "Missing field template", - "missingInputForField": "{{nodeLabel}} -> {{fieldLabel}}: missing input", + "missingInputForField": "missing input", "missingNodeTemplate": "Missing node template", - "collectionEmpty": "{{nodeLabel}} -> {{fieldLabel}} empty collection", - "collectionTooFewItems": "{{nodeLabel}} -> {{fieldLabel}}: too few items, minimum {{minItems}}", - "collectionTooManyItems": "{{nodeLabel}} -> {{fieldLabel}}: too many items, maximum {{maxItems}}", + "emptyBatches": "empty batches", + "batchNodeNotConnected": "Batch node not connected: {{label}}", + "batchNodeEmptyCollection": "Some batch nodes have empty collections", + "invalidBatchConfigurationCannotCalculate": "Invalid batch configuration; cannot calculate", + "collectionTooFewItems": "too few items, minimum {{minItems}}", + "collectionTooManyItems": "too many items, maximum {{maxItems}}", + "collectionStringTooLong": "too long, max {{maxLength}}", + "collectionStringTooShort": "too short, min {{minLength}}", + "collectionNumberGTMax": "{{value}} > {{maximum}} (inc max)", + "collectionNumberLTMin": "{{value}} < {{minimum}} (inc min)", + "collectionNumberGTExclusiveMax": "{{value}} >= {{exclusiveMaximum}} (exc max)", + "collectionNumberLTExclusiveMin": "{{value}} <= {{exclusiveMinimum}} (exc min)", + "collectionNumberNotMultipleOf": "{{value}} not multiple of {{multipleOf}}", + "batchNodeCollectionSizeMismatch": "Collection size mismatch on Batch {{batchGroupId}}", "noModelSelected": "No model selected", "noT5EncoderModelSelected": "No T5 Encoder model selected for FLUX generation", "noFLUXVAEModelSelected": "No VAE model selected for FLUX generation", @@ -1932,6 +1965,24 @@ "description": "Generates an edge map from the selected layer using the PiDiNet edge detection model.", "scribble": "Scribble", "quantize_edges": "Quantize Edges" + }, + "img_blur": { + "label": "Blur Image", + "description": "Blurs the selected layer.", + "blur_type": "Blur Type", + "blur_radius": "Radius", + "gaussian_type": "Gaussian", + "box_type": "Box" + }, + "img_noise": { + "label": "Noise Image", + "description": "Adds noise to the selected layer.", + "noise_type": "Noise Type", + "noise_amount": "Amount", + "gaussian_type": "Gaussian", + "salt_and_pepper_type": "Salt and Pepper", + "noise_color": "Colored Noise", + "size": "Noise Size" } }, "transform": { diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts index 1964aa7ef2f..750582d9865 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts @@ -1,16 +1,14 @@ -import { logger } from 'app/logging/logger'; import { enqueueRequested } from 'app/store/actions'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import { selectNodesSlice } from 'features/nodes/store/selectors'; -import { isImageFieldCollectionInputInstance } from 'features/nodes/types/field'; -import { isInvocationNode } from 'features/nodes/types/invocation'; +import { isBatchNode, isInvocationNode } from 'features/nodes/types/invocation'; import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph'; import { buildWorkflowWithValidation } from 'features/nodes/util/workflow/buildWorkflow'; +import { resolveBatchValue } from 'features/queue/store/readiness'; +import { groupBy } from 'lodash-es'; import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue'; import type { Batch, BatchConfig } from 'services/api/types'; -const log = logger('workflows'); - export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) => { startAppListening({ predicate: (action): action is ReturnType => @@ -33,28 +31,54 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) = const data: Batch['data'] = []; - // Skip edges from batch nodes - these should not be in the graph, they exist only in the UI - const imageBatchNodes = nodes.nodes.filter(isInvocationNode).filter((node) => node.data.type === 'image_batch'); - for (const node of imageBatchNodes) { - const images = node.data.inputs['images']; - if (!isImageFieldCollectionInputInstance(images)) { - log.warn({ nodeId: node.id }, 'Image batch images field is not an image collection'); - break; - } - const edgesFromImageBatch = nodes.edges.filter((e) => e.source === node.id && e.sourceHandle === 'image'); - const batchDataCollectionItem: NonNullable[number] = []; - for (const edge of edgesFromImageBatch) { - if (!edge.targetHandle) { - break; + const invocationNodes = nodes.nodes.filter(isInvocationNode); + const batchNodes = invocationNodes.filter(isBatchNode); + + // Handle zipping batch nodes. First group the batch nodes by their batch_group_id + const groupedBatchNodes = groupBy(batchNodes, (node) => node.data.inputs['batch_group_id']?.value); + + // Then, we will create a batch data collection item for each group + for (const [batchGroupId, batchNodes] of Object.entries(groupedBatchNodes)) { + const zippedBatchDataCollectionItems: NonNullable[number] = []; + + for (const node of batchNodes) { + const value = resolveBatchValue(node, invocationNodes, nodes.edges); + const sourceHandle = node.data.type === 'image_batch' ? 'image' : 'value'; + const edgesFromBatch = nodes.edges.filter((e) => e.source === node.id && e.sourceHandle === sourceHandle); + if (batchGroupId !== 'None') { + // If this batch node has a batch_group_id, we will zip the data collection items + for (const edge of edgesFromBatch) { + if (!edge.targetHandle) { + break; + } + zippedBatchDataCollectionItems.push({ + node_path: edge.target, + field_name: edge.targetHandle, + items: value, + }); + } + } else { + // Otherwise add the data collection items to root of the batch so they are not zipped + const productBatchDataCollectionItems: NonNullable[number] = []; + for (const edge of edgesFromBatch) { + if (!edge.targetHandle) { + break; + } + productBatchDataCollectionItems.push({ + node_path: edge.target, + field_name: edge.targetHandle, + items: value, + }); + } + if (productBatchDataCollectionItems.length > 0) { + data.push(productBatchDataCollectionItems); + } } - batchDataCollectionItem.push({ - node_path: edge.target, - field_name: edge.targetHandle, - items: images.value, - }); } - if (batchDataCollectionItem.length > 0) { - data.push(batchDataCollectionItem); + + // Finally, if this batch data collection item has any items, add it to the data array + if (batchGroupId !== 'None' && zippedBatchDataCollectionItems.length > 0) { + data.push(zippedBatchDataCollectionItems); } } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterBlur.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterBlur.tsx new file mode 100644 index 00000000000..958b35c8361 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterBlur.tsx @@ -0,0 +1,72 @@ +import type { ComboboxOnChange } from '@invoke-ai/ui-library'; +import { Combobox, CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import type { BlurFilterConfig, BlurTypes } from 'features/controlLayers/store/filters'; +import { IMAGE_FILTERS, isBlurTypes } from 'features/controlLayers/store/filters'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; +const DEFAULTS = IMAGE_FILTERS.img_blur.buildDefaults(); + +export const FilterBlur = memo(({ onChange, config }: Props) => { + const { t } = useTranslation(); + const handleBlurTypeChange = useCallback( + (v) => { + if (!isBlurTypes(v?.value)) { + return; + } + onChange({ ...config, blur_type: v.value }); + }, + [config, onChange] + ); + + const handleRadiusChange = useCallback( + (v: number) => { + onChange({ ...config, radius: v }); + }, + [config, onChange] + ); + + const options: { label: string; value: BlurTypes }[] = useMemo( + () => [ + { label: t('controlLayers.filter.img_blur.gaussian_type'), value: 'gaussian' }, + { label: t('controlLayers.filter.img_blur.box_type'), value: 'box' }, + ], + [t] + ); + + const value = useMemo(() => options.filter((o) => o.value === config.blur_type)[0], [options, config.blur_type]); + + return ( + <> + + {t('controlLayers.filter.img_blur.blur_type')} + + + + {t('controlLayers.filter.img_blur.blur_radius')} + + + + + ); +}); + +FilterBlur.displayName = 'FilterBlur'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterNoise.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterNoise.tsx new file mode 100644 index 00000000000..32926234264 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterNoise.tsx @@ -0,0 +1,111 @@ +import type { ComboboxOnChange } from '@invoke-ai/ui-library'; +import { Combobox, CompositeNumberInput, CompositeSlider, FormControl, FormLabel, Switch } from '@invoke-ai/ui-library'; +import type { NoiseFilterConfig, NoiseTypes } from 'features/controlLayers/store/filters'; +import { IMAGE_FILTERS, isNoiseTypes } from 'features/controlLayers/store/filters'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; +const DEFAULTS = IMAGE_FILTERS.img_noise.buildDefaults(); + +export const FilterNoise = memo(({ onChange, config }: Props) => { + const { t } = useTranslation(); + const handleNoiseTypeChange = useCallback( + (v) => { + if (!isNoiseTypes(v?.value)) { + return; + } + onChange({ ...config, noise_type: v.value }); + }, + [config, onChange] + ); + + const handleAmountChange = useCallback( + (v: number) => { + onChange({ ...config, amount: v }); + }, + [config, onChange] + ); + + const handleColorChange = useCallback( + (e: ChangeEvent) => { + onChange({ ...config, noise_color: e.target.checked }); + }, + [config, onChange] + ); + + const handleSizeChange = useCallback( + (v: number) => { + onChange({ ...config, size: v }); + }, + [config, onChange] + ); + + const options: { label: string; value: NoiseTypes }[] = useMemo( + () => [ + { label: t('controlLayers.filter.img_noise.gaussian_type'), value: 'gaussian' }, + { label: t('controlLayers.filter.img_noise.salt_and_pepper_type'), value: 'salt_and_pepper' }, + ], + [t] + ); + + const value = useMemo(() => options.filter((o) => o.value === config.noise_type)[0], [options, config.noise_type]); + + return ( + <> + + {t('controlLayers.filter.img_noise.noise_type')} + + + + {t('controlLayers.filter.img_noise.noise_amount')} + + + + + {t('controlLayers.filter.img_noise.size')} + + + + + {t('controlLayers.filter.img_noise.noise_color')} + + + + ); +}); + +FilterNoise.displayName = 'Filternoise'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterSettings.tsx index 411571fe09c..ba81ba30b09 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterSettings.tsx @@ -1,4 +1,5 @@ import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { FilterBlur } from 'features/controlLayers/components/Filters/FilterBlur'; import { FilterCannyEdgeDetection } from 'features/controlLayers/components/Filters/FilterCannyEdgeDetection'; import { FilterColorMap } from 'features/controlLayers/components/Filters/FilterColorMap'; import { FilterContentShuffle } from 'features/controlLayers/components/Filters/FilterContentShuffle'; @@ -8,6 +9,7 @@ import { FilterHEDEdgeDetection } from 'features/controlLayers/components/Filter import { FilterLineartEdgeDetection } from 'features/controlLayers/components/Filters/FilterLineartEdgeDetection'; import { FilterMediaPipeFaceDetection } from 'features/controlLayers/components/Filters/FilterMediaPipeFaceDetection'; import { FilterMLSDDetection } from 'features/controlLayers/components/Filters/FilterMLSDDetection'; +import { FilterNoise } from 'features/controlLayers/components/Filters/FilterNoise'; import { FilterPiDiNetEdgeDetection } from 'features/controlLayers/components/Filters/FilterPiDiNetEdgeDetection'; import { FilterSpandrel } from 'features/controlLayers/components/Filters/FilterSpandrel'; import type { FilterConfig } from 'features/controlLayers/store/filters'; @@ -19,6 +21,10 @@ type Props = { filterConfig: FilterConfig; onChange: (filterConfig: FilterConfig export const FilterSettings = memo(({ filterConfig, onChange }: Props) => { const { t } = useTranslation(); + if (filterConfig.type === 'img_blur') { + return ; + } + if (filterConfig.type === 'canny_edge_detection') { return ; } @@ -59,6 +65,10 @@ export const FilterSettings = memo(({ filterConfig, onChange }: Props) => { return ; } + if (filterConfig.type === 'img_noise') { + return ; + } + if (filterConfig.type === 'spandrel_filter') { return ; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts index 3c949560a6b..e8d8568fb41 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts @@ -297,10 +297,9 @@ export class CanvasEntityFilterer extends CanvasModuleBase { const imageState = imageDTOToImageObject(filterResult.value); this.$imageState.set(imageState); - // Destroy any existing masked image and create a new one - if (this.imageModule) { - this.imageModule.destroy(); - } + // Stash the existing image module - we will destroy it after the new image is rendered to prevent a flash + // of an empty layer + const oldImageModule = this.imageModule; this.imageModule = new CanvasObjectImage(imageState, this); @@ -309,6 +308,16 @@ export class CanvasEntityFilterer extends CanvasModuleBase { this.konva.group.add(this.imageModule.konva.group); + // The filtered image have some transparency, so we need to hide the objects of the parent entity to prevent the + // two images from blending. We will show the objects again in the teardown method, which is always called after + // the filter finishes (applied or canceled). + this.parent.renderer.hideObjects(); + + if (oldImageModule) { + // Destroy the old image module now that the new one is rendered + oldImageModule.destroy(); + } + // The porcessing is complete, set can set the last processed hash and isProcessing to false this.$lastProcessedHash.set(hash); @@ -424,6 +433,8 @@ export class CanvasEntityFilterer extends CanvasModuleBase { teardown = () => { this.unsubscribe(); + // Re-enable the objects of the parent entity + this.parent.renderer.showObjects(); this.konva.group.remove(); // The reset must be done _after_ unsubscribing from listeners, in case the listeners would otherwise react to // the reset. For example, if auto-processing is enabled and we reset the state, it may trigger processing. diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts index 15a4a35fcd4..18e7e7f0e55 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts @@ -185,6 +185,14 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase { return didRender; }; + hideObjects = () => { + this.konva.objectGroup.hide(); + }; + + showObjects = () => { + this.konva.objectGroup.show(); + }; + adoptObjectRenderer = (renderer: AnyObjectRenderer) => { this.renderers.set(renderer.id, renderer); renderer.konva.group.moveTo(this.konva.objectGroup); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityTransformer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityTransformer.ts index b29e0486753..110e74f5b48 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityTransformer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityTransformer.ts @@ -10,6 +10,7 @@ import { getKonvaNodeDebugAttrs, getPrefixedId, offsetCoord, + roundRect, } from 'features/controlLayers/konva/util'; import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import type { Coordinate, Rect, RectWithRotation } from 'features/controlLayers/store/types'; @@ -773,7 +774,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase { const rect = this.getRelativeRect(); const rasterizeResult = await withResultAsync(() => this.parent.renderer.rasterize({ - rect, + rect: roundRect(rect), replaceObjects: true, ignoreCache: true, attrs: { opacity: 1, filters: [] }, diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts index a2d28b5ac0e..f5a30afcc7e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts @@ -740,3 +740,12 @@ export const getColorAtCoordinate = (stage: Konva.Stage, coord: Coordinate): Rgb return { r, g, b }; }; + +export const roundRect = (rect: Rect): Rect => { + return { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/filters.ts b/invokeai/frontend/web/src/features/controlLayers/store/filters.ts index 89ebcc054a9..799c277b931 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/filters.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/filters.ts @@ -95,6 +95,28 @@ const zSpandrelFilterConfig = z.object({ }); export type SpandrelFilterConfig = z.infer; +const zBlurTypes = z.enum(['gaussian', 'box']); +export type BlurTypes = z.infer; +export const isBlurTypes = (v: unknown): v is BlurTypes => zBlurTypes.safeParse(v).success; +const zBlurFilterConfig = z.object({ + type: z.literal('img_blur'), + blur_type: zBlurTypes, + radius: z.number().gte(0), +}); +export type BlurFilterConfig = z.infer; + +const zNoiseTypes = z.enum(['gaussian', 'salt_and_pepper']); +export type NoiseTypes = z.infer; +export const isNoiseTypes = (v: unknown): v is NoiseTypes => zNoiseTypes.safeParse(v).success; +const zNoiseFilterConfig = z.object({ + type: z.literal('img_noise'), + noise_type: zNoiseTypes, + amount: z.number().gte(0).lte(1), + noise_color: z.boolean(), + size: z.number().int().gte(1), +}); +export type NoiseFilterConfig = z.infer; + const zFilterConfig = z.discriminatedUnion('type', [ zCannyEdgeDetectionFilterConfig, zColorMapFilterConfig, @@ -109,6 +131,8 @@ const zFilterConfig = z.discriminatedUnion('type', [ zPiDiNetEdgeDetectionFilterConfig, zDWOpenposeDetectionFilterConfig, zSpandrelFilterConfig, + zBlurFilterConfig, + zNoiseFilterConfig, ]); export type FilterConfig = z.infer; @@ -126,6 +150,8 @@ const zFilterType = z.enum([ 'pidi_edge_detection', 'dw_openpose_detection', 'spandrel_filter', + 'img_blur', + 'img_noise', ]); export type FilterType = z.infer; export const isFilterType = (v: unknown): v is FilterType => zFilterType.safeParse(v).success; @@ -429,6 +455,62 @@ export const IMAGE_FILTERS: { [key in FilterConfig['type']]: ImageFilterData ({ + type: 'img_blur', + blur_type: 'gaussian', + radius: 8, + }), + buildGraph: ({ image_name }, { blur_type, radius }) => { + const graph = new Graph(getPrefixedId('img_blur')); + const node = graph.addNode({ + id: getPrefixedId('img_blur'), + type: 'img_blur', + image: { image_name }, + blur_type: blur_type, + radius: radius, + }); + return { + graph, + outputNodeId: node.id, + }; + }, + }, + img_noise: { + type: 'img_noise', + buildDefaults: () => ({ + type: 'img_noise', + noise_type: 'gaussian', + amount: 0.3, + noise_color: true, + size: 1, + }), + buildGraph: ({ image_name }, { noise_type, amount, noise_color, size }) => { + const graph = new Graph(getPrefixedId('img_noise')); + const node = graph.addNode({ + id: getPrefixedId('img_noise'), + type: 'img_noise', + image: { image_name }, + noise_type: noise_type, + amount: amount, + noise_color: noise_color, + size: size, + }); + const rand = graph.addNode({ + id: getPrefixedId('rand_int'), + use_cache: false, + type: 'rand_int', + low: 0, + high: 2147483647, + }); + graph.addEdge(rand, 'value', node, 'seed'); + return { + graph, + outputNodeId: node.id, + }; + }, + }, } as const; /** diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.test.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.test.ts index 9d9e1974dee..43d217c423e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.test.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.test.ts @@ -1,4 +1,5 @@ import type { + BlurFilterConfig, CannyEdgeDetectionFilterConfig, ColorMapFilterConfig, ContentShuffleFilterConfig, @@ -12,6 +13,7 @@ import type { LineartEdgeDetectionFilterConfig, MediaPipeFaceDetectionFilterConfig, MLSDDetectionFilterConfig, + NoiseFilterConfig, NormalMapFilterConfig, PiDiNetEdgeDetectionFilterConfig, } from 'features/controlLayers/store/filters'; @@ -54,6 +56,7 @@ describe('Control Adapter Types', () => { }); test('Processor Configs', () => { // Types derived from OpenAPI + type _BlurFilterConfig = Required, 'type' | 'radius' | 'blur_type'>>; type _CannyEdgeDetectionFilterConfig = Required< Pick, 'type' | 'low_threshold' | 'high_threshold'> >; @@ -71,6 +74,9 @@ describe('Control Adapter Types', () => { type _MLSDDetectionFilterConfig = Required< Pick, 'type' | 'score_threshold' | 'distance_threshold'> >; + type _NoiseFilterConfig = Required< + Pick, 'type' | 'noise_type' | 'amount' | 'noise_color' | 'size'> + >; type _NormalMapFilterConfig = Required, 'type'>>; type _DWOpenposeDetectionFilterConfig = Required< Pick, 'type' | 'draw_body' | 'draw_face' | 'draw_hands'> @@ -81,6 +87,7 @@ describe('Control Adapter Types', () => { // The processor configs are manually modeled zod schemas. This test ensures that the inferred types are correct. // The types prefixed with `_` are types generated from OpenAPI, while the types without the prefix are manually modeled. + assert>(); assert>(); assert>(); assert>(); @@ -90,6 +97,7 @@ describe('Control Adapter Types', () => { assert>(); assert>(); assert>(); + assert>(); assert>(); assert>(); assert>(); diff --git a/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx b/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx index 30c3edf3d37..139a6af8bf3 100644 --- a/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx +++ b/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx @@ -11,7 +11,7 @@ import type { DndTargetState } from 'features/dnd/types'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import { selectMaxImageUploadCount } from 'features/system/store/configSlice'; import { toast } from 'features/toast/toast'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { uploadImages } from 'services/api/endpoints/images'; import { useBoardName } from 'services/api/hooks/useBoardName'; @@ -72,11 +72,10 @@ export const FullscreenDropzone = memo(() => { const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount); const [dndState, setDndState] = useState('idle'); - const uploadFilesSchema = useMemo(() => getFilesSchema(maxImageUploadCount), [maxImageUploadCount]); - const validateAndUploadFiles = useCallback( (files: File[]) => { const { getState } = getStore(); + const uploadFilesSchema = getFilesSchema(maxImageUploadCount); const parseResult = uploadFilesSchema.safeParse(files); if (!parseResult.success) { @@ -105,7 +104,18 @@ export const FullscreenDropzone = memo(() => { uploadImages(uploadArgs); }, - [maxImageUploadCount, t, uploadFilesSchema] + [maxImageUploadCount, t] + ); + + const onPaste = useCallback( + (e: ClipboardEvent) => { + if (!e.clipboardData?.files) { + return; + } + const files = Array.from(e.clipboardData.files); + validateAndUploadFiles(files); + }, + [validateAndUploadFiles] ); useEffect(() => { @@ -144,24 +154,12 @@ export const FullscreenDropzone = memo(() => { }, [validateAndUploadFiles]); useEffect(() => { - const controller = new AbortController(); - - document.addEventListener( - 'paste', - (e) => { - if (!e.clipboardData?.files) { - return; - } - const files = Array.from(e.clipboardData.files); - validateAndUploadFiles(files); - }, - { signal: controller.signal } - ); + window.addEventListener('paste', onPaste); return () => { - controller.abort(); + window.removeEventListener('paste', onPaste); }; - }, [validateAndUploadFiles]); + }, [onPaste]); return ( diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index 9b8972e3c4a..026a2aa0db9 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -1,3 +1,4 @@ +import { logger } from 'app/logging/logger'; import type { AppDispatch, RootState } from 'app/store/store'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import type { @@ -9,7 +10,6 @@ import { selectComparisonImages } from 'features/gallery/components/ImageViewer/ import type { BoardId } from 'features/gallery/store/types'; import { addImagesToBoard, - addImagesToNodeImageFieldCollectionAction, createNewCanvasEntityFromImage, removeImagesFromBoard, replaceCanvasEntityObjectsWithImage, @@ -19,10 +19,14 @@ import { setRegionalGuidanceReferenceImage, setUpscaleInitialImage, } from 'features/imageActions/actions'; -import type { FieldIdentifier } from 'features/nodes/types/field'; +import { fieldImageCollectionValueChanged } from 'features/nodes/store/nodesSlice'; +import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors'; +import { type FieldIdentifier, isImageFieldCollectionInputInstance } from 'features/nodes/types/field'; import type { ImageDTO } from 'services/api/types'; import type { JsonObject } from 'type-fest'; +const log = logger('dnd'); + type RecordUnknown = Record; type DndData< @@ -268,15 +272,27 @@ export const addImagesToNodeImageFieldCollectionDndTarget: DndTarget< } const { fieldIdentifier } = targetData.payload; - const imageDTOs: ImageDTO[] = []; + + const fieldInputInstance = selectFieldInputInstance( + selectNodesSlice(getState()), + fieldIdentifier.nodeId, + fieldIdentifier.fieldName + ); + + if (!isImageFieldCollectionInputInstance(fieldInputInstance)) { + log.warn({ fieldIdentifier }, 'Attempted to add images to a non-image field collection'); + return; + } + + const newValue = fieldInputInstance.value ? [...fieldInputInstance.value] : []; if (singleImageDndSource.typeGuard(sourceData)) { - imageDTOs.push(sourceData.payload.imageDTO); + newValue.push({ image_name: sourceData.payload.imageDTO.image_name }); } else { - imageDTOs.push(...sourceData.payload.imageDTOs); + newValue.push(...sourceData.payload.imageDTOs.map(({ image_name }) => ({ image_name }))); } - addImagesToNodeImageFieldCollectionAction({ fieldIdentifier, imageDTOs, dispatch, getState }); + dispatch(fieldImageCollectionValueChanged({ ...fieldIdentifier, value: newValue })); }, }; //#endregion diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts index a6f09377c5a..d9822f4ed4d 100644 --- a/invokeai/frontend/web/src/features/imageActions/actions.ts +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -1,4 +1,3 @@ -import { logger } from 'app/logging/logger'; import type { AppDispatch, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; import { selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks'; @@ -31,19 +30,15 @@ import { imageDTOToImageObject, imageDTOToImageWithDims, initialControlNet } fro import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import type { BoardId } from 'features/gallery/store/types'; -import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; -import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors'; -import { type FieldIdentifier, isImageFieldCollectionInputInstance } from 'features/nodes/types/field'; +import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; +import type { FieldIdentifier } from 'features/nodes/types/field'; import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import { getOptimalDimension } from 'features/parameters/util/optimalDimension'; -import { uniqBy } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; -const log = logger('system'); - export const setGlobalReferenceImage = (arg: { imageDTO: ImageDTO; entityIdentifier: CanvasEntityIdentifier<'reference_image'>; @@ -77,54 +72,6 @@ export const setNodeImageFieldImage = (arg: { dispatch(fieldImageValueChanged({ ...fieldIdentifier, value: imageDTO })); }; -export const addImagesToNodeImageFieldCollectionAction = (arg: { - imageDTOs: ImageDTO[]; - fieldIdentifier: FieldIdentifier; - dispatch: AppDispatch; - getState: () => RootState; -}) => { - const { imageDTOs, fieldIdentifier, dispatch, getState } = arg; - const fieldInputInstance = selectFieldInputInstance( - selectNodesSlice(getState()), - fieldIdentifier.nodeId, - fieldIdentifier.fieldName - ); - - if (!isImageFieldCollectionInputInstance(fieldInputInstance)) { - log.warn({ fieldIdentifier }, 'Attempted to add images to a non-image field collection'); - return; - } - - const images = fieldInputInstance.value ? [...fieldInputInstance.value] : []; - images.push(...imageDTOs.map(({ image_name }) => ({ image_name }))); - const uniqueImages = uniqBy(images, 'image_name'); - dispatch(fieldImageCollectionValueChanged({ ...fieldIdentifier, value: uniqueImages })); -}; - -export const removeImageFromNodeImageFieldCollectionAction = (arg: { - imageName: string; - fieldIdentifier: FieldIdentifier; - dispatch: AppDispatch; - getState: () => RootState; -}) => { - const { imageName, fieldIdentifier, dispatch, getState } = arg; - const fieldInputInstance = selectFieldInputInstance( - selectNodesSlice(getState()), - fieldIdentifier.nodeId, - fieldIdentifier.fieldName - ); - - if (!isImageFieldCollectionInputInstance(fieldInputInstance)) { - log.warn({ fieldIdentifier }, 'Attempted to remove image from a non-image field collection'); - return; - } - - const images = fieldInputInstance.value ? [...fieldInputInstance.value] : []; - const imagesWithoutTheImageToRemove = images.filter((image) => image.image_name !== imageName); - const uniqueImages = uniqBy(imagesWithoutTheImageToRemove, 'image_name'); - dispatch(fieldImageCollectionValueChanged({ ...fieldIdentifier, value: uniqueImages })); -}; - export const setComparisonImage = (arg: { imageDTO: ImageDTO; dispatch: AppDispatch }) => { const { imageDTO, dispatch } = arg; dispatch(imageToCompareChanged(imageDTO)); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FieldHandle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FieldHandle.tsx index 143dee983fb..ea96c8d06ca 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FieldHandle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FieldHandle.tsx @@ -36,8 +36,10 @@ const FieldHandle = (props: FieldHandleProps) => { borderWidth: !isSingle(type) ? 4 : 0, borderStyle: 'solid', borderColor: color, - borderRadius: isModelType ? 4 : '100%', + borderRadius: isModelType || type.batch ? 4 : '100%', zIndex: 1, + transform: type.batch ? 'rotate(45deg) translateX(-0.3rem) translateY(-0.3rem)' : 'none', + transformOrigin: 'center', }; if (handleType === 'target') { diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx index 38742215429..91ad8abcf7e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx @@ -1,5 +1,9 @@ +import { FloatGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorFieldComponent'; import { ImageFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldCollectionInputComponent'; +import { IntegerGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerGeneratorFieldComponent'; import ModelIdentifierFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelIdentifierFieldInputComponent'; +import { NumberFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/NumberFieldCollectionInputComponent'; +import { StringFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StringFieldCollectionInputComponent'; import { useFieldInputInstance } from 'features/nodes/hooks/useFieldInputInstance'; import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate'; import { @@ -21,8 +25,12 @@ import { isControlNetModelFieldInputTemplate, isEnumFieldInputInstance, isEnumFieldInputTemplate, + isFloatFieldCollectionInputInstance, + isFloatFieldCollectionInputTemplate, isFloatFieldInputInstance, isFloatFieldInputTemplate, + isFloatGeneratorFieldInputInstance, + isFloatGeneratorFieldInputTemplate, isFluxMainModelFieldInputInstance, isFluxMainModelFieldInputTemplate, isFluxVAEModelFieldInputInstance, @@ -31,8 +39,12 @@ import { isImageFieldCollectionInputTemplate, isImageFieldInputInstance, isImageFieldInputTemplate, + isIntegerFieldCollectionInputInstance, + isIntegerFieldCollectionInputTemplate, isIntegerFieldInputInstance, isIntegerFieldInputTemplate, + isIntegerGeneratorFieldInputInstance, + isIntegerGeneratorFieldInputTemplate, isIPAdapterModelFieldInputInstance, isIPAdapterModelFieldInputTemplate, isLoRAModelFieldInputInstance, @@ -51,6 +63,8 @@ import { isSDXLRefinerModelFieldInputTemplate, isSpandrelImageToImageModelFieldInputInstance, isSpandrelImageToImageModelFieldInputTemplate, + isStringFieldCollectionInputInstance, + isStringFieldCollectionInputTemplate, isStringFieldInputInstance, isStringFieldInputTemplate, isT2IAdapterModelFieldInputInstance, @@ -97,6 +111,10 @@ const InputFieldRenderer = ({ nodeId, fieldName }: InputFieldProps) => { const fieldInstance = useFieldInputInstance(nodeId, fieldName); const fieldTemplate = useFieldInputTemplate(nodeId, fieldName); + if (isStringFieldCollectionInputInstance(fieldInstance) && isStringFieldCollectionInputTemplate(fieldTemplate)) { + return ; + } + if (isStringFieldInputInstance(fieldInstance) && isStringFieldInputTemplate(fieldTemplate)) { return ; } @@ -105,13 +123,22 @@ const InputFieldRenderer = ({ nodeId, fieldName }: InputFieldProps) => { return ; } - if ( - (isIntegerFieldInputInstance(fieldInstance) && isIntegerFieldInputTemplate(fieldTemplate)) || - (isFloatFieldInputInstance(fieldInstance) && isFloatFieldInputTemplate(fieldTemplate)) - ) { + if (isIntegerFieldInputInstance(fieldInstance) && isIntegerFieldInputTemplate(fieldTemplate)) { return ; } + if (isFloatFieldInputInstance(fieldInstance) && isFloatFieldInputTemplate(fieldTemplate)) { + return ; + } + + if (isIntegerFieldCollectionInputInstance(fieldInstance) && isIntegerFieldCollectionInputTemplate(fieldTemplate)) { + return ; + } + + if (isFloatFieldCollectionInputInstance(fieldInstance) && isFloatFieldCollectionInputTemplate(fieldTemplate)) { + return ; + } + if (isEnumFieldInputInstance(fieldInstance) && isEnumFieldInputTemplate(fieldTemplate)) { return ; } @@ -216,6 +243,14 @@ const InputFieldRenderer = ({ nodeId, fieldName }: InputFieldProps) => { return ; } + if (isFloatGeneratorFieldInputInstance(fieldInstance) && isFloatGeneratorFieldInputTemplate(fieldTemplate)) { + return ; + } + + if (isIntegerGeneratorFieldInputInstance(fieldInstance) && isIntegerGeneratorFieldInputTemplate(fieldTemplate)) { + return ; + } + if (fieldTemplate) { // Fallback for when there is no component for the type return null; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck.tsx index 3166236c635..bbbe6deac16 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck.tsx @@ -1,6 +1,6 @@ import { Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { createSelector } from '@reduxjs/toolkit'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { $templates } from 'features/nodes/store/nodesSlice'; import { selectInvocationNode, selectNodesSlice } from 'features/nodes/store/selectors'; @@ -18,7 +18,7 @@ export const InvocationInputFieldCheck = memo(({ nodeId, fieldName, children }: const templates = useStore($templates); const selector = useMemo( () => - createSelector(selectNodesSlice, (nodesSlice) => { + createMemoizedSelector(selectNodesSlice, (nodesSlice) => { const node = selectInvocationNode(nodesSlice, nodeId); const instance = node.data.inputs[fieldName]; const template = templates[node.data.type]; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/EnumFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/EnumFieldInputComponent.tsx index 0a632ee94e9..8fb917aabb0 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/EnumFieldInputComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/EnumFieldInputComponent.tsx @@ -26,7 +26,7 @@ const EnumFieldInputComponent = (props: FieldComponentProps + + + + + + + {field.value.type === FloatGeneratorArithmeticSequenceType && ( + + )} + {field.value.type === FloatGeneratorLinearDistributionType && ( + + )} + {field.value.type === FloatGeneratorUniformRandomDistributionType && ( + + )} + {field.value.type === FloatGeneratorParseStringType && ( + + )} + + + + + {resolvedValuesAsString} + + + + + + ); + } +); + +FloatGeneratorFieldInputComponent.displayName = 'FloatGeneratorFieldInputComponent'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorLinearDistributionSettings.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorLinearDistributionSettings.tsx new file mode 100644 index 00000000000..9c1a435673d --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorLinearDistributionSettings.tsx @@ -0,0 +1,57 @@ +import { CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import type { FloatGeneratorLinearDistribution } from 'features/nodes/types/field'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +type FloatGeneratorLinearDistributionSettingsProps = { + state: FloatGeneratorLinearDistribution; + onChange: (state: FloatGeneratorLinearDistribution) => void; +}; +export const FloatGeneratorLinearDistributionSettings = memo( + ({ state, onChange }: FloatGeneratorLinearDistributionSettingsProps) => { + const { t } = useTranslation(); + + const onChangeStart = useCallback( + (start: number) => { + onChange({ ...state, start }); + }, + [onChange, state] + ); + const onChangeEnd = useCallback( + (end: number) => { + onChange({ ...state, end }); + }, + [onChange, state] + ); + const onChangeCount = useCallback( + (count: number) => { + onChange({ ...state, count }); + }, + [onChange, state] + ); + + return ( + + + {t('common.start')} + + + + {t('common.end')} + + + + {t('common.count')} + + + + ); + } +); +FloatGeneratorLinearDistributionSettings.displayName = 'FloatGeneratorLinearDistributionSettings'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorParseStringSettings.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorParseStringSettings.tsx new file mode 100644 index 00000000000..d5abafa6a35 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorParseStringSettings.tsx @@ -0,0 +1,48 @@ +import { Flex, FormControl, FormLabel, Input, Textarea } from '@invoke-ai/ui-library'; +import type { FloatGeneratorParseString } from 'features/nodes/types/field'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +type FloatGeneratorParseStringSettingsProps = { + state: FloatGeneratorParseString; + onChange: (state: FloatGeneratorParseString) => void; +}; +export const FloatGeneratorParseStringSettings = memo(({ state, onChange }: FloatGeneratorParseStringSettingsProps) => { + const { t } = useTranslation(); + + const onChangeSplitOn = useCallback( + (e: ChangeEvent) => { + onChange({ ...state, splitOn: e.target.value }); + }, + [onChange, state] + ); + + const onChangeInput = useCallback( + (e: ChangeEvent) => { + onChange({ ...state, input: e.target.value }); + }, + [onChange, state] + ); + + return ( + + + {t('nodes.splitOn')} + + + + {t('common.input')} +