From 5d5157fc6512a3e264fa310c9d1030701ceef8b6 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Wed, 10 May 2023 18:08:33 -0400 Subject: [PATCH 01/27] make conditioning.py work with compel 1.1.5 --- invokeai/backend/prompting/conditioning.py | 100 ++++++++++----------- 1 file changed, 46 insertions(+), 54 deletions(-) diff --git a/invokeai/backend/prompting/conditioning.py b/invokeai/backend/prompting/conditioning.py index d9130ace04..f94f82ef72 100644 --- a/invokeai/backend/prompting/conditioning.py +++ b/invokeai/backend/prompting/conditioning.py @@ -16,6 +16,7 @@ from compel.prompt_parser import ( FlattenedPrompt, Fragment, PromptParser, + Conjunction, ) import invokeai.backend.util.logging as logger @@ -25,58 +26,51 @@ from ..stable_diffusion import InvokeAIDiffuserComponent from ..util import torch_dtype -def get_uc_and_c_and_ec( - prompt_string, model, log_tokens=False, skip_normalize_legacy_blend=False -): +def get_uc_and_c_and_ec(prompt_string, + model: InvokeAIDiffuserComponent, + log_tokens=False, skip_normalize_legacy_blend=False): # lazy-load any deferred textual inversions. # this might take a couple of seconds the first time a textual inversion is used. - model.textual_inversion_manager.create_deferred_token_ids_for_any_trigger_terms( - prompt_string - ) + model.textual_inversion_manager.create_deferred_token_ids_for_any_trigger_terms(prompt_string) - tokenizer = model.tokenizer - compel = Compel( - tokenizer=tokenizer, - text_encoder=model.text_encoder, - textual_inversion_manager=model.textual_inversion_manager, - dtype_for_device_getter=torch_dtype, - truncate_long_prompts=False - ) + compel = Compel(tokenizer=model.tokenizer, + text_encoder=model.text_encoder, + textual_inversion_manager=model.textual_inversion_manager, + dtype_for_device_getter=torch_dtype, + truncate_long_prompts=False, + ) # get rid of any newline characters prompt_string = prompt_string.replace("\n", " ") - ( - positive_prompt_string, - negative_prompt_string, - ) = split_prompt_to_positive_and_negative(prompt_string) - legacy_blend = try_parse_legacy_blend( - positive_prompt_string, skip_normalize_legacy_blend - ) - positive_prompt: Union[FlattenedPrompt, Blend] + positive_prompt_string, negative_prompt_string = split_prompt_to_positive_and_negative(prompt_string) + + legacy_blend = try_parse_legacy_blend(positive_prompt_string, skip_normalize_legacy_blend) + positive_conjunction: Conjunction if legacy_blend is not None: - positive_prompt = legacy_blend + positive_conjunction = legacy_blend else: - positive_prompt = Compel.parse_prompt_string(positive_prompt_string) - negative_prompt: Union[FlattenedPrompt, Blend] = Compel.parse_prompt_string( - negative_prompt_string - ) + positive_conjunction = Compel.parse_prompt_string(positive_prompt_string) + positive_prompt = positive_conjunction.prompts[0] + negative_conjunction = Compel.parse_prompt_string(negative_prompt_string) + negative_prompt: FlattenedPrompt | Blend = negative_conjunction.prompts[0] + + tokens_count = get_max_token_count(model.tokenizer, positive_prompt) if log_tokens or getattr(Globals, "log_tokenization", False): - log_tokenization(positive_prompt, negative_prompt, tokenizer=tokenizer) + log_tokenization(positive_prompt, negative_prompt, tokenizer=model.tokenizer) - c, options = compel.build_conditioning_tensor_for_prompt_object(positive_prompt) - uc, _ = compel.build_conditioning_tensor_for_prompt_object(negative_prompt) - [c, uc] = compel.pad_conditioning_tensors_to_same_length([c, uc]) + with InvokeAIDiffuserComponent.custom_attention_context(model.unet, + extra_conditioning_info=None, + step_count=-1): + c, options = compel.build_conditioning_tensor_for_prompt_object(positive_prompt) + uc, _ = compel.build_conditioning_tensor_for_prompt_object(negative_prompt) - tokens_count = get_max_token_count(tokenizer, positive_prompt) - - ec = InvokeAIDiffuserComponent.ExtraConditioningInfo( - tokens_count_including_eos_bos=tokens_count, - cross_attention_control_args=options.get("cross_attention_control", None), - ) + # now build the "real" ec + ec = InvokeAIDiffuserComponent.ExtraConditioningInfo(tokens_count_including_eos_bos=tokens_count, + cross_attention_control_args=options.get( + 'cross_attention_control', None)) return uc, c, ec - def get_prompt_structure( prompt_string, skip_normalize_legacy_blend: bool = False ) -> (Union[FlattenedPrompt, Blend], FlattenedPrompt): @@ -87,18 +81,17 @@ def get_prompt_structure( legacy_blend = try_parse_legacy_blend( positive_prompt_string, skip_normalize_legacy_blend ) - positive_prompt: Union[FlattenedPrompt, Blend] + positive_prompt: Conjunction if legacy_blend is not None: - positive_prompt = legacy_blend + positive_conjunction = legacy_blend else: - positive_prompt = Compel.parse_prompt_string(positive_prompt_string) - negative_prompt: Union[FlattenedPrompt, Blend] = Compel.parse_prompt_string( - negative_prompt_string - ) + positive_conjunction = Compel.parse_prompt_string(positive_prompt_string) + positive_prompt = positive_conjunction.prompts[0] + negative_conjunction = Compel.parse_prompt_string(negative_prompt_string) + negative_prompt: FlattenedPrompt|Blend = negative_conjunction.prompts[0] return positive_prompt, negative_prompt - def get_max_token_count( tokenizer, prompt: Union[FlattenedPrompt, Blend], truncate_if_too_long=False ) -> int: @@ -245,22 +238,21 @@ def log_tokenization_for_text(text, tokenizer, display_label=None, truncate_if_t logger.info(f"[TOKENLOG] Tokens Discarded ({totalTokens - usedTokens}):") logger.debug(f"{discarded}\x1b[0m") - -def try_parse_legacy_blend(text: str, skip_normalize: bool = False) -> Optional[Blend]: +def try_parse_legacy_blend(text: str, skip_normalize: bool = False) -> Optional[Conjunction]: weighted_subprompts = split_weighted_subprompts(text, skip_normalize=skip_normalize) if len(weighted_subprompts) <= 1: return None strings = [x[0] for x in weighted_subprompts] - weights = [x[1] for x in weighted_subprompts] pp = PromptParser() parsed_conjunctions = [pp.parse_conjunction(x) for x in strings] - flattened_prompts = [x.prompts[0] for x in parsed_conjunctions] - - return Blend( - prompts=flattened_prompts, weights=weights, normalize_weights=not skip_normalize - ) - + flattened_prompts = [] + weights = [] + for i, x in enumerate(parsed_conjunctions): + if len(x.prompts)>0: + flattened_prompts.append(x.prompts[0]) + weights.append(weighted_subprompts[i][1]) + return Conjunction([Blend(prompts=flattened_prompts, weights=weights, normalize_weights=not skip_normalize)]) def split_weighted_subprompts(text, skip_normalize=False) -> list: """ From aca47704814a2642dd2e58cba598abea25bbed19 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Wed, 10 May 2023 21:40:44 -0400 Subject: [PATCH 02/27] fixed compel.py as requested --- invokeai/app/invocations/compel.py | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/invokeai/app/invocations/compel.py b/invokeai/app/invocations/compel.py index 1fb7832031..329f1b6f08 100644 --- a/invokeai/app/invocations/compel.py +++ b/invokeai/app/invocations/compel.py @@ -100,7 +100,8 @@ class CompelInvocation(BaseInvocation): # TODO: support legacy blend? - prompt: Union[FlattenedPrompt, Blend] = Compel.parse_prompt_string(prompt_str) + conjunction = Compel.parse_prompt_string(prompt_str) + prompt: Union[FlattenedPrompt, Blend] = conjunction.prompts[0] if getattr(Globals, "log_tokenization", False): log_tokenization_for_prompt_object(prompt, tokenizer) diff --git a/pyproject.toml b/pyproject.toml index fd671fee23..2d685ffe02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ "albumentations", "click", "clip_anytorch", # replacing "clip @ https://github.com/openai/CLIP/archive/eaa22acb90a5876642d0507623e859909230a52d.zip", - "compel~=1.1.5", + "compel~=1.1.5", "datasets", "diffusers[torch]~=0.16.1", "dnspython==2.2.1", From 9e594f90185de88b8e9c20f8ecaf6030d3ae7f17 Mon Sep 17 00:00:00 2001 From: Eugene Date: Thu, 11 May 2023 00:34:12 -0400 Subject: [PATCH 03/27] pad conditioning tensors to same length fixes crash when prompt length is greater than 75 tokens --- invokeai/backend/prompting/conditioning.py | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/backend/prompting/conditioning.py b/invokeai/backend/prompting/conditioning.py index f94f82ef72..71e51f1103 100644 --- a/invokeai/backend/prompting/conditioning.py +++ b/invokeai/backend/prompting/conditioning.py @@ -64,6 +64,7 @@ def get_uc_and_c_and_ec(prompt_string, step_count=-1): c, options = compel.build_conditioning_tensor_for_prompt_object(positive_prompt) uc, _ = compel.build_conditioning_tensor_for_prompt_object(negative_prompt) + [c, uc] = compel.pad_conditioning_tensors_to_same_length([c, uc]) # now build the "real" ec ec = InvokeAIDiffuserComponent.ExtraConditioningInfo(tokens_count_including_eos_bos=tokens_count, From 037078c8ad6d584949eb8671269aa8c0e7ec2e03 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 11 May 2023 21:13:18 -0400 Subject: [PATCH 04/27] make InvokeAIDiffuserComponent.custom_attention_control a classmethod --- .../stable_diffusion/diffusers_pipeline.py | 5 ++- .../diffusion/cross_attention_control.py | 37 ++++++------------ .../diffusion/shared_invokeai_diffusion.py | 38 ++++++++++++------- 3 files changed, 39 insertions(+), 41 deletions(-) diff --git a/invokeai/backend/stable_diffusion/diffusers_pipeline.py b/invokeai/backend/stable_diffusion/diffusers_pipeline.py index 94ec9da7e8..10e0ad4da3 100644 --- a/invokeai/backend/stable_diffusion/diffusers_pipeline.py +++ b/invokeai/backend/stable_diffusion/diffusers_pipeline.py @@ -545,8 +545,9 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): additional_guidance = [] extra_conditioning_info = conditioning_data.extra with self.invokeai_diffuser.custom_attention_context( - extra_conditioning_info=extra_conditioning_info, - step_count=len(self.scheduler.timesteps), + self.invokeai_diffuser.model, + extra_conditioning_info=extra_conditioning_info, + step_count=len(self.scheduler.timesteps), ): yield PipelineIntermediateState( run_id=run_id, diff --git a/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py b/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py index dfd19ea964..79a0982cfe 100644 --- a/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py +++ b/invokeai/backend/stable_diffusion/diffusion/cross_attention_control.py @@ -10,6 +10,7 @@ import diffusers import psutil import torch from compel.cross_attention_control import Arguments +from diffusers.models.unet_2d_condition import UNet2DConditionModel from diffusers.models.attention_processor import AttentionProcessor from torch import nn @@ -352,8 +353,7 @@ def restore_default_cross_attention( else: remove_attention_function(model) - -def override_cross_attention(model, context: Context, is_running_diffusers=False): +def setup_cross_attention_control_attention_processors(unet: UNet2DConditionModel, context: Context): """ Inject attention parameters and functions into the passed in model to enable cross attention editing. @@ -372,37 +372,22 @@ def override_cross_attention(model, context: Context, is_running_diffusers=False indices = torch.arange(max_length, dtype=torch.long) for name, a0, a1, b0, b1 in context.arguments.edit_opcodes: if b0 < max_length: - if name == "equal": # or (name == "replace" and a1 - a0 == b1 - b0): + if name == "equal":# or (name == "replace" and a1 - a0 == b1 - b0): # these tokens have not been edited indices[b0:b1] = indices_target[a0:a1] mask[b0:b1] = 1 context.cross_attention_mask = mask.to(device) context.cross_attention_index_map = indices.to(device) - if is_running_diffusers: - unet = model - old_attn_processors = unet.attn_processors - if torch.backends.mps.is_available(): - # see note in StableDiffusionGeneratorPipeline.__init__ about borked slicing on MPS - unet.set_attn_processor(SwapCrossAttnProcessor()) - else: - # try to re-use an existing slice size - default_slice_size = 4 - slice_size = next( - ( - p.slice_size - for p in old_attn_processors.values() - if type(p) is SlicedAttnProcessor - ), - default_slice_size, - ) - unet.set_attn_processor(SlicedSwapCrossAttnProcesser(slice_size=slice_size)) - return old_attn_processors + old_attn_processors = unet.attn_processors + if torch.backends.mps.is_available(): + # see note in StableDiffusionGeneratorPipeline.__init__ about borked slicing on MPS + unet.set_attn_processor(SwapCrossAttnProcessor()) else: - context.register_cross_attention_modules(model) - inject_attention_function(model, context) - return None - + # try to re-use an existing slice size + default_slice_size = 4 + slice_size = next((p.slice_size for p in old_attn_processors.values() if type(p) is SlicedAttnProcessor), default_slice_size) + unet.set_attn_processor(SlicedSwapCrossAttnProcesser(slice_size=slice_size)) def get_cross_attention_modules( model, which: CrossAttentionType diff --git a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py index b0c85e9fd3..245317bcde 100644 --- a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py +++ b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py @@ -5,6 +5,7 @@ from typing import Any, Callable, Dict, Optional, Union import numpy as np import torch +from diffusers import UNet2DConditionModel from diffusers.models.attention_processor import AttentionProcessor from typing_extensions import TypeAlias @@ -17,8 +18,8 @@ from .cross_attention_control import ( CrossAttentionType, SwapCrossAttnContext, get_cross_attention_modules, - override_cross_attention, restore_default_cross_attention, + setup_cross_attention_control_attention_processors, ) from .cross_attention_map_saving import AttentionMapSaver @@ -79,24 +80,35 @@ class InvokeAIDiffuserComponent: self.cross_attention_control_context = None self.sequential_guidance = Globals.sequential_guidance + @classmethod @contextmanager def custom_attention_context( - self, extra_conditioning_info: Optional[ExtraConditioningInfo], step_count: int + cls, + unet: UNet2DConditionModel, # note: also may futz with the text encoder depending on requested LoRAs + extra_conditioning_info: Optional[ExtraConditioningInfo], + step_count: int ): - do_swap = ( - extra_conditioning_info is not None - and extra_conditioning_info.wants_cross_attention_control - ) - old_attn_processor = None - if do_swap: - old_attn_processor = self.override_cross_attention( - extra_conditioning_info, step_count=step_count - ) + old_attn_processors = None + if extra_conditioning_info and ( + extra_conditioning_info.wants_cross_attention_control + ): + old_attn_processors = unet.attn_processors + # Load lora conditions into the model + if extra_conditioning_info.wants_cross_attention_control: + cross_attention_control_context = Context( + arguments=extra_conditioning_info.cross_attention_control_args, + step_count=step_count, + ) + setup_cross_attention_control_attention_processors( + unet, + cross_attention_control_context, + ) + try: yield None finally: - if old_attn_processor is not None: - self.restore_default_cross_attention(old_attn_processor) + if old_attn_processors is not None: + unet.set_attn_processor(old_attn_processors) # TODO resuscitate attention map saving # self.remove_attention_map_saving() From 8f8cd907878f00772e6c24185387d09dbd1167e7 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Fri, 12 May 2023 13:59:00 -0400 Subject: [PATCH 05/27] comment out customer_attention_context --- invokeai/backend/prompting/conditioning.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/invokeai/backend/prompting/conditioning.py b/invokeai/backend/prompting/conditioning.py index 71e51f1103..42b1736d00 100644 --- a/invokeai/backend/prompting/conditioning.py +++ b/invokeai/backend/prompting/conditioning.py @@ -59,12 +59,15 @@ def get_uc_and_c_and_ec(prompt_string, if log_tokens or getattr(Globals, "log_tokenization", False): log_tokenization(positive_prompt, negative_prompt, tokenizer=model.tokenizer) - with InvokeAIDiffuserComponent.custom_attention_context(model.unet, - extra_conditioning_info=None, - step_count=-1): - c, options = compel.build_conditioning_tensor_for_prompt_object(positive_prompt) - uc, _ = compel.build_conditioning_tensor_for_prompt_object(negative_prompt) - [c, uc] = compel.pad_conditioning_tensors_to_same_length([c, uc]) + # The below has been commented out as it is an instance method used for cleanly loading LoRA models, but is not currently needed. + # TODO: Reimplement custom_attention for 3.0 support of LoRA. + + # with InvokeAIDiffuserComponent.custom_attention_context(model.unet, + # extra_conditioning_info=None, + # step_count=-1): + # c, options = compel.build_conditioning_tensor_for_prompt_object(positive_prompt) + # uc, _ = compel.build_conditioning_tensor_for_prompt_object(negative_prompt) + # [c, uc] = compel.pad_conditioning_tensors_to_same_length([c, uc]) # now build the "real" ec ec = InvokeAIDiffuserComponent.ExtraConditioningInfo(tokens_count_including_eos_bos=tokens_count, From b72c9787a931c9122e4953cfb3bbf01c3911a8a3 Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 14 May 2023 00:37:55 -0400 Subject: [PATCH 06/27] Revert "comment out customer_attention_context" This reverts commit 8f8cd907878f00772e6c24185387d09dbd1167e7. Due to NameError: name 'options' is not defined --- invokeai/backend/prompting/conditioning.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/invokeai/backend/prompting/conditioning.py b/invokeai/backend/prompting/conditioning.py index 42b1736d00..71e51f1103 100644 --- a/invokeai/backend/prompting/conditioning.py +++ b/invokeai/backend/prompting/conditioning.py @@ -59,15 +59,12 @@ def get_uc_and_c_and_ec(prompt_string, if log_tokens or getattr(Globals, "log_tokenization", False): log_tokenization(positive_prompt, negative_prompt, tokenizer=model.tokenizer) - # The below has been commented out as it is an instance method used for cleanly loading LoRA models, but is not currently needed. - # TODO: Reimplement custom_attention for 3.0 support of LoRA. - - # with InvokeAIDiffuserComponent.custom_attention_context(model.unet, - # extra_conditioning_info=None, - # step_count=-1): - # c, options = compel.build_conditioning_tensor_for_prompt_object(positive_prompt) - # uc, _ = compel.build_conditioning_tensor_for_prompt_object(negative_prompt) - # [c, uc] = compel.pad_conditioning_tensors_to_same_length([c, uc]) + with InvokeAIDiffuserComponent.custom_attention_context(model.unet, + extra_conditioning_info=None, + step_count=-1): + c, options = compel.build_conditioning_tensor_for_prompt_object(positive_prompt) + uc, _ = compel.build_conditioning_tensor_for_prompt_object(negative_prompt) + [c, uc] = compel.pad_conditioning_tensors_to_same_length([c, uc]) # now build the "real" ec ec = InvokeAIDiffuserComponent.ExtraConditioningInfo(tokens_count_including_eos_bos=tokens_count, From 050add58d2186c0df049f56bf08a624761bd1ce0 Mon Sep 17 00:00:00 2001 From: Damian Stewart Date: Sun, 14 May 2023 12:20:54 +0200 Subject: [PATCH 07/27] fix getting conditionings --- invokeai/backend/prompting/conditioning.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/invokeai/backend/prompting/conditioning.py b/invokeai/backend/prompting/conditioning.py index 71e51f1103..a6aa5b68f1 100644 --- a/invokeai/backend/prompting/conditioning.py +++ b/invokeai/backend/prompting/conditioning.py @@ -59,14 +59,10 @@ def get_uc_and_c_and_ec(prompt_string, if log_tokens or getattr(Globals, "log_tokenization", False): log_tokenization(positive_prompt, negative_prompt, tokenizer=model.tokenizer) - with InvokeAIDiffuserComponent.custom_attention_context(model.unet, - extra_conditioning_info=None, - step_count=-1): - c, options = compel.build_conditioning_tensor_for_prompt_object(positive_prompt) - uc, _ = compel.build_conditioning_tensor_for_prompt_object(negative_prompt) - [c, uc] = compel.pad_conditioning_tensors_to_same_length([c, uc]) + c, options = compel.build_conditioning_tensor_for_prompt_object(positive_prompt) + uc, _ = compel.build_conditioning_tensor_for_prompt_object(negative_prompt) + [c, uc] = compel.pad_conditioning_tensors_to_same_length([c, uc]) - # now build the "real" ec ec = InvokeAIDiffuserComponent.ExtraConditioningInfo(tokens_count_including_eos_bos=tokens_count, cross_attention_control_args=options.get( 'cross_attention_control', None)) From 0221ca8f49db4a0f80635ced038f5bc33206f642 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 13 May 2023 16:24:04 +1000 Subject: [PATCH 08/27] fix(ui): use cloned canvas for retrieving dataURL/Blobs --- .../src/features/canvas/util/getCanvasData.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/invokeai/frontend/web/src/features/canvas/util/getCanvasData.ts b/invokeai/frontend/web/src/features/canvas/util/getCanvasData.ts index 131b109f55..28900fcc44 100644 --- a/invokeai/frontend/web/src/features/canvas/util/getCanvasData.ts +++ b/invokeai/frontend/web/src/features/canvas/util/getCanvasData.ts @@ -26,11 +26,11 @@ export const getCanvasData = async (state: RootState) => { layerState: { objects }, boundingBoxCoordinates, boundingBoxDimensions, - stageScale, isMaskEnabled, shouldPreserveMaskedArea, boundingBoxScaleMethod: boundingBoxScale, scaledBoundingBoxDimensions, + stageCoordinates, } = state.canvas; const boundingBox = { @@ -46,14 +46,14 @@ export const getCanvasData = async (state: RootState) => { // generationParameters.bounding_box = boundingBox; - const tempScale = canvasBaseLayer.scale(); + // clone the base layer so we don't affect the actual canvas during scaling + const clonedBaseLayer = canvasBaseLayer.clone(); - canvasBaseLayer.scale({ - x: 1 / stageScale, - y: 1 / stageScale, - }); + // scale to 1 so we get an uninterpolated image + clonedBaseLayer.scale({ x: 1, y: 1 }); - const absPos = canvasBaseLayer.getAbsolutePosition(); + // absolute position is needed to get the bounding box coords relative to the base layer + const absPos = clonedBaseLayer.getAbsolutePosition(); const offsetBoundingBox = { x: boundingBox.x + absPos.x, @@ -62,35 +62,41 @@ export const getCanvasData = async (state: RootState) => { height: boundingBox.height, }; - const baseDataURL = canvasBaseLayer.toDataURL(offsetBoundingBox); + // get a dataURL of the bbox'd region (will convert this to an ImageData to check its transparency) + const baseDataURL = clonedBaseLayer.toDataURL(offsetBoundingBox); + + // get a blob (will upload this as the canvas intermediate) const baseBlob = await canvasToBlob( - canvasBaseLayer.toCanvas(offsetBoundingBox) + clonedBaseLayer.toCanvas(offsetBoundingBox) ); - canvasBaseLayer.scale(tempScale); - + // build a new mask layer and get its dataURL and blob const { maskDataURL, maskBlob } = await generateMask( isMaskEnabled ? objects.filter(isCanvasMaskLine) : [], boundingBox ); + // convert to ImageData (via pure jank) const baseImageData = await dataURLToImageData( baseDataURL, boundingBox.width, boundingBox.height ); + // convert to ImageData (via pure jank) const maskImageData = await dataURLToImageData( maskDataURL, boundingBox.width, boundingBox.height ); + // check transparency const { isPartiallyTransparent: baseIsPartiallyTransparent, isFullyTransparent: baseIsFullyTransparent, } = getImageDataTransparency(baseImageData.data); + // check mask for black const doesMaskHaveBlackPixels = areAnyPixelsBlack(maskImageData.data); if (state.system.enableImageDebugging) { From 5e4457445f77f2c52aa3bfc50db85f83f17308cf Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 15 May 2023 13:53:41 +1000 Subject: [PATCH 09/27] feat(ui): make toast/hotkey into logical components --- .../frontend/web/src/app/components/App.tsx | 121 +++++++++--------- .../components/GlobalHotkeys.ts} | 11 +- .../web/src/app/components/Toaster.ts | 65 ++++++++++ .../listeners/initialImageSelected.ts | 2 +- .../src/common/components/ImageUploader.tsx | 15 ++- .../components/CurrentImageButtons.tsx | 24 ++-- .../gallery/components/HoverableImage.tsx | 6 +- .../features/nodes/components/AddNodeMenu.tsx | 10 +- .../nodes/components/search/NodeSearch.tsx | 8 +- .../parameters/hooks/useParameters.ts | 28 ++-- .../features/system/hooks/useToastWatcher.ts | 34 ----- .../src/features/system/store/systemSlice.ts | 2 +- .../services/events/util/setEventListeners.ts | 2 +- 13 files changed, 181 insertions(+), 147 deletions(-) rename invokeai/frontend/web/src/{common/hooks/useGlobalHotkeys.ts => app/components/GlobalHotkeys.ts} (89%) create mode 100644 invokeai/frontend/web/src/app/components/Toaster.ts delete mode 100644 invokeai/frontend/web/src/features/system/hooks/useToastWatcher.ts diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index eb6496f43e..e819c04352 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -2,9 +2,6 @@ import ImageUploader from 'common/components/ImageUploader'; import SiteHeader from 'features/system/components/SiteHeader'; import ProgressBar from 'features/system/components/ProgressBar'; import InvokeTabs from 'features/ui/components/InvokeTabs'; - -import useToastWatcher from 'features/system/hooks/useToastWatcher'; - import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton'; import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons'; import { Box, Flex, Grid, Portal } from '@chakra-ui/react'; @@ -17,13 +14,14 @@ import { motion, AnimatePresence } from 'framer-motion'; import Loading from 'common/components/Loading/Loading'; import { useIsApplicationReady } from 'features/system/hooks/useIsApplicationReady'; import { PartialAppConfig } from 'app/types/invokeai'; -import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys'; import { configChanged } from 'features/system/store/configSlice'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useLogger } from 'app/logging/useLogger'; import ParametersDrawer from 'features/ui/components/ParametersDrawer'; import { languageSelector } from 'features/system/store/systemSelectors'; import i18n from 'i18n'; +import Toaster from './Toaster'; +import GlobalHotkeys from './GlobalHotkeys'; const DEFAULT_CONFIG = {}; @@ -38,9 +36,6 @@ const App = ({ headerComponent, setIsReady, }: Props) => { - useToastWatcher(); - useGlobalHotkeys(); - const language = useAppSelector(languageSelector); const log = useLogger(); @@ -77,65 +72,69 @@ const App = ({ }, [isApplicationReady, setIsReady]); return ( - - {isLightboxEnabled && } - - - - {headerComponent || } - + + {isLightboxEnabled && } + + + - - - - + {headerComponent || } + + + + + - - + + - - {!isApplicationReady && !loadingOverridden && ( - - - - - - - )} - + + {!isApplicationReady && !loadingOverridden && ( + + + + + + + )} + - - - - - - - + + + + + + + + + + ); }; diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/app/components/GlobalHotkeys.ts similarity index 89% rename from invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts rename to invokeai/frontend/web/src/app/components/GlobalHotkeys.ts index 3935a390fb..c4660416bf 100644 --- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts +++ b/invokeai/frontend/web/src/app/components/GlobalHotkeys.ts @@ -10,6 +10,7 @@ import { togglePinParametersPanel, } from 'features/ui/store/uiSlice'; import { isEqual } from 'lodash-es'; +import React, { memo } from 'react'; import { isHotkeyPressed, useHotkeys } from 'react-hotkeys-hook'; const globalHotkeysSelector = createSelector( @@ -27,7 +28,11 @@ const globalHotkeysSelector = createSelector( // TODO: Does not catch keypresses while focused in an input. Maybe there is a way? -export const useGlobalHotkeys = () => { +/** + * Logical component. Handles app-level global hotkeys. + * @returns null + */ +const GlobalHotkeys: React.FC = () => { const dispatch = useAppDispatch(); const { shift } = useAppSelector(globalHotkeysSelector); @@ -75,4 +80,8 @@ export const useGlobalHotkeys = () => { useHotkeys('4', () => { dispatch(setActiveTab('nodes')); }); + + return null; }; + +export default memo(GlobalHotkeys); diff --git a/invokeai/frontend/web/src/app/components/Toaster.ts b/invokeai/frontend/web/src/app/components/Toaster.ts new file mode 100644 index 0000000000..66ba1d4925 --- /dev/null +++ b/invokeai/frontend/web/src/app/components/Toaster.ts @@ -0,0 +1,65 @@ +import { useToast, UseToastOptions } from '@chakra-ui/react'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { toastQueueSelector } from 'features/system/store/systemSelectors'; +import { addToast, clearToastQueue } from 'features/system/store/systemSlice'; +import { useCallback, useEffect } from 'react'; + +export type MakeToastArg = string | UseToastOptions; + +/** + * Makes a toast from a string or a UseToastOptions object. + * If a string is passed, the toast will have the status 'info' and will be closable with a duration of 2500ms. + */ +export const makeToast = (arg: MakeToastArg): UseToastOptions => { + if (typeof arg === 'string') { + return { + title: arg, + status: 'info', + isClosable: true, + duration: 2500, + }; + } + + return { status: 'info', isClosable: true, duration: 2500, ...arg }; +}; + +/** + * Logical component. Watches the toast queue and makes toasts when the queue is not empty. + * @returns null + */ +const Toaster = () => { + const dispatch = useAppDispatch(); + const toastQueue = useAppSelector(toastQueueSelector); + const toast = useToast(); + useEffect(() => { + toastQueue.forEach((t) => { + toast(t); + }); + toastQueue.length > 0 && dispatch(clearToastQueue()); + }, [dispatch, toast, toastQueue]); + + return null; +}; + +/** + * Returns a function that can be used to make a toast. + * @example + * const toaster = useAppToaster(); + * toaster('Hello world!'); + * toaster({ title: 'Hello world!', status: 'success' }); + * @returns A function that can be used to make a toast. + * @see makeToast + * @see MakeToastArg + * @see UseToastOptions + */ +export const useAppToaster = () => { + const dispatch = useAppDispatch(); + const toaster = useCallback( + (arg: MakeToastArg) => dispatch(addToast(makeToast(arg))), + [dispatch] + ); + + return toaster; +}; + +export default Toaster; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts index 6bc2f9e9bc..ae3a35f537 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts @@ -2,11 +2,11 @@ import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { Image, isInvokeAIImage } from 'app/types/invokeai'; import { selectResultsById } from 'features/gallery/store/resultsSlice'; import { selectUploadsById } from 'features/gallery/store/uploadsSlice'; -import { makeToast } from 'features/system/hooks/useToastWatcher'; import { t } from 'i18next'; import { addToast } from 'features/system/store/systemSlice'; import { startAppListening } from '..'; import { initialImageSelected } from 'features/parameters/store/actions'; +import { makeToast } from 'app/components/Toaster'; export const addInitialImageSelectedListener = () => { startAppListening({ diff --git a/invokeai/frontend/web/src/common/components/ImageUploader.tsx b/invokeai/frontend/web/src/common/components/ImageUploader.tsx index ee3b9d135e..c773fb85ed 100644 --- a/invokeai/frontend/web/src/common/components/ImageUploader.tsx +++ b/invokeai/frontend/web/src/common/components/ImageUploader.tsx @@ -1,4 +1,4 @@ -import { Box, useToast } from '@chakra-ui/react'; +import { Box } from '@chakra-ui/react'; import { ImageUploaderTriggerContext } from 'app/contexts/ImageUploaderTriggerContext'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import useImageUploader from 'common/hooks/useImageUploader'; @@ -16,6 +16,7 @@ import { FileRejection, useDropzone } from 'react-dropzone'; import { useTranslation } from 'react-i18next'; import { imageUploaded } from 'services/thunks/image'; import ImageUploadOverlay from './ImageUploadOverlay'; +import { useAppToaster } from 'app/components/Toaster'; type ImageUploaderProps = { children: ReactNode; @@ -25,7 +26,7 @@ const ImageUploader = (props: ImageUploaderProps) => { const { children } = props; const dispatch = useAppDispatch(); const activeTabName = useAppSelector(activeTabNameSelector); - const toast = useToast({}); + const toaster = useAppToaster(); const { t } = useTranslation(); const [isHandlingUpload, setIsHandlingUpload] = useState(false); const { setOpenUploader } = useImageUploader(); @@ -37,14 +38,14 @@ const ImageUploader = (props: ImageUploaderProps) => { (acc: string, cur: { message: string }) => `${acc}\n${cur.message}`, '' ); - toast({ + toaster({ title: t('toast.uploadFailed'), description: msg, status: 'error', isClosable: true, }); }, - [t, toast] + [t, toaster] ); const fileAcceptedCallback = useCallback( @@ -105,7 +106,7 @@ const ImageUploader = (props: ImageUploaderProps) => { e.stopImmediatePropagation(); if (imageItems.length > 1) { - toast({ + toaster({ description: t('toast.uploadFailedMultipleImagesDesc'), status: 'error', isClosable: true, @@ -116,7 +117,7 @@ const ImageUploader = (props: ImageUploaderProps) => { const file = imageItems[0].getAsFile(); if (!file) { - toast({ + toaster({ description: t('toast.uploadFailedUnableToLoadDesc'), status: 'error', isClosable: true, @@ -130,7 +131,7 @@ const ImageUploader = (props: ImageUploaderProps) => { return () => { document.removeEventListener('paste', pasteImageListener); }; - }, [t, dispatch, toast, activeTabName]); + }, [t, dispatch, toaster, activeTabName]); const overlaySecondaryText = ['img2img', 'unifiedCanvas'].includes( activeTabName diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx index e76f3fa41e..980c317ac3 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx @@ -5,15 +5,8 @@ import { ButtonGroup, Flex, FlexProps, - IconButton, Link, - Menu, - MenuButton, - MenuItemOption, - MenuList, - MenuOptionGroup, useDisclosure, - useToast, } from '@chakra-ui/react'; // import { runESRGAN, runFacetool } from 'app/socketio/actions'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; @@ -70,6 +63,7 @@ import FaceRestoreSettings from 'features/parameters/components/Parameters/FaceR import UpscaleSettings from 'features/parameters/components/Parameters/Upscale/UpscaleSettings'; import { allParametersSet } from 'features/parameters/store/generationSlice'; import DeleteImageButton from './ImageActionButtons/DeleteImageButton'; +import { useAppToaster } from 'app/components/Toaster'; const currentImageButtonsSelector = createSelector( [ @@ -164,7 +158,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { onClose: onDeleteDialogClose, } = useDisclosure(); - const toast = useToast(); + const toaster = useAppToaster(); const { t } = useTranslation(); const { recallPrompt, recallSeed, recallAllParameters } = useParameters(); @@ -213,7 +207,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { const url = getImageUrl(); if (!url) { - toast({ + toaster({ title: t('toast.problemCopyingImageLink'), status: 'error', duration: 2500, @@ -224,14 +218,14 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { } navigator.clipboard.writeText(url).then(() => { - toast({ + toaster({ title: t('toast.imageLinkCopied'), status: 'success', duration: 2500, isClosable: true, }); }); - }, [toast, shouldTransformUrls, getUrl, t, image]); + }, [toaster, shouldTransformUrls, getUrl, t, image]); const handlePreviewVisibility = useCallback(() => { dispatch(setShouldHidePreview(!shouldHidePreview)); @@ -346,13 +340,13 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { dispatch(setActiveTab('unifiedCanvas')); } - toast({ + toaster({ title: t('toast.sentToUnifiedCanvas'), status: 'success', duration: 2500, isClosable: true, }); - }, [image, isLightboxOpen, dispatch, activeTabName, toast, t]); + }, [image, isLightboxOpen, dispatch, activeTabName, toaster, t]); useHotkeys( 'i', @@ -360,7 +354,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { if (image) { handleClickShowImageDetails(); } else { - toast({ + toaster({ title: t('toast.metadataLoadFailed'), status: 'error', duration: 2500, @@ -368,7 +362,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { }); } }, - [image, shouldShowImageDetails] + [image, shouldShowImageDetails, toaster] ); const handleDelete = useCallback(() => { diff --git a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx index c3980a9ad4..6eb44de99c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx @@ -6,7 +6,6 @@ import { MenuItem, MenuList, useDisclosure, - useToast, } from '@chakra-ui/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { imageSelected } from 'features/gallery/store/gallerySlice'; @@ -35,6 +34,7 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useParameters } from 'features/parameters/hooks/useParameters'; import { initialImageSelected } from 'features/parameters/store/actions'; import { requestedImageDeletion } from '../store/actions'; +import { useAppToaster } from 'app/components/Toaster'; export const selector = createSelector( [gallerySelector, systemSelector, lightboxSelector, activeTabNameSelector], @@ -101,7 +101,7 @@ const HoverableImage = memo((props: HoverableImageProps) => { const [isHovered, setIsHovered] = useState(false); - const toast = useToast(); + const toaster = useAppToaster(); const { t } = useTranslation(); @@ -176,7 +176,7 @@ const HoverableImage = memo((props: HoverableImageProps) => { dispatch(setActiveTab('unifiedCanvas')); } - toast({ + toaster({ title: t('toast.sentToUnifiedCanvas'), status: 'success', duration: 2500, diff --git a/invokeai/frontend/web/src/features/nodes/components/AddNodeMenu.tsx b/invokeai/frontend/web/src/features/nodes/components/AddNodeMenu.tsx index a4ce2f55f6..db390ed518 100644 --- a/invokeai/frontend/web/src/features/nodes/components/AddNodeMenu.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/AddNodeMenu.tsx @@ -13,10 +13,9 @@ import { nodeAdded } from '../store/nodesSlice'; import { map } from 'lodash-es'; import { RootState } from 'app/store/store'; import { useBuildInvocation } from '../hooks/useBuildInvocation'; -import { addToast } from 'features/system/store/systemSlice'; -import { makeToast } from 'features/system/hooks/useToastWatcher'; import { AnyInvocationType } from 'services/events/types'; import IAIIconButton from 'common/components/IAIIconButton'; +import { useAppToaster } from 'app/components/Toaster'; const AddNodeMenu = () => { const dispatch = useAppDispatch(); @@ -27,22 +26,23 @@ const AddNodeMenu = () => { const buildInvocation = useBuildInvocation(); + const toaster = useAppToaster(); + const addNode = useCallback( (nodeType: AnyInvocationType) => { const invocation = buildInvocation(nodeType); if (!invocation) { - const toast = makeToast({ + toaster({ status: 'error', title: `Unknown Invocation type ${nodeType}`, }); - dispatch(addToast(toast)); return; } dispatch(nodeAdded(invocation)); }, - [dispatch, buildInvocation] + [dispatch, buildInvocation, toaster] ); return ( diff --git a/invokeai/frontend/web/src/features/nodes/components/search/NodeSearch.tsx b/invokeai/frontend/web/src/features/nodes/components/search/NodeSearch.tsx index b06619e76f..c441297fe8 100644 --- a/invokeai/frontend/web/src/features/nodes/components/search/NodeSearch.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/search/NodeSearch.tsx @@ -16,11 +16,11 @@ import { import { Tooltip } from '@chakra-ui/tooltip'; import { AnyInvocationType } from 'services/events/types'; import { useBuildInvocation } from 'features/nodes/hooks/useBuildInvocation'; -import { makeToast } from 'features/system/hooks/useToastWatcher'; import { addToast } from 'features/system/store/systemSlice'; import { nodeAdded } from '../../store/nodesSlice'; import Fuse from 'fuse.js'; import { InvocationTemplate } from 'features/nodes/types/types'; +import { useAppToaster } from 'app/components/Toaster'; interface NodeListItemProps { title: string; @@ -63,6 +63,7 @@ const NodeSearch = () => { const buildInvocation = useBuildInvocation(); const dispatch = useAppDispatch(); + const toaster = useAppToaster(); const [searchText, setSearchText] = useState(''); const [showNodeList, setShowNodeList] = useState(false); @@ -89,17 +90,16 @@ const NodeSearch = () => { const invocation = buildInvocation(nodeType); if (!invocation) { - const toast = makeToast({ + toaster({ status: 'error', title: `Unknown Invocation type ${nodeType}`, }); - dispatch(addToast(toast)); return; } dispatch(nodeAdded(invocation)); }, - [dispatch, buildInvocation] + [dispatch, buildInvocation, toaster] ); const renderNodeList = () => { diff --git a/invokeai/frontend/web/src/features/parameters/hooks/useParameters.ts b/invokeai/frontend/web/src/features/parameters/hooks/useParameters.ts index a093010343..138d54402c 100644 --- a/invokeai/frontend/web/src/features/parameters/hooks/useParameters.ts +++ b/invokeai/frontend/web/src/features/parameters/hooks/useParameters.ts @@ -1,4 +1,3 @@ -import { useToast } from '@chakra-ui/react'; import { useAppDispatch } from 'app/store/storeHooks'; import { isFinite, isString } from 'lodash-es'; import { useCallback } from 'react'; @@ -10,10 +9,11 @@ import { NUMPY_RAND_MAX } from 'app/constants'; import { initialImageSelected } from '../store/actions'; import { Image } from 'app/types/invokeai'; import { setActiveTab } from 'features/ui/store/uiSlice'; +import { useAppToaster } from 'app/components/Toaster'; export const useParameters = () => { const dispatch = useAppDispatch(); - const toast = useToast(); + const toaster = useAppToaster(); const { t } = useTranslation(); const setBothPrompts = useSetBothPrompts(); @@ -23,7 +23,7 @@ export const useParameters = () => { const recallPrompt = useCallback( (prompt: unknown) => { if (!isString(prompt)) { - toast({ + toaster({ title: t('toast.promptNotSet'), description: t('toast.promptNotSetDesc'), status: 'warning', @@ -34,14 +34,14 @@ export const useParameters = () => { } setBothPrompts(prompt); - toast({ + toaster({ title: t('toast.promptSet'), status: 'info', duration: 2500, isClosable: true, }); }, - [t, toast, setBothPrompts] + [t, toaster, setBothPrompts] ); /** @@ -51,7 +51,7 @@ export const useParameters = () => { (seed: unknown) => { const s = Number(seed); if (!isFinite(s) || (isFinite(s) && !(s >= 0 && s <= NUMPY_RAND_MAX))) { - toast({ + toaster({ title: t('toast.seedNotSet'), description: t('toast.seedNotSetDesc'), status: 'warning', @@ -62,14 +62,14 @@ export const useParameters = () => { } dispatch(setSeed(s)); - toast({ + toaster({ title: t('toast.seedSet'), status: 'info', duration: 2500, isClosable: true, }); }, - [t, toast, dispatch] + [t, toaster, dispatch] ); /** @@ -78,7 +78,7 @@ export const useParameters = () => { const recallInitialImage = useCallback( async (image: unknown) => { if (!isImageField(image)) { - toast({ + toaster({ title: t('toast.initialImageNotSet'), description: t('toast.initialImageNotSetDesc'), status: 'warning', @@ -91,14 +91,14 @@ export const useParameters = () => { dispatch( initialImageSelected({ name: image.image_name, type: image.image_type }) ); - toast({ + toaster({ title: t('toast.initialImageSet'), status: 'info', duration: 2500, isClosable: true, }); }, - [t, toast, dispatch] + [t, toaster, dispatch] ); /** @@ -123,14 +123,14 @@ export const useParameters = () => { dispatch(setActiveTab('txt2img')); } - toast({ + toaster({ title: t('toast.parametersSet'), status: 'success', duration: 2500, isClosable: true, }); } else { - toast({ + toaster({ title: t('toast.parametersNotSet'), description: t('toast.parametersNotSetDesc'), status: 'error', @@ -139,7 +139,7 @@ export const useParameters = () => { }); } }, - [t, toast, dispatch] + [t, toaster, dispatch] ); return { diff --git a/invokeai/frontend/web/src/features/system/hooks/useToastWatcher.ts b/invokeai/frontend/web/src/features/system/hooks/useToastWatcher.ts deleted file mode 100644 index b51bf48a36..0000000000 --- a/invokeai/frontend/web/src/features/system/hooks/useToastWatcher.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useToast, UseToastOptions } from '@chakra-ui/react'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { toastQueueSelector } from 'features/system/store/systemSelectors'; -import { clearToastQueue } from 'features/system/store/systemSlice'; -import { useEffect } from 'react'; - -export type MakeToastArg = string | UseToastOptions; - -export const makeToast = (arg: MakeToastArg): UseToastOptions => { - if (typeof arg === 'string') { - return { - title: arg, - status: 'info', - isClosable: true, - duration: 2500, - }; - } - - return { status: 'info', isClosable: true, duration: 2500, ...arg }; -}; - -const useToastWatcher = () => { - const dispatch = useAppDispatch(); - const toastQueue = useAppSelector(toastQueueSelector); - const toast = useToast(); - useEffect(() => { - toastQueue.forEach((t) => { - toast(t); - }); - toastQueue.length > 0 && dispatch(clearToastQueue()); - }, [dispatch, toast, toastQueue]); -}; - -export default useToastWatcher; diff --git a/invokeai/frontend/web/src/features/system/store/systemSlice.ts b/invokeai/frontend/web/src/features/system/store/systemSlice.ts index 5cc6ca3a43..bbe7ed4da6 100644 --- a/invokeai/frontend/web/src/features/system/store/systemSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/systemSlice.ts @@ -15,7 +15,7 @@ import { } from 'services/events/actions'; import { ProgressImage } from 'services/events/types'; -import { makeToast } from '../hooks/useToastWatcher'; +import { makeToast } from '../../../app/components/Toaster'; import { sessionCanceled, sessionInvoked } from 'services/thunks/session'; import { receivedModels } from 'services/thunks/model'; import { parsedOpenAPISchema } from 'features/nodes/store/nodesSlice'; diff --git a/invokeai/frontend/web/src/services/events/util/setEventListeners.ts b/invokeai/frontend/web/src/services/events/util/setEventListeners.ts index 88bb11147c..e9356dd271 100644 --- a/invokeai/frontend/web/src/services/events/util/setEventListeners.ts +++ b/invokeai/frontend/web/src/services/events/util/setEventListeners.ts @@ -22,7 +22,7 @@ import { } from 'services/thunks/gallery'; import { receivedModels } from 'services/thunks/model'; import { receivedOpenAPISchema } from 'services/thunks/schema'; -import { makeToast } from '../../../features/system/hooks/useToastWatcher'; +import { makeToast } from '../../../app/components/Toaster'; import { addToast } from '../../../features/system/store/systemSlice'; type SetEventListenersArg = { From e1e5266fc3133612273a41028ef4d07c7fba63b2 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 15 May 2023 17:45:05 +1000 Subject: [PATCH 10/27] feat(ui): refactor base image uploading logic --- invokeai/frontend/web/public/locales/en.json | 2 +- .../frontend/web/src/app/components/App.tsx | 3 + .../components/AuxiliaryProgressIndicator.tsx | 44 +++++ .../listeners/imageUploaded.ts | 3 + .../web/src/common/components/IAIInput.tsx | 3 +- .../src/common/components/IAINumberInput.tsx | 2 + .../web/src/common/components/IAITextarea.tsx | 9 ++ .../common/components/ImageToImageButtons.tsx | 8 +- .../src/common/components/ImageUploader.tsx | 152 +++++++++--------- .../common/components/ImageUploaderButton.tsx | 11 +- .../components/ImageUploaderIconButton.tsx | 7 +- .../src/common/components/Loading/Loading.tsx | 1 - .../web/src/common/hooks/useImageUploader.ts | 21 ++- .../src/common/util/stopPastePropagation.ts | 5 + .../canvas/components/IAICanvasResizer.tsx | 2 +- .../components/GalleryProgressImage.tsx | 5 +- .../Core/ParamNegativeConditioning.tsx | 5 +- .../Core/ParamPositiveConditioning.tsx | 5 +- .../system/store/systemPersistDenylist.ts | 23 +-- .../src/features/system/store/systemSlice.ts | 24 +++ .../src/features/ui/components/InvokeTabs.tsx | 4 + 21 files changed, 213 insertions(+), 126 deletions(-) create mode 100644 invokeai/frontend/web/src/app/components/AuxiliaryProgressIndicator.tsx create mode 100644 invokeai/frontend/web/src/common/components/IAITextarea.tsx create mode 100644 invokeai/frontend/web/src/common/util/stopPastePropagation.ts diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index f82b3af677..319a920025 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -552,8 +552,8 @@ "canceled": "Processing Canceled", "tempFoldersEmptied": "Temp Folder Emptied", "uploadFailed": "Upload failed", - "uploadFailedMultipleImagesDesc": "Multiple images pasted, may only upload one image at a time", "uploadFailedUnableToLoadDesc": "Unable to load file", + "uploadFailedInvalidUploadDesc": "Must be single PNG or JPEG image", "downloadImageStarted": "Image Download Started", "imageCopied": "Image Copied", "imageLinkCopied": "Image Link Copied", diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index e819c04352..929c64edbb 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -22,6 +22,7 @@ import { languageSelector } from 'features/system/store/systemSelectors'; import i18n from 'i18n'; import Toaster from './Toaster'; import GlobalHotkeys from './GlobalHotkeys'; +import AuxiliaryProgressIndicator from './AuxiliaryProgressIndicator'; const DEFAULT_CONFIG = {}; @@ -99,6 +100,8 @@ const App = ({ + {/* */} + {!isApplicationReady && !loadingOverridden && ( { + const { isUploading } = system; + + let tooltip = ''; + + if (isUploading) { + tooltip = 'Uploading...'; + } + + return { + tooltip, + shouldShow: isUploading, + }; +}); + +export const AuxiliaryProgressIndicator = () => { + const { shouldShow, tooltip } = useAppSelector(selector); + + if (!shouldShow) { + return null; + } + + return ( + + + + + + ); +}; + +export default memo(AuxiliaryProgressIndicator); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts index c32da2e710..5b67be418f 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts @@ -3,6 +3,7 @@ import { startAppListening } from '..'; import { uploadAdded } from 'features/gallery/store/uploadsSlice'; import { imageSelected } from 'features/gallery/store/gallerySlice'; import { imageUploaded } from 'services/thunks/image'; +import { addToast } from 'features/system/store/systemSlice'; export const addImageUploadedListener = () => { startAppListening({ @@ -17,6 +18,8 @@ export const addImageUploadedListener = () => { dispatch(uploadAdded(image)); + dispatch(addToast({ title: 'Image Uploaded', status: 'success' })); + if (state.gallery.shouldAutoSwitchToNewImages) { dispatch(imageSelected(image)); } diff --git a/invokeai/frontend/web/src/common/components/IAIInput.tsx b/invokeai/frontend/web/src/common/components/IAIInput.tsx index 3e90dca83a..3cba36d2c9 100644 --- a/invokeai/frontend/web/src/common/components/IAIInput.tsx +++ b/invokeai/frontend/web/src/common/components/IAIInput.tsx @@ -5,6 +5,7 @@ import { Input, InputProps, } from '@chakra-ui/react'; +import { stopPastePropagation } from 'common/util/stopPastePropagation'; import { ChangeEvent, memo } from 'react'; interface IAIInputProps extends InputProps { @@ -31,7 +32,7 @@ const IAIInput = (props: IAIInputProps) => { {...formControlProps} > {label !== '' && {label}} - + ); }; diff --git a/invokeai/frontend/web/src/common/components/IAINumberInput.tsx b/invokeai/frontend/web/src/common/components/IAINumberInput.tsx index 762182eb47..bf598f3b12 100644 --- a/invokeai/frontend/web/src/common/components/IAINumberInput.tsx +++ b/invokeai/frontend/web/src/common/components/IAINumberInput.tsx @@ -14,6 +14,7 @@ import { Tooltip, TooltipProps, } from '@chakra-ui/react'; +import { stopPastePropagation } from 'common/util/stopPastePropagation'; import { clamp } from 'lodash-es'; import { FocusEvent, memo, useEffect, useState } from 'react'; @@ -125,6 +126,7 @@ const IAINumberInput = (props: Props) => { onChange={handleOnChange} onBlur={handleBlur} {...rest} + onPaste={stopPastePropagation} > {showStepper && ( diff --git a/invokeai/frontend/web/src/common/components/IAITextarea.tsx b/invokeai/frontend/web/src/common/components/IAITextarea.tsx new file mode 100644 index 0000000000..b5247887bb --- /dev/null +++ b/invokeai/frontend/web/src/common/components/IAITextarea.tsx @@ -0,0 +1,9 @@ +import { Textarea, TextareaProps, forwardRef } from '@chakra-ui/react'; +import { stopPastePropagation } from 'common/util/stopPastePropagation'; +import { memo } from 'react'; + +const IAITextarea = forwardRef((props: TextareaProps, ref) => { + return