From ad96857e0f3e6c7d3538035938ce64624945d978 Mon Sep 17 00:00:00 2001 From: Ryan Dick Date: Tue, 13 Feb 2024 17:50:20 -0500 Subject: [PATCH 01/37] Fix avoid storing extra conditioning info in two places. --- invokeai/app/invocations/latent.py | 2 -- invokeai/backend/stable_diffusion/diffusers_pipeline.py | 5 +++-- .../backend/stable_diffusion/diffusion/conditioning_data.py | 5 ----- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py index 97d3c705d4..9e9cb2d1c7 100644 --- a/invokeai/app/invocations/latent.py +++ b/invokeai/app/invocations/latent.py @@ -360,7 +360,6 @@ class DenoiseLatentsInvocation(BaseInvocation): ) -> ConditioningData: positive_cond_data = context.conditioning.load(self.positive_conditioning.conditioning_name) c = positive_cond_data.conditionings[0].to(device=unet.device, dtype=unet.dtype) - extra_conditioning_info = c.extra_conditioning negative_cond_data = context.conditioning.load(self.negative_conditioning.conditioning_name) uc = negative_cond_data.conditionings[0].to(device=unet.device, dtype=unet.dtype) @@ -370,7 +369,6 @@ class DenoiseLatentsInvocation(BaseInvocation): text_embeddings=c, guidance_scale=self.cfg_scale, guidance_rescale_multiplier=self.cfg_rescale_multiplier, - extra=extra_conditioning_info, postprocessing_settings=PostprocessingSettings( threshold=0.0, # threshold, warmup=0.2, # warmup, diff --git a/invokeai/backend/stable_diffusion/diffusers_pipeline.py b/invokeai/backend/stable_diffusion/diffusers_pipeline.py index fd3ecde47b..918ca538a3 100644 --- a/invokeai/backend/stable_diffusion/diffusers_pipeline.py +++ b/invokeai/backend/stable_diffusion/diffusers_pipeline.py @@ -427,10 +427,11 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): return latents, attention_map_saver ip_adapter_unet_patcher = None - if conditioning_data.extra is not None and conditioning_data.extra.wants_cross_attention_control: + extra_conditioning_info = conditioning_data.text_embeddings.extra_conditioning + if extra_conditioning_info is not None and extra_conditioning_info.wants_cross_attention_control: attn_ctx = self.invokeai_diffuser.custom_attention_context( self.invokeai_diffuser.model, - extra_conditioning_info=conditioning_data.extra, + extra_conditioning_info=extra_conditioning_info, step_count=len(self.scheduler.timesteps), ) self.use_ip_adapter = False diff --git a/invokeai/backend/stable_diffusion/diffusion/conditioning_data.py b/invokeai/backend/stable_diffusion/diffusion/conditioning_data.py index 0676555f7a..b67669cefa 100644 --- a/invokeai/backend/stable_diffusion/diffusion/conditioning_data.py +++ b/invokeai/backend/stable_diffusion/diffusion/conditioning_data.py @@ -21,11 +21,7 @@ class ExtraConditioningInfo: @dataclass class BasicConditioningInfo: embeds: torch.Tensor - # TODO(ryand): Right now we awkwardly copy the extra conditioning info from here up to `ConditioningData`. This - # should only be stored in one place. extra_conditioning: Optional[ExtraConditioningInfo] - # weight: float - # mode: ConditioningAlgo def to(self, device, dtype=None): self.embeds = self.embeds.to(device=device, dtype=dtype) @@ -83,7 +79,6 @@ class ConditioningData: ref [Common Diffusion Noise Schedules and Sample Steps are Flawed](https://arxiv.org/pdf/2305.08891.pdf) """ guidance_rescale_multiplier: float = 0 - extra: Optional[ExtraConditioningInfo] = None scheduler_args: dict[str, Any] = field(default_factory=dict) """ Additional arguments to pass to invokeai_diffuser.do_latent_postprocessing(). From 9bc4e7a5934cd1bd27be2a4036e4297487faee3b Mon Sep 17 00:00:00 2001 From: Ryan Dick Date: Wed, 14 Feb 2024 15:52:34 -0500 Subject: [PATCH 02/37] Remove use of **kwargs in do_unet_step(...), where full parameter list is known and supported. --- .../stable_diffusion/diffusers_pipeline.py | 1 - .../diffusion/shared_invokeai_diffusion.py | 60 +++++++++---------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/invokeai/backend/stable_diffusion/diffusers_pipeline.py b/invokeai/backend/stable_diffusion/diffusers_pipeline.py index 918ca538a3..d7f17fb744 100644 --- a/invokeai/backend/stable_diffusion/diffusers_pipeline.py +++ b/invokeai/backend/stable_diffusion/diffusers_pipeline.py @@ -598,7 +598,6 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): step_index=step_index, total_step_count=total_step_count, conditioning_data=conditioning_data, - # extra: down_block_additional_residuals=down_block_additional_residuals, # for ControlNet mid_block_additional_residual=mid_block_additional_residual, # for ControlNet down_intrablock_additional_residuals=down_intrablock_additional_residuals, # for T2I-Adapter diff --git a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py index 455e5e1096..353256006a 100644 --- a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py +++ b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py @@ -224,10 +224,12 @@ class InvokeAIDiffuserComponent: self, sample: torch.Tensor, timestep: torch.Tensor, - conditioning_data, # TODO: type + conditioning_data: ConditioningData, step_index: int, total_step_count: int, - **kwargs, + down_block_additional_residuals: Optional[torch.Tensor] = None, # for ControlNet + mid_block_additional_residual: Optional[torch.Tensor] = None, # for ControlNet + down_intrablock_additional_residuals: Optional[torch.Tensor] = None, # for T2I-Adapter ): cross_attention_control_types_to_do = [] context: Context = self.cross_attention_control_context @@ -236,7 +238,6 @@ class InvokeAIDiffuserComponent: cross_attention_control_types_to_do = context.get_active_cross_attention_control_types_for_step( percent_through ) - wants_cross_attention_control = len(cross_attention_control_types_to_do) > 0 if wants_cross_attention_control: @@ -244,31 +245,37 @@ class InvokeAIDiffuserComponent: unconditioned_next_x, conditioned_next_x, ) = self._apply_cross_attention_controlled_conditioning( - sample, - timestep, - conditioning_data, - cross_attention_control_types_to_do, - **kwargs, + x=sample, + sigma=timestep, + conditioning_data=conditioning_data, + cross_attention_control_types_to_do=cross_attention_control_types_to_do, + down_block_additional_residuals=down_block_additional_residuals, + mid_block_additional_residual=mid_block_additional_residual, + down_intrablock_additional_residuals=down_intrablock_additional_residuals, ) elif self.sequential_guidance: ( unconditioned_next_x, conditioned_next_x, ) = self._apply_standard_conditioning_sequentially( - sample, - timestep, - conditioning_data, - **kwargs, + x=sample, + sigma=timestep, + conditioning_data=conditioning_data, + down_block_additional_residuals=down_block_additional_residuals, + mid_block_additional_residual=mid_block_additional_residual, + down_intrablock_additional_residuals=down_intrablock_additional_residuals, ) else: ( unconditioned_next_x, conditioned_next_x, ) = self._apply_standard_conditioning( - sample, - timestep, - conditioning_data, - **kwargs, + x=sample, + sigma=timestep, + conditioning_data=conditioning_data, + down_block_additional_residuals=down_block_additional_residuals, + mid_block_additional_residual=mid_block_additional_residual, + down_intrablock_additional_residuals=down_intrablock_additional_residuals, ) return unconditioned_next_x, conditioned_next_x @@ -335,7 +342,7 @@ class InvokeAIDiffuserComponent: # methods below are called from do_diffusion_step and should be considered private to this class. - def _apply_standard_conditioning(self, x, sigma, conditioning_data: ConditioningData, **kwargs): + def _apply_standard_conditioning(self, x, sigma, conditioning_data: ConditioningData): """Runs the conditioned and unconditioned UNet forward passes in a single batch for faster inference speed at the cost of higher memory usage. """ @@ -384,7 +391,6 @@ class InvokeAIDiffuserComponent: cross_attention_kwargs=cross_attention_kwargs, encoder_attention_mask=encoder_attention_mask, added_cond_kwargs=added_cond_kwargs, - **kwargs, ) unconditioned_next_x, conditioned_next_x = both_results.chunk(2) return unconditioned_next_x, conditioned_next_x @@ -394,14 +400,15 @@ class InvokeAIDiffuserComponent: x: torch.Tensor, sigma, conditioning_data: ConditioningData, - **kwargs, + down_block_additional_residuals: Optional[torch.Tensor] = None, # for ControlNet + mid_block_additional_residual: Optional[torch.Tensor] = None, # for ControlNet + down_intrablock_additional_residuals: Optional[torch.Tensor] = None, # for T2I-Adapter ): """Runs the conditioned and unconditioned UNet forward passes sequentially for lower memory usage at the cost of slower execution speed. """ # low-memory sequential path uncond_down_block, cond_down_block = None, None - down_block_additional_residuals = kwargs.pop("down_block_additional_residuals", None) if down_block_additional_residuals is not None: uncond_down_block, cond_down_block = [], [] for down_block in down_block_additional_residuals: @@ -410,7 +417,6 @@ class InvokeAIDiffuserComponent: cond_down_block.append(_cond_down) uncond_down_intrablock, cond_down_intrablock = None, None - down_intrablock_additional_residuals = kwargs.pop("down_intrablock_additional_residuals", None) if down_intrablock_additional_residuals is not None: uncond_down_intrablock, cond_down_intrablock = [], [] for down_intrablock in down_intrablock_additional_residuals: @@ -419,7 +425,6 @@ class InvokeAIDiffuserComponent: cond_down_intrablock.append(_cond_down) uncond_mid_block, cond_mid_block = None, None - mid_block_additional_residual = kwargs.pop("mid_block_additional_residual", None) if mid_block_additional_residual is not None: uncond_mid_block, cond_mid_block = mid_block_additional_residual.chunk(2) @@ -451,7 +456,6 @@ class InvokeAIDiffuserComponent: mid_block_additional_residual=uncond_mid_block, down_intrablock_additional_residuals=uncond_down_intrablock, added_cond_kwargs=added_cond_kwargs, - **kwargs, ) # Run conditional UNet denoising. @@ -481,7 +485,6 @@ class InvokeAIDiffuserComponent: mid_block_additional_residual=cond_mid_block, down_intrablock_additional_residuals=cond_down_intrablock, added_cond_kwargs=added_cond_kwargs, - **kwargs, ) return unconditioned_next_x, conditioned_next_x @@ -491,12 +494,13 @@ class InvokeAIDiffuserComponent: sigma, conditioning_data, cross_attention_control_types_to_do, - **kwargs, + down_block_additional_residuals: Optional[torch.Tensor] = None, # for ControlNet + mid_block_additional_residual: Optional[torch.Tensor] = None, # for ControlNet + down_intrablock_additional_residuals: Optional[torch.Tensor] = None, # for T2I-Adapter ): context: Context = self.cross_attention_control_context uncond_down_block, cond_down_block = None, None - down_block_additional_residuals = kwargs.pop("down_block_additional_residuals", None) if down_block_additional_residuals is not None: uncond_down_block, cond_down_block = [], [] for down_block in down_block_additional_residuals: @@ -505,7 +509,6 @@ class InvokeAIDiffuserComponent: cond_down_block.append(_cond_down) uncond_down_intrablock, cond_down_intrablock = None, None - down_intrablock_additional_residuals = kwargs.pop("down_intrablock_additional_residuals", None) if down_intrablock_additional_residuals is not None: uncond_down_intrablock, cond_down_intrablock = [], [] for down_intrablock in down_intrablock_additional_residuals: @@ -514,7 +517,6 @@ class InvokeAIDiffuserComponent: cond_down_intrablock.append(_cond_down) uncond_mid_block, cond_mid_block = None, None - mid_block_additional_residual = kwargs.pop("mid_block_additional_residual", None) if mid_block_additional_residual is not None: uncond_mid_block, cond_mid_block = mid_block_additional_residual.chunk(2) @@ -543,7 +545,6 @@ class InvokeAIDiffuserComponent: mid_block_additional_residual=uncond_mid_block, down_intrablock_additional_residuals=uncond_down_intrablock, added_cond_kwargs=added_cond_kwargs, - **kwargs, ) if is_sdxl: @@ -563,7 +564,6 @@ class InvokeAIDiffuserComponent: mid_block_additional_residual=cond_mid_block, down_intrablock_additional_residuals=cond_down_intrablock, added_cond_kwargs=added_cond_kwargs, - **kwargs, ) return unconditioned_next_x, conditioned_next_x From 204e7d383bad736bcb8051503c911320aedd41f7 Mon Sep 17 00:00:00 2001 From: Ryan Dick Date: Wed, 14 Feb 2024 16:00:11 -0500 Subject: [PATCH 03/37] Remove outdated comments related to T2I-Adapters and ControlNets. --- .../backend/stable_diffusion/diffusers_pipeline.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/invokeai/backend/stable_diffusion/diffusers_pipeline.py b/invokeai/backend/stable_diffusion/diffusers_pipeline.py index d7f17fb744..538e0ea990 100644 --- a/invokeai/backend/stable_diffusion/diffusers_pipeline.py +++ b/invokeai/backend/stable_diffusion/diffusers_pipeline.py @@ -545,15 +545,9 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): # Otherwise, set the IP-Adapter's scale to 0, so it has no effect. ip_adapter_unet_patcher.set_scale(i, 0.0) - # Handle ControlNet(s) and T2I-Adapter(s) + # Handle ControlNet(s) down_block_additional_residuals = None mid_block_additional_residual = None - down_intrablock_additional_residuals = None - # if control_data is not None and t2i_adapter_data is not None: - # TODO(ryand): This is a limitation of the UNet2DConditionModel API, not a fundamental incompatibility - # between ControlNets and T2I-Adapters. We will try to fix this upstream in diffusers. - # raise Exception("ControlNet(s) and T2I-Adapter(s) cannot be used simultaneously (yet).") - # elif control_data is not None: if control_data is not None: down_block_additional_residuals, mid_block_additional_residual = self.invokeai_diffuser.do_controlnet_step( control_data=control_data, @@ -563,7 +557,9 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): total_step_count=total_step_count, conditioning_data=conditioning_data, ) - # elif t2i_adapter_data is not None: + + # Handle T2I-Adapter(s) + down_intrablock_additional_residuals = None if t2i_adapter_data is not None: accum_adapter_state = None for single_t2i_adapter_data in t2i_adapter_data: @@ -589,7 +585,6 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): for idx, value in enumerate(single_t2i_adapter_data.adapter_state): accum_adapter_state[idx] += value * t2i_adapter_weight - # down_block_additional_residuals = accum_adapter_state down_intrablock_additional_residuals = accum_adapter_state uc_noise_pred, c_noise_pred = self.invokeai_diffuser.do_unet_step( From 7651eeea8d294ef3da2121e016d413e7c654d96b Mon Sep 17 00:00:00 2001 From: Ryan Dick Date: Wed, 14 Feb 2024 18:17:46 -0500 Subject: [PATCH 04/37] Merge sequential conditioning and cac conditioning logic to eliminate a bunch of duplication. --- .../diffusion/shared_invokeai_diffusion.py | 159 +++++++----------- 1 file changed, 59 insertions(+), 100 deletions(-) diff --git a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py index 353256006a..2b72c808e4 100644 --- a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py +++ b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py @@ -232,28 +232,16 @@ class InvokeAIDiffuserComponent: down_intrablock_additional_residuals: Optional[torch.Tensor] = None, # for T2I-Adapter ): cross_attention_control_types_to_do = [] - context: Context = self.cross_attention_control_context if self.cross_attention_control_context is not None: percent_through = step_index / total_step_count - cross_attention_control_types_to_do = context.get_active_cross_attention_control_types_for_step( - percent_through + cross_attention_control_types_to_do = ( + self.cross_attention_control_context.get_active_cross_attention_control_types_for_step(percent_through) ) wants_cross_attention_control = len(cross_attention_control_types_to_do) > 0 - if wants_cross_attention_control: - ( - unconditioned_next_x, - conditioned_next_x, - ) = self._apply_cross_attention_controlled_conditioning( - x=sample, - sigma=timestep, - conditioning_data=conditioning_data, - cross_attention_control_types_to_do=cross_attention_control_types_to_do, - down_block_additional_residuals=down_block_additional_residuals, - mid_block_additional_residual=mid_block_additional_residual, - down_intrablock_additional_residuals=down_intrablock_additional_residuals, - ) - elif self.sequential_guidance: + if wants_cross_attention_control or self.sequential_guidance: + # If wants_cross_attention_control is True, we force the sequential mode to be used, because cross-attention + # control is currently only supported in sequential mode. ( unconditioned_next_x, conditioned_next_x, @@ -261,6 +249,7 @@ class InvokeAIDiffuserComponent: x=sample, sigma=timestep, conditioning_data=conditioning_data, + cross_attention_control_types_to_do=cross_attention_control_types_to_do, down_block_additional_residuals=down_block_additional_residuals, mid_block_additional_residual=mid_block_additional_residual, down_intrablock_additional_residuals=down_intrablock_additional_residuals, @@ -342,7 +331,15 @@ class InvokeAIDiffuserComponent: # methods below are called from do_diffusion_step and should be considered private to this class. - def _apply_standard_conditioning(self, x, sigma, conditioning_data: ConditioningData): + def _apply_standard_conditioning( + self, + x, + sigma, + conditioning_data: ConditioningData, + down_block_additional_residuals: Optional[torch.Tensor] = None, # for ControlNet + mid_block_additional_residual: Optional[torch.Tensor] = None, # for ControlNet + down_intrablock_additional_residuals: Optional[torch.Tensor] = None, # for T2I-Adapter + ): """Runs the conditioned and unconditioned UNet forward passes in a single batch for faster inference speed at the cost of higher memory usage. """ @@ -390,6 +387,9 @@ class InvokeAIDiffuserComponent: both_conditionings, cross_attention_kwargs=cross_attention_kwargs, encoder_attention_mask=encoder_attention_mask, + down_block_additional_residuals=down_block_additional_residuals, + mid_block_additional_residual=mid_block_additional_residual, + down_intrablock_additional_residuals=down_intrablock_additional_residuals, added_cond_kwargs=added_cond_kwargs, ) unconditioned_next_x, conditioned_next_x = both_results.chunk(2) @@ -400,6 +400,7 @@ class InvokeAIDiffuserComponent: x: torch.Tensor, sigma, conditioning_data: ConditioningData, + cross_attention_control_types_to_do: list[CrossAttentionType], down_block_additional_residuals: Optional[torch.Tensor] = None, # for ControlNet mid_block_additional_residual: Optional[torch.Tensor] = None, # for ControlNet down_intrablock_additional_residuals: Optional[torch.Tensor] = None, # for T2I-Adapter @@ -407,7 +408,8 @@ class InvokeAIDiffuserComponent: """Runs the conditioned and unconditioned UNet forward passes sequentially for lower memory usage at the cost of slower execution speed. """ - # low-memory sequential path + # Since we are running the conditioned and unconditioned passes sequentially, we need to split the ControlNet + # and T2I-Adapter residuals into two chunks. uncond_down_block, cond_down_block = None, None if down_block_additional_residuals is not None: uncond_down_block, cond_down_block = [], [] @@ -428,8 +430,26 @@ class InvokeAIDiffuserComponent: if mid_block_additional_residual is not None: uncond_mid_block, cond_mid_block = mid_block_additional_residual.chunk(2) - # Run unconditional UNet denoising. + # If cross-attention control is enabled, prepare the SwapCrossAttnContext. + cross_attn_processor_context = None + if self.cross_attention_control_context is not None: + # Note that the SwapCrossAttnContext is initialized with an empty list of cross_attention_types_to_do. + # This list is empty because cross-attention control is not applied in the unconditioned pass. This field + # will be populated before the conditioned pass. + cross_attn_processor_context = SwapCrossAttnContext( + modified_text_embeddings=self.cross_attention_control_context.arguments.edited_conditioning, + index_map=self.cross_attention_control_context.cross_attention_index_map, + mask=self.cross_attention_control_context.cross_attention_mask, + cross_attention_types_to_do=[], + ) + + ##################### + # Unconditioned pass + ##################### + cross_attention_kwargs = None + + # Prepare IP-Adapter cross-attention kwargs for the unconditioned pass. if conditioning_data.ip_adapter_conditioning is not None: # Note that we 'unsqueeze' to produce tensors of shape (batch_size=1, num_ip_images, seq_len, token_len). cross_attention_kwargs = { @@ -439,6 +459,11 @@ class InvokeAIDiffuserComponent: ] } + # Prepare cross-attention control kwargs for the unconditioned pass. + if cross_attn_processor_context is not None: + cross_attention_kwargs = {"swap_cross_attn_context": cross_attn_processor_context} + + # Prepare SDXL conditioning kwargs for the unconditioned pass. added_cond_kwargs = None is_sdxl = type(conditioning_data.text_embeddings) is SDXLConditioningInfo if is_sdxl: @@ -447,6 +472,7 @@ class InvokeAIDiffuserComponent: "time_ids": conditioning_data.unconditioned_embeddings.add_time_ids, } + # Run unconditioned UNet denoising (i.e. negative prompt). unconditioned_next_x = self.model_forward_callback( x, sigma, @@ -458,8 +484,13 @@ class InvokeAIDiffuserComponent: added_cond_kwargs=added_cond_kwargs, ) - # Run conditional UNet denoising. + ################### + # Conditioned pass + ################### + cross_attention_kwargs = None + + # Prepare IP-Adapter cross-attention kwargs for the conditioned pass. if conditioning_data.ip_adapter_conditioning is not None: # Note that we 'unsqueeze' to produce tensors of shape (batch_size=1, num_ip_images, seq_len, token_len). cross_attention_kwargs = { @@ -469,6 +500,12 @@ class InvokeAIDiffuserComponent: ] } + # Prepare cross-attention control kwargs for the conditioned pass. + if cross_attn_processor_context is not None: + cross_attn_processor_context.cross_attention_types_to_do = cross_attention_control_types_to_do + cross_attention_kwargs = {"swap_cross_attn_context": cross_attn_processor_context} + + # Prepare SDXL conditioning kwargs for the conditioned pass. added_cond_kwargs = None if is_sdxl: added_cond_kwargs = { @@ -476,6 +513,7 @@ class InvokeAIDiffuserComponent: "time_ids": conditioning_data.text_embeddings.add_time_ids, } + # Run conditioned UNet denoising (i.e. positive prompt). conditioned_next_x = self.model_forward_callback( x, sigma, @@ -488,85 +526,6 @@ class InvokeAIDiffuserComponent: ) return unconditioned_next_x, conditioned_next_x - def _apply_cross_attention_controlled_conditioning( - self, - x: torch.Tensor, - sigma, - conditioning_data, - cross_attention_control_types_to_do, - down_block_additional_residuals: Optional[torch.Tensor] = None, # for ControlNet - mid_block_additional_residual: Optional[torch.Tensor] = None, # for ControlNet - down_intrablock_additional_residuals: Optional[torch.Tensor] = None, # for T2I-Adapter - ): - context: Context = self.cross_attention_control_context - - uncond_down_block, cond_down_block = None, None - if down_block_additional_residuals is not None: - uncond_down_block, cond_down_block = [], [] - for down_block in down_block_additional_residuals: - _uncond_down, _cond_down = down_block.chunk(2) - uncond_down_block.append(_uncond_down) - cond_down_block.append(_cond_down) - - uncond_down_intrablock, cond_down_intrablock = None, None - if down_intrablock_additional_residuals is not None: - uncond_down_intrablock, cond_down_intrablock = [], [] - for down_intrablock in down_intrablock_additional_residuals: - _uncond_down, _cond_down = down_intrablock.chunk(2) - uncond_down_intrablock.append(_uncond_down) - cond_down_intrablock.append(_cond_down) - - uncond_mid_block, cond_mid_block = None, None - if mid_block_additional_residual is not None: - uncond_mid_block, cond_mid_block = mid_block_additional_residual.chunk(2) - - cross_attn_processor_context = SwapCrossAttnContext( - modified_text_embeddings=context.arguments.edited_conditioning, - index_map=context.cross_attention_index_map, - mask=context.cross_attention_mask, - cross_attention_types_to_do=[], - ) - - added_cond_kwargs = None - is_sdxl = type(conditioning_data.text_embeddings) is SDXLConditioningInfo - if is_sdxl: - added_cond_kwargs = { - "text_embeds": conditioning_data.unconditioned_embeddings.pooled_embeds, - "time_ids": conditioning_data.unconditioned_embeddings.add_time_ids, - } - - # no cross attention for unconditioning (negative prompt) - unconditioned_next_x = self.model_forward_callback( - x, - sigma, - conditioning_data.unconditioned_embeddings.embeds, - {"swap_cross_attn_context": cross_attn_processor_context}, - down_block_additional_residuals=uncond_down_block, - mid_block_additional_residual=uncond_mid_block, - down_intrablock_additional_residuals=uncond_down_intrablock, - added_cond_kwargs=added_cond_kwargs, - ) - - if is_sdxl: - added_cond_kwargs = { - "text_embeds": conditioning_data.text_embeddings.pooled_embeds, - "time_ids": conditioning_data.text_embeddings.add_time_ids, - } - - # do requested cross attention types for conditioning (positive prompt) - cross_attn_processor_context.cross_attention_types_to_do = cross_attention_control_types_to_do - conditioned_next_x = self.model_forward_callback( - x, - sigma, - conditioning_data.text_embeddings.embeds, - {"swap_cross_attn_context": cross_attn_processor_context}, - down_block_additional_residuals=cond_down_block, - mid_block_additional_residual=cond_mid_block, - down_intrablock_additional_residuals=cond_down_intrablock, - added_cond_kwargs=added_cond_kwargs, - ) - return unconditioned_next_x, conditioned_next_x - def _combine(self, unconditioned_next_x, conditioned_next_x, guidance_scale): # to scale how much effect conditioning has, calculate the changes it does and then scale that scaled_delta = (conditioned_next_x - unconditioned_next_x) * guidance_scale From 6935830f99a3e9e78fcc861cc3b1d2c346ed109d Mon Sep 17 00:00:00 2001 From: Ryan Dick Date: Wed, 14 Feb 2024 18:18:58 -0500 Subject: [PATCH 05/37] Remove unused constructor declared with typo in name: __int__. --- .../diffusion/cross_attention_control.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py b/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py index 1741440bc2..2bbee87f09 100644 --- a/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py +++ b/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py @@ -533,18 +533,6 @@ class SwapCrossAttnContext: mask: torch.Tensor # in the target space of the index_map cross_attention_types_to_do: list[CrossAttentionType] = field(default_factory=list) - def __int__( - self, - cac_types_to_do: [CrossAttentionType], - modified_text_embeddings: torch.Tensor, - index_map: torch.Tensor, - mask: torch.Tensor, - ): - self.cross_attention_types_to_do = cac_types_to_do - self.modified_text_embeddings = modified_text_embeddings - self.index_map = index_map - self.mask = mask - def wants_cross_attention_control(self, attn_type: CrossAttentionType) -> bool: return attn_type in self.cross_attention_types_to_do From eef3373799201f354dbf5b76f1c2738db2946bd6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 2 Mar 2024 21:37:49 +1100 Subject: [PATCH 06/37] ci: fix workflows Do not split up "on change" and "do the thing". Less convoluted, no catch-22 with required checks for PRs. --- .../actions/install-frontend-deps/action.yml | 14 +-- .../actions/install-python-deps/action.yml | 11 -- .github/workflows/check-frontend.yml | 43 ------- .github/workflows/check-python.yml | 33 ------ .github/workflows/frontend-checks.yml | 62 +++++++++++ .github/workflows/frontend-tests.yml | 42 +++++++ .github/workflows/mkdocs-material.yml | 23 +++- .../workflows/on-change-check-frontend.yml | 39 ------- .github/workflows/on-change-check-python.yml | 42 ------- .github/workflows/on-change-pytest.yml | 42 ------- .github/workflows/python-checks.yml | 58 ++++++++++ .../{check-pytest.yml => python-test.yml} | 41 ++++--- .github/workflows/release.yml | 105 ++++++++++++------ 13 files changed, 287 insertions(+), 268 deletions(-) delete mode 100644 .github/actions/install-python-deps/action.yml delete mode 100644 .github/workflows/check-frontend.yml delete mode 100644 .github/workflows/check-python.yml create mode 100644 .github/workflows/frontend-checks.yml create mode 100644 .github/workflows/frontend-tests.yml delete mode 100644 .github/workflows/on-change-check-frontend.yml delete mode 100644 .github/workflows/on-change-check-python.yml delete mode 100644 .github/workflows/on-change-pytest.yml create mode 100644 .github/workflows/python-checks.yml rename .github/workflows/{check-pytest.yml => python-test.yml} (63%) diff --git a/.github/actions/install-frontend-deps/action.yml b/.github/actions/install-frontend-deps/action.yml index b9d910ca99..32b4987249 100644 --- a/.github/actions/install-frontend-deps/action.yml +++ b/.github/actions/install-frontend-deps/action.yml @@ -1,33 +1,33 @@ -name: Install frontend dependencies +name: install frontend dependencies description: Installs frontend dependencies with pnpm, with caching runs: using: 'composite' steps: - - name: Setup Node 18 + - name: setup node 18 uses: actions/setup-node@v4 with: node-version: '18' - - name: Setup pnpm + - name: setup pnpm uses: pnpm/action-setup@v2 with: version: 8 run_install: false - - name: Get pnpm store directory + - name: get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v3 - name: Setup pnpm cache + - name: setup cache + uses: actions/cache@v4 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - - name: Install frontend dependencies + - name: install frontend dependencies run: pnpm install --prefer-frozen-lockfile shell: bash working-directory: invokeai/frontend/web diff --git a/.github/actions/install-python-deps/action.yml b/.github/actions/install-python-deps/action.yml deleted file mode 100644 index 4c0d351899..0000000000 --- a/.github/actions/install-python-deps/action.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Install python dependencies -description: Install python dependencies with pip, with caching -runs: - using: 'composite' - steps: - - name: Setup python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - cache: pip - cache-dependency-path: pyproject.toml diff --git a/.github/workflows/check-frontend.yml b/.github/workflows/check-frontend.yml deleted file mode 100644 index 8134926556..0000000000 --- a/.github/workflows/check-frontend.yml +++ /dev/null @@ -1,43 +0,0 @@ -# This workflow runs the frontend code quality checks. -# -# It may be triggered via dispatch, or by another workflow. - -name: 'Check: frontend' - -on: - workflow_dispatch: - workflow_call: - -defaults: - run: - working-directory: invokeai/frontend/web - -jobs: - check-frontend: - runs-on: ubuntu-latest - timeout-minutes: 10 # expected run time: <2 min - steps: - - uses: actions/checkout@v4 - - - name: Set up frontend - uses: ./.github/actions/install-frontend-deps - - - name: Run tsc check - run: 'pnpm run lint:tsc' - shell: bash - - - name: Run dpdm check - run: 'pnpm run lint:dpdm' - shell: bash - - - name: Run eslint check - run: 'pnpm run lint:eslint' - shell: bash - - - name: Run prettier check - run: 'pnpm run lint:prettier' - shell: bash - - - name: Run knip check - run: 'pnpm run lint:knip' - shell: bash diff --git a/.github/workflows/check-python.yml b/.github/workflows/check-python.yml deleted file mode 100644 index 63a6c46b0a..0000000000 --- a/.github/workflows/check-python.yml +++ /dev/null @@ -1,33 +0,0 @@ -# This workflow runs the python code quality checks. -# -# It may be triggered via dispatch, or by another workflow. -# -# TODO: Add mypy or pyright to the checks. - -name: 'Check: python' - -on: - workflow_dispatch: - workflow_call: - -jobs: - check-backend: - runs-on: ubuntu-latest - timeout-minutes: 5 # expected run time: <1 min - steps: - - uses: actions/checkout@v4 - - - name: Install python dependencies - uses: ./.github/actions/install-python-deps - - - name: Install ruff - run: pip install ruff - shell: bash - - - name: Ruff check - run: ruff check --output-format=github . - shell: bash - - - name: Ruff format - run: ruff format --check . - shell: bash diff --git a/.github/workflows/frontend-checks.yml b/.github/workflows/frontend-checks.yml new file mode 100644 index 0000000000..86f15fd562 --- /dev/null +++ b/.github/workflows/frontend-checks.yml @@ -0,0 +1,62 @@ +name: 'frontend checks' + +on: + push: + branches: + - 'main' + pull_request: + types: + - 'ready_for_review' + - 'opened' + - 'synchronize' + merge_group: + workflow_dispatch: + workflow_call: + +defaults: + run: + working-directory: invokeai/frontend/web + +jobs: + check-frontend: + runs-on: ubuntu-latest + timeout-minutes: 10 # expected run time: <2 min + steps: + - uses: actions/checkout@v4 + + - name: check for changed frontend files + id: changed-files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + frontend: + - 'invokeai/frontend/web/**' + + - name: install dependencies + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + uses: ./.github/actions/install-frontend-deps + + - name: tsc + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + run: 'pnpm lint:tsc' + shell: bash + + - name: dpdm + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + run: 'pnpm lint:dpdm' + shell: bash + + - name: eslint + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + run: 'pnpm lint:eslint' + shell: bash + + - name: prettier + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + run: 'pnpm lint:prettier' + shell: bash + + - name: knip + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + run: 'pnpm lint:knip' + shell: bash diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 0000000000..9ac74f16c0 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,42 @@ +name: 'frontend tests' + +on: + push: + branches: + - 'main' + pull_request: + types: + - 'ready_for_review' + - 'opened' + - 'synchronize' + merge_group: + workflow_dispatch: + workflow_call: + +defaults: + run: + working-directory: invokeai/frontend/web + +jobs: + check-frontend: + runs-on: ubuntu-latest + timeout-minutes: 10 # expected run time: <2 min + steps: + - uses: actions/checkout@v4 + + - name: check for changed frontend files + id: changed-files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + frontend: + - 'invokeai/frontend/web/**' + + - name: install dependencies + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + uses: ./.github/actions/install-frontend-deps + + - name: vitest + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + run: 'pnpm test:no-watch' + shell: bash diff --git a/.github/workflows/mkdocs-material.yml b/.github/workflows/mkdocs-material.yml index cbcfbf0835..419d87f37b 100644 --- a/.github/workflows/mkdocs-material.yml +++ b/.github/workflows/mkdocs-material.yml @@ -21,18 +21,29 @@ jobs: SITE_URL: 'https://${{ github.repository_owner }}.github.io/InvokeAI' steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: checkout + uses: actions/checkout@v4 + + - name: setup python + uses: actions/setup-python@v5 with: python-version: '3.10' cache: pip cache-dependency-path: pyproject.toml - - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v4 + + - name: set cache id + run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + + - name: use cache + uses: actions/cache@v4 with: key: mkdocs-material-${{ env.cache_id }} path: .cache restore-keys: | mkdocs-material- - - run: python -m pip install ".[docs]" - - run: mkdocs gh-deploy --force + + - name: install dependencies + run: python -m pip install ".[docs]" + + - name: build & deploy + run: mkdocs gh-deploy --force diff --git a/.github/workflows/on-change-check-frontend.yml b/.github/workflows/on-change-check-frontend.yml deleted file mode 100644 index 5e8704ad71..0000000000 --- a/.github/workflows/on-change-check-frontend.yml +++ /dev/null @@ -1,39 +0,0 @@ -# This workflow runs of `check-frontend.yml` on push or pull request. -# -# The actual checks are in a separate workflow to support simpler workflow -# composition without awkward or complicated conditionals. - -name: 'On change: run check-frontend' - -on: - push: - branches: - - 'main' - pull_request: - types: - - 'ready_for_review' - - 'opened' - - 'synchronize' - merge_group: - -jobs: - check-changed-frontend-files: - if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - outputs: - frontend_any_changed: ${{ steps.changed-files.outputs.frontend_any_changed }} - steps: - - uses: actions/checkout@v4 - - - name: Check for changed frontend files - id: changed-files - uses: tj-actions/changed-files@v41 - with: - files_yaml: | - frontend: - - 'invokeai/frontend/web/**' - - run-check-frontend: - needs: check-changed-frontend-files - if: ${{ needs.check-changed-frontend-files.outputs.frontend_any_changed == 'true' }} - uses: ./.github/workflows/check-frontend.yml diff --git a/.github/workflows/on-change-check-python.yml b/.github/workflows/on-change-check-python.yml deleted file mode 100644 index e73198b3fa..0000000000 --- a/.github/workflows/on-change-check-python.yml +++ /dev/null @@ -1,42 +0,0 @@ -# This workflow runs of `check-python.yml` on push or pull request. -# -# The actual checks are in a separate workflow to support simpler workflow -# composition without awkward or complicated conditionals. - -name: 'On change: run check-python' - -on: - push: - branches: - - 'main' - pull_request: - types: - - 'ready_for_review' - - 'opened' - - 'synchronize' - merge_group: - -jobs: - check-changed-python-files: - if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - outputs: - python_any_changed: ${{ steps.changed-files.outputs.python_any_changed }} - steps: - - uses: actions/checkout@v4 - - - name: Check for changed python files - id: changed-files - uses: tj-actions/changed-files@v41 - with: - files_yaml: | - python: - - 'pyproject.toml' - - 'invokeai/**' - - '!invokeai/frontend/web/**' - - 'tests/**' - - run-check-python: - needs: check-changed-python-files - if: ${{ needs.check-changed-python-files.outputs.python_any_changed == 'true' }} - uses: ./.github/workflows/check-python.yml diff --git a/.github/workflows/on-change-pytest.yml b/.github/workflows/on-change-pytest.yml deleted file mode 100644 index 0c174098bb..0000000000 --- a/.github/workflows/on-change-pytest.yml +++ /dev/null @@ -1,42 +0,0 @@ -# This workflow runs of `check-pytest.yml` on push or pull request. -# -# The actual checks are in a separate workflow to support simpler workflow -# composition without awkward or complicated conditionals. - -name: 'On change: run pytest' - -on: - push: - branches: - - 'main' - pull_request: - types: - - 'ready_for_review' - - 'opened' - - 'synchronize' - merge_group: - -jobs: - check-changed-python-files: - if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - outputs: - python_any_changed: ${{ steps.changed-files.outputs.python_any_changed }} - steps: - - uses: actions/checkout@v4 - - - name: Check for changed python files - id: changed-files - uses: tj-actions/changed-files@v41 - with: - files_yaml: | - python: - - 'pyproject.toml' - - 'invokeai/**' - - '!invokeai/frontend/web/**' - - 'tests/**' - - run-pytest: - needs: check-changed-python-files - if: ${{ needs.check-changed-python-files.outputs.python_any_changed == 'true' }} - uses: ./.github/workflows/check-pytest.yml diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml new file mode 100644 index 0000000000..2496942148 --- /dev/null +++ b/.github/workflows/python-checks.yml @@ -0,0 +1,58 @@ +# TODO: Add mypy or pyright to the checks. + +name: 'python checks' + +on: + push: + branches: + - 'main' + pull_request: + types: + - 'ready_for_review' + - 'opened' + - 'synchronize' + merge_group: + workflow_dispatch: + workflow_call: + +jobs: + check-backend: + runs-on: ubuntu-latest + timeout-minutes: 5 # expected run time: <1 min + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: check for changed python files + id: changed-files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + python: + - 'pyproject.toml' + - 'invokeai/**' + - '!invokeai/frontend/web/**' + - 'tests/**' + + - name: setup python + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: pip + cache-dependency-path: pyproject.toml + + - name: install ruff + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} + run: pip install ruff + shell: bash + + - name: ruff check + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} + run: ruff check --output-format=github . + shell: bash + + - name: ruff format + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} + run: ruff format --check . + shell: bash diff --git a/.github/workflows/check-pytest.yml b/.github/workflows/python-test.yml similarity index 63% rename from .github/workflows/check-pytest.yml rename to .github/workflows/python-test.yml index aedc0e59c3..7f8ec5af5a 100644 --- a/.github/workflows/check-pytest.yml +++ b/.github/workflows/python-test.yml @@ -1,10 +1,15 @@ -# This workflow runs pytest on the codebase in a matrix of platforms. -# -# It may be triggered via dispatch, or by another workflow. - -name: 'Check: pytest' +name: 'python tests' on: + push: + branches: + - 'main' + pull_request: + types: + - 'ready_for_review' + - 'opened' + - 'synchronize' + merge_group: workflow_dispatch: workflow_call: @@ -44,29 +49,39 @@ jobs: github-env: $env:GITHUB_ENV name: ${{ matrix.pytorch }} on ${{ matrix.python-version }} runs-on: ${{ matrix.os }} - timeout-minutes: 30 # expected run time: <10 min, depending on platform + timeout-minutes: 15 # expected run time: 2-6 min, depending on platform env: PIP_USE_PEP517: '1' steps: - - uses: actions/checkout@v4 + - name: checkout + uses: actions/checkout@v4 - - name: set test prompt to main branch validation - run: echo "TEST_PROMPTS=tests/validate_pr_prompt.txt" >> ${{ matrix.github-env }} + - name: check for changed python files + id: changed-files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + python: + - 'pyproject.toml' + - 'invokeai/**' + - '!invokeai/frontend/web/**' + - 'tests/**' - name: setup python + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip cache-dependency-path: pyproject.toml - - name: install invokeai + - name: install dependencies + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} env: PIP_EXTRA_INDEX_URL: ${{ matrix.extra-index-url }} run: > - pip3 install - --editable=".[test]" + pip3 install --editable=".[test]" - name: run pytest - id: run-pytest + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} run: pytest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f9ca098d5..bce20d15af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: build & release on: push: @@ -7,60 +7,85 @@ on: workflow_dispatch: inputs: skip_code_checks: - description: 'Skip code checks' + description: 'Skip code checks (disables publish)' required: true default: true type: boolean jobs: - check-version: + frontend-checks: + if: github.event.inputs.skip_code_checks != 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: run-frontend-checks + uses: ./.github/workflows/frontend-checks.yml - - uses: samuelcolvin/check-python-version@v4 + frontend-tests: + if: github.event.inputs.skip_code_checks != 'true' + runs-on: ubuntu-latest + steps: + - name: run-frontend-tests + uses: ./.github/workflows/frontend-tests.yml + + python-checks: + if: github.event.inputs.skip_code_checks != 'true' + runs-on: ubuntu-latest + steps: + - name: run-python-checks + uses: ./.github/workflows/python-checks.yml + + python-tests: + if: github.event.inputs.skip_code_checks != 'true' + runs-on: ubuntu-latest + steps: + - name: run-python-tests + uses: ./.github/workflows/python-tests.yml + + check-version: + if: github.event.inputs.skip_code_checks != 'true' + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: check python version + uses: samuelcolvin/check-python-version@v4 id: check-python-version with: version_file_path: invokeai/version/invokeai_version.py - check-frontend: - if: github.event.inputs.skip_code_checks != 'true' - uses: ./.github/workflows/check-frontend.yml - - check-python: - if: github.event.inputs.skip_code_checks != 'true' - uses: ./.github/workflows/check-python.yml - - check-pytest: - if: github.event.inputs.skip_code_checks != 'true' - uses: ./.github/workflows/check-pytest.yml - build: runs-on: ubuntu-latest + timeout-minutes: 15 # expected run time: <10 min steps: - - uses: actions/checkout@v4 + - name: checkout + uses: actions/checkout@v4 - - name: Install python dependencies - uses: ./.github/actions/install-python-deps + - name: setup python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: pip + cache-dependency-path: pyproject.toml - - name: Install pypa/build + - name: install pypa/build run: pip install --upgrade build - - name: Setup frontend + - name: setup frontend uses: ./.github/actions/install-frontend-deps - - name: Run create_installer.sh + - name: create installer id: create_installer - run: ./create_installer.sh --skip_frontend_checks + run: ./create_installer.sh working-directory: installer - - name: Upload python distribution artifact + - name: upload python distribution artifact uses: actions/upload-artifact@v4 with: name: dist path: ${{ steps.create_installer.outputs.DIST_PATH }} - - name: Upload installer artifact + - name: upload installer artifact uses: actions/upload-artifact@v4 with: name: ${{ steps.create_installer.outputs.INSTALLER_FILENAME }} @@ -68,36 +93,52 @@ jobs: publish-testpypi: runs-on: ubuntu-latest - needs: [check-version, check-frontend, check-python, check-pytest, build] + needs: + [ + check-version, + frontend-checks, + frontend-tests, + python-checks, + python-tests, + build, + ] if: github.event_name != 'workflow_dispatch' environment: name: testpypi url: https://test.pypi.org/p/invokeai steps: - - name: Download distribution from build job + - name: download distribution from build job uses: actions/download-artifact@v4 with: name: dist path: dist/ - - name: Publish distribution to TestPyPI + - name: publish distribution to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ publish-pypi: runs-on: ubuntu-latest - needs: [check-version, check-frontend, check-python, check-pytest, build] + needs: + [ + check-version, + frontend-checks, + frontend-tests, + python-checks, + python-tests, + build, + ] if: github.event_name != 'workflow_dispatch' environment: name: pypi url: https://pypi.org/p/invokeai steps: - - name: Download distribution from build job + - name: download distribution from build job uses: actions/download-artifact@v4 with: name: dist path: dist/ - - name: Publish distribution to PyPI + - name: publish distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 From 0c6b0cfdab88d8f31dae12fc8cd8f38912eccb0e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 2 Mar 2024 21:42:04 +1100 Subject: [PATCH 07/37] ci: tidy pr labeler labels --- .github/pr_labels.yml | 28 ++++++++++++++-------------- .github/workflows/label-pr.yml | 12 +++++++----- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/.github/pr_labels.yml b/.github/pr_labels.yml index 9580ccb0be..fdf11a470f 100644 --- a/.github/pr_labels.yml +++ b/.github/pr_labels.yml @@ -1,59 +1,59 @@ -Root: +root: - changed-files: - any-glob-to-any-file: '*' -PythonDeps: +python-deps: - changed-files: - any-glob-to-any-file: 'pyproject.toml' -Python: +python: - changed-files: - all-globs-to-any-file: - 'invokeai/**' - '!invokeai/frontend/web/**' -PythonTests: +python-tests: - changed-files: - any-glob-to-any-file: 'tests/**' -CICD: +ci-cd: - changed-files: - any-glob-to-any-file: .github/** -Docker: +docker: - changed-files: - any-glob-to-any-file: docker/** -Installer: +installer: - changed-files: - any-glob-to-any-file: installer/** -Documentation: +docs: - changed-files: - any-glob-to-any-file: docs/** -Invocations: +invocations: - changed-files: - any-glob-to-any-file: 'invokeai/app/invocations/**' -Backend: +backend: - changed-files: - any-glob-to-any-file: 'invokeai/backend/**' -Api: +api: - changed-files: - any-glob-to-any-file: 'invokeai/app/api/**' -Services: +services: - changed-files: - any-glob-to-any-file: 'invokeai/app/services/**' -FrontendDeps: +frontend-deps: - changed-files: - any-glob-to-any-file: - '**/*/package.json' - '**/*/pnpm-lock.yaml' -Frontend: +frontend: - changed-files: - any-glob-to-any-file: 'invokeai/frontend/web/**' diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index bc14e2f2c8..1a98512190 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -1,6 +1,6 @@ -name: "Pull Request Labeler" +name: 'label PRs' on: -- pull_request_target + - pull_request_target jobs: labeler: @@ -9,8 +9,10 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - name: Checkout + - name: checkout uses: actions/checkout@v4 - - uses: actions/labeler@v5 + + - name: label PRs + uses: actions/labeler@v5 with: - configuration-path: .github/pr_labels.yml \ No newline at end of file + configuration-path: .github/pr_labels.yml From 06fc6ccfe584df8e7211160605360102cdce8805 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 2 Mar 2024 21:54:16 +1100 Subject: [PATCH 08/37] ci: workflow & job names --- .github/workflows/frontend-checks.yml | 2 +- .github/workflows/frontend-tests.yml | 2 +- .github/workflows/python-checks.yml | 2 +- .github/workflows/python-test.yml | 15 ++++++++------- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/frontend-checks.yml b/.github/workflows/frontend-checks.yml index 86f15fd562..44f12dabd8 100644 --- a/.github/workflows/frontend-checks.yml +++ b/.github/workflows/frontend-checks.yml @@ -18,7 +18,7 @@ defaults: working-directory: invokeai/frontend/web jobs: - check-frontend: + frontend-checks: runs-on: ubuntu-latest timeout-minutes: 10 # expected run time: <2 min steps: diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 9ac74f16c0..7c29799796 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -18,7 +18,7 @@ defaults: working-directory: invokeai/frontend/web jobs: - check-frontend: + frontend-tests: runs-on: ubuntu-latest timeout-minutes: 10 # expected run time: <2 min steps: diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index 2496942148..7b528af731 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -16,7 +16,7 @@ on: workflow_call: jobs: - check-backend: + python-checks: runs-on: ubuntu-latest timeout-minutes: 5 # expected run time: <1 min steps: diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 7f8ec5af5a..b90040c52d 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -23,31 +23,32 @@ jobs: matrix: python-version: - '3.10' - pytorch: + - '3.11' + platform: - linux-cuda-11_7 - linux-rocm-5_2 - linux-cpu - macos-default - windows-cpu include: - - pytorch: linux-cuda-11_7 + - platform: linux-cuda-11_7 os: ubuntu-22.04 github-env: $GITHUB_ENV - - pytorch: linux-rocm-5_2 + - platform: linux-rocm-5_2 os: ubuntu-22.04 extra-index-url: 'https://download.pytorch.org/whl/rocm5.2' github-env: $GITHUB_ENV - - pytorch: linux-cpu + - platform: linux-cpu os: ubuntu-22.04 extra-index-url: 'https://download.pytorch.org/whl/cpu' github-env: $GITHUB_ENV - - pytorch: macos-default + - platform: macos-default os: macOS-12 github-env: $GITHUB_ENV - - pytorch: windows-cpu + - platform: windows-cpu os: windows-2022 github-env: $env:GITHUB_ENV - name: ${{ matrix.pytorch }} on ${{ matrix.python-version }} + name: ${{ matrix.platform }} on py${{ matrix.python-version }} runs-on: ${{ matrix.os }} timeout-minutes: 15 # expected run time: 2-6 min, depending on platform env: From 3ba5c2b0b43fa93dd8dfeb5a6f729aec4ae79b19 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 2 Mar 2024 22:45:28 +1100 Subject: [PATCH 09/37] ci: split build job --- .github/workflows/build.yml | 43 +++++++++++++++ .github/workflows/release.yml | 99 ++++++++++------------------------- 2 files changed, 70 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..728f8e5fea --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,43 @@ +name: build installer + +on: + workflow_dispatch: + workflow_call: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 5 # expected run time: <2 min + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: pip + cache-dependency-path: pyproject.toml + + - name: install pypa/build + run: pip install --upgrade build + + - name: setup frontend + uses: ./.github/actions/install-frontend-deps + + - name: create installer + id: create_installer + run: ./create_installer.sh + working-directory: installer + + - name: upload python distribution artifact + uses: actions/upload-artifact@v4 + with: + name: dist + path: ${{ steps.create_installer.outputs.DIST_PATH }} + + - name: upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.create_installer.outputs.INSTALLER_FILENAME }} + path: ${{ steps.create_installer.outputs.INSTALLER_PATH }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bce20d15af..6e7a689629 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,44 +5,9 @@ on: tags: - 'v*' workflow_dispatch: - inputs: - skip_code_checks: - description: 'Skip code checks (disables publish)' - required: true - default: true - type: boolean jobs: - frontend-checks: - if: github.event.inputs.skip_code_checks != 'true' - runs-on: ubuntu-latest - steps: - - name: run-frontend-checks - uses: ./.github/workflows/frontend-checks.yml - - frontend-tests: - if: github.event.inputs.skip_code_checks != 'true' - runs-on: ubuntu-latest - steps: - - name: run-frontend-tests - uses: ./.github/workflows/frontend-tests.yml - - python-checks: - if: github.event.inputs.skip_code_checks != 'true' - runs-on: ubuntu-latest - steps: - - name: run-python-checks - uses: ./.github/workflows/python-checks.yml - - python-tests: - if: github.event.inputs.skip_code_checks != 'true' - runs-on: ubuntu-latest - steps: - - name: run-python-tests - uses: ./.github/workflows/python-tests.yml - check-version: - if: github.event.inputs.skip_code_checks != 'true' runs-on: ubuntu-latest steps: - name: checkout @@ -54,45 +19,36 @@ jobs: with: version_file_path: invokeai/version/invokeai_version.py + frontend-checks: + needs: check-version + uses: ./.github/workflows/frontend-checks.yml + + frontend-tests: + needs: check-version + uses: ./.github/workflows/frontend-tests.yml + + python-checks: + needs: check-version + uses: ./.github/workflows/python-checks.yml + + python-tests: + needs: check-version + uses: ./.github/workflows/python-tests.yml + build: - runs-on: ubuntu-latest - timeout-minutes: 15 # expected run time: <10 min - steps: - - name: checkout - uses: actions/checkout@v4 - - - name: setup python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - cache: pip - cache-dependency-path: pyproject.toml - - - name: install pypa/build - run: pip install --upgrade build - - - name: setup frontend - uses: ./.github/actions/install-frontend-deps - - - name: create installer - id: create_installer - run: ./create_installer.sh - working-directory: installer - - - name: upload python distribution artifact - uses: actions/upload-artifact@v4 - with: - name: dist - path: ${{ steps.create_installer.outputs.DIST_PATH }} - - - name: upload installer artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.create_installer.outputs.INSTALLER_FILENAME }} - path: ${{ steps.create_installer.outputs.INSTALLER_PATH }} + needs: + [ + check-version, + frontend-checks, + frontend-tests, + python-checks, + python-tests, + ] + uses: ./.github/workflows/build.yml publish-testpypi: runs-on: ubuntu-latest + timeout-minutes: 5 # expected run time: <1 min needs: [ check-version, @@ -102,7 +58,6 @@ jobs: python-tests, build, ] - if: github.event_name != 'workflow_dispatch' environment: name: testpypi url: https://test.pypi.org/p/invokeai @@ -120,6 +75,7 @@ jobs: publish-pypi: runs-on: ubuntu-latest + timeout-minutes: 5 # expected run time: <1 min needs: [ check-version, @@ -129,7 +85,6 @@ jobs: python-tests, build, ] - if: github.event_name != 'workflow_dispatch' environment: name: pypi url: https://pypi.org/p/invokeai From b2a850b5ea47dac588f68dca416dd9e93061696f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 2 Mar 2024 22:53:35 +1100 Subject: [PATCH 10/37] ci: rename jobs, remove extraneous needs in release --- .../workflows/{build.yml => build-installer.yml} | 2 +- .../{python-test.yml => python-tests.yml} | 0 .github/workflows/release.yml | 16 ++-------------- 3 files changed, 3 insertions(+), 15 deletions(-) rename .github/workflows/{build.yml => build-installer.yml} (98%) rename .github/workflows/{python-test.yml => python-tests.yml} (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build-installer.yml similarity index 98% rename from .github/workflows/build.yml rename to .github/workflows/build-installer.yml index 728f8e5fea..dbd797c5e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build-installer.yml @@ -5,7 +5,7 @@ on: workflow_call: jobs: - build: + build-installer: runs-on: ubuntu-latest timeout-minutes: 5 # expected run time: <2 min steps: diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-tests.yml similarity index 100% rename from .github/workflows/python-test.yml rename to .github/workflows/python-tests.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e7a689629..444ae33714 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: build & release +name: release on: push: @@ -20,31 +20,19 @@ jobs: version_file_path: invokeai/version/invokeai_version.py frontend-checks: - needs: check-version uses: ./.github/workflows/frontend-checks.yml frontend-tests: - needs: check-version uses: ./.github/workflows/frontend-tests.yml python-checks: - needs: check-version uses: ./.github/workflows/python-checks.yml python-tests: - needs: check-version uses: ./.github/workflows/python-tests.yml build: - needs: - [ - check-version, - frontend-checks, - frontend-tests, - python-checks, - python-tests, - ] - uses: ./.github/workflows/build.yml + uses: ./.github/workflows/build-installer.yml publish-testpypi: runs-on: ubuntu-latest From 09037b7cd40fb34a95534a1b03661a77e75e43a4 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 2 Mar 2024 23:05:24 +1100 Subject: [PATCH 11/37] ci: add conditionals for jobs based on dispatch/call --- .github/workflows/frontend-checks.yml | 13 +++++++------ .github/workflows/frontend-tests.yml | 5 +++-- .github/workflows/python-checks.yml | 9 +++++---- .github/workflows/python-tests.yml | 7 ++++--- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/workflows/frontend-checks.yml b/.github/workflows/frontend-checks.yml index 44f12dabd8..680c0a5b22 100644 --- a/.github/workflows/frontend-checks.yml +++ b/.github/workflows/frontend-checks.yml @@ -25,6 +25,7 @@ jobs: - uses: actions/checkout@v4 - name: check for changed frontend files + if: ${{ github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' }} id: changed-files uses: tj-actions/changed-files@v42 with: @@ -33,30 +34,30 @@ jobs: - 'invokeai/frontend/web/**' - name: install dependencies - if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} uses: ./.github/actions/install-frontend-deps - name: tsc - if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} run: 'pnpm lint:tsc' shell: bash - name: dpdm - if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} run: 'pnpm lint:dpdm' shell: bash - name: eslint - if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} run: 'pnpm lint:eslint' shell: bash - name: prettier - if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} run: 'pnpm lint:prettier' shell: bash - name: knip - if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} run: 'pnpm lint:knip' shell: bash diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 7c29799796..f7bd9be7a3 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -25,6 +25,7 @@ jobs: - uses: actions/checkout@v4 - name: check for changed frontend files + if: ${{ github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' }} id: changed-files uses: tj-actions/changed-files@v42 with: @@ -33,10 +34,10 @@ jobs: - 'invokeai/frontend/web/**' - name: install dependencies - if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} uses: ./.github/actions/install-frontend-deps - name: vitest - if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} run: 'pnpm test:no-watch' shell: bash diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index 7b528af731..f92065059c 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -24,6 +24,7 @@ jobs: uses: actions/checkout@v4 - name: check for changed python files + if: ${{ github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' }} id: changed-files uses: tj-actions/changed-files@v42 with: @@ -35,7 +36,7 @@ jobs: - 'tests/**' - name: setup python - if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} uses: actions/setup-python@v5 with: python-version: '3.10' @@ -43,16 +44,16 @@ jobs: cache-dependency-path: pyproject.toml - name: install ruff - if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} run: pip install ruff shell: bash - name: ruff check - if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} run: ruff check --output-format=github . shell: bash - name: ruff format - if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} run: ruff format --check . shell: bash diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index b90040c52d..3d37355373 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -58,6 +58,7 @@ jobs: uses: actions/checkout@v4 - name: check for changed python files + if: ${{ github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' }} id: changed-files uses: tj-actions/changed-files@v42 with: @@ -69,7 +70,7 @@ jobs: - 'tests/**' - name: setup python - if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -77,12 +78,12 @@ jobs: cache-dependency-path: pyproject.toml - name: install dependencies - if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} env: PIP_EXTRA_INDEX_URL: ${{ matrix.extra-index-url }} run: > pip3 install --editable=".[test]" - name: run pytest - if: ${{ steps.changed-files.outputs.python_any_changed == 'true' }} + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} run: pytest From d2ad465e96ccecd3b647556dcfb07b765f983e67 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 2 Mar 2024 23:23:57 +1100 Subject: [PATCH 12/37] ci: rename test matrix Now python version: platform, e.g. `py3.10: linux-cpu` This displays better in GH actions. --- .github/workflows/python-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 3d37355373..4afc3480a0 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -48,7 +48,7 @@ jobs: - platform: windows-cpu os: windows-2022 github-env: $env:GITHUB_ENV - name: ${{ matrix.platform }} on py${{ matrix.python-version }} + name: "py${{ matrix.python-version }}: ${{ matrix.platform }}" runs-on: ${{ matrix.os }} timeout-minutes: 15 # expected run time: 2-6 min, depending on platform env: From 51cc9f9466c93370fcfa402bd82154e7bd968ccb Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 2 Mar 2024 23:48:23 +1100 Subject: [PATCH 13/37] ci: add comments to workflows --- .github/workflows/build-installer.yml | 2 ++ .github/workflows/frontend-checks.yml | 5 +++++ .github/workflows/frontend-tests.yml | 5 +++++ .github/workflows/python-checks.yml | 5 +++++ .github/workflows/python-tests.yml | 7 ++++++- .github/workflows/release.yml | 9 +++++++++ 6 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-installer.yml b/.github/workflows/build-installer.yml index dbd797c5e6..9d6d42c8d9 100644 --- a/.github/workflows/build-installer.yml +++ b/.github/workflows/build-installer.yml @@ -1,3 +1,5 @@ +# Builds and uploads the installer and python build artifacts. + name: build installer on: diff --git a/.github/workflows/frontend-checks.yml b/.github/workflows/frontend-checks.yml index 680c0a5b22..e621348af4 100644 --- a/.github/workflows/frontend-checks.yml +++ b/.github/workflows/frontend-checks.yml @@ -1,3 +1,8 @@ +# Runs frontend code quality checks. +# +# Checks for changes to frontend files before running the checks. +# When manually triggered or when called from another workflow, always runs the checks. + name: 'frontend checks' on: diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index f7bd9be7a3..e4e18f2571 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -1,3 +1,8 @@ +# Runs frontend tests. +# +# Checks for changes to frontend files before running the tests. +# When manually triggered or called from another workflow, always runs the tests. + name: 'frontend tests' on: diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index f92065059c..cbf986d8da 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -1,3 +1,8 @@ +# Runs python code quality checks. +# +# Checks for changes to python files before running the checks. +# When manually triggered or called from another workflow, always runs the tests. +# # TODO: Add mypy or pyright to the checks. name: 'python checks' diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 4afc3480a0..d261a90451 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -1,3 +1,8 @@ +# Runs python tests on a matrix of python versions and platforms. +# +# Checks for changes to python files before running the tests. +# When manually triggered or called from another workflow, always runs the tests. + name: 'python tests' on: @@ -48,7 +53,7 @@ jobs: - platform: windows-cpu os: windows-2022 github-env: $env:GITHUB_ENV - name: "py${{ matrix.python-version }}: ${{ matrix.platform }}" + name: 'py${{ matrix.python-version }}: ${{ matrix.platform }}' runs-on: ${{ matrix.os }} timeout-minutes: 15 # expected run time: 2-6 min, depending on platform env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 444ae33714..037a082722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,12 @@ +# Main release workflow. Triggered on tag push or manual trigger. +# +# - Runs all code checks and tests +# - Verifies the app version matches the tag version. +# - Builds the installer and build, uploading them as artifacts. +# - Publishes to TestPyPI and PyPI. Both are conditional on the previous steps passing and require a manual approval. +# +# See docs/RELEASE.md for more information on the release process. + name: release on: From f8b54930f04c46cabadb81610c541331bd000300 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 2 Mar 2024 23:58:33 +1100 Subject: [PATCH 14/37] docs: update RELEASE.md --- docs/RELEASE.md | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 3a0375b027..82bf68b535 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -23,13 +23,13 @@ It is triggered on **tag push**, when the tag matches `v*`. It doesn't matter if Run `make tag-release` to tag the current commit and kick off the workflow. -The release may also be run [manually]. +The release may also be dispatched [manually]. ### Workflow Jobs and Process The workflow consists of a number of concurrently-run jobs, and two final publish jobs. -The publish jobs run if the 5 concurrent jobs all succeed and if/when the publish jobs are approved. +The publish jobs require manual approval and are only run if the other jobs succeed. #### `check-version` Job @@ -43,17 +43,16 @@ This job uses [samuelcolvin/check-python-version]. #### Check and Test Jobs -This is our test suite. - -- **`check-pytest`**: runs `pytest` on matrix of platforms -- **`check-python`**: runs `ruff` (format and lint) -- **`check-frontend`**: runs `prettier` (format), `eslint` (lint), `madge` (circular refs) and `tsc` (static type check) +- **`python-tests`**: runs `pytest` on matrix of platforms +- **`python-checks`**: runs `ruff` (format and lint) +- **`frontend-tests`**: runs `vitest` +- **`frontend-checks`**: runs `prettier` (format), `eslint` (lint), `dpdm` (circular refs), `tsc` (static type check) and `knip` (unused imports) > **TODO** We should add `mypy` or `pyright` to the **`check-python`** job. > **TODO** We should add an end-to-end test job that generates an image. -#### `build` Job +#### `build-installer` Job This sets up both python and frontend dependencies and builds the python package. Internally, this runs `installer/create_installer.sh` and uploads two artifacts: @@ -62,7 +61,7 @@ This sets up both python and frontend dependencies and builds the python package #### Sanity Check & Smoke Test -At this point, the release workflow pauses (the remaining jobs all require approval). +At this point, the release workflow pauses as the remaining publish jobs require approval. A maintainer should go to the **Summary** tab of the workflow, download the installer and test it. Ensure the app loads and generates. @@ -70,7 +69,7 @@ A maintainer should go to the **Summary** tab of the workflow, download the inst #### PyPI Publish Jobs -The publish jobs will skip if any of the previous jobs skip or fail. +The publish jobs will run if any of the previous jobs fail. They use [GitHub environments], which are configured as [trusted publishers] on PyPI. @@ -119,13 +118,17 @@ Once the release is published to PyPI, it's time to publish the GitHub release. > **TODO** Workflows can create a GitHub release from a template and upload release assets. One popular action to handle this is [ncipollo/release-action]. A future enhancement to the release process could set this up. -## Manually Running the Release Workflow +## Manual Build -The release workflow can be run manually. This is useful to get an installer build and test it out without needing to push a tag. +The `build installer` workflow can be dispatched manually. This is useful to test the installer for a given branch or tag. -When run this way, you'll see **Skip code checks** checkbox. This allows the workflow to run without the time-consuming 3 code quality check jobs. +No checks are run, it just builds. -The publish jobs will skip if the workflow was run manually. +## Manual Release + +The `release` workflow can be dispatched manually. You must dispatch the workflow from the right tag, else it will fail the version check. + +This functionality is available as a fallback in case something goes wonky. Typically, releases should be triggered via tag push as described above. [InvokeAI Releases Page]: https://github.com/invoke-ai/InvokeAI/releases [PyPI]: https://pypi.org/ @@ -136,4 +139,4 @@ The publish jobs will skip if the workflow was run manually. [GitHub environments]: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment [trusted publishers]: https://docs.pypi.org/trusted-publishers/ [samuelcolvin/check-python-version]: https://github.com/samuelcolvin/check-python-version -[manually]: #manually-running-the-release-workflow +[manually]: #manual-release From 73bec56c597545fe12cb585812ae91fbda0cbfc4 Mon Sep 17 00:00:00 2001 From: Ryan Dick Date: Thu, 15 Feb 2024 17:22:37 -0500 Subject: [PATCH 15/37] Delete unused functions from shared_invokeai_diffusion.py. --- .../diffusion/shared_invokeai_diffusion.py | 51 ------------------- 1 file changed, 51 deletions(-) diff --git a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py index 2b72c808e4..c6b85d2bd6 100644 --- a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py +++ b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py @@ -592,54 +592,3 @@ class InvokeAIDiffuserComponent: self.last_percent_through = percent_through return latents.to(device=dev) - - # todo: make this work - @classmethod - def apply_conjunction(cls, x, t, forward_func, uc, c_or_weighted_c_list, global_guidance_scale): - x_in = torch.cat([x] * 2) - t_in = torch.cat([t] * 2) # aka sigmas - - deltas = None - uncond_latents = None - weighted_cond_list = ( - c_or_weighted_c_list if isinstance(c_or_weighted_c_list, list) else [(c_or_weighted_c_list, 1)] - ) - - # below is fugly omg - conditionings = [uc] + [c for c, weight in weighted_cond_list] - weights = [1] + [weight for c, weight in weighted_cond_list] - chunk_count = math.ceil(len(conditionings) / 2) - deltas = None - for chunk_index in range(chunk_count): - offset = chunk_index * 2 - chunk_size = min(2, len(conditionings) - offset) - - if chunk_size == 1: - c_in = conditionings[offset] - latents_a = forward_func(x_in[:-1], t_in[:-1], c_in) - latents_b = None - else: - c_in = torch.cat(conditionings[offset : offset + 2]) - latents_a, latents_b = forward_func(x_in, t_in, c_in).chunk(2) - - # first chunk is guaranteed to be 2 entries: uncond_latents + first conditioining - if chunk_index == 0: - uncond_latents = latents_a - deltas = latents_b - uncond_latents - else: - deltas = torch.cat((deltas, latents_a - uncond_latents)) - if latents_b is not None: - deltas = torch.cat((deltas, latents_b - uncond_latents)) - - # merge the weighted deltas together into a single merged delta - per_delta_weights = torch.tensor(weights[1:], dtype=deltas.dtype, device=deltas.device) - normalize = False - if normalize: - per_delta_weights /= torch.sum(per_delta_weights) - reshaped_weights = per_delta_weights.reshape(per_delta_weights.shape + (1, 1, 1)) - deltas_merged = torch.sum(deltas * reshaped_weights, dim=0, keepdim=True) - - # old_return_value = super().forward(x, sigma, uncond, cond, cond_scale) - # assert(0 == len(torch.nonzero(old_return_value - (uncond_latents + deltas_merged * cond_scale)))) - - return uncond_latents + deltas_merged * global_guidance_scale From cc45007dc4ff55575b622d0a1992bf151dfb0744 Mon Sep 17 00:00:00 2001 From: Ryan Dick Date: Thu, 15 Feb 2024 17:28:55 -0500 Subject: [PATCH 16/37] Remove unused code for attention map saving. --- invokeai/app/invocations/latent.py | 5 +- invokeai/backend/stable_diffusion/__init__.py | 2 - .../stable_diffusion/diffusers_pipeline.py | 41 +- .../stable_diffusion/diffusion/__init__.py | 2 - .../diffusion/cross_attention_control.py | 453 +----------------- .../diffusion/cross_attention_map_saving.py | 100 ---- .../diffusion/shared_invokeai_diffusion.py | 29 +- 7 files changed, 18 insertions(+), 614 deletions(-) delete mode 100644 invokeai/backend/stable_diffusion/diffusion/cross_attention_map_saving.py diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py index 9e9cb2d1c7..d9125f0f37 100644 --- a/invokeai/app/invocations/latent.py +++ b/invokeai/app/invocations/latent.py @@ -775,10 +775,7 @@ class DenoiseLatentsInvocation(BaseInvocation): denoising_end=self.denoising_end, ) - ( - result_latents, - result_attention_map_saver, - ) = pipeline.latents_from_embeddings( + result_latents = pipeline.latents_from_embeddings( latents=latents, timesteps=timesteps, init_timestep=init_timestep, diff --git a/invokeai/backend/stable_diffusion/__init__.py b/invokeai/backend/stable_diffusion/__init__.py index 8b3f701064..ed6782eefa 100644 --- a/invokeai/backend/stable_diffusion/__init__.py +++ b/invokeai/backend/stable_diffusion/__init__.py @@ -4,13 +4,11 @@ Initialization file for the invokeai.backend.stable_diffusion package from .diffusers_pipeline import PipelineIntermediateState, StableDiffusionGeneratorPipeline # noqa: F401 from .diffusion import InvokeAIDiffuserComponent # noqa: F401 -from .diffusion.cross_attention_map_saving import AttentionMapSaver # noqa: F401 from .seamless import set_seamless # noqa: F401 __all__ = [ "PipelineIntermediateState", "StableDiffusionGeneratorPipeline", "InvokeAIDiffuserComponent", - "AttentionMapSaver", "set_seamless", ] diff --git a/invokeai/backend/stable_diffusion/diffusers_pipeline.py b/invokeai/backend/stable_diffusion/diffusers_pipeline.py index 538e0ea990..9a08787878 100644 --- a/invokeai/backend/stable_diffusion/diffusers_pipeline.py +++ b/invokeai/backend/stable_diffusion/diffusers_pipeline.py @@ -12,7 +12,6 @@ import torch import torchvision.transforms as T from diffusers.models import AutoencoderKL, UNet2DConditionModel from diffusers.models.controlnet import ControlNetModel -from diffusers.pipelines.stable_diffusion import StableDiffusionPipelineOutput from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion import StableDiffusionPipeline from diffusers.pipelines.stable_diffusion.safety_checker import StableDiffusionSafetyChecker from diffusers.schedulers import KarrasDiffusionSchedulers @@ -26,9 +25,9 @@ from invokeai.app.services.config import InvokeAIAppConfig from invokeai.backend.ip_adapter.ip_adapter import IPAdapter from invokeai.backend.ip_adapter.unet_patcher import UNetPatcher from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningData +from invokeai.backend.stable_diffusion.diffusion.shared_invokeai_diffusion import InvokeAIDiffuserComponent from ..util import auto_detect_slice_size, normalize_device -from .diffusion import AttentionMapSaver, InvokeAIDiffuserComponent @dataclass @@ -39,7 +38,6 @@ class PipelineIntermediateState: timestep: int latents: torch.Tensor predicted_original: Optional[torch.Tensor] = None - attention_map_saver: Optional[AttentionMapSaver] = None @dataclass @@ -190,19 +188,6 @@ class T2IAdapterData: end_step_percent: float = Field(default=1.0) -@dataclass -class InvokeAIStableDiffusionPipelineOutput(StableDiffusionPipelineOutput): - r""" - Output class for InvokeAI's Stable Diffusion pipeline. - - Args: - attention_map_saver (`AttentionMapSaver`): Object containing attention maps that can be displayed to the user - after generation completes. Optional. - """ - - attention_map_saver: Optional[AttentionMapSaver] - - class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): r""" Pipeline for text-to-image generation using Stable Diffusion. @@ -343,9 +328,9 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): masked_latents: Optional[torch.Tensor] = None, gradient_mask: Optional[bool] = False, seed: Optional[int] = None, - ) -> tuple[torch.Tensor, Optional[AttentionMapSaver]]: + ) -> torch.Tensor: if init_timestep.shape[0] == 0: - return latents, None + return latents if additional_guidance is None: additional_guidance = [] @@ -385,7 +370,7 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): additional_guidance.append(AddsMaskGuidance(mask, orig_latents, self.scheduler, noise, gradient_mask)) try: - latents, attention_map_saver = self.generate_latents_from_embeddings( + latents = self.generate_latents_from_embeddings( latents, timesteps, conditioning_data, @@ -402,7 +387,7 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): if mask is not None and not gradient_mask: latents = torch.lerp(orig_latents, latents.to(dtype=orig_latents.dtype), mask.to(dtype=orig_latents.dtype)) - return latents, attention_map_saver + return latents def generate_latents_from_embeddings( self, @@ -415,16 +400,15 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): ip_adapter_data: Optional[list[IPAdapterData]] = None, t2i_adapter_data: Optional[list[T2IAdapterData]] = None, callback: Callable[[PipelineIntermediateState], None] = None, - ): + ) -> torch.Tensor: self._adjust_memory_efficient_attention(latents) if additional_guidance is None: additional_guidance = [] batch_size = latents.shape[0] - attention_map_saver: Optional[AttentionMapSaver] = None if timesteps.shape[0] == 0: - return latents, attention_map_saver + return latents ip_adapter_unet_patcher = None extra_conditioning_info = conditioning_data.text_embeddings.extra_conditioning @@ -432,7 +416,6 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): attn_ctx = self.invokeai_diffuser.custom_attention_context( self.invokeai_diffuser.model, extra_conditioning_info=extra_conditioning_info, - step_count=len(self.scheduler.timesteps), ) self.use_ip_adapter = False elif ip_adapter_data is not None: @@ -483,13 +466,6 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): predicted_original = getattr(step_output, "pred_original_sample", None) - # TODO resuscitate attention map saving - # if i == len(timesteps)-1 and extra_conditioning_info is not None: - # eos_token_index = extra_conditioning_info.tokens_count_including_eos_bos - 1 - # attention_map_token_ids = range(1, eos_token_index) - # attention_map_saver = AttentionMapSaver(token_ids=attention_map_token_ids, latents_shape=latents.shape[-2:]) - # self.invokeai_diffuser.setup_attention_map_saving(attention_map_saver) - if callback is not None: callback( PipelineIntermediateState( @@ -499,11 +475,10 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): timestep=int(t), latents=latents, predicted_original=predicted_original, - attention_map_saver=attention_map_saver, ) ) - return latents, attention_map_saver + return latents @torch.inference_mode() def step( diff --git a/invokeai/backend/stable_diffusion/diffusion/__init__.py b/invokeai/backend/stable_diffusion/diffusion/__init__.py index e68340168a..854d127a36 100644 --- a/invokeai/backend/stable_diffusion/diffusion/__init__.py +++ b/invokeai/backend/stable_diffusion/diffusion/__init__.py @@ -2,6 +2,4 @@ Initialization file for invokeai.models.diffusion """ -from .cross_attention_control import InvokeAICrossAttentionMixin # noqa: F401 -from .cross_attention_map_saving import AttentionMapSaver # noqa: F401 from .shared_invokeai_diffusion import InvokeAIDiffuserComponent # noqa: F401 diff --git a/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py b/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py index 2bbee87f09..4278f08bff 100644 --- a/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py +++ b/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py @@ -3,19 +3,13 @@ import enum -import math from dataclasses import dataclass, field -from typing import Callable, Optional +from typing import Optional -import diffusers -import psutil import torch from compel.cross_attention_control import Arguments -from diffusers.models.attention_processor import Attention, AttentionProcessor, AttnProcessor, SlicedAttnProcessor +from diffusers.models.attention_processor import Attention, SlicedAttnProcessor from diffusers.models.unets.unet_2d_condition import UNet2DConditionModel -from torch import nn - -import invokeai.backend.util.logging as logger from ...util import torch_dtype @@ -25,72 +19,14 @@ class CrossAttentionType(enum.Enum): TOKENS = 2 -class Context: - cross_attention_mask: Optional[torch.Tensor] - cross_attention_index_map: Optional[torch.Tensor] - - class Action(enum.Enum): - NONE = 0 - SAVE = (1,) - APPLY = 2 - - def __init__(self, arguments: Arguments, step_count: int): +class CrossAttnControlContext: + def __init__(self, arguments: Arguments): """ :param arguments: Arguments for the cross-attention control process - :param step_count: The absolute total number of steps of diffusion (for img2img this is likely larger than the number of steps that will actually run) """ - self.cross_attention_mask = None - self.cross_attention_index_map = None - self.self_cross_attention_action = Context.Action.NONE - self.tokens_cross_attention_action = Context.Action.NONE + self.cross_attention_mask: Optional[torch.Tensor] = None + self.cross_attention_index_map: Optional[torch.Tensor] = None self.arguments = arguments - self.step_count = step_count - - self.self_cross_attention_module_identifiers = [] - self.tokens_cross_attention_module_identifiers = [] - - self.saved_cross_attention_maps = {} - - self.clear_requests(cleanup=True) - - def register_cross_attention_modules(self, model): - for name, _module in get_cross_attention_modules(model, CrossAttentionType.SELF): - if name in self.self_cross_attention_module_identifiers: - raise AssertionError(f"name {name} cannot appear more than once") - self.self_cross_attention_module_identifiers.append(name) - for name, _module in get_cross_attention_modules(model, CrossAttentionType.TOKENS): - if name in self.tokens_cross_attention_module_identifiers: - raise AssertionError(f"name {name} cannot appear more than once") - self.tokens_cross_attention_module_identifiers.append(name) - - def request_save_attention_maps(self, cross_attention_type: CrossAttentionType): - if cross_attention_type == CrossAttentionType.SELF: - self.self_cross_attention_action = Context.Action.SAVE - else: - self.tokens_cross_attention_action = Context.Action.SAVE - - def request_apply_saved_attention_maps(self, cross_attention_type: CrossAttentionType): - if cross_attention_type == CrossAttentionType.SELF: - self.self_cross_attention_action = Context.Action.APPLY - else: - self.tokens_cross_attention_action = Context.Action.APPLY - - def is_tokens_cross_attention(self, module_identifier) -> bool: - return module_identifier in self.tokens_cross_attention_module_identifiers - - def get_should_save_maps(self, module_identifier: str) -> bool: - if module_identifier in self.self_cross_attention_module_identifiers: - return self.self_cross_attention_action == Context.Action.SAVE - elif module_identifier in self.tokens_cross_attention_module_identifiers: - return self.tokens_cross_attention_action == Context.Action.SAVE - return False - - def get_should_apply_saved_maps(self, module_identifier: str) -> bool: - if module_identifier in self.self_cross_attention_module_identifiers: - return self.self_cross_attention_action == Context.Action.APPLY - elif module_identifier in self.tokens_cross_attention_module_identifiers: - return self.tokens_cross_attention_action == Context.Action.APPLY - return False def get_active_cross_attention_control_types_for_step( self, percent_through: float = None @@ -111,219 +47,8 @@ class Context: to_control.append(CrossAttentionType.TOKENS) return to_control - def save_slice( - self, - identifier: str, - slice: torch.Tensor, - dim: Optional[int], - offset: int, - slice_size: Optional[int], - ): - if identifier not in self.saved_cross_attention_maps: - self.saved_cross_attention_maps[identifier] = { - "dim": dim, - "slice_size": slice_size, - "slices": {offset or 0: slice}, - } - else: - self.saved_cross_attention_maps[identifier]["slices"][offset or 0] = slice - def get_slice( - self, - identifier: str, - requested_dim: Optional[int], - requested_offset: int, - slice_size: int, - ): - saved_attention_dict = self.saved_cross_attention_maps[identifier] - if requested_dim is None: - if saved_attention_dict["dim"] is not None: - raise RuntimeError(f"dim mismatch: expected dim=None, have {saved_attention_dict['dim']}") - return saved_attention_dict["slices"][0] - - if saved_attention_dict["dim"] == requested_dim: - if slice_size != saved_attention_dict["slice_size"]: - raise RuntimeError( - f"slice_size mismatch: expected slice_size={slice_size}, have {saved_attention_dict['slice_size']}" - ) - return saved_attention_dict["slices"][requested_offset] - - if saved_attention_dict["dim"] is None: - whole_saved_attention = saved_attention_dict["slices"][0] - if requested_dim == 0: - return whole_saved_attention[requested_offset : requested_offset + slice_size] - elif requested_dim == 1: - return whole_saved_attention[:, requested_offset : requested_offset + slice_size] - - raise RuntimeError(f"Cannot convert dim {saved_attention_dict['dim']} to requested dim {requested_dim}") - - def get_slicing_strategy(self, identifier: str) -> tuple[Optional[int], Optional[int]]: - saved_attention = self.saved_cross_attention_maps.get(identifier, None) - if saved_attention is None: - return None, None - return saved_attention["dim"], saved_attention["slice_size"] - - def clear_requests(self, cleanup=True): - self.tokens_cross_attention_action = Context.Action.NONE - self.self_cross_attention_action = Context.Action.NONE - if cleanup: - self.saved_cross_attention_maps = {} - - def offload_saved_attention_slices_to_cpu(self): - for _key, map_dict in self.saved_cross_attention_maps.items(): - for offset, slice in map_dict["slices"].items(): - map_dict[offset] = slice.to("cpu") - - -class InvokeAICrossAttentionMixin: - """ - Enable InvokeAI-flavoured Attention calculation, which does aggressive low-memory slicing and calls - through both to an attention_slice_wrangler and a slicing_strategy_getter for custom attention map wrangling - and dymamic slicing strategy selection. - """ - - def __init__(self): - self.mem_total_gb = psutil.virtual_memory().total // (1 << 30) - self.attention_slice_wrangler = None - self.slicing_strategy_getter = None - self.attention_slice_calculated_callback = None - - def set_attention_slice_wrangler( - self, - wrangler: Optional[Callable[[nn.Module, torch.Tensor, int, int, int], torch.Tensor]], - ): - """ - Set custom attention calculator to be called when attention is calculated - :param wrangler: Callback, with args (module, suggested_attention_slice, dim, offset, slice_size), - which returns either the suggested_attention_slice or an adjusted equivalent. - `module` is the current Attention module for which the callback is being invoked. - `suggested_attention_slice` is the default-calculated attention slice - `dim` is -1 if the attenion map has not been sliced, or 0 or 1 for dimension-0 or dimension-1 slicing. - If `dim` is >= 0, `offset` and `slice_size` specify the slice start and length. - - Pass None to use the default attention calculation. - :return: - """ - self.attention_slice_wrangler = wrangler - - def set_slicing_strategy_getter(self, getter: Optional[Callable[[nn.Module], tuple[int, int]]]): - self.slicing_strategy_getter = getter - - def set_attention_slice_calculated_callback(self, callback: Optional[Callable[[torch.Tensor], None]]): - self.attention_slice_calculated_callback = callback - - def einsum_lowest_level(self, query, key, value, dim, offset, slice_size): - # calculate attention scores - # attention_scores = torch.einsum('b i d, b j d -> b i j', q, k) - attention_scores = torch.baddbmm( - torch.empty( - query.shape[0], - query.shape[1], - key.shape[1], - dtype=query.dtype, - device=query.device, - ), - query, - key.transpose(-1, -2), - beta=0, - alpha=self.scale, - ) - - # calculate attention slice by taking the best scores for each latent pixel - default_attention_slice = attention_scores.softmax(dim=-1, dtype=attention_scores.dtype) - attention_slice_wrangler = self.attention_slice_wrangler - if attention_slice_wrangler is not None: - attention_slice = attention_slice_wrangler(self, default_attention_slice, dim, offset, slice_size) - else: - attention_slice = default_attention_slice - - if self.attention_slice_calculated_callback is not None: - self.attention_slice_calculated_callback(attention_slice, dim, offset, slice_size) - - hidden_states = torch.bmm(attention_slice, value) - return hidden_states - - def einsum_op_slice_dim0(self, q, k, v, slice_size): - r = torch.zeros(q.shape[0], q.shape[1], v.shape[2], device=q.device, dtype=q.dtype) - for i in range(0, q.shape[0], slice_size): - end = i + slice_size - r[i:end] = self.einsum_lowest_level(q[i:end], k[i:end], v[i:end], dim=0, offset=i, slice_size=slice_size) - return r - - def einsum_op_slice_dim1(self, q, k, v, slice_size): - r = torch.zeros(q.shape[0], q.shape[1], v.shape[2], device=q.device, dtype=q.dtype) - for i in range(0, q.shape[1], slice_size): - end = i + slice_size - r[:, i:end] = self.einsum_lowest_level(q[:, i:end], k, v, dim=1, offset=i, slice_size=slice_size) - return r - - def einsum_op_mps_v1(self, q, k, v): - if q.shape[1] <= 4096: # (512x512) max q.shape[1]: 4096 - return self.einsum_lowest_level(q, k, v, None, None, None) - else: - slice_size = math.floor(2**30 / (q.shape[0] * q.shape[1])) - return self.einsum_op_slice_dim1(q, k, v, slice_size) - - def einsum_op_mps_v2(self, q, k, v): - if self.mem_total_gb > 8 and q.shape[1] <= 4096: - return self.einsum_lowest_level(q, k, v, None, None, None) - else: - return self.einsum_op_slice_dim0(q, k, v, 1) - - def einsum_op_tensor_mem(self, q, k, v, max_tensor_mb): - size_mb = q.shape[0] * q.shape[1] * k.shape[1] * q.element_size() // (1 << 20) - if size_mb <= max_tensor_mb: - return self.einsum_lowest_level(q, k, v, None, None, None) - div = 1 << int((size_mb - 1) / max_tensor_mb).bit_length() - if div <= q.shape[0]: - return self.einsum_op_slice_dim0(q, k, v, q.shape[0] // div) - return self.einsum_op_slice_dim1(q, k, v, max(q.shape[1] // div, 1)) - - def einsum_op_cuda(self, q, k, v): - # check if we already have a slicing strategy (this should only happen during cross-attention controlled generation) - slicing_strategy_getter = self.slicing_strategy_getter - if slicing_strategy_getter is not None: - (dim, slice_size) = slicing_strategy_getter(self) - if dim is not None: - # print("using saved slicing strategy with dim", dim, "slice size", slice_size) - if dim == 0: - return self.einsum_op_slice_dim0(q, k, v, slice_size) - elif dim == 1: - return self.einsum_op_slice_dim1(q, k, v, slice_size) - - # fallback for when there is no saved strategy, or saved strategy does not slice - mem_free_total = get_mem_free_total(q.device) - # Divide factor of safety as there's copying and fragmentation - return self.einsum_op_tensor_mem(q, k, v, mem_free_total / 3.3 / (1 << 20)) - - def get_invokeai_attention_mem_efficient(self, q, k, v): - if q.device.type == "cuda": - # print("in get_attention_mem_efficient with q shape", q.shape, ", k shape", k.shape, ", free memory is", get_mem_free_total(q.device)) - return self.einsum_op_cuda(q, k, v) - - if q.device.type == "mps" or q.device.type == "cpu": - if self.mem_total_gb >= 32: - return self.einsum_op_mps_v1(q, k, v) - return self.einsum_op_mps_v2(q, k, v) - - # Smaller slices are faster due to L2/L3/SLC caches. - # Tested on i7 with 8MB L3 cache. - return self.einsum_op_tensor_mem(q, k, v, 32) - - -def restore_default_cross_attention( - model, - is_running_diffusers: bool, - restore_attention_processor: Optional[AttentionProcessor] = None, -): - if is_running_diffusers: - unet = model - unet.set_attn_processor(restore_attention_processor or AttnProcessor()) - else: - remove_attention_function(model) - - -def setup_cross_attention_control_attention_processors(unet: UNet2DConditionModel, context: Context): +def setup_cross_attention_control_attention_processors(unet: UNet2DConditionModel, context: CrossAttnControlContext): """ Inject attention parameters and functions into the passed in model to enable cross attention editing. @@ -362,170 +87,6 @@ def setup_cross_attention_control_attention_processors(unet: UNet2DConditionMode unet.set_attn_processor(SlicedSwapCrossAttnProcesser(slice_size=slice_size)) -def get_cross_attention_modules(model, which: CrossAttentionType) -> list[tuple[str, InvokeAICrossAttentionMixin]]: - cross_attention_class: type = InvokeAIDiffusersCrossAttention - which_attn = "attn1" if which is CrossAttentionType.SELF else "attn2" - attention_module_tuples = [ - (name, module) - for name, module in model.named_modules() - if isinstance(module, cross_attention_class) and which_attn in name - ] - cross_attention_modules_in_model_count = len(attention_module_tuples) - expected_count = 16 - if cross_attention_modules_in_model_count != expected_count: - # non-fatal error but .swap() won't work. - logger.error( - f"Error! CrossAttentionControl found an unexpected number of {cross_attention_class} modules in the model " - f"(expected {expected_count}, found {cross_attention_modules_in_model_count}). Either monkey-patching " - "failed or some assumption has changed about the structure of the model itself. Please fix the " - f"monkey-patching, and/or update the {expected_count} above to an appropriate number, and/or find and " - "inform someone who knows what it means. This error is non-fatal, but it is likely that .swap() and " - "attention map display will not work properly until it is fixed." - ) - return attention_module_tuples - - -def inject_attention_function(unet, context: Context): - # ORIGINAL SOURCE CODE: https://github.com/huggingface/diffusers/blob/91ddd2a25b848df0fa1262d4f1cd98c7ccb87750/src/diffusers/models/attention.py#L276 - - def attention_slice_wrangler(module, suggested_attention_slice: torch.Tensor, dim, offset, slice_size): - # memory_usage = suggested_attention_slice.element_size() * suggested_attention_slice.nelement() - - attention_slice = suggested_attention_slice - - if context.get_should_save_maps(module.identifier): - # print(module.identifier, "saving suggested_attention_slice of shape", - # suggested_attention_slice.shape, "dim", dim, "offset", offset) - slice_to_save = attention_slice.to("cpu") if dim is not None else attention_slice - context.save_slice( - module.identifier, - slice_to_save, - dim=dim, - offset=offset, - slice_size=slice_size, - ) - elif context.get_should_apply_saved_maps(module.identifier): - # print(module.identifier, "applying saved attention slice for dim", dim, "offset", offset) - saved_attention_slice = context.get_slice(module.identifier, dim, offset, slice_size) - - # slice may have been offloaded to CPU - saved_attention_slice = saved_attention_slice.to(suggested_attention_slice.device) - - if context.is_tokens_cross_attention(module.identifier): - index_map = context.cross_attention_index_map - remapped_saved_attention_slice = torch.index_select(saved_attention_slice, -1, index_map) - this_attention_slice = suggested_attention_slice - - mask = context.cross_attention_mask.to(torch_dtype(suggested_attention_slice.device)) - saved_mask = mask - this_mask = 1 - mask - attention_slice = remapped_saved_attention_slice * saved_mask + this_attention_slice * this_mask - else: - # just use everything - attention_slice = saved_attention_slice - - return attention_slice - - cross_attention_modules = get_cross_attention_modules( - unet, CrossAttentionType.TOKENS - ) + get_cross_attention_modules(unet, CrossAttentionType.SELF) - for identifier, module in cross_attention_modules: - module.identifier = identifier - try: - module.set_attention_slice_wrangler(attention_slice_wrangler) - module.set_slicing_strategy_getter(lambda module: context.get_slicing_strategy(identifier)) # noqa: B023 - except AttributeError as e: - if is_attribute_error_about(e, "set_attention_slice_wrangler"): - print(f"TODO: implement set_attention_slice_wrangler for {type(module)}") # TODO - else: - raise - - -def remove_attention_function(unet): - cross_attention_modules = get_cross_attention_modules( - unet, CrossAttentionType.TOKENS - ) + get_cross_attention_modules(unet, CrossAttentionType.SELF) - for _identifier, module in cross_attention_modules: - try: - # clear wrangler callback - module.set_attention_slice_wrangler(None) - module.set_slicing_strategy_getter(None) - except AttributeError as e: - if is_attribute_error_about(e, "set_attention_slice_wrangler"): - print(f"TODO: implement set_attention_slice_wrangler for {type(module)}") - else: - raise - - -def is_attribute_error_about(error: AttributeError, attribute: str): - if hasattr(error, "name"): # Python 3.10 - return error.name == attribute - else: # Python 3.9 - return attribute in str(error) - - -def get_mem_free_total(device): - # only on cuda - if not torch.cuda.is_available(): - return None - stats = torch.cuda.memory_stats(device) - mem_active = stats["active_bytes.all.current"] - mem_reserved = stats["reserved_bytes.all.current"] - mem_free_cuda, _ = torch.cuda.mem_get_info(device) - mem_free_torch = mem_reserved - mem_active - mem_free_total = mem_free_cuda + mem_free_torch - return mem_free_total - - -class InvokeAIDiffusersCrossAttention(diffusers.models.attention.Attention, InvokeAICrossAttentionMixin): - def __init__(self, **kwargs): - super().__init__(**kwargs) - InvokeAICrossAttentionMixin.__init__(self) - - def _attention(self, query, key, value, attention_mask=None): - # default_result = super()._attention(query, key, value) - if attention_mask is not None: - print(f"{type(self).__name__} ignoring passed-in attention_mask") - attention_result = self.get_invokeai_attention_mem_efficient(query, key, value) - - hidden_states = self.reshape_batch_dim_to_heads(attention_result) - return hidden_states - - -## 🧨diffusers implementation follows - - -""" -# base implementation - -class AttnProcessor: - def __call__(self, attn: Attention, hidden_states, encoder_hidden_states=None, attention_mask=None): - batch_size, sequence_length, _ = hidden_states.shape - attention_mask = attn.prepare_attention_mask(attention_mask, sequence_length) - - query = attn.to_q(hidden_states) - query = attn.head_to_batch_dim(query) - - encoder_hidden_states = encoder_hidden_states if encoder_hidden_states is not None else hidden_states - key = attn.to_k(encoder_hidden_states) - value = attn.to_v(encoder_hidden_states) - key = attn.head_to_batch_dim(key) - value = attn.head_to_batch_dim(value) - - attention_probs = attn.get_attention_scores(query, key, attention_mask) - hidden_states = torch.bmm(attention_probs, value) - hidden_states = attn.batch_to_head_dim(hidden_states) - - # linear proj - hidden_states = attn.to_out[0](hidden_states) - # dropout - hidden_states = attn.to_out[1](hidden_states) - - return hidden_states - -""" - - @dataclass class SwapCrossAttnContext: modified_text_embeddings: torch.Tensor diff --git a/invokeai/backend/stable_diffusion/diffusion/cross_attention_map_saving.py b/invokeai/backend/stable_diffusion/diffusion/cross_attention_map_saving.py deleted file mode 100644 index 82c9f1dcea..0000000000 --- a/invokeai/backend/stable_diffusion/diffusion/cross_attention_map_saving.py +++ /dev/null @@ -1,100 +0,0 @@ -import math -from typing import Optional - -import torch -from PIL import Image -from torchvision.transforms.functional import InterpolationMode -from torchvision.transforms.functional import resize as tv_resize - - -class AttentionMapSaver: - def __init__(self, token_ids: range, latents_shape: torch.Size): - self.token_ids = token_ids - self.latents_shape = latents_shape - # self.collated_maps = #torch.zeros([len(token_ids), latents_shape[0], latents_shape[1]]) - self.collated_maps: dict[str, torch.Tensor] = {} - - def clear_maps(self): - self.collated_maps = {} - - def add_attention_maps(self, maps: torch.Tensor, key: str): - """ - Accumulate the given attention maps and store by summing with existing maps at the passed-in key (if any). - :param maps: Attention maps to store. Expected shape [A, (H*W), N] where A is attention heads count, H and W are the map size (fixed per-key) and N is the number of tokens (typically 77). - :param key: Storage key. If a map already exists for this key it will be summed with the incoming data. In this case the maps sizes (H and W) should match. - :return: None - """ - key_and_size = f"{key}_{maps.shape[1]}" - - # extract desired tokens - maps = maps[:, :, self.token_ids] - - # merge attention heads to a single map per token - maps = torch.sum(maps, 0) - - # store - if key_and_size not in self.collated_maps: - self.collated_maps[key_and_size] = torch.zeros_like(maps, device="cpu") - self.collated_maps[key_and_size] += maps.cpu() - - def write_maps_to_disk(self, path: str): - pil_image = self.get_stacked_maps_image() - if pil_image is not None: - pil_image.save(path, "PNG") - - def get_stacked_maps_image(self) -> Optional[Image.Image]: - """ - Scale all collected attention maps to the same size, blend them together and return as an image. - :return: An image containing a vertical stack of blended attention maps, one for each requested token. - """ - num_tokens = len(self.token_ids) - if num_tokens == 0: - return None - - latents_height = self.latents_shape[0] - latents_width = self.latents_shape[1] - - merged = None - - for _key, maps in self.collated_maps.items(): - # maps has shape [(H*W), N] for N tokens - # but we want [N, H, W] - this_scale_factor = math.sqrt(maps.shape[0] / (latents_width * latents_height)) - this_maps_height = int(float(latents_height) * this_scale_factor) - this_maps_width = int(float(latents_width) * this_scale_factor) - # and we need to do some dimension juggling - maps = torch.reshape( - torch.swapdims(maps, 0, 1), - [num_tokens, this_maps_height, this_maps_width], - ) - - # scale to output size if necessary - if this_scale_factor != 1: - maps = tv_resize(maps, [latents_height, latents_width], InterpolationMode.BICUBIC) - - # normalize - maps_min = torch.min(maps) - maps_range = torch.max(maps) - maps_min - # print(f"map {key} size {[this_maps_width, this_maps_height]} range {[maps_min, maps_min + maps_range]}") - maps_normalized = (maps - maps_min) / maps_range - # expand to (-0.1, 1.1) and clamp - maps_normalized_expanded = maps_normalized * 1.1 - 0.05 - maps_normalized_expanded_clamped = torch.clamp(maps_normalized_expanded, 0, 1) - - # merge together, producing a vertical stack - maps_stacked = torch.reshape( - maps_normalized_expanded_clamped, - [num_tokens * latents_height, latents_width], - ) - - if merged is None: - merged = maps_stacked - else: - # screen blend - merged = 1 - (1 - maps_stacked) * (1 - merged) - - if merged is None: - return None - - merged_bytes = merged.mul(0xFF).byte() - return Image.fromarray(merged_bytes.numpy(), mode="L") diff --git a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py index c6b85d2bd6..58ab16bae8 100644 --- a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py +++ b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py @@ -17,13 +17,11 @@ from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( ) from .cross_attention_control import ( - Context, CrossAttentionType, + CrossAttnControlContext, SwapCrossAttnContext, - get_cross_attention_modules, setup_cross_attention_control_attention_processors, ) -from .cross_attention_map_saving import AttentionMapSaver ModelForwardCallback: TypeAlias = Union[ # x, t, conditioning, Optional[cross-attention kwargs] @@ -69,14 +67,12 @@ class InvokeAIDiffuserComponent: self, unet: UNet2DConditionModel, extra_conditioning_info: Optional[ExtraConditioningInfo], - step_count: int, ): old_attn_processors = unet.attn_processors try: - self.cross_attention_control_context = Context( + self.cross_attention_control_context = CrossAttnControlContext( arguments=extra_conditioning_info.cross_attention_control_args, - step_count=step_count, ) setup_cross_attention_control_attention_processors( unet, @@ -87,27 +83,6 @@ class InvokeAIDiffuserComponent: finally: self.cross_attention_control_context = None unet.set_attn_processor(old_attn_processors) - # TODO resuscitate attention map saving - # self.remove_attention_map_saving() - - def setup_attention_map_saving(self, saver: AttentionMapSaver): - def callback(slice, dim, offset, slice_size, key): - if dim is not None: - # sliced tokens attention map saving is not implemented - return - saver.add_attention_maps(slice, key) - - tokens_cross_attention_modules = get_cross_attention_modules(self.model, CrossAttentionType.TOKENS) - for identifier, module in tokens_cross_attention_modules: - key = "down" if identifier.startswith("down") else "up" if identifier.startswith("up") else "mid" - module.set_attention_slice_calculated_callback( - lambda slice, dim, offset, slice_size, key=key: callback(slice, dim, offset, slice_size, key) - ) - - def remove_attention_map_saving(self): - tokens_cross_attention_modules = get_cross_attention_modules(self.model, CrossAttentionType.TOKENS) - for _, module in tokens_cross_attention_modules: - module.set_attention_slice_calculated_callback(None) def do_controlnet_step( self, From a72056e0df7f12cb1213a526448df993ad7efe14 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 24 Feb 2024 10:22:22 -0500 Subject: [PATCH 17/37] make model key assignment deterministic - When installing, model keys are now calculated from the model contents. - .safetensors, .ckpt and other single file models are hashed with sha1 - The contents of diffusers directories are hashed using imohash (faster) fixup yaml->sql db migration script to assign deterministic key - this commit also detects and assigns the correct image encoder for ip adapter models. --- .../model_install/model_install_default.py | 8 +++---- .../migrations/util/migrate_yaml_config_1.py | 22 ++++++++++++++----- invokeai/backend/model_manager/hash.py | 20 ++++++++++++++--- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/invokeai/app/services/model_install/model_install_default.py b/invokeai/app/services/model_install/model_install_default.py index f522282fee..51c4878709 100644 --- a/invokeai/app/services/model_install/model_install_default.py +++ b/invokeai/app/services/model_install/model_install_default.py @@ -7,7 +7,6 @@ import time from hashlib import sha256 from pathlib import Path from queue import Empty, Queue -from random import randbytes from shutil import copyfile, copytree, move, rmtree from tempfile import mkdtemp from typing import Any, Dict, List, Optional, Set, Union @@ -526,9 +525,6 @@ class ModelInstallService(ModelInstallServiceBase): setattr(info, key, value) return info - def _create_key(self) -> str: - return sha256(randbytes(100)).hexdigest()[0:32] - def _register( self, model_path: Path, config: Optional[Dict[str, Any]] = None, info: Optional[AnyModelConfig] = None ) -> str: @@ -536,6 +532,10 @@ class ModelInstallService(ModelInstallServiceBase): # in which case the key field should have been populated by the caller (e.g. in `install_path`). config["key"] = config.get("key", self._create_key()) info = info or ModelProbe.probe(model_path, config) + override_key: Optional[str] = config.get("key") if config else None + + assert info.original_hash # always assigned by probe() + info.key = override_key or info.original_hash model_path = model_path.absolute() if model_path.is_relative_to(self.app_config.models_path): diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py b/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py index 2da998a532..fed15a1db1 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py @@ -3,7 +3,6 @@ import json import sqlite3 -from hashlib import sha1 from logging import Logger from pathlib import Path from typing import Optional @@ -78,14 +77,22 @@ class MigrateModelYamlToDb1: self.logger.warning(f"The model at {stanza.path} is not a valid file or directory. Skipping migration.") continue - assert isinstance(model_key, str) - new_key = sha1(model_key.encode("utf-8")).hexdigest() - stanza["base"] = BaseModelType(base_type) stanza["type"] = ModelType(model_type) stanza["name"] = model_name stanza["original_hash"] = hash stanza["current_hash"] = hash + new_key = hash # deterministic key assignment + + # special case for ip adapters, which need the new `image_encoder_model_id` field + if stanza["type"] == ModelType.IPAdapter: + try: + stanza["image_encoder_model_id"] = self._get_image_encoder_model_id( + self.config.models_path / stanza.path + ) + except OSError: + self.logger.warning(f"Could not determine image encoder for {stanza.path}. Skipping.") + continue new_config: AnyModelConfig = ModelsValidator.validate_python(stanza) # type: ignore # see https://github.com/pydantic/pydantic/discussions/7094 @@ -95,7 +102,7 @@ class MigrateModelYamlToDb1: self.logger.info(f"Updating model {model_name} with information from models.yaml using key {key}") self._update_model(key, new_config) else: - self.logger.info(f"Adding model {model_name} with key {model_key}") + self.logger.info(f"Adding model {model_name} with key {new_key}") self._add_model(new_key, new_config) except DuplicateModelException: self.logger.warning(f"Model {model_name} is already in the database") @@ -149,3 +156,8 @@ class MigrateModelYamlToDb1: ) except sqlite3.IntegrityError as exc: raise DuplicateModelException(f"{record.name}: model is already in database") from exc + + def _get_image_encoder_model_id(self, model_path: Path) -> str: + with open(model_path / "image_encoder.txt") as f: + encoder = f.read() + return encoder.strip() diff --git a/invokeai/backend/model_manager/hash.py b/invokeai/backend/model_manager/hash.py index fb563a8cda..c4f4165ebf 100644 --- a/invokeai/backend/model_manager/hash.py +++ b/invokeai/backend/model_manager/hash.py @@ -28,14 +28,28 @@ class FastModelHash(object): """ model_location = Path(model_location) if model_location.is_file(): - return cls._hash_file(model_location) + return cls._hash_file_sha1(model_location) elif model_location.is_dir(): return cls._hash_dir(model_location) else: raise OSError(f"Not a valid file or directory: {model_location}") @classmethod - def _hash_file(cls, model_location: Union[str, Path]) -> str: + def _hash_file_sha1(cls, model_location: Union[str, Path]) -> str: + """ + Compute full sha1 hash over a single file and return its hexdigest. + + :param model_location: Path to the model file + """ + BLOCK_SIZE = 65536 + file_hash = hashlib.sha1() + with open(model_location, "rb") as f: + data = f.read(BLOCK_SIZE) + file_hash.update(data) + return file_hash.hexdigest() + + @classmethod + def _hash_file_fast(cls, model_location: Union[str, Path]) -> str: """ Fasthash a single file and return its hexdigest. @@ -56,7 +70,7 @@ class FastModelHash(object): if not file.endswith((".ckpt", ".safetensors", ".bin", ".pt", ".pth")): continue path = (Path(root) / file).as_posix() - fast_hash = cls._hash_file(path) + fast_hash = cls._hash_file_fast(path) components.update({path: fast_hash}) # hash all the model hashes together, using alphabetic file order From 908e915a71cabbeda879082c4df9872d104a6dc3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 27 Feb 2024 20:51:49 +1100 Subject: [PATCH 18/37] feat(mm): use blake3 for hashing --- invokeai/backend/model_manager/hash.py | 60 +++++++++++--------------- pyproject.toml | 2 +- 2 files changed, 26 insertions(+), 36 deletions(-) diff --git a/invokeai/backend/model_manager/hash.py b/invokeai/backend/model_manager/hash.py index c4f4165ebf..9ca778a65e 100644 --- a/invokeai/backend/model_manager/hash.py +++ b/invokeai/backend/model_manager/hash.py @@ -7,13 +7,12 @@ from invokeai.backend.model_managre.model_hash import FastModelHash >>> FastModelHash.hash('/home/models/stable-diffusion-v1.5') 'a8e693a126ea5b831c96064dc569956f' """ - -import hashlib import os from pathlib import Path -from typing import Dict, Union +from typing import Union -from imohash import hashfile +from blake3 import blake3 +from tqdm import tqdm class FastModelHash(object): @@ -28,53 +27,44 @@ class FastModelHash(object): """ model_location = Path(model_location) if model_location.is_file(): - return cls._hash_file_sha1(model_location) + return cls._hash_file(model_location) elif model_location.is_dir(): return cls._hash_dir(model_location) else: raise OSError(f"Not a valid file or directory: {model_location}") @classmethod - def _hash_file_sha1(cls, model_location: Union[str, Path]) -> str: + def _hash_file(cls, model_location: Union[str, Path]) -> str: """ - Compute full sha1 hash over a single file and return its hexdigest. + Compute full BLAKE3 hash over a single file and return its hexdigest. :param model_location: Path to the model file """ - BLOCK_SIZE = 65536 - file_hash = hashlib.sha1() - with open(model_location, "rb") as f: - data = f.read(BLOCK_SIZE) - file_hash.update(data) - return file_hash.hexdigest() - - @classmethod - def _hash_file_fast(cls, model_location: Union[str, Path]) -> str: - """ - Fasthash a single file and return its hexdigest. - - :param model_location: Path to the model file - """ - # we return md5 hash of the filehash to make it shorter - # cryptographic security not needed here - return hashlib.md5(hashfile(model_location)).hexdigest() + file_hasher = blake3(max_threads=blake3.AUTO) + file_hasher.update_mmap(model_location) + return file_hasher.hexdigest() @classmethod def _hash_dir(cls, model_location: Union[str, Path]) -> str: - components: Dict[str, str] = {} + """ + Compute full BLAKE3 hash over all files in a directory and return its hexdigest. + + :param model_location: Path to the model directory + """ + components: list[str] = [] for root, _dirs, files in os.walk(model_location): for file in files: # only tally tensor files because diffusers config files change slightly # depending on how the model was downloaded/converted. - if not file.endswith((".ckpt", ".safetensors", ".bin", ".pt", ".pth")): - continue - path = (Path(root) / file).as_posix() - fast_hash = cls._hash_file_fast(path) - components.update({path: fast_hash}) + if file.endswith((".ckpt", ".safetensors", ".bin", ".pt", ".pth")): + components.append((Path(root, file).as_posix())) - # hash all the model hashes together, using alphabetic file order - md5 = hashlib.md5() - for _path, fast_hash in sorted(components.items()): - md5.update(fast_hash.encode("utf-8")) - return md5.hexdigest() + component_hashes: list[str] = [] + + for component in tqdm(sorted(components), desc=f"Hashing model components for {model_location}"): + file_hasher = blake3(max_threads=blake3.AUTO) + file_hasher.update_mmap(component) + component_hashes.append(file_hasher.hexdigest()) + + return blake3(b"".join([bytes.fromhex(h) for h in component_hashes])).hexdigest() diff --git a/pyproject.toml b/pyproject.toml index 26db5a63c7..22a6058bbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ dependencies = [ # Auxiliary dependencies, pinned only if necessary. "albumentations", + "blake3", "click", "datasets", "Deprecated", @@ -72,7 +73,6 @@ dependencies = [ "easing-functions", "einops", "facexlib", - "imohash", "matplotlib", # needed for plotting of Penner easing functions "npyscreen", "omegaconf", From 2e4672f9311c95f346a638ce0deea83787e908b4 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 27 Feb 2024 20:54:46 +1100 Subject: [PATCH 19/37] feat(mm): make hash.py a script for testing --- invokeai/backend/model_manager/hash.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/invokeai/backend/model_manager/hash.py b/invokeai/backend/model_manager/hash.py index 9ca778a65e..eaf1020ffa 100644 --- a/invokeai/backend/model_manager/hash.py +++ b/invokeai/backend/model_manager/hash.py @@ -7,8 +7,12 @@ from invokeai.backend.model_managre.model_hash import FastModelHash >>> FastModelHash.hash('/home/models/stable-diffusion-v1.5') 'a8e693a126ea5b831c96064dc569956f' """ +import cProfile import os +import pstats +import threading from pathlib import Path +from tempfile import TemporaryDirectory from typing import Union from blake3 import blake3 @@ -58,7 +62,7 @@ class FastModelHash(object): # only tally tensor files because diffusers config files change slightly # depending on how the model was downloaded/converted. if file.endswith((".ckpt", ".safetensors", ".bin", ".pt", ".pth")): - components.append((Path(root, file).as_posix())) + components.append((Path(root, file).resolve().as_posix())) component_hashes: list[str] = [] @@ -68,3 +72,20 @@ class FastModelHash(object): component_hashes.append(file_hasher.hexdigest()) return blake3(b"".join([bytes.fromhex(h) for h in component_hashes])).hexdigest() + + +if __name__ == "__main__": + with TemporaryDirectory() as tempdir: + profile_path = Path(tempdir, "profile_results.pstats").as_posix() + profiler = cProfile.Profile() + profiler.enable() + t = threading.Thread( + target=FastModelHash.hash, args=("/media/rhino/invokeai/models/sd-1/main/stable-diffusion-v1-5-inpainting",) + ) + t.start() + t.join() + profiler.disable() + stats = pstats.Stats(profiler).sort_stats(pstats.SortKey.TIME) + stats.dump_stats(profile_path) + + os.system(f"snakeviz {profile_path}") From 982076d7d7ed397829316df4366629a9eb952072 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 28 Feb 2024 01:50:05 +1100 Subject: [PATCH 20/37] feat(mm): add hashing algos to ModelHash - Some algos are slow, so it is now just called ModelHash - Added all hashlib algos, plus BLAKE3 and the fast (but incorrect) SHA1 algo --- .../migrations/util/migrate_yaml_config_1.py | 4 +- invokeai/backend/model_manager/hash.py | 120 ++++++++++++------ invokeai/backend/model_manager/probe.py | 4 +- 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py b/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py index fed15a1db1..a52bb4f599 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py @@ -21,7 +21,7 @@ from invokeai.backend.model_manager.config import ( ModelConfigFactory, ModelType, ) -from invokeai.backend.model_manager.hash import FastModelHash +from invokeai.backend.model_manager.hash import ModelHash ModelsValidator = TypeAdapter(AnyModelConfig) @@ -72,7 +72,7 @@ class MigrateModelYamlToDb1: base_type, model_type, model_name = str(model_key).split("/") try: - hash = FastModelHash.hash(self.config.models_path / stanza.path) + hash = ModelHash.hash(self.config.models_path / stanza.path) except OSError: self.logger.warning(f"The model at {stanza.path} is not a valid file or directory. Skipping migration.") continue diff --git a/invokeai/backend/model_manager/hash.py b/invokeai/backend/model_manager/hash.py index eaf1020ffa..3144123761 100644 --- a/invokeai/backend/model_manager/hash.py +++ b/invokeai/backend/model_manager/hash.py @@ -7,53 +7,82 @@ from invokeai.backend.model_managre.model_hash import FastModelHash >>> FastModelHash.hash('/home/models/stable-diffusion-v1.5') 'a8e693a126ea5b831c96064dc569956f' """ -import cProfile +import hashlib import os -import pstats -import threading from pathlib import Path -from tempfile import TemporaryDirectory -from typing import Union +from typing import Literal, Union from blake3 import blake3 -from tqdm import tqdm + +MODEL_FILE_EXTENSIONS = (".ckpt", ".safetensors", ".bin", ".pt", ".pth") + +ALGORITHMS = Literal[ + "md5", + "sha1", + "sha1_fast", + "sha224", + "sha256", + "sha384", + "sha512", + "blake2b", + "blake2s", + "sha3_224", + "sha3_256", + "sha3_384", + "sha3_512", + "shake_128", + "shake_256", + "blake3", +] -class FastModelHash(object): - """FastModelHash obect provides one public class method, hash().""" +class ModelHash: + """ModelHash provides one public class method, hash().""" @classmethod - def hash(cls, model_location: Union[str, Path]) -> str: + def hash(cls, model_location: Union[str, Path], algorithm: ALGORITHMS = "blake3") -> str: """ Return hexdigest string for model located at model_location. + If model_location is a directory, the hash is computed by hashing the hashes of all model files in the + directory. The final composite hash is always computed using BLAKE3. + :param model_location: Path to the model + :param algorithm: Hashing algorithm to use """ model_location = Path(model_location) if model_location.is_file(): - return cls._hash_file(model_location) + return cls._hash_file(model_location, algorithm) elif model_location.is_dir(): - return cls._hash_dir(model_location) + return cls._hash_dir(model_location, algorithm) else: raise OSError(f"Not a valid file or directory: {model_location}") @classmethod - def _hash_file(cls, model_location: Union[str, Path]) -> str: + def _hash_file(cls, model_location: Union[str, Path], algorithm: ALGORITHMS) -> str: """ - Compute full BLAKE3 hash over a single file and return its hexdigest. + Compute the hash for a single file and return its hexdigest. :param model_location: Path to the model file + :param algorithm: Hashing algorithm to use """ - file_hasher = blake3(max_threads=blake3.AUTO) - file_hasher.update_mmap(model_location) - return file_hasher.hexdigest() + + if algorithm == "blake3": + return cls._blake3(model_location) + elif algorithm == "sha1_fast": + return cls._sha1_fast(model_location) + elif algorithm in hashlib.algorithms_available: + return cls._hashlib(model_location, algorithm) + else: + raise ValueError(f"Algorithm {algorithm} not available") @classmethod - def _hash_dir(cls, model_location: Union[str, Path]) -> str: + def _hash_dir(cls, model_location: Union[str, Path], algorithm: ALGORITHMS) -> str: """ - Compute full BLAKE3 hash over all files in a directory and return its hexdigest. + Compute the hash for all files in a directory and return a hexdigest. :param model_location: Path to the model directory + :param algorithm: Hashing algorithm to use """ components: list[str] = [] @@ -61,31 +90,42 @@ class FastModelHash(object): for file in files: # only tally tensor files because diffusers config files change slightly # depending on how the model was downloaded/converted. - if file.endswith((".ckpt", ".safetensors", ".bin", ".pt", ".pth")): - components.append((Path(root, file).resolve().as_posix())) + if file.endswith(MODEL_FILE_EXTENSIONS): + components.append((Path(root, file).as_posix())) component_hashes: list[str] = [] + for component in sorted(components): + component_hashes.append(cls._hash_file(component, algorithm)) - for component in tqdm(sorted(components), desc=f"Hashing model components for {model_location}"): - file_hasher = blake3(max_threads=blake3.AUTO) - file_hasher.update_mmap(component) - component_hashes.append(file_hasher.hexdigest()) + # BLAKE3 is cryptographically secure. We may as well fall back on a secure algorithm + # for the composite hash + composite_hasher = blake3() + for h in components: + composite_hasher.update(h.encode("utf-8")) + return composite_hasher.hexdigest() - return blake3(b"".join([bytes.fromhex(h) for h in component_hashes])).hexdigest() + @staticmethod + def _blake3(file_path: Union[str, Path]) -> str: + """Hashes a file using BLAKE3""" + file_hasher = blake3(max_threads=blake3.AUTO) + file_hasher.update_mmap(file_path) + return file_hasher.hexdigest() + @staticmethod + def _sha1_fast(file_path: Union[str, Path]) -> str: + """Hashes a file using SHA1, but with a block size of 2**16. The result is not a standard SHA1 hash due to the + # padding introduced by the block size. The algorithm is, however, very fast.""" + BLOCK_SIZE = 2**16 + file_hash = hashlib.sha1() + with open(file_path, "rb") as f: + data = f.read(BLOCK_SIZE) + file_hash.update(data) + return file_hash.hexdigest() -if __name__ == "__main__": - with TemporaryDirectory() as tempdir: - profile_path = Path(tempdir, "profile_results.pstats").as_posix() - profiler = cProfile.Profile() - profiler.enable() - t = threading.Thread( - target=FastModelHash.hash, args=("/media/rhino/invokeai/models/sd-1/main/stable-diffusion-v1-5-inpainting",) - ) - t.start() - t.join() - profiler.disable() - stats = pstats.Stats(profiler).sort_stats(pstats.SortKey.TIME) - stats.dump_stats(profile_path) - - os.system(f"snakeviz {profile_path}") + @staticmethod + def _hashlib(file_path: Union[str, Path], algorithm: ALGORITHMS) -> str: + """Hashes a file using a hashlib algorithm""" + file_hasher = hashlib.new(algorithm) + with open(file_path, "rb") as f: + file_hasher.update(f.read()) + return file_hasher.hexdigest() diff --git a/invokeai/backend/model_manager/probe.py b/invokeai/backend/model_manager/probe.py index 11b8f46951..1611a76558 100644 --- a/invokeai/backend/model_manager/probe.py +++ b/invokeai/backend/model_manager/probe.py @@ -21,7 +21,7 @@ from .config import ( ModelVariantType, SchedulerPredictionType, ) -from .hash import FastModelHash +from .hash import ModelHash from .util.model_util import lora_token_vector_length, read_checkpoint_meta CkptType = Dict[str, Any] @@ -147,7 +147,7 @@ class ModelProbe(object): if not probe_class: raise InvalidModelConfigException(f"Unhandled combination of {format_type} and {model_type}") - hash = FastModelHash.hash(model_path) + hash = ModelHash.hash(model_path) probe = probe_class(model_path) fields["path"] = model_path.as_posix() From ec8ed530a756d56bb2449b696c712ae1cebf8feb Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 28 Feb 2024 13:06:21 +1100 Subject: [PATCH 21/37] feat(mm): modularize ModelHash to facilitate testing --- invokeai/backend/model_manager/hash.py | 39 +++++++++++++++----------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/invokeai/backend/model_manager/hash.py b/invokeai/backend/model_manager/hash.py index 3144123761..a7ac014194 100644 --- a/invokeai/backend/model_manager/hash.py +++ b/invokeai/backend/model_manager/hash.py @@ -50,6 +50,7 @@ class ModelHash: :param model_location: Path to the model :param algorithm: Hashing algorithm to use """ + model_location = Path(model_location) if model_location.is_file(): return cls._hash_file(model_location, algorithm) @@ -59,7 +60,7 @@ class ModelHash: raise OSError(f"Not a valid file or directory: {model_location}") @classmethod - def _hash_file(cls, model_location: Union[str, Path], algorithm: ALGORITHMS) -> str: + def _hash_file(cls, model_location: Path, algorithm: ALGORITHMS) -> str: """ Compute the hash for a single file and return its hexdigest. @@ -77,44 +78,48 @@ class ModelHash: raise ValueError(f"Algorithm {algorithm} not available") @classmethod - def _hash_dir(cls, model_location: Union[str, Path], algorithm: ALGORITHMS) -> str: + def _hash_dir(cls, model_location: Path, algorithm: ALGORITHMS) -> str: """ Compute the hash for all files in a directory and return a hexdigest. :param model_location: Path to the model directory :param algorithm: Hashing algorithm to use """ - components: list[str] = [] - - for root, _dirs, files in os.walk(model_location): - for file in files: - # only tally tensor files because diffusers config files change slightly - # depending on how the model was downloaded/converted. - if file.endswith(MODEL_FILE_EXTENSIONS): - components.append((Path(root, file).as_posix())) + model_component_paths = cls._get_file_paths(model_location) component_hashes: list[str] = [] - for component in sorted(components): + for component in sorted(model_component_paths): component_hashes.append(cls._hash_file(component, algorithm)) # BLAKE3 is cryptographically secure. We may as well fall back on a secure algorithm # for the composite hash composite_hasher = blake3() - for h in components: + for h in component_hashes: composite_hasher.update(h.encode("utf-8")) return composite_hasher.hexdigest() + @classmethod + def _get_file_paths(cls, dir: Path) -> list[Path]: + """Return a list of all model files in the directory.""" + files: list[Path] = [] + for root, _dirs, _files in os.walk(dir): + for file in _files: + if file.endswith(MODEL_FILE_EXTENSIONS): + files.append(Path(root, file)) + return files + @staticmethod - def _blake3(file_path: Union[str, Path]) -> str: + def _blake3(file_path: Path) -> str: """Hashes a file using BLAKE3""" file_hasher = blake3(max_threads=blake3.AUTO) file_hasher.update_mmap(file_path) return file_hasher.hexdigest() @staticmethod - def _sha1_fast(file_path: Union[str, Path]) -> str: - """Hashes a file using SHA1, but with a block size of 2**16. The result is not a standard SHA1 hash due to the - # padding introduced by the block size. The algorithm is, however, very fast.""" + def _sha1_fast(file_path: Path) -> str: + """Hashes a file using SHA1, but with a block size of 2**16. + The result is not a correct SHA1 hash for the file, due to the padding introduced by the block size. + The algorithm is, however, very fast.""" BLOCK_SIZE = 2**16 file_hash = hashlib.sha1() with open(file_path, "rb") as f: @@ -123,7 +128,7 @@ class ModelHash: return file_hash.hexdigest() @staticmethod - def _hashlib(file_path: Union[str, Path], algorithm: ALGORITHMS) -> str: + def _hashlib(file_path: Path, algorithm: ALGORITHMS) -> str: """Hashes a file using a hashlib algorithm""" file_hasher = hashlib.new(algorithm) with open(file_path, "rb") as f: From 86982f305985a1f00d9d4bbf84af7abbf0733a37 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 28 Feb 2024 13:21:29 +1100 Subject: [PATCH 22/37] feat(mm): make ModelHash instantiatable, taking an algorithm as arg --- .../migrations/util/migrate_yaml_config_1.py | 2 +- invokeai/backend/model_manager/hash.py | 90 ++++++++++--------- invokeai/backend/model_manager/probe.py | 2 +- 3 files changed, 51 insertions(+), 43 deletions(-) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py b/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py index a52bb4f599..be4d5f0140 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/util/migrate_yaml_config_1.py @@ -72,7 +72,7 @@ class MigrateModelYamlToDb1: base_type, model_type, model_name = str(model_key).split("/") try: - hash = ModelHash.hash(self.config.models_path / stanza.path) + hash = ModelHash().hash(self.config.models_path / stanza.path) except OSError: self.logger.warning(f"The model at {stanza.path} is not a valid file or directory. Skipping migration.") continue diff --git a/invokeai/backend/model_manager/hash.py b/invokeai/backend/model_manager/hash.py index a7ac014194..1139a1dacf 100644 --- a/invokeai/backend/model_manager/hash.py +++ b/invokeai/backend/model_manager/hash.py @@ -10,13 +10,13 @@ from invokeai.backend.model_managre.model_hash import FastModelHash import hashlib import os from pathlib import Path -from typing import Literal, Union +from typing import Callable, Literal, Union from blake3 import blake3 MODEL_FILE_EXTENSIONS = (".ckpt", ".safetensors", ".bin", ".pt", ".pth") -ALGORITHMS = Literal[ +ALGORITHM = Literal[ "md5", "sha1", "sha1_fast", @@ -37,10 +37,39 @@ ALGORITHMS = Literal[ class ModelHash: - """ModelHash provides one public class method, hash().""" + """ + Creates a hash of a model using a specified algorithm. - @classmethod - def hash(cls, model_location: Union[str, Path], algorithm: ALGORITHMS = "blake3") -> str: + :param algorithm: Hashing algorithm to use. Defaults to BLAKE3. + + If the model is a single file, it is hashed directly using the provided algorithm. + + If the model is a directory, each model weights file in the directory is hashed using the provided algorithm. + + Only files with the following extensions are hashed: .ckpt, .safetensors, .bin, .pt, .pth + + The final hash is computed by hashing the hashes of all model files in the directory using BLAKE3, ensuring + that directory hashes are never weaker than the file hashes. + + Usage + + ```py + ModelHash().hash("path/to/some/model.safetensors") + ModelHash("md5").hash("path/to/model/dir/") + ``` + """ + + def __init__(self, algorithm: ALGORITHM = "blake3") -> None: + if algorithm == "blake3": + self._hash_file = self._blake3 + elif algorithm == "sha1_fast": + self._hash_file = self._sha1_fast + elif algorithm in hashlib.algorithms_available: + self._hash_file = self._get_hashlib(algorithm) + else: + raise ValueError(f"Algorithm {algorithm} not available") + + def hash(self, model_location: Union[str, Path]) -> str: """ Return hexdigest string for model located at model_location. @@ -48,48 +77,23 @@ class ModelHash: directory. The final composite hash is always computed using BLAKE3. :param model_location: Path to the model - :param algorithm: Hashing algorithm to use """ model_location = Path(model_location) if model_location.is_file(): - return cls._hash_file(model_location, algorithm) + return self._hash_file(model_location) elif model_location.is_dir(): - return cls._hash_dir(model_location, algorithm) + return self._hash_dir(model_location) else: raise OSError(f"Not a valid file or directory: {model_location}") - @classmethod - def _hash_file(cls, model_location: Path, algorithm: ALGORITHMS) -> str: - """ - Compute the hash for a single file and return its hexdigest. - - :param model_location: Path to the model file - :param algorithm: Hashing algorithm to use - """ - - if algorithm == "blake3": - return cls._blake3(model_location) - elif algorithm == "sha1_fast": - return cls._sha1_fast(model_location) - elif algorithm in hashlib.algorithms_available: - return cls._hashlib(model_location, algorithm) - else: - raise ValueError(f"Algorithm {algorithm} not available") - - @classmethod - def _hash_dir(cls, model_location: Path, algorithm: ALGORITHMS) -> str: - """ - Compute the hash for all files in a directory and return a hexdigest. - - :param model_location: Path to the model directory - :param algorithm: Hashing algorithm to use - """ - model_component_paths = cls._get_file_paths(model_location) + def _hash_dir(self, model_location: Path) -> str: + """Compute the hash for all files in a directory and return a hexdigest.""" + model_component_paths = self._get_file_paths(model_location) component_hashes: list[str] = [] for component in sorted(model_component_paths): - component_hashes.append(cls._hash_file(component, algorithm)) + component_hashes.append(self._hash_file(component)) # BLAKE3 is cryptographically secure. We may as well fall back on a secure algorithm # for the composite hash @@ -128,9 +132,13 @@ class ModelHash: return file_hash.hexdigest() @staticmethod - def _hashlib(file_path: Path, algorithm: ALGORITHMS) -> str: + def _get_hashlib(algorithm: ALGORITHM) -> Callable[[Path], str]: """Hashes a file using a hashlib algorithm""" - file_hasher = hashlib.new(algorithm) - with open(file_path, "rb") as f: - file_hasher.update(f.read()) - return file_hasher.hexdigest() + + def hasher(file_path: Path) -> str: + file_hasher = hashlib.new(algorithm) + with open(file_path, "rb") as f: + file_hasher.update(f.read()) + return file_hasher.hexdigest() + + return hasher diff --git a/invokeai/backend/model_manager/probe.py b/invokeai/backend/model_manager/probe.py index 1611a76558..a7250f33d1 100644 --- a/invokeai/backend/model_manager/probe.py +++ b/invokeai/backend/model_manager/probe.py @@ -147,7 +147,7 @@ class ModelProbe(object): if not probe_class: raise InvalidModelConfigException(f"Unhandled combination of {format_type} and {model_type}") - hash = ModelHash.hash(model_path) + hash = ModelHash().hash(model_path) probe = probe_class(model_path) fields["path"] = model_path.as_posix() From 863ce007128d8d9acce60b54d229905300254e36 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 28 Feb 2024 13:24:50 +1100 Subject: [PATCH 23/37] tests(mm): add tests for ModelHash --- tests/test_model_hash.py | 80 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/test_model_hash.py diff --git a/tests/test_model_hash.py b/tests/test_model_hash.py new file mode 100644 index 0000000000..763aa4fc63 --- /dev/null +++ b/tests/test_model_hash.py @@ -0,0 +1,80 @@ +# pyright:reportPrivateUsage=false + +from pathlib import Path +from typing import Iterable + +import pytest +from blake3 import blake3 + +from invokeai.backend.model_manager.hash import ALGORITHM, ModelHash + +test_cases: list[tuple[ALGORITHM, str]] = [ + ("md5", "a0cd925fc063f98dbf029eee315060c3"), + ("sha1", "9e362940e5603fdc60566ea100a288ba2fe48b8c"), + ("sha256", "6dbdb6a147ad4d808455652bf5a10120161678395f6bfbd21eb6fe4e731aceeb"), + ( + "sha512", + "c4a10476b21e00042f638ad5755c561d91f2bb599d3504d25409495e1c7eda94543332a1a90fbb4efdaf9ee462c33e0336b5eae4acfb1fa0b186af452dd67dc6", + ), + ("blake3", "ce3f0c5f3c05d119f4a5dcaf209b50d3149046a0d3a9adee9fed4c83cad6b4d0"), +] + + +@pytest.mark.parametrize("algorithm,expected_hash", test_cases) +def test_model_hash_hashes_file(tmp_path: Path, algorithm: ALGORITHM, expected_hash: str): + file = Path(tmp_path / "test") + file.write_text("model data") + md5 = ModelHash(algorithm).hash(file) + assert md5 == expected_hash + + +@pytest.mark.parametrize("algorithm", ["md5", "sha1", "sha256", "sha512", "blake3"]) +def test_model_hash_hashes_dir(tmp_path: Path, algorithm: ALGORITHM): + model_hash = ModelHash(algorithm) + files = [Path(tmp_path, f"{i}.bin") for i in range(5)] + + for f in files: + f.write_text("data") + + md5 = model_hash.hash(tmp_path) + + # Manual implementation of composite hash - always uses BLAKE3 + composite_hasher = blake3() + for f in files: + h = model_hash.hash(f) + composite_hasher.update(h.encode("utf-8")) + + assert md5 == composite_hasher.hexdigest() + + +def test_model_hash_raises_error_on_invalid_algorithm(): + with pytest.raises(ValueError, match="Algorithm invalid_algorithm not available"): + ModelHash("invalid_algorithm") # pyright: ignore [reportArgumentType] + + +def paths_to_str_set(paths: Iterable[Path]) -> set[str]: + return {str(p) for p in paths} + + +def test_model_hash_filters_out_non_model_files(tmp_path: Path): + model_files = { + Path(tmp_path, f"{i}.{ext}") for i, ext in enumerate([".ckpt", ".safetensors", ".bin", ".pt", ".pth"]) + } + + for i, f in enumerate(model_files): + f.write_text(f"data{i}") + + assert paths_to_str_set(ModelHash._get_file_paths(tmp_path)) == paths_to_str_set(model_files) + + # Add file that should be ignored - hash should not change + file = tmp_path / "test.icecream" + file.write_text("data") + + assert paths_to_str_set(ModelHash._get_file_paths(tmp_path)) == paths_to_str_set(model_files) + + # Add file that should not be ignored - hash should change + file = tmp_path / "test.bin" + file.write_text("more data") + model_files.add(file) + + assert paths_to_str_set(ModelHash._get_file_paths(tmp_path)) == paths_to_str_set(model_files) From ae99428883dc7f7f2768a744a6167dc061342c28 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 3 Mar 2024 11:08:24 +1100 Subject: [PATCH 24/37] fix(mm): use UUIDv4 for key This changes the functionality of this PR to only use the updated hashing for model hashes with a UUID for the key. --- invokeai/app/services/model_install/model_install_default.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/invokeai/app/services/model_install/model_install_default.py b/invokeai/app/services/model_install/model_install_default.py index 51c4878709..b3c6015f4d 100644 --- a/invokeai/app/services/model_install/model_install_default.py +++ b/invokeai/app/services/model_install/model_install_default.py @@ -20,6 +20,7 @@ from invokeai.app.services.download import DownloadJob, DownloadQueueServiceBase from invokeai.app.services.events.events_base import EventServiceBase from invokeai.app.services.invoker import Invoker from invokeai.app.services.model_records import DuplicateModelException, ModelRecordServiceBase +from invokeai.app.util.misc import uuid_string from invokeai.backend.model_manager.config import ( AnyModelConfig, BaseModelType, @@ -149,7 +150,7 @@ class ModelInstallService(ModelInstallServiceBase): config = config or {} if not config.get("source"): config["source"] = model_path.resolve().as_posix() - config["key"] = config.get("key", self._create_key()) + config["key"] = config.get("key", uuid_string()) info: AnyModelConfig = self._probe_model(Path(model_path), config) @@ -530,7 +531,7 @@ class ModelInstallService(ModelInstallServiceBase): ) -> str: # Note that we may be passed a pre-populated AnyModelConfig object, # in which case the key field should have been populated by the caller (e.g. in `install_path`). - config["key"] = config.get("key", self._create_key()) + config["key"] = config.get("key", uuid_string()) info = info or ModelProbe.probe(model_path, config) override_key: Optional[str] = config.get("key") if config else None From 554d1757921c4fe71a4e962fe50e8db022905b79 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 3 Mar 2024 14:14:15 +1100 Subject: [PATCH 25/37] feat(mm): improved model hash class - Use memory view for hashlib algorithms (closer to python 3.11's filehash API in hashlib) - Remove `sha1_fast` (realized it doesn't even hash the whole file, it just does the first block) - Add support for custom file filters - Update docstrings - Update tests --- invokeai/backend/model_manager/hash.py | 141 ++++++++++++++++--------- tests/test_model_hash.py | 30 ++++-- 2 files changed, 114 insertions(+), 57 deletions(-) diff --git a/invokeai/backend/model_manager/hash.py b/invokeai/backend/model_manager/hash.py index 1139a1dacf..656b591f4a 100644 --- a/invokeai/backend/model_manager/hash.py +++ b/invokeai/backend/model_manager/hash.py @@ -7,10 +7,11 @@ from invokeai.backend.model_managre.model_hash import FastModelHash >>> FastModelHash.hash('/home/models/stable-diffusion-v1.5') 'a8e693a126ea5b831c96064dc569956f' """ + import hashlib import os from pathlib import Path -from typing import Callable, Literal, Union +from typing import Callable, Literal, Optional, Union from blake3 import blake3 @@ -19,7 +20,6 @@ MODEL_FILE_EXTENSIONS = (".ckpt", ".safetensors", ".bin", ".pt", ".pth") ALGORITHM = Literal[ "md5", "sha1", - "sha1_fast", "sha224", "sha256", "sha384", @@ -40,7 +40,9 @@ class ModelHash: """ Creates a hash of a model using a specified algorithm. - :param algorithm: Hashing algorithm to use. Defaults to BLAKE3. + Args: + algorithm: Hashing algorithm to use. Defaults to BLAKE3. + file_filter: A function that takes a file name and returns True if the file should be included in the hash. If the model is a single file, it is hashed directly using the provided algorithm. @@ -51,45 +53,57 @@ class ModelHash: The final hash is computed by hashing the hashes of all model files in the directory using BLAKE3, ensuring that directory hashes are never weaker than the file hashes. - Usage - - ```py - ModelHash().hash("path/to/some/model.safetensors") - ModelHash("md5").hash("path/to/model/dir/") - ``` + Usage: + ```py + # BLAKE3 hash + ModelHash().hash("path/to/some/model.safetensors") + # MD5 + ModelHash("md5").hash("path/to/model/dir/") + ``` """ - def __init__(self, algorithm: ALGORITHM = "blake3") -> None: + def __init__(self, algorithm: ALGORITHM = "blake3", file_filter: Optional[Callable[[str], bool]] = None) -> None: if algorithm == "blake3": self._hash_file = self._blake3 - elif algorithm == "sha1_fast": - self._hash_file = self._sha1_fast elif algorithm in hashlib.algorithms_available: self._hash_file = self._get_hashlib(algorithm) else: raise ValueError(f"Algorithm {algorithm} not available") - def hash(self, model_location: Union[str, Path]) -> str: - """ - Return hexdigest string for model located at model_location. + self._file_filter = file_filter or self._default_file_filter - If model_location is a directory, the hash is computed by hashing the hashes of all model files in the + def hash(self, model_path: Union[str, Path]) -> str: + """ + Return hexdigest of hash of model located at model_path using the algorithm provided at class instantiation. + + If model_path is a directory, the hash is computed by hashing the hashes of all model files in the directory. The final composite hash is always computed using BLAKE3. - :param model_location: Path to the model + Args: + model_path: Path to the model + + Returns: + str: Hexdigest of the hash of the model """ - model_location = Path(model_location) - if model_location.is_file(): - return self._hash_file(model_location) - elif model_location.is_dir(): - return self._hash_dir(model_location) + model_path = Path(model_path) + if model_path.is_file(): + return self._hash_file(model_path) + elif model_path.is_dir(): + return self._hash_dir(model_path) else: - raise OSError(f"Not a valid file or directory: {model_location}") + raise OSError(f"Not a valid file or directory: {model_path}") - def _hash_dir(self, model_location: Path) -> str: - """Compute the hash for all files in a directory and return a hexdigest.""" - model_component_paths = self._get_file_paths(model_location) + def _hash_dir(self, dir: Path) -> str: + """Compute the hash for all files in a directory and return a hexdigest. + + Args: + dir: Path to the directory + + Returns: + str: Hexdigest of the hash of the directory + """ + model_component_paths = self._get_file_paths(dir, self._file_filter) component_hashes: list[str] = [] for component in sorted(model_component_paths): @@ -102,43 +116,70 @@ class ModelHash: composite_hasher.update(h.encode("utf-8")) return composite_hasher.hexdigest() - @classmethod - def _get_file_paths(cls, dir: Path) -> list[Path]: - """Return a list of all model files in the directory.""" + @staticmethod + def _get_file_paths(model_path: Path, file_filter: Callable[[str], bool]) -> list[Path]: + """Return a list of all model files in the directory. + + Args: + model_path: Path to the model + file_filter: Function that takes a file name and returns True if the file should be included in the list. + + Returns: + List of all model files in the directory + """ + files: list[Path] = [] - for root, _dirs, _files in os.walk(dir): + for root, _dirs, _files in os.walk(model_path): for file in _files: - if file.endswith(MODEL_FILE_EXTENSIONS): + if file_filter(file): files.append(Path(root, file)) return files @staticmethod def _blake3(file_path: Path) -> str: - """Hashes a file using BLAKE3""" + """Hashes a file using BLAKE3 + + Args: + file_path: Path to the file to hash + + Returns: + Hexdigest of the hash of the file + """ file_hasher = blake3(max_threads=blake3.AUTO) file_hasher.update_mmap(file_path) return file_hasher.hexdigest() @staticmethod - def _sha1_fast(file_path: Path) -> str: - """Hashes a file using SHA1, but with a block size of 2**16. - The result is not a correct SHA1 hash for the file, due to the padding introduced by the block size. - The algorithm is, however, very fast.""" - BLOCK_SIZE = 2**16 - file_hash = hashlib.sha1() - with open(file_path, "rb") as f: - data = f.read(BLOCK_SIZE) - file_hash.update(data) - return file_hash.hexdigest() + def _get_hashlib(algorithm: ALGORITHM) -> Callable[[Path], str]: + """Factory function that returns a function to hash a file with the given algorithm. + + Args: + algorithm: Hashing algorithm to use + + Returns: + A function that hashes a file using the given algorithm + """ + + def hashlib_hasher(file_path: Path) -> str: + """Hashes a file using a hashlib algorithm. Uses `memoryview` to avoid reading the entire file into memory.""" + hasher = hashlib.new(algorithm) + buffer = bytearray(128 * 1024) + mv = memoryview(buffer) + with open(file_path, "rb", buffering=0) as f: + while n := f.readinto(mv): + hasher.update(mv[:n]) + return hasher.hexdigest() + + return hashlib_hasher @staticmethod - def _get_hashlib(algorithm: ALGORITHM) -> Callable[[Path], str]: - """Hashes a file using a hashlib algorithm""" + def _default_file_filter(file_path: str) -> bool: + """A default file filter that only includes files with the following extensions: .ckpt, .safetensors, .bin, .pt, .pth - def hasher(file_path: Path) -> str: - file_hasher = hashlib.new(algorithm) - with open(file_path, "rb") as f: - file_hasher.update(f.read()) - return file_hasher.hexdigest() + Args: + file_path: Path to the file - return hasher + Returns: + True if the file matches the given extensions, otherwise False + """ + return file_path.endswith(MODEL_FILE_EXTENSIONS) diff --git a/tests/test_model_hash.py b/tests/test_model_hash.py index 763aa4fc63..641a150034 100644 --- a/tests/test_model_hash.py +++ b/tests/test_model_hash.py @@ -6,7 +6,7 @@ from typing import Iterable import pytest from blake3 import blake3 -from invokeai.backend.model_manager.hash import ALGORITHM, ModelHash +from invokeai.backend.model_manager.hash import ALGORITHM, MODEL_FILE_EXTENSIONS, ModelHash test_cases: list[tuple[ALGORITHM, str]] = [ ("md5", "a0cd925fc063f98dbf029eee315060c3"), @@ -57,24 +57,40 @@ def paths_to_str_set(paths: Iterable[Path]) -> set[str]: def test_model_hash_filters_out_non_model_files(tmp_path: Path): - model_files = { - Path(tmp_path, f"{i}.{ext}") for i, ext in enumerate([".ckpt", ".safetensors", ".bin", ".pt", ".pth"]) - } + model_files = {Path(tmp_path, f"{i}{ext}") for i, ext in enumerate(MODEL_FILE_EXTENSIONS)} for i, f in enumerate(model_files): f.write_text(f"data{i}") - assert paths_to_str_set(ModelHash._get_file_paths(tmp_path)) == paths_to_str_set(model_files) + assert paths_to_str_set(ModelHash._get_file_paths(tmp_path, ModelHash._default_file_filter)) == paths_to_str_set( + model_files + ) # Add file that should be ignored - hash should not change file = tmp_path / "test.icecream" file.write_text("data") - assert paths_to_str_set(ModelHash._get_file_paths(tmp_path)) == paths_to_str_set(model_files) + assert paths_to_str_set(ModelHash._get_file_paths(tmp_path, ModelHash._default_file_filter)) == paths_to_str_set( + model_files + ) # Add file that should not be ignored - hash should change file = tmp_path / "test.bin" file.write_text("more data") model_files.add(file) - assert paths_to_str_set(ModelHash._get_file_paths(tmp_path)) == paths_to_str_set(model_files) + assert paths_to_str_set(ModelHash._get_file_paths(tmp_path, ModelHash._default_file_filter)) == paths_to_str_set( + model_files + ) + + +def test_model_hash_uses_custom_filter(tmp_path: Path): + model_files = {Path(tmp_path, f"file{ext}") for ext in [".pickme", ".ignoreme"]} + + for i, f in enumerate(model_files): + f.write_text(f"data{i}") + + def file_filter(file_path: str) -> bool: + return file_path.endswith(".pickme") + + assert {p.name for p in ModelHash._get_file_paths(tmp_path, file_filter)} == {"file.pickme"} From 2f372d9b18eefc9775938b721081fdc06d75420c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 3 Mar 2024 14:22:15 +1100 Subject: [PATCH 26/37] tests(mm): update tests to reflect using UUID for key --- tests/app/services/model_install/test_model_install.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/app/services/model_install/test_model_install.py b/tests/app/services/model_install/test_model_install.py index 00c463745c..4e146b44f9 100644 --- a/tests/app/services/model_install/test_model_install.py +++ b/tests/app/services/model_install/test_model_install.py @@ -3,6 +3,7 @@ Test the model installer """ import platform +import uuid from pathlib import Path import pytest @@ -30,9 +31,8 @@ def test_registration(mm2_installer: ModelInstallServiceBase, embedding_file: Pa matches = store.search_by_attr(model_name="test_embedding") assert len(matches) == 0 key = mm2_installer.register_path(embedding_file) - assert key is not None - assert key != "" - assert len(key) == 32 + # Not raising here is sufficient - key should be UUIDv4 + uuid.UUID(key, version=4) def test_registration_meta(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None: From 735857479d02cb8fc3a20659c7f7aa84638460fe Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sat, 2 Mar 2024 19:31:13 -0500 Subject: [PATCH 27/37] fix(canvas): use corrected mask for pasteback --- invokeai/app/invocations/latent.py | 60 ++++++++++++------- .../util/graph/buildCanvasInpaintGraph.ts | 4 +- .../util/graph/buildCanvasOutpaintGraph.ts | 4 +- .../util/graph/buildCanvasSDXLInpaintGraph.ts | 4 +- .../graph/buildCanvasSDXLOutpaintGraph.ts | 4 +- 5 files changed, 45 insertions(+), 31 deletions(-) diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py index d9125f0f37..5931b1b8f7 100644 --- a/invokeai/app/invocations/latent.py +++ b/invokeai/app/invocations/latent.py @@ -173,6 +173,16 @@ class CreateDenoiseMaskInvocation(BaseInvocation): ) +@invocation_output("gradient_mask_output") +class GradientMaskOutput(BaseInvocationOutput): + """Outputs a denoise mask and an image representing the total gradient of the mask.""" + + denoise_mask: DenoiseMaskField = OutputField(description="Mask for denoise model run") + expanded_mask_area: ImageField = OutputField( + description="Image representing the total gradient area of the mask. For paste-back purposes." + ) + + @invocation( "create_gradient_mask", title="Create Gradient Mask", @@ -193,38 +203,42 @@ class CreateGradientMaskInvocation(BaseInvocation): ) @torch.no_grad() - def invoke(self, context: InvocationContext) -> DenoiseMaskOutput: + def invoke(self, context: InvocationContext) -> GradientMaskOutput: mask_image = context.images.get_pil(self.mask.image_name, mode="L") - if self.coherence_mode == "Box Blur": - blur_mask = mask_image.filter(ImageFilter.BoxBlur(self.edge_radius)) - else: # Gaussian Blur OR Staged - # Gaussian Blur uses standard deviation. 1/2 radius is a good approximation - blur_mask = mask_image.filter(ImageFilter.GaussianBlur(self.edge_radius / 2)) + if self.edge_radius > 0: + if self.coherence_mode == "Box Blur": + blur_mask = mask_image.filter(ImageFilter.BoxBlur(self.edge_radius)) + else: # Gaussian Blur OR Staged + # Gaussian Blur uses standard deviation. 1/2 radius is a good approximation + blur_mask = mask_image.filter(ImageFilter.GaussianBlur(self.edge_radius / 2)) - mask_tensor: torch.Tensor = image_resized_to_grid_as_tensor(mask_image, normalize=False) - blur_tensor: torch.Tensor = image_resized_to_grid_as_tensor(blur_mask, normalize=False) + blur_tensor: torch.Tensor = image_resized_to_grid_as_tensor(blur_mask, normalize=False) - # redistribute blur so that the edges are 0 and blur out to 1 - blur_tensor = (blur_tensor - 0.5) * 2 + # redistribute blur so that the original edges are 0 and blur outwards to 1 + blur_tensor = (blur_tensor - 0.5) * 2 - threshold = 1 - self.minimum_denoise + threshold = 1 - self.minimum_denoise + + if self.coherence_mode == "Staged": + # wherever the blur_tensor is less than fully masked, convert it to threshold + blur_tensor = torch.where((blur_tensor < 1) & (blur_tensor > 0), threshold, blur_tensor) + else: + # wherever the blur_tensor is above threshold but less than 1, drop it to threshold + blur_tensor = torch.where((blur_tensor > threshold) & (blur_tensor < 1), threshold, blur_tensor) - if self.coherence_mode == "Staged": - # wherever the blur_tensor is masked to any degree, convert it to threshold - blur_tensor = torch.where((blur_tensor < 1), threshold, blur_tensor) else: - # wherever the blur_tensor is above threshold but less than 1, drop it to threshold - blur_tensor = torch.where((blur_tensor > threshold) & (blur_tensor < 1), threshold, blur_tensor) - - # multiply original mask to force actually masked regions to 0 - blur_tensor = mask_tensor * blur_tensor + blur_tensor: torch.Tensor = image_resized_to_grid_as_tensor(mask_image, normalize=False) mask_name = context.tensors.save(tensor=blur_tensor.unsqueeze(1)) - return DenoiseMaskOutput.build( - mask_name=mask_name, - masked_latents_name=None, - gradient=True, + # compute a [0, 1] mask from the blur_tensor + expanded_mask = torch.where((blur_tensor < 1), 0, 1) + expanded_mask_image = Image.fromarray((expanded_mask.squeeze(0).numpy() * 255).astype(np.uint8), mode="L") + expanded_image_dto = context.images.save(expanded_mask_image) + + return GradientMaskOutput( + denoise_mask=DenoiseMaskField(mask_name=mask_name, masked_latents_name=None, gradient=True), + expanded_mask_area=ImageField(image_name=expanded_image_dto.image_name), ) diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasInpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasInpaintGraph.ts index 00bad63c3b..2672cf5be3 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasInpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasInpaintGraph.ts @@ -344,8 +344,8 @@ export const buildCanvasInpaintGraph = ( }, { source: { - node_id: MASK_RESIZE_UP, - field: 'image', + node_id: INPAINT_CREATE_MASK, + field: 'expanded_mask_area', }, destination: { node_id: MASK_RESIZE_DOWN, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasOutpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasOutpaintGraph.ts index 75f9a15f48..a9707e50f8 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasOutpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasOutpaintGraph.ts @@ -439,8 +439,8 @@ export const buildCanvasOutpaintGraph = ( }, { source: { - node_id: MASK_RESIZE_UP, - field: 'image', + node_id: INPAINT_CREATE_MASK, + field: 'expanded_mask_area', }, destination: { node_id: MASK_RESIZE_DOWN, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLInpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLInpaintGraph.ts index fc60805e85..9f4e75de48 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLInpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLInpaintGraph.ts @@ -355,8 +355,8 @@ export const buildCanvasSDXLInpaintGraph = ( }, { source: { - node_id: MASK_RESIZE_UP, - field: 'image', + node_id: INPAINT_CREATE_MASK, + field: 'expanded_mask_area', }, destination: { node_id: MASK_RESIZE_DOWN, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLOutpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLOutpaintGraph.ts index 44950ff40a..6c5a31926a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLOutpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildCanvasSDXLOutpaintGraph.ts @@ -448,8 +448,8 @@ export const buildCanvasSDXLOutpaintGraph = ( }, { source: { - node_id: MASK_RESIZE_UP, - field: 'image', + node_id: INPAINT_CREATE_MASK, + field: 'expanded_mask_area', }, destination: { node_id: MASK_RESIZE_DOWN, From 48e323d887c614b8dbf7d56a4ac7962fa09696f9 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sat, 2 Mar 2024 20:32:36 -0500 Subject: [PATCH 28/37] docs: added both create mask nodes to defaultNodes --- docs/nodes/defaultNodes.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/nodes/defaultNodes.md b/docs/nodes/defaultNodes.md index f62332da24..b78c9af901 100644 --- a/docs/nodes/defaultNodes.md +++ b/docs/nodes/defaultNodes.md @@ -19,6 +19,8 @@ their descriptions. | Conditioning Primitive | A conditioning tensor primitive value | | Content Shuffle Processor | Applies content shuffle processing to image | | ControlNet | Collects ControlNet info to pass to other nodes | +| Create Denoise Mask | Converts a greyscale or transparency image into a mask for denoising. | +| Create Gradient Mask | Creates a mask for Gradient ("soft", "differential") inpainting that gradually expands during denoising. Improves edge coherence. | | Denoise Latents | Denoises noisy latents to decodable images | | Divide Integers | Divides two numbers | | Dynamic Prompt | Parses a prompt using adieyal/dynamicprompts' random or combinatorial generator | From ef958568acf02e839765a7e699f1a81c22f54efd Mon Sep 17 00:00:00 2001 From: Wubbbi Date: Sun, 3 Mar 2024 23:48:08 +0100 Subject: [PATCH 29/37] Update Transformers 4.37.2 -> 4.38.2 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 22a6058bbd..d9f9634739 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,12 +51,12 @@ dependencies = [ "torchmetrics==0.11.4", "torchsde==0.2.6", "torchvision==0.16.2", - "transformers==4.37.2", + "transformers==4.38.2", # Core application dependencies, pinned for reproducible builds. "fastapi-events==0.10.1", "fastapi==0.109.2", - "huggingface-hub==0.20.3", + "huggingface-hub==0.21.3", "pydantic-settings==2.1.0", "pydantic==2.6.1", "python-socketio==5.11.1", From 02dc1a87801034639b00db64e0d2e9f84ba88b51 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Fri, 1 Mar 2024 10:37:30 -0500 Subject: [PATCH 30/37] consolidate tabs for main model and concepts in generation panel --- invokeai/frontend/web/public/locales/en.json | 1 + .../features/lora/components/LoRASelect.tsx | 2 +- .../GenerationSettingsAccordion.tsx | 68 +++++++------------ 3 files changed, 27 insertions(+), 44 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 4065b0db86..4fe763922e 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -860,6 +860,7 @@ "models": { "addLora": "Add LoRA", "allLoRAsAdded": "All LoRAs added", + "concepts": "Concepts", "loraAlreadyAdded": "LoRA already added", "esrganModel": "ESRGAN Model", "loading": "loading", diff --git a/invokeai/frontend/web/src/features/lora/components/LoRASelect.tsx b/invokeai/frontend/web/src/features/lora/components/LoRASelect.tsx index e7d40c5eaf..851d098763 100644 --- a/invokeai/frontend/web/src/features/lora/components/LoRASelect.tsx +++ b/invokeai/frontend/web/src/features/lora/components/LoRASelect.tsx @@ -59,7 +59,7 @@ const LoRASelect = () => { return ( - {t('models.lora')} + {t('models.concepts')} { () => createMemoizedSelector(selectLoraSlice, (lora) => { const enabledLoRAsCount = filter(lora.loras, (l) => !!l.isEnabled).length; - const loraTabBadges = enabledLoRAsCount ? [enabledLoRAsCount] : EMPTY_ARRAY; + const loraTabBadges = enabledLoRAsCount ? [`${enabledLoRAsCount} ${t('models.concepts')}`] : EMPTY_ARRAY; const accordionBadges = modelConfig ? [modelConfig.name, modelConfig.base] : EMPTY_ARRAY; return { loraTabBadges, accordionBadges }; }), - [modelConfig] + [modelConfig, t] ); const { loraTabBadges, accordionBadges } = useAppSelector(selectBadges); const { isOpen: isOpenExpander, onToggle: onToggleExpander } = useExpanderToggle({ @@ -58,39 +48,31 @@ export const GenerationSettingsAccordion = memo(() => { return ( - - - {t('accordions.generation.modelTab')} - {t('accordions.generation.conceptsTab')} - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + ); }); From 94005b55019b7ee552466a50b5cd6a8854c6b032 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Fri, 1 Mar 2024 10:59:35 -0500 Subject: [PATCH 31/37] add button to navigate to model manager if tab is enabled --- .../ImportQueue/ImportQueueBadge.tsx | 2 +- .../NavigateToModelManagerButton.tsx | 36 +++++++++++++++++++ .../GenerationSettingsAccordion.tsx | 6 +++- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ImportQueue/ImportQueueBadge.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ImportQueue/ImportQueueBadge.tsx index 54a4eeaeb5..bbd0421d37 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ImportQueue/ImportQueueBadge.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ImportQueue/ImportQueueBadge.tsx @@ -15,7 +15,7 @@ const STATUSES = { const ImportQueueBadge = ({ status, errorReason }: { status?: ModelInstallStatus; errorReason?: string | null }) => { const { t } = useTranslation(); - if (!status) { + if (!status || !Object.keys(STATUSES).includes(status)) { return <>; } diff --git a/invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx b/invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx new file mode 100644 index 0000000000..733fb83826 --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/MainModel/NavigateToModelManagerButton.tsx @@ -0,0 +1,36 @@ +import type { IconButtonProps } from '@invoke-ai/ui-library'; +import { IconButton } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { setActiveTab } from 'features/ui/store/uiSlice'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiGearSixBold } from 'react-icons/pi'; + +export const NavigateToModelManagerButton = memo((props: Omit) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const disabledTabs = useAppSelector((s) => s.config.disabledTabs); + const shouldShowButton = useMemo(() => !disabledTabs.includes('modelManager'), [disabledTabs]); + + const handleClick = useCallback(() => { + dispatch(setActiveTab('modelManager')); + }, [dispatch]); + + if (!shouldShowButton) { + return null; + } + + return ( + } + tooltip={t('modelManager.modelManager')} + aria-label={t('modelManager.modelManager')} + onClick={handleClick} + size="sm" + variant="ghost" + {...props} + /> + ); +}); + +NavigateToModelManagerButton.displayName = 'NavigateToModelManagerButton'; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx index 9d1bf9eb83..1258189f40 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx @@ -10,6 +10,7 @@ import { SyncModelsIconButton } from 'features/modelManagerV2/components/SyncMod import ParamCFGScale from 'features/parameters/components/Core/ParamCFGScale'; import ParamScheduler from 'features/parameters/components/Core/ParamScheduler'; import ParamSteps from 'features/parameters/components/Core/ParamSteps'; +import { NavigateToModelManagerButton } from 'features/parameters/components/MainModel/NavigateToModelManagerButton'; import ParamMainModelSelect from 'features/parameters/components/MainModel/ParamMainModelSelect'; import { useExpanderToggle } from 'features/settingsAccordions/hooks/useExpanderToggle'; import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; @@ -56,7 +57,10 @@ export const GenerationSettingsAccordion = memo(() => { - + + + + From f2d5fb176f933d55e41ea4d20c9d76c9adca8413 Mon Sep 17 00:00:00 2001 From: B N Date: Fri, 1 Mar 2024 03:57:36 +0100 Subject: [PATCH 32/37] translationBot(ui): update translation (German) Currently translated at 80.4% (1183 of 1470 strings) Co-authored-by: B N Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/de/ Translation: InvokeAI/Web UI --- invokeai/frontend/web/public/locales/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/public/locales/de.json b/invokeai/frontend/web/public/locales/de.json index 65aa7b2a7a..00602f965a 100644 --- a/invokeai/frontend/web/public/locales/de.json +++ b/invokeai/frontend/web/public/locales/de.json @@ -967,7 +967,7 @@ "resumeFailed": "Problem beim Fortsetzen des Prozesses", "pruneFailed": "Problem beim leeren der Warteschlange", "pauseTooltip": "Prozess anhalten", - "back": "Hinten", + "back": "Ende", "resumeSucceeded": "Prozess wird fortgesetzt", "resumeTooltip": "Prozess wieder aufnehmen", "time": "Zeit", From 4deb60f3656521380fccf6f87759ed518e954e8e Mon Sep 17 00:00:00 2001 From: Riccardo Giovanetti Date: Fri, 1 Mar 2024 03:57:37 +0100 Subject: [PATCH 33/37] translationBot(ui): update translation (Italian) Currently translated at 98.0% (1442 of 1470 strings) Co-authored-by: Riccardo Giovanetti Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ Translation: InvokeAI/Web UI --- invokeai/frontend/web/public/locales/it.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json index 1a55f967f7..74aa7744ca 100644 --- a/invokeai/frontend/web/public/locales/it.json +++ b/invokeai/frontend/web/public/locales/it.json @@ -114,7 +114,8 @@ "checkpoint": "Checkpoint", "safetensors": "Safetensors", "ai": "ia", - "file": "File" + "file": "File", + "toResolve": "Da risolvere" }, "gallery": { "generations": "Generazioni", From 264aee3ffa1ec8e24183a725a75f76084799dcfd Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Fri, 1 Mar 2024 03:57:39 +0100 Subject: [PATCH 34/37] translationBot(ui): update translation files Updated by "Cleanup translation files" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ Translation: InvokeAI/Web UI --- invokeai/frontend/web/public/locales/de.json | 2 -- invokeai/frontend/web/public/locales/es.json | 2 -- invokeai/frontend/web/public/locales/it.json | 24 ------------------- invokeai/frontend/web/public/locales/ko.json | 2 -- invokeai/frontend/web/public/locales/nl.json | 24 ------------------- invokeai/frontend/web/public/locales/ru.json | 24 ------------------- invokeai/frontend/web/public/locales/tr.json | 4 ---- .../frontend/web/public/locales/zh_CN.json | 24 ------------------- 8 files changed, 106 deletions(-) diff --git a/invokeai/frontend/web/public/locales/de.json b/invokeai/frontend/web/public/locales/de.json index 00602f965a..23211c4e10 100644 --- a/invokeai/frontend/web/public/locales/de.json +++ b/invokeai/frontend/web/public/locales/de.json @@ -134,8 +134,6 @@ "loadMore": "Mehr laden", "noImagesInGallery": "Keine Bilder in der Galerie", "loading": "Lade", - "preparingDownload": "bereite Download vor", - "preparingDownloadFailed": "Problem beim Download vorbereiten", "deleteImage": "Lösche Bild", "copy": "Kopieren", "download": "Runterladen", diff --git a/invokeai/frontend/web/public/locales/es.json b/invokeai/frontend/web/public/locales/es.json index f85cd89721..a4a5aeac90 100644 --- a/invokeai/frontend/web/public/locales/es.json +++ b/invokeai/frontend/web/public/locales/es.json @@ -505,8 +505,6 @@ "seamLowThreshold": "Bajo", "coherencePassHeader": "Parámetros de la coherencia", "compositingSettingsHeader": "Ajustes de la composición", - "coherenceSteps": "Pasos", - "coherenceStrength": "Fuerza", "patchmatchDownScaleSize": "Reducir a escala", "coherenceMode": "Modo" }, diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json index 74aa7744ca..b3bd378783 100644 --- a/invokeai/frontend/web/public/locales/it.json +++ b/invokeai/frontend/web/public/locales/it.json @@ -143,8 +143,6 @@ "copy": "Copia", "download": "Scarica", "setCurrentImage": "Imposta come immagine corrente", - "preparingDownload": "Preparazione del download", - "preparingDownloadFailed": "Problema durante la preparazione del download", "downloadSelection": "Scarica gli elementi selezionati", "noImageSelected": "Nessuna immagine selezionata", "deleteSelection": "Elimina la selezione", @@ -610,8 +608,6 @@ "seamLowThreshold": "Basso", "seamHighThreshold": "Alto", "coherencePassHeader": "Passaggio di coerenza", - "coherenceSteps": "Passi", - "coherenceStrength": "Forza", "compositingSettingsHeader": "Impostazioni di composizione", "patchmatchDownScaleSize": "Ridimensiona", "coherenceMode": "Modalità", @@ -1401,19 +1397,6 @@ "Regola la maschera." ] }, - "compositingCoherenceSteps": { - "heading": "Passi", - "paragraphs": [ - "Numero di passi utilizzati nel Passaggio di Coerenza.", - "Simile ai passi di generazione." - ] - }, - "compositingBlur": { - "heading": "Sfocatura", - "paragraphs": [ - "Il raggio di sfocatura della maschera." - ] - }, "compositingCoherenceMode": { "heading": "Modalità", "paragraphs": [ @@ -1432,13 +1415,6 @@ "Un secondo ciclo di riduzione del rumore aiuta a comporre l'immagine Inpaint/Outpaint." ] }, - "compositingStrength": { - "heading": "Forza", - "paragraphs": [ - "Quantità di rumore aggiunta per il Passaggio di Coerenza.", - "Simile alla forza di riduzione del rumore." - ] - }, "paramNegativeConditioning": { "paragraphs": [ "Il processo di generazione evita i concetti nel prompt negativo. Utilizzatelo per escludere qualità o oggetti dall'output.", diff --git a/invokeai/frontend/web/public/locales/ko.json b/invokeai/frontend/web/public/locales/ko.json index 13f09d69ea..4cfb59f781 100644 --- a/invokeai/frontend/web/public/locales/ko.json +++ b/invokeai/frontend/web/public/locales/ko.json @@ -123,8 +123,6 @@ "autoSwitchNewImages": "새로운 이미지로 자동 전환", "loading": "불러오는 중", "unableToLoad": "갤러리를 로드할 수 없음", - "preparingDownload": "다운로드 준비", - "preparingDownloadFailed": "다운로드 준비 중 발생한 문제", "singleColumnLayout": "단일 열 레이아웃", "image": "이미지", "loadMore": "더 불러오기", diff --git a/invokeai/frontend/web/public/locales/nl.json b/invokeai/frontend/web/public/locales/nl.json index c23030bf54..9399dc4898 100644 --- a/invokeai/frontend/web/public/locales/nl.json +++ b/invokeai/frontend/web/public/locales/nl.json @@ -97,8 +97,6 @@ "featuresWillReset": "Als je deze afbeelding verwijdert, dan worden deze functies onmiddellijk teruggezet.", "loading": "Bezig met laden", "unableToLoad": "Kan galerij niet laden", - "preparingDownload": "Bezig met voorbereiden van download", - "preparingDownloadFailed": "Fout bij voorbereiden van download", "downloadSelection": "Download selectie", "currentlyInUse": "Deze afbeelding is momenteel in gebruik door de volgende functies:", "copy": "Kopieer", @@ -535,8 +533,6 @@ "coherencePassHeader": "Coherentiestap", "maskBlur": "Vervaag", "maskBlurMethod": "Vervagingsmethode", - "coherenceSteps": "Stappen", - "coherenceStrength": "Sterkte", "seamHighThreshold": "Hoog", "seamLowThreshold": "Laag", "invoke": { @@ -1139,13 +1135,6 @@ "Een afbeeldingsgrootte (in aantal pixels) equivalent aan 512x512 wordt aanbevolen voor SD1.5-modellen. Een grootte-equivalent van 1024x1024 wordt aanbevolen voor SDXL-modellen." ] }, - "compositingCoherenceSteps": { - "heading": "Stappen", - "paragraphs": [ - "Het aantal te gebruiken ontruisingsstappen in de coherentiefase.", - "Gelijk aan de hoofdparameter Stappen." - ] - }, "dynamicPrompts": { "paragraphs": [ "Dynamische prompts vormt een enkele prompt om in vele.", @@ -1160,12 +1149,6 @@ ], "heading": "VAE" }, - "compositingBlur": { - "heading": "Vervaging", - "paragraphs": [ - "De vervagingsstraal van het masker." - ] - }, "paramIterations": { "paragraphs": [ "Het aantal te genereren afbeeldingen.", @@ -1240,13 +1223,6 @@ ], "heading": "Ontruisingssterkte" }, - "compositingStrength": { - "heading": "Sterkte", - "paragraphs": [ - "Ontruisingssterkte voor de coherentiefase.", - "Gelijk aan de parameter Ontruisingssterkte Afbeelding naar afbeelding." - ] - }, "paramNegativeConditioning": { "paragraphs": [ "Het genereerproces voorkomt de gegeven begrippen in de negatieve prompt. Gebruik dit om bepaalde zaken of voorwerpen uit te sluiten van de uitvoerafbeelding.", diff --git a/invokeai/frontend/web/public/locales/ru.json b/invokeai/frontend/web/public/locales/ru.json index 8468554bab..00e64826e7 100644 --- a/invokeai/frontend/web/public/locales/ru.json +++ b/invokeai/frontend/web/public/locales/ru.json @@ -143,8 +143,6 @@ "problemDeletingImagesDesc": "Не удалось удалить одно или несколько изображений", "loading": "Загрузка", "unableToLoad": "Невозможно загрузить галерею", - "preparingDownload": "Подготовка к скачиванию", - "preparingDownloadFailed": "Проблема с подготовкой к скачиванию", "image": "изображение", "drop": "перебросить", "problemDeletingImages": "Проблема с удалением изображений", @@ -612,9 +610,7 @@ "maskBlurMethod": "Метод размытия", "seamLowThreshold": "Низкий", "seamHighThreshold": "Высокий", - "coherenceSteps": "Шагов", "coherencePassHeader": "Порог Coherence", - "coherenceStrength": "Сила", "compositingSettingsHeader": "Настройки компоновки", "invoke": { "noNodesInGraph": "Нет узлов в графе", @@ -1321,13 +1317,6 @@ "Размер изображения (в пикселях), эквивалентный 512x512, рекомендуется для моделей SD1.5, а размер, эквивалентный 1024x1024, рекомендуется для моделей SDXL." ] }, - "compositingCoherenceSteps": { - "heading": "Шаги", - "paragraphs": [ - "Количество шагов снижения шума, используемых при прохождении когерентности.", - "То же, что и основной параметр «Шаги»." - ] - }, "dynamicPrompts": { "paragraphs": [ "Динамические запросы превращают одно приглашение на множество.", @@ -1342,12 +1331,6 @@ ], "heading": "VAE" }, - "compositingBlur": { - "heading": "Размытие", - "paragraphs": [ - "Радиус размытия маски." - ] - }, "paramIterations": { "paragraphs": [ "Количество изображений, которые нужно сгенерировать.", @@ -1422,13 +1405,6 @@ ], "heading": "Шумоподавление" }, - "compositingStrength": { - "heading": "Сила", - "paragraphs": [ - null, - "То же, что параметр «Сила шумоподавления img2img»." - ] - }, "paramNegativeConditioning": { "paragraphs": [ "Stable Diffusion пытается избежать указанных в отрицательном запросе концепций. Используйте это, чтобы исключить качества или объекты из вывода.", diff --git a/invokeai/frontend/web/public/locales/tr.json b/invokeai/frontend/web/public/locales/tr.json index 9fdbae0481..74465c15ed 100644 --- a/invokeai/frontend/web/public/locales/tr.json +++ b/invokeai/frontend/web/public/locales/tr.json @@ -355,7 +355,6 @@ "starImage": "Yıldız Koy", "download": "İndir", "deleteSelection": "Seçileni Sil", - "preparingDownloadFailed": "İndirme Hazırlanırken Sorun", "problemDeletingImages": "Görsel Silmede Sorun", "featuresWillReset": "Bu görseli silerseniz, o özellikler resetlenecektir.", "galleryImageResetSize": "Boyutu Resetle", @@ -377,7 +376,6 @@ "setCurrentImage": "Çalışma Görseli Yap", "unableToLoad": "Galeri Yüklenemedi", "downloadSelection": "Seçileni İndir", - "preparingDownload": "İndirmeye Hazırlanıyor", "singleColumnLayout": "Tek Sütun Düzen", "generations": "Çıktılar", "showUploads": "Yüklenenleri Göster", @@ -723,7 +721,6 @@ "clipSkip": "CLIP Atlama", "randomizeSeed": "Rastgele Tohum", "cfgScale": "CFG Ölçeği", - "coherenceStrength": "Etki", "controlNetControlMode": "Yönetim Kipi", "general": "Genel", "img2imgStrength": "Görselden Görsel Ölçüsü", @@ -793,7 +790,6 @@ "cfgRescaleMultiplier": "CFG Rescale Çarpanı", "cfgRescale": "CFG Rescale", "coherencePassHeader": "Uyum Geçişi", - "coherenceSteps": "Adım", "infillMethod": "Doldurma Yöntemi", "maskBlurMethod": "Bulandırma Yöntemi", "steps": "Adım", diff --git a/invokeai/frontend/web/public/locales/zh_CN.json b/invokeai/frontend/web/public/locales/zh_CN.json index 3e4319fef8..673a2c4019 100644 --- a/invokeai/frontend/web/public/locales/zh_CN.json +++ b/invokeai/frontend/web/public/locales/zh_CN.json @@ -136,8 +136,6 @@ "copy": "复制", "download": "下载", "setCurrentImage": "设为当前图像", - "preparingDownload": "准备下载", - "preparingDownloadFailed": "准备下载时出现问题", "downloadSelection": "下载所选内容", "noImageSelected": "无选中的图像", "deleteSelection": "删除所选内容", @@ -616,11 +614,9 @@ "incompatibleBaseModelForControlAdapter": "有 #{{number}} 个 Control Adapter 模型与主模型不兼容。" }, "patchmatchDownScaleSize": "缩小", - "coherenceSteps": "步数", "clipSkip": "CLIP 跳过层", "compositingSettingsHeader": "合成设置", "useCpuNoise": "使用 CPU 噪声", - "coherenceStrength": "强度", "enableNoiseSettings": "启用噪声设置", "coherenceMode": "模式", "cpuNoise": "CPU 噪声", @@ -1402,19 +1398,6 @@ "图像尺寸(单位:像素)建议 SD 1.5 模型使用等效 512x512 的尺寸,SDXL 模型使用等效 1024x1024 的尺寸。" ] }, - "compositingCoherenceSteps": { - "heading": "步数", - "paragraphs": [ - "一致性层中使用的去噪步数。", - "与主参数中的步数相同。" - ] - }, - "compositingBlur": { - "heading": "模糊", - "paragraphs": [ - "遮罩模糊半径。" - ] - }, "noiseUseCPU": { "heading": "使用 CPU 噪声", "paragraphs": [ @@ -1467,13 +1450,6 @@ "第二轮去噪有助于合成内补/外扩图像。" ] }, - "compositingStrength": { - "heading": "强度", - "paragraphs": [ - "一致性层使用的去噪强度。", - "去噪强度与图生图的参数相同。" - ] - }, "paramNegativeConditioning": { "paragraphs": [ "生成过程会避免生成负向提示词中的概念。使用此选项来使输出排除部分质量或对象。", From f6028a4c61b795c0cc7d0777528c4c18e51a4b31 Mon Sep 17 00:00:00 2001 From: Ryan Dick Date: Fri, 1 Mar 2024 19:10:30 -0500 Subject: [PATCH 35/37] Log a stack trace for invocation errors. --- .../app/services/session_processor/session_processor_default.py | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py index c0b98220c8..a9039e2481 100644 --- a/invokeai/app/services/session_processor/session_processor_default.py +++ b/invokeai/app/services/session_processor/session_processor_default.py @@ -200,6 +200,7 @@ class DefaultSessionProcessor(SessionProcessorBase): self._invoker.services.logger.error( f"Error while invoking session {self._queue_item.session_id}, invocation {self._invocation.id} ({self._invocation.get_type()}):\n{e}" ) + self._invoker.services.logger.error(error) # Send error event self._invoker.services.events.emit_invocation_error( From 893bcd16fc97b8debea7b6c7d6946b6b08d395c5 Mon Sep 17 00:00:00 2001 From: Brandon Rising Date: Thu, 29 Feb 2024 15:13:38 -0500 Subject: [PATCH 36/37] Next: Allow in place local installs of models --- invokeai/app/api/routers/model_manager.py | 2 ++ invokeai/app/services/model_install/model_install_default.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 50ebe5ce64..47e0fd314e 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -451,6 +451,7 @@ async def add_model_record( ) async def install_model( source: str = Query(description="Model source to install, can be a local path, repo_id, or remote URL"), + inplace: Optional[bool] = Query(description="Whether or not to install a local model in place", default=False), # TODO(MM2): Can we type this? config: Optional[Dict[str, Any]] = Body( description="Dict of fields that override auto-probed values in the model config record, such as name, description and prediction_type ", @@ -493,6 +494,7 @@ async def install_model( source=source, config=config, access_token=access_token, + inplace=bool(inplace), ) logger.info(f"Started installation of {source}") except UnknownModelException as e: diff --git a/invokeai/app/services/model_install/model_install_default.py b/invokeai/app/services/model_install/model_install_default.py index b3c6015f4d..b91f961099 100644 --- a/invokeai/app/services/model_install/model_install_default.py +++ b/invokeai/app/services/model_install/model_install_default.py @@ -178,13 +178,14 @@ class ModelInstallService(ModelInstallServiceBase): source: str, config: Optional[Dict[str, Any]] = None, access_token: Optional[str] = None, + inplace: bool = False, ) -> ModelInstallJob: variants = "|".join(ModelRepoVariant.__members__.values()) hf_repoid_re = f"^([^/:]+/[^/:]+)(?::({variants})?(?::/?([^:]+))?)?$" source_obj: Optional[StringLikeSource] = None if Path(source).exists(): # A local file or directory - source_obj = LocalModelSource(path=Path(source)) + source_obj = LocalModelSource(path=Path(source), inplace=inplace) elif match := re.match(hf_repoid_re, source): source_obj = HFModelSource( repo_id=match.group(1), From 8b34f5298c74eec1552a271ec3636f9955f53c34 Mon Sep 17 00:00:00 2001 From: Mary Hipp Rogers Date: Mon, 4 Mar 2024 09:39:03 -0500 Subject: [PATCH 37/37] Default model settings (#5850) * UI in MM to create trigger phrases * add scheduler and vaePrecision to config * UI for configuring default settings for models' * hook MM default model settings up to API * add button to set default settings in parameters * pull out trigger phrases * back-end for default settings * lint * remove log; gi * ruff * ruff format --------- Co-authored-by: Mary Hipp --- invokeai/app/api/routers/model_manager.py | 43 +++++ .../model_metadata/metadata_store_base.py | 18 ++- .../model_metadata/metadata_store_sql.py | 79 +++++----- .../model_manager/metadata/metadata_base.py | 15 +- invokeai/frontend/web/public/locales/en.json | 7 + .../middleware/listenerMiddleware/index.ts | 4 + .../listeners/setDefaultSettings.ts | 96 ++++++++++++ .../frontend/web/src/app/types/invokeai.ts | 3 + .../modelManagerV2/subpanels/ModelPane.tsx | 2 +- .../subpanels/ModelPanel/DefaultSettings.tsx | 66 ++++++++ .../DefaultCfgRescaleMultiplier.tsx | 72 +++++++++ .../DefaultSettings/DefaultCfgScale.tsx | 72 +++++++++ .../DefaultSettings/DefaultScheduler.tsx | 50 ++++++ .../DefaultSettings/DefaultSettingsForm.tsx | 147 ++++++++++++++++++ .../DefaultSettings/DefaultSteps.tsx | 72 +++++++++ .../ModelPanel/DefaultSettings/DefaultVae.tsx | 65 ++++++++ .../DefaultSettings/DefaultVaePrecision.tsx | 51 ++++++ .../DefaultSettings/SettingToggle.tsx | 28 ++++ .../ModelPanel/Metadata/ModelMetadata.tsx | 18 +++ .../subpanels/ModelPanel/Model.tsx | 51 +++++- .../subpanels/ModelPanel/ModelView.tsx | 122 ++++++--------- .../MainModel/UseDefaultSettingsButton.tsx | 28 ++++ .../src/features/parameters/store/actions.ts | 2 + .../parameters/store/generationSlice.ts | 6 + .../GenerationSettingsAccordion.tsx | 2 + .../src/features/system/store/configSlice.ts | 2 + .../web/src/services/api/endpoints/models.ts | 19 +++ .../frontend/web/src/services/api/schema.ts | 110 ++++++++++++- 28 files changed, 1126 insertions(+), 124 deletions(-) create mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/setDefaultSettings.ts create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultCfgRescaleMultiplier.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultCfgScale.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultScheduler.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultSettingsForm.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultSteps.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultVae.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultVaePrecision.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/SettingToggle.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Metadata/ModelMetadata.tsx create mode 100644 invokeai/frontend/web/src/features/parameters/components/MainModel/UseDefaultSettingsButton.tsx diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 47e0fd314e..78ef965d5a 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -14,6 +14,7 @@ from starlette.exceptions import HTTPException from typing_extensions import Annotated from invokeai.app.services.model_install import ModelInstallJob +from invokeai.app.services.model_metadata.metadata_store_base import ModelMetadataChanges from invokeai.app.services.model_records import ( DuplicateModelException, InvalidModelException, @@ -32,6 +33,7 @@ from invokeai.backend.model_manager.config import ( ) from invokeai.backend.model_manager.merge import MergeInterpolationMethod, ModelMerger from invokeai.backend.model_manager.metadata import AnyModelRepoMetadata +from invokeai.backend.model_manager.metadata.metadata_base import BaseMetadata from invokeai.backend.model_manager.search import ModelSearch from ..dependencies import ApiDependencies @@ -243,6 +245,47 @@ async def get_model_metadata( return result +@model_manager_router.patch( + "/i/{key}/metadata", + operation_id="update_model_metadata", + responses={ + 201: { + "description": "The model metadata was updated successfully", + "content": {"application/json": {"example": example_model_metadata}}, + }, + 400: {"description": "Bad request"}, + }, +) +async def update_model_metadata( + key: str = Path(description="Key of the model repo metadata to fetch."), + changes: ModelMetadataChanges = Body(description="The changes"), +) -> Optional[AnyModelRepoMetadata]: + """Updates or creates a model metadata object.""" + record_store = ApiDependencies.invoker.services.model_manager.store + metadata_store = ApiDependencies.invoker.services.model_manager.store.metadata_store + + try: + original_metadata = record_store.get_metadata(key) + if original_metadata: + if changes.default_settings: + original_metadata.default_settings = changes.default_settings + + metadata_store.update_metadata(key, original_metadata) + else: + metadata_store.add_metadata( + key, BaseMetadata(name="", author="", default_settings=changes.default_settings) + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"An error occurred while updating the model metadata: {e}", + ) + + result: Optional[AnyModelRepoMetadata] = record_store.get_metadata(key) + + return result + + @model_manager_router.get( "/tags", operation_id="list_tags", diff --git a/invokeai/app/services/model_metadata/metadata_store_base.py b/invokeai/app/services/model_metadata/metadata_store_base.py index e0e4381b09..882575a4bf 100644 --- a/invokeai/app/services/model_metadata/metadata_store_base.py +++ b/invokeai/app/services/model_metadata/metadata_store_base.py @@ -4,9 +4,25 @@ Storage for Model Metadata """ from abc import ABC, abstractmethod -from typing import List, Set, Tuple +from typing import List, Optional, Set, Tuple +from pydantic import Field + +from invokeai.app.util.model_exclude_null import BaseModelExcludeNull from invokeai.backend.model_manager.metadata import AnyModelRepoMetadata +from invokeai.backend.model_manager.metadata.metadata_base import ModelDefaultSettings + + +class ModelMetadataChanges(BaseModelExcludeNull, extra="allow"): + """A set of changes to apply to model metadata. + Only limited changes are valid: + - `default_settings`: the user-configured default settings for this model + """ + + default_settings: Optional[ModelDefaultSettings] = Field( + default=None, description="The user-configured default settings for this model" + ) + """The user-configured default settings for this model""" class ModelMetadataStoreBase(ABC): diff --git a/invokeai/app/services/model_metadata/metadata_store_sql.py b/invokeai/app/services/model_metadata/metadata_store_sql.py index afe9d2c8c6..4f8170448f 100644 --- a/invokeai/app/services/model_metadata/metadata_store_sql.py +++ b/invokeai/app/services/model_metadata/metadata_store_sql.py @@ -179,44 +179,45 @@ class ModelMetadataStoreSQL(ModelMetadataStoreBase): ) return {x[0] for x in self._cursor.fetchall()} - def _update_tags(self, model_key: str, tags: Set[str]) -> None: + def _update_tags(self, model_key: str, tags: Optional[Set[str]]) -> None: """Update tags for the model referenced by model_key.""" - # remove previous tags from this model - self._cursor.execute( - """--sql - DELETE FROM model_tags - WHERE model_id=?; - """, - (model_key,), - ) + if tags: + # remove previous tags from this model + self._cursor.execute( + """--sql + DELETE FROM model_tags + WHERE model_id=?; + """, + (model_key,), + ) - for tag in tags: - self._cursor.execute( - """--sql - INSERT OR IGNORE INTO tags ( - tag_text - ) - VALUES (?); - """, - (tag,), - ) - self._cursor.execute( - """--sql - SELECT tag_id - FROM tags - WHERE tag_text = ? - LIMIT 1; - """, - (tag,), - ) - tag_id = self._cursor.fetchone()[0] - self._cursor.execute( - """--sql - INSERT OR IGNORE INTO model_tags ( - model_id, - tag_id - ) - VALUES (?,?); - """, - (model_key, tag_id), - ) + for tag in tags: + self._cursor.execute( + """--sql + INSERT OR IGNORE INTO tags ( + tag_text + ) + VALUES (?); + """, + (tag,), + ) + self._cursor.execute( + """--sql + SELECT tag_id + FROM tags + WHERE tag_text = ? + LIMIT 1; + """, + (tag,), + ) + tag_id = self._cursor.fetchone()[0] + self._cursor.execute( + """--sql + INSERT OR IGNORE INTO model_tags ( + model_id, + tag_id + ) + VALUES (?,?); + """, + (model_key, tag_id), + ) diff --git a/invokeai/backend/model_manager/metadata/metadata_base.py b/invokeai/backend/model_manager/metadata/metadata_base.py index 379369f9f5..5f062d0a04 100644 --- a/invokeai/backend/model_manager/metadata/metadata_base.py +++ b/invokeai/backend/model_manager/metadata/metadata_base.py @@ -25,6 +25,7 @@ from pydantic.networks import AnyHttpUrl from requests.sessions import Session from typing_extensions import Annotated +from invokeai.app.invocations.constants import SCHEDULER_NAME_VALUES from invokeai.backend.model_manager import ModelRepoVariant from ..util import select_hf_files @@ -68,12 +69,24 @@ class RemoteModelFile(BaseModel): sha256: Optional[str] = Field(description="SHA256 hash of this model (not always available)", default=None) +class ModelDefaultSettings(BaseModel): + vae: str | None + vae_precision: str | None + scheduler: SCHEDULER_NAME_VALUES | None + steps: int | None + cfg_scale: float | None + cfg_rescale_multiplier: float | None + + class ModelMetadataBase(BaseModel): """Base class for model metadata information.""" name: str = Field(description="model's name") author: str = Field(description="model's author") - tags: Set[str] = Field(description="tags provided by model source") + tags: Optional[Set[str]] = Field(description="tags provided by model source", default=None) + default_settings: Optional[ModelDefaultSettings] = Field( + description="default settings for this model", default=None + ) class BaseMetadata(ModelMetadataBase): diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 4fe763922e..406a33d9e8 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -78,6 +78,7 @@ "aboutDesc": "Using Invoke for work? Check out:", "aboutHeading": "Own Your Creative Power", "accept": "Accept", + "add": "Add", "advanced": "Advanced", "advancedOptions": "Advanced Options", "ai": "ai", @@ -734,6 +735,8 @@ "customConfig": "Custom Config", "customConfigFileLocation": "Custom Config File Location", "customSaveLocation": "Custom Save Location", + "defaultSettings": "Default Settings", + "defaultSettingsSaved": "Default Settings Saved", "delete": "Delete", "deleteConfig": "Delete Config", "deleteModel": "Delete Model", @@ -768,6 +771,7 @@ "mergedModelName": "Merged Model Name", "mergedModelSaveLocation": "Save Location", "mergeModels": "Merge Models", + "metadata": "Metadata", "model": "Model", "modelAdded": "Model Added", "modelConversionFailed": "Model Conversion Failed", @@ -839,9 +843,12 @@ "statusConverting": "Converting", "syncModels": "Sync Models", "syncModelsDesc": "If your models are out of sync with the backend, you can refresh them up using this option. This is generally handy in cases where you add models to the InvokeAI root folder or autoimport directory after the application has booted.", + "triggerPhrases": "Trigger Phrases", + "typePhraseHere": "Type phrase here", "upcastAttention": "Upcast Attention", "updateModel": "Update Model", "useCustomConfig": "Use Custom Config", + "useDefaultSettings": "Use Default Settings", "v1": "v1", "v2_768": "v2 (768px)", "v2_base": "v2 (512px)", diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index c5d86a127f..8e2715e3fa 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -55,6 +55,8 @@ import { addUpscaleRequestedListener } from 'app/store/middleware/listenerMiddle import { addWorkflowLoadRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested'; import type { AppDispatch, RootState } from 'app/store/store'; +import { addSetDefaultSettingsListener } from './listeners/setDefaultSettings'; + export const listenerMiddleware = createListenerMiddleware(); export type AppStartListening = TypedStartListening; @@ -153,3 +155,5 @@ addUpscaleRequestedListener(startAppListening); // Dynamic prompts addDynamicPromptsListener(startAppListening); + +addSetDefaultSettingsListener(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/setDefaultSettings.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/setDefaultSettings.ts new file mode 100644 index 0000000000..cd4c574be4 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/setDefaultSettings.ts @@ -0,0 +1,96 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { setDefaultSettings } from 'features/parameters/store/actions'; +import { + setCfgRescaleMultiplier, + setCfgScale, + setScheduler, + setSteps, + vaePrecisionChanged, + vaeSelected, +} from 'features/parameters/store/generationSlice'; +import { + isParameterCFGRescaleMultiplier, + isParameterCFGScale, + isParameterPrecision, + isParameterScheduler, + isParameterSteps, + zParameterVAEModel, +} from 'features/parameters/types/parameterSchemas'; +import { addToast } from 'features/system/store/systemSlice'; +import { makeToast } from 'features/system/util/makeToast'; +import { t } from 'i18next'; +import { map } from 'lodash-es'; +import { modelsApi } from 'services/api/endpoints/models'; + +export const addSetDefaultSettingsListener = (startAppListening: AppStartListening) => { + startAppListening({ + actionCreator: setDefaultSettings, + effect: async (action, { dispatch, getState }) => { + const state = getState(); + + const currentModel = state.generation.model; + + if (!currentModel) { + return; + } + + const metadata = await dispatch(modelsApi.endpoints.getModelMetadata.initiate(currentModel.key)).unwrap(); + + if (!metadata || !metadata.default_settings) { + return; + } + + const { vae, vae_precision, cfg_scale, cfg_rescale_multiplier, steps, scheduler } = metadata.default_settings; + + if (vae) { + // we store this as "default" within default settings + // to distinguish it from no default set + if (vae === 'default') { + dispatch(vaeSelected(null)); + } else { + const { data } = modelsApi.endpoints.getVaeModels.select()(state); + const vaeArray = map(data?.entities); + const validVae = vaeArray.find((model) => model.key === vae); + + const result = zParameterVAEModel.safeParse(validVae); + if (!result.success) { + return; + } + dispatch(vaeSelected(result.data)); + } + } + + if (vae_precision) { + if (isParameterPrecision(vae_precision)) { + dispatch(vaePrecisionChanged(vae_precision)); + } + } + + if (cfg_scale) { + if (isParameterCFGScale(cfg_scale)) { + dispatch(setCfgScale(cfg_scale)); + } + } + + if (cfg_rescale_multiplier) { + if (isParameterCFGRescaleMultiplier(cfg_rescale_multiplier)) { + dispatch(setCfgRescaleMultiplier(cfg_rescale_multiplier)); + } + } + + if (steps) { + if (isParameterSteps(steps)) { + dispatch(setSteps(steps)); + } + } + + if (scheduler) { + if (isParameterScheduler(scheduler)) { + dispatch(setScheduler(scheduler)); + } + } + + dispatch(addToast(makeToast({ title: t('toast.parameterSet', { parameter: 'Default settings' }) }))); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/types/invokeai.ts b/invokeai/frontend/web/src/app/types/invokeai.ts index a2b17b483d..0092d0c99e 100644 --- a/invokeai/frontend/web/src/app/types/invokeai.ts +++ b/invokeai/frontend/web/src/app/types/invokeai.ts @@ -1,4 +1,5 @@ import type { CONTROLNET_PROCESSORS } from 'features/controlAdapters/store/constants'; +import type { ParameterPrecision, ParameterScheduler } from 'features/parameters/types/parameterSchemas'; import type { InvokeTabName } from 'features/ui/store/tabMap'; import type { O } from 'ts-toolbelt'; @@ -82,6 +83,8 @@ export type AppConfig = { guidance: NumericalParameterConfig; cfgRescaleMultiplier: NumericalParameterConfig; img2imgStrength: NumericalParameterConfig; + scheduler?: ParameterScheduler; + vaePrecision?: ParameterPrecision; // Canvas boundingBoxHeight: NumericalParameterConfig; // initial value comes from model boundingBoxWidth: NumericalParameterConfig; // initial value comes from model diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPane.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPane.tsx index 9cae8d2984..c19aceda11 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPane.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPane.tsx @@ -8,7 +8,7 @@ export const ModelPane = () => { const selectedModelKey = useAppSelector((s) => s.modelmanagerV2.selectedModelKey); return ( - {selectedModelKey ? : } + {selectedModelKey ? : } ); }; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings.tsx new file mode 100644 index 0000000000..d45f33e390 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings.tsx @@ -0,0 +1,66 @@ +import { skipToken } from '@reduxjs/toolkit/query'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import Loading from 'common/components/Loading/Loading'; +import { selectConfigSlice } from 'features/system/store/configSlice'; +import { isNil } from 'lodash-es'; +import { useMemo } from 'react'; +import { useGetModelMetadataQuery } from 'services/api/endpoints/models'; + +import { DefaultSettingsForm } from './DefaultSettings/DefaultSettingsForm'; + +const initialStatesSelector = createMemoizedSelector(selectConfigSlice, (config) => { + const { steps, guidance, scheduler, cfgRescaleMultiplier, vaePrecision } = config.sd; + + return { + initialSteps: steps.initial, + initialCfg: guidance.initial, + initialScheduler: scheduler, + initialCfgRescaleMultiplier: cfgRescaleMultiplier.initial, + initialVaePrecision: vaePrecision, + }; +}); + +export const DefaultSettings = () => { + const selectedModelKey = useAppSelector((s) => s.modelmanagerV2.selectedModelKey); + + const { data, isLoading } = useGetModelMetadataQuery(selectedModelKey ?? skipToken); + const { initialSteps, initialCfg, initialScheduler, initialCfgRescaleMultiplier, initialVaePrecision } = + useAppSelector(initialStatesSelector); + + const defaultSettingsDefaults = useMemo(() => { + return { + vae: { isEnabled: !isNil(data?.default_settings?.vae), value: data?.default_settings?.vae || 'default' }, + vaePrecision: { + isEnabled: !isNil(data?.default_settings?.vae_precision), + value: data?.default_settings?.vae_precision || initialVaePrecision || 'fp32', + }, + scheduler: { + isEnabled: !isNil(data?.default_settings?.scheduler), + value: data?.default_settings?.scheduler || initialScheduler || 'euler', + }, + steps: { isEnabled: !isNil(data?.default_settings?.steps), value: data?.default_settings?.steps || initialSteps }, + cfgScale: { + isEnabled: !isNil(data?.default_settings?.cfg_scale), + value: data?.default_settings?.cfg_scale || initialCfg, + }, + cfgRescaleMultiplier: { + isEnabled: !isNil(data?.default_settings?.cfg_rescale_multiplier), + value: data?.default_settings?.cfg_rescale_multiplier || initialCfgRescaleMultiplier, + }, + }; + }, [ + data?.default_settings, + initialSteps, + initialCfg, + initialScheduler, + initialCfgRescaleMultiplier, + initialVaePrecision, + ]); + + if (isLoading) { + return ; + } + + return ; +}; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultCfgRescaleMultiplier.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultCfgRescaleMultiplier.tsx new file mode 100644 index 0000000000..fd88bab662 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultCfgRescaleMultiplier.tsx @@ -0,0 +1,72 @@ +import { CompositeNumberInput, CompositeSlider, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import { useCallback, useMemo } from 'react'; +import type { UseControllerProps } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { DefaultSettingsFormData } from './DefaultSettingsForm'; + +type DefaultCfgRescaleMultiplierType = DefaultSettingsFormData['cfgRescaleMultiplier']; + +export function DefaultCfgRescaleMultiplier(props: UseControllerProps) { + const { field } = useController(props); + + const sliderMin = useAppSelector((s) => s.config.sd.cfgRescaleMultiplier.sliderMin); + const sliderMax = useAppSelector((s) => s.config.sd.cfgRescaleMultiplier.sliderMax); + const numberInputMin = useAppSelector((s) => s.config.sd.cfgRescaleMultiplier.numberInputMin); + const numberInputMax = useAppSelector((s) => s.config.sd.cfgRescaleMultiplier.numberInputMax); + const coarseStep = useAppSelector((s) => s.config.sd.cfgRescaleMultiplier.coarseStep); + const fineStep = useAppSelector((s) => s.config.sd.cfgRescaleMultiplier.fineStep); + const { t } = useTranslation(); + const marks = useMemo(() => [sliderMin, Math.floor(sliderMax / 2), sliderMax], [sliderMax, sliderMin]); + + const onChange = useCallback( + (v: number) => { + const updatedValue = { + ...(field.value as DefaultCfgRescaleMultiplierType), + value: v, + }; + field.onChange(updatedValue); + }, + [field] + ); + + const value = useMemo(() => { + return (field.value as DefaultCfgRescaleMultiplierType).value; + }, [field.value]); + + const isDisabled = useMemo(() => { + return !(field.value as DefaultCfgRescaleMultiplierType).isEnabled; + }, [field.value]); + + return ( + + + {t('parameters.cfgRescaleMultiplier')} + + + + + + + ); +} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultCfgScale.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultCfgScale.tsx new file mode 100644 index 0000000000..8e49517eb4 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultCfgScale.tsx @@ -0,0 +1,72 @@ +import { CompositeNumberInput, CompositeSlider, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import { useCallback, useMemo } from 'react'; +import type { UseControllerProps } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { DefaultSettingsFormData } from './DefaultSettingsForm'; + +type DefaultCfgType = DefaultSettingsFormData['cfgScale']; + +export function DefaultCfgScale(props: UseControllerProps) { + const { field } = useController(props); + + const sliderMin = useAppSelector((s) => s.config.sd.guidance.sliderMin); + const sliderMax = useAppSelector((s) => s.config.sd.guidance.sliderMax); + const numberInputMin = useAppSelector((s) => s.config.sd.guidance.numberInputMin); + const numberInputMax = useAppSelector((s) => s.config.sd.guidance.numberInputMax); + const coarseStep = useAppSelector((s) => s.config.sd.guidance.coarseStep); + const fineStep = useAppSelector((s) => s.config.sd.guidance.fineStep); + const { t } = useTranslation(); + const marks = useMemo(() => [sliderMin, Math.floor(sliderMax / 2), sliderMax], [sliderMax, sliderMin]); + + const onChange = useCallback( + (v: number) => { + const updatedValue = { + ...(field.value as DefaultCfgType), + value: v, + }; + field.onChange(updatedValue); + }, + [field] + ); + + const value = useMemo(() => { + return (field.value as DefaultCfgType).value; + }, [field.value]); + + const isDisabled = useMemo(() => { + return !(field.value as DefaultCfgType).isEnabled; + }, [field.value]); + + return ( + + + {t('parameters.cfgScale')} + + + + + + + ); +} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultScheduler.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultScheduler.tsx new file mode 100644 index 0000000000..46b42fd873 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultScheduler.tsx @@ -0,0 +1,50 @@ +import type { ComboboxOnChange } from '@invoke-ai/ui-library'; +import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import { SCHEDULER_OPTIONS } from 'features/parameters/types/constants'; +import { isParameterScheduler } from 'features/parameters/types/parameterSchemas'; +import { useCallback, useMemo } from 'react'; +import type { UseControllerProps } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { DefaultSettingsFormData } from './DefaultSettingsForm'; + +type DefaultSchedulerType = DefaultSettingsFormData['scheduler']; + +export function DefaultScheduler(props: UseControllerProps) { + const { t } = useTranslation(); + const { field } = useController(props); + + const onChange = useCallback( + (v) => { + if (!isParameterScheduler(v?.value)) { + return; + } + const updatedValue = { + ...(field.value as DefaultSchedulerType), + value: v.value, + }; + field.onChange(updatedValue); + }, + [field] + ); + + const value = useMemo( + () => SCHEDULER_OPTIONS.find((o) => o.value === (field.value as DefaultSchedulerType).value), + [field] + ); + + const isDisabled = useMemo(() => { + return !(field.value as DefaultSchedulerType).isEnabled; + }, [field.value]); + + return ( + + + {t('parameters.scheduler')} + + + + ); +} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultSettingsForm.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultSettingsForm.tsx new file mode 100644 index 0000000000..699e3e3445 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultSettingsForm.tsx @@ -0,0 +1,147 @@ +import { Button, Flex, Heading } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import type { ParameterScheduler } from 'features/parameters/types/parameterSchemas'; +import { addToast } from 'features/system/store/systemSlice'; +import { makeToast } from 'features/system/util/makeToast'; +import { useCallback } from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { IoPencil } from 'react-icons/io5'; +import { useUpdateModelMetadataMutation } from 'services/api/endpoints/models'; + +import { DefaultCfgRescaleMultiplier } from './DefaultCfgRescaleMultiplier'; +import { DefaultCfgScale } from './DefaultCfgScale'; +import { DefaultScheduler } from './DefaultScheduler'; +import { DefaultSteps } from './DefaultSteps'; +import { DefaultVae } from './DefaultVae'; +import { DefaultVaePrecision } from './DefaultVaePrecision'; +import { SettingToggle } from './SettingToggle'; + +export interface FormField { + value: T; + isEnabled: boolean; +} + +export type DefaultSettingsFormData = { + vae: FormField; + vaePrecision: FormField; + scheduler: FormField; + steps: FormField; + cfgScale: FormField; + cfgRescaleMultiplier: FormField; +}; + +export const DefaultSettingsForm = ({ + defaultSettingsDefaults, +}: { + defaultSettingsDefaults: DefaultSettingsFormData; +}) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const selectedModelKey = useAppSelector((s) => s.modelmanagerV2.selectedModelKey); + + const [editModelMetadata, { isLoading }] = useUpdateModelMetadataMutation(); + + const { handleSubmit, control, formState } = useForm({ + defaultValues: defaultSettingsDefaults, + }); + + const onSubmit = useCallback>( + (data) => { + if (!selectedModelKey) { + return; + } + + const body = { + vae: data.vae.isEnabled ? data.vae.value : null, + vae_precision: data.vaePrecision.isEnabled ? data.vaePrecision.value : null, + cfg_scale: data.cfgScale.isEnabled ? data.cfgScale.value : null, + cfg_rescale_multiplier: data.cfgRescaleMultiplier.isEnabled ? data.cfgRescaleMultiplier.value : null, + steps: data.steps.isEnabled ? data.steps.value : null, + scheduler: data.scheduler.isEnabled ? data.scheduler.value : null, + }; + + editModelMetadata({ + key: selectedModelKey, + body: { default_settings: body }, + }) + .unwrap() + .then((_) => { + dispatch( + addToast( + makeToast({ + title: t('modelManager.defaultSettingsSaved'), + status: 'success', + }) + ) + ); + }) + .catch((error) => { + if (error) { + dispatch( + addToast( + makeToast({ + title: `${error.data.detail} `, + status: 'error', + }) + ) + ); + } + }); + }, + [selectedModelKey, dispatch, editModelMetadata, t] + ); + + return ( + <> + + {t('modelManager.defaultSettings')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultSteps.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultSteps.tsx new file mode 100644 index 0000000000..4ccef8fd73 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultSteps.tsx @@ -0,0 +1,72 @@ +import { CompositeNumberInput, CompositeSlider, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import { useCallback, useMemo } from 'react'; +import type { UseControllerProps } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { DefaultSettingsFormData } from './DefaultSettingsForm'; + +type DefaultSteps = DefaultSettingsFormData['steps']; + +export function DefaultSteps(props: UseControllerProps) { + const { field } = useController(props); + + const sliderMin = useAppSelector((s) => s.config.sd.steps.sliderMin); + const sliderMax = useAppSelector((s) => s.config.sd.steps.sliderMax); + const numberInputMin = useAppSelector((s) => s.config.sd.steps.numberInputMin); + const numberInputMax = useAppSelector((s) => s.config.sd.steps.numberInputMax); + const coarseStep = useAppSelector((s) => s.config.sd.steps.coarseStep); + const fineStep = useAppSelector((s) => s.config.sd.steps.fineStep); + const { t } = useTranslation(); + const marks = useMemo(() => [sliderMin, Math.floor(sliderMax / 2), sliderMax], [sliderMax, sliderMin]); + + const onChange = useCallback( + (v: number) => { + const updatedValue = { + ...(field.value as DefaultSteps), + value: v, + }; + field.onChange(updatedValue); + }, + [field] + ); + + const value = useMemo(() => { + return (field.value as DefaultSteps).value; + }, [field.value]); + + const isDisabled = useMemo(() => { + return !(field.value as DefaultSteps).isEnabled; + }, [field.value]); + + return ( + + + {t('parameters.steps')} + + + + + + + ); +} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultVae.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultVae.tsx new file mode 100644 index 0000000000..b32f17dca1 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultVae.tsx @@ -0,0 +1,65 @@ +import type { ComboboxOnChange } from '@invoke-ai/ui-library'; +import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useAppSelector } from 'app/store/storeHooks'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import { map } from 'lodash-es'; +import { useCallback, useMemo } from 'react'; +import type { UseControllerProps } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useGetModelConfigQuery, useGetVaeModelsQuery } from 'services/api/endpoints/models'; + +import type { DefaultSettingsFormData } from './DefaultSettingsForm'; + +type DefaultVaeType = DefaultSettingsFormData['vae']; + +export function DefaultVae(props: UseControllerProps) { + const { t } = useTranslation(); + const { field } = useController(props); + const selectedModelKey = useAppSelector((s) => s.modelmanagerV2.selectedModelKey); + const { data: modelData } = useGetModelConfigQuery(selectedModelKey ?? skipToken); + + const { compatibleOptions } = useGetVaeModelsQuery(undefined, { + selectFromResult: ({ data }) => { + const modelArray = map(data?.entities); + const compatibleOptions = modelArray + .filter((vae) => vae.base === modelData?.base) + .map((vae) => ({ label: vae.name, value: vae.key })); + + const defaultOption = { label: 'Default VAE', value: 'default' }; + + return { compatibleOptions: [defaultOption, ...compatibleOptions] }; + }, + }); + + const onChange = useCallback( + (v) => { + const newValue = !v?.value ? 'default' : v.value; + + const updatedValue = { + ...(field.value as DefaultVaeType), + value: newValue, + }; + field.onChange(updatedValue); + }, + [field] + ); + + const value = useMemo(() => { + return compatibleOptions.find((vae) => vae.value === (field.value as DefaultVaeType).value); + }, [compatibleOptions, field.value]); + + const isDisabled = useMemo(() => { + return !(field.value as DefaultVaeType).isEnabled; + }, [field.value]); + + return ( + + + {t('modelManager.vae')} + + + + ); +} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultVaePrecision.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultVaePrecision.tsx new file mode 100644 index 0000000000..240342b446 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/DefaultVaePrecision.tsx @@ -0,0 +1,51 @@ +import type { ComboboxOnChange } from '@invoke-ai/ui-library'; +import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import { isParameterPrecision } from 'features/parameters/types/parameterSchemas'; +import { useCallback, useMemo } from 'react'; +import type { UseControllerProps } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { DefaultSettingsFormData } from './DefaultSettingsForm'; + +const options = [ + { label: 'FP16', value: 'fp16' }, + { label: 'FP32', value: 'fp32' }, +]; + +type DefaultVaePrecisionType = DefaultSettingsFormData['vaePrecision']; + +export function DefaultVaePrecision(props: UseControllerProps) { + const { t } = useTranslation(); + const { field } = useController(props); + + const onChange = useCallback( + (v) => { + if (!isParameterPrecision(v?.value)) { + return; + } + const updatedValue = { + ...(field.value as DefaultVaePrecisionType), + value: v.value, + }; + field.onChange(updatedValue); + }, + [field] + ); + + const value = useMemo(() => options.find((o) => o.value === (field.value as DefaultVaePrecisionType).value), [field]); + + const isDisabled = useMemo(() => { + return !(field.value as DefaultVaePrecisionType).isEnabled; + }, [field.value]); + + return ( + + + {t('modelManager.vaePrecision')} + + + + ); +} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/SettingToggle.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/SettingToggle.tsx new file mode 100644 index 0000000000..bcea4959a8 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/DefaultSettings/SettingToggle.tsx @@ -0,0 +1,28 @@ +import { Switch } from '@invoke-ai/ui-library'; +import type { ChangeEvent } from 'react'; +import { useCallback, useMemo } from 'react'; +import type { UseControllerProps } from 'react-hook-form'; +import { useController } from 'react-hook-form'; + +import type { DefaultSettingsFormData, FormField } from './DefaultSettingsForm'; + +export function SettingToggle(props: UseControllerProps) { + const { field } = useController(props); + + const value = useMemo(() => { + return !!(field.value as FormField).isEnabled; + }, [field.value]); + + const onChange = useCallback( + (e: ChangeEvent) => { + const updatedValue: FormField = { + ...(field.value as FormField), + isEnabled: e.target.checked, + }; + field.onChange(updatedValue); + }, + [field] + ); + + return ; +} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Metadata/ModelMetadata.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Metadata/ModelMetadata.tsx new file mode 100644 index 0000000000..7dc3c0bf62 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Metadata/ModelMetadata.tsx @@ -0,0 +1,18 @@ +import { Flex } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useAppSelector } from 'app/store/storeHooks'; +import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer'; +import { useGetModelMetadataQuery } from 'services/api/endpoints/models'; + +export const ModelMetadata = () => { + const selectedModelKey = useAppSelector((s) => s.modelmanagerV2.selectedModelKey); + const { data: metadata } = useGetModelMetadataQuery(selectedModelKey ?? skipToken); + + return ( + <> + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Model.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Model.tsx index 6db804cccf..96e2629443 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Model.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Model.tsx @@ -1,9 +1,58 @@ +import { Box, Flex, Heading, Tab, TabList, TabPanel, TabPanels, Tabs, Text } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; +import { useTranslation } from 'react-i18next'; +import { useGetModelConfigQuery } from 'services/api/endpoints/models'; +import { ModelMetadata } from './Metadata/ModelMetadata'; +import { ModelAttrView } from './ModelAttrView'; import { ModelEdit } from './ModelEdit'; import { ModelView } from './ModelView'; export const Model = () => { + const { t } = useTranslation(); const selectedModelMode = useAppSelector((s) => s.modelmanagerV2.selectedModelMode); - return selectedModelMode === 'view' ? : ; + const selectedModelKey = useAppSelector((s) => s.modelmanagerV2.selectedModelKey); + const { data, isLoading } = useGetModelConfigQuery(selectedModelKey ?? skipToken); + + if (isLoading) { + return {t('common.loading')}; + } + + if (!data) { + return {t('common.somethingWentWrong')}; + } + + return ( + <> + + + {data.name} + + + {data.source && ( + + {t('modelManager.source')}: {data?.source} + + )} + + + + + + + + {t('modelManager.settings')} + {t('modelManager.metadata')} + + + + {selectedModelMode === 'view' ? : } + + + + + + + ); }; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx index 2acbfe8b3e..0b25e5fdc7 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx @@ -1,12 +1,11 @@ -import { Box, Button, Flex, Heading, Text } from '@invoke-ai/ui-library'; +import { Box, Button, Flex, Text } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer'; import { setSelectedModelMode } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { IoPencil } from 'react-icons/io5'; -import { useGetModelConfigQuery, useGetModelMetadataQuery } from 'services/api/endpoints/models'; +import { useGetModelConfigQuery } from 'services/api/endpoints/models'; import type { CheckpointModelConfig, ControlNetModelConfig, @@ -18,6 +17,7 @@ import type { VAEModelConfig, } from 'services/api/types'; +import { DefaultSettings } from './DefaultSettings'; import { ModelAttrView } from './ModelAttrView'; import { ModelConvert } from './ModelConvert'; @@ -26,7 +26,6 @@ export const ModelView = () => { const dispatch = useAppDispatch(); const selectedModelKey = useAppSelector((s) => s.modelmanagerV2.selectedModelKey); const { data, isLoading } = useGetModelConfigQuery(selectedModelKey ?? skipToken); - const { data: metadata } = useGetModelMetadataQuery(selectedModelKey ?? skipToken); const modelData = useMemo(() => { if (!data) { @@ -73,85 +72,56 @@ export const ModelView = () => { return {t('common.somethingWentWrong')}; } return ( - - - - - {modelData.name} - - - {modelData.source && ( - - {t('modelManager.source')}: {modelData.source} - - )} - - + + + + {modelData.type === 'main' && modelData.format === 'checkpoint' && } - - - - - - - - {t('modelManager.modelSettings')} - - - - - - - - - - - - {modelData.type === 'main' && ( - <> - - {modelData.format === 'diffusers' && ( - - )} - {modelData.format === 'checkpoint' && ( - - )} - - - - - - - - - - - - - )} - {modelData.type === 'ip_adapter' && ( + + + + + + + + + + {modelData.type === 'main' && ( + <> - - - )} - - - + {modelData.format === 'diffusers' && ( + + )} + {modelData.format === 'checkpoint' && ( + + )} - {metadata && ( - <> - - {t('modelManager.modelMetadata')} - - - - - - )} + + + + + + + + + + + + )} + {modelData.type === 'ip_adapter' && ( + + + + )} + + + + + ); }; diff --git a/invokeai/frontend/web/src/features/parameters/components/MainModel/UseDefaultSettingsButton.tsx b/invokeai/frontend/web/src/features/parameters/components/MainModel/UseDefaultSettingsButton.tsx new file mode 100644 index 0000000000..7b322a3227 --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/MainModel/UseDefaultSettingsButton.tsx @@ -0,0 +1,28 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { setDefaultSettings } from 'features/parameters/store/actions'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RiSparklingFill } from 'react-icons/ri'; + +export const UseDefaultSettingsButton = () => { + const model = useAppSelector((s) => s.generation.model); + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const handleClickDefaultSettings = useCallback(() => { + dispatch(setDefaultSettings()); + }, [dispatch]); + + return ( + } + tooltip={t('modelManager.useDefaultSettings')} + aria-label={t('modelManager.useDefaultSettings')} + isDisabled={!model} + onClick={handleClickDefaultSettings} + size="sm" + variant="ghost" + /> + ); +}; diff --git a/invokeai/frontend/web/src/features/parameters/store/actions.ts b/invokeai/frontend/web/src/features/parameters/store/actions.ts index f7bf127c05..3b43129720 100644 --- a/invokeai/frontend/web/src/features/parameters/store/actions.ts +++ b/invokeai/frontend/web/src/features/parameters/store/actions.ts @@ -5,3 +5,5 @@ import type { ImageDTO } from 'services/api/types'; export const initialImageSelected = createAction('generation/initialImageSelected'); export const modelSelected = createAction('generation/modelSelected'); + +export const setDefaultSettings = createAction('generation/setDefaultSettings'); diff --git a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts index 49ca507439..0f36d8b477 100644 --- a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts @@ -230,6 +230,12 @@ export const generationSlice = createSlice({ state.height = optimalDimension; } } + if (action.payload.sd?.scheduler) { + state.scheduler = action.payload.sd.scheduler; + } + if (action.payload.sd?.vaePrecision) { + state.vaePrecision = action.payload.sd.vaePrecision; + } }); // TODO: This is a temp fix to reduce issues with T2I adapter having a different downscaling diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx index 1258189f40..ab2d5abed6 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx @@ -12,6 +12,7 @@ import ParamScheduler from 'features/parameters/components/Core/ParamScheduler'; import ParamSteps from 'features/parameters/components/Core/ParamSteps'; import { NavigateToModelManagerButton } from 'features/parameters/components/MainModel/NavigateToModelManagerButton'; import ParamMainModelSelect from 'features/parameters/components/MainModel/ParamMainModelSelect'; +import { UseDefaultSettingsButton } from 'features/parameters/components/MainModel/UseDefaultSettingsButton'; import { useExpanderToggle } from 'features/settingsAccordions/hooks/useExpanderToggle'; import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; import { filter } from 'lodash-es'; @@ -58,6 +59,7 @@ export const GenerationSettingsAccordion = memo(() => { + diff --git a/invokeai/frontend/web/src/features/system/store/configSlice.ts b/invokeai/frontend/web/src/features/system/store/configSlice.ts index 4e1b734a66..76280df1ce 100644 --- a/invokeai/frontend/web/src/features/system/store/configSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/configSlice.ts @@ -41,6 +41,8 @@ const initialConfigState: AppConfig = { boundingBoxHeight: { ...baseDimensionConfig }, scaledBoundingBoxWidth: { ...baseDimensionConfig }, scaledBoundingBoxHeight: { ...baseDimensionConfig }, + scheduler: 'euler', + vaePrecision: 'fp32', steps: { initial: 30, sliderMin: 1, diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index 04c65b59f6..dac6594255 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -24,7 +24,15 @@ export type UpdateModelArg = { body: paths['/api/v2/models/i/{key}']['patch']['requestBody']['content']['application/json']; }; +type UpdateModelMetadataArg = { + key: paths['/api/v2/models/i/{key}/metadata']['patch']['parameters']['path']['key']; + body: paths['/api/v2/models/i/{key}/metadata']['patch']['requestBody']['content']['application/json']; +}; + type UpdateModelResponse = paths['/api/v2/models/i/{key}']['patch']['responses']['200']['content']['application/json']; +type UpdateModelMetadataResponse = + paths['/api/v2/models/i/{key}/metadata']['patch']['responses']['200']['content']['application/json']; + type GetModelConfigResponse = paths['/api/v2/models/i/{key}']['get']['responses']['200']['content']['application/json']; type GetModelMetadataResponse = @@ -172,6 +180,16 @@ export const modelsApi = api.injectEndpoints({ }, invalidatesTags: ['Model'], }), + updateModelMetadata: build.mutation({ + query: ({ key, body }) => { + return { + url: buildModelsUrl(`i/${key}/metadata`), + method: 'PATCH', + body: body, + }; + }, + invalidatesTags: ['Model'], + }), installModel: build.mutation({ query: ({ source, config, access_token }) => { return { @@ -351,6 +369,7 @@ export const { useGetModelMetadataQuery, useDeleteModelImportMutation, usePruneModelImportsMutation, + useUpdateModelMetadataMutation, } = modelsApi; const upsertModelConfigs = ( diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 12227d1ae9..560feb93ba 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -60,6 +60,11 @@ export type paths = { * @description Get a model metadata object. */ get: operations["get_model_metadata"]; + /** + * Update Model Metadata + * @description Updates or creates a model metadata object. + */ + patch: operations["update_model_metadata"]; }; "/api/v2/models/tags": { /** @@ -757,7 +762,14 @@ export type components = { * Tags * @description tags provided by model source */ - tags: string[]; + tags?: string[] | null; + /** + * Trigger Phrases + * @description trigger phrases for this model + */ + trigger_phrases?: string[] | null; + /** @description default settings for this model */ + default_settings?: components["schemas"]["ModelDefaultSettings"] | null; /** * Type * @default basemetadata @@ -1806,7 +1818,14 @@ export type components = { * Tags * @description tags provided by model source */ - tags: string[]; + tags?: string[] | null; + /** + * Trigger Phrases + * @description trigger phrases for this model + */ + trigger_phrases?: string[] | null; + /** @description default settings for this model */ + default_settings?: components["schemas"]["ModelDefaultSettings"] | null; /** * Files * @description model files and their sizes @@ -4264,7 +4283,7 @@ export type components = { * @description The nodes in this graph */ nodes: { - [key: string]: components["schemas"]["ColorInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLLoraLoaderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["FloatInvocation"] | components["schemas"]["ColorMapImageProcessorInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["DWOpenposeImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["DepthAnythingImageProcessorInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["StringInvocation"] | components["schemas"]["MergeTilesToImageInvocation"]; + [key: string]: components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["ColorMapImageProcessorInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["DepthAnythingImageProcessorInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["SDXLLoraLoaderInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["DWOpenposeImageProcessorInvocation"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["StringInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["FloatInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["CoreMetadataInvocation"]; }; /** * Edges @@ -4301,7 +4320,7 @@ export type components = { * @description The results of node executions */ results: { - [key: string]: components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["SDXLLoraLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["String2Output"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["ClipSkipInvocationOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["FloatCollectionOutput"]; + [key: string]: components["schemas"]["BooleanOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["ClipSkipInvocationOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["SDXLLoraLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"]; }; /** * Errors @@ -4424,7 +4443,14 @@ export type components = { * Tags * @description tags provided by model source */ - tags: string[]; + tags?: string[] | null; + /** + * Trigger Phrases + * @description trigger phrases for this model + */ + trigger_phrases?: string[] | null; + /** @description default settings for this model */ + default_settings?: components["schemas"]["ModelDefaultSettings"] | null; /** * Files * @description model files and their sizes @@ -7430,6 +7456,21 @@ export type components = { */ type: "mlsd_image_processor"; }; + /** ModelDefaultSettings */ + ModelDefaultSettings: { + /** Vae */ + vae: string | null; + /** Vae Precision */ + vae_precision: string | null; + /** Scheduler */ + scheduler: ("ddim" | "ddpm" | "deis" | "lms" | "lms_k" | "pndm" | "heun" | "heun_k" | "euler" | "euler_k" | "euler_a" | "kdpm_2" | "kdpm_2_a" | "dpmpp_2s" | "dpmpp_2s_k" | "dpmpp_2m" | "dpmpp_2m_k" | "dpmpp_2m_sde" | "dpmpp_2m_sde_k" | "dpmpp_sde" | "dpmpp_sde_k" | "unipc" | "lcm") | null; + /** Steps */ + steps: number | null; + /** Cfg Scale */ + cfg_scale: number | null; + /** Cfg Rescale Multiplier */ + cfg_rescale_multiplier: number | null; + }; /** * ModelFormat * @description Storage format of model. @@ -7556,6 +7597,24 @@ export type components = { */ unet: components["schemas"]["UNetField"]; }; + /** + * ModelMetadataChanges + * @description A set of changes to apply to model metadata. + * + * Only limited changes are valid: + * - `trigger_phrases`: the list of trigger phrases for this model + * - `default_settings`: the user-configured default settings for this model + */ + ModelMetadataChanges: { + /** + * Trigger Phrases + * @description The model's list of trigger phrases + */ + trigger_phrases?: string[] | null; + /** @description The user-configured default settings for this model */ + default_settings?: components["schemas"]["ModelDefaultSettings"] | null; + [key: string]: unknown; + }; /** * ModelRecordOrderBy * @description The order in which to return model summaries. @@ -11203,6 +11262,47 @@ export type operations = { }; }; }; + /** + * Update Model Metadata + * @description Updates or creates a model metadata object. + */ + update_model_metadata: { + parameters: { + path: { + /** @description Key of the model repo metadata to fetch. */ + key: string; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ModelMetadataChanges"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": (components["schemas"]["BaseMetadata"] | components["schemas"]["HuggingFaceMetadata"] | components["schemas"]["CivitaiMetadata"]) | null; + }; + }; + /** @description The model metadata was updated successfully */ + 201: { + content: { + "application/json": unknown; + }; + }; + /** @description Bad request */ + 400: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; /** * List Tags * @description Get a unique set of all the model tags.