From 960eae8255f0891d69a348d045f2014cc9a76acf Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 30 Apr 2024 09:30:11 -0400 Subject: [PATCH 01/27] Update TRAINING.md --- docs/features/TRAINING.md | 276 +------------------------------------- 1 file changed, 2 insertions(+), 274 deletions(-) diff --git a/docs/features/TRAINING.md b/docs/features/TRAINING.md index 7be9aff0f2..47f8557889 100644 --- a/docs/features/TRAINING.md +++ b/docs/features/TRAINING.md @@ -4,278 +4,6 @@ title: Training # :material-file-document: Training -# Textual Inversion Training -## **Personalizing Text-to-Image Generation** +Invoke Training has moved to its own repository, with a dedicated UI for accessing common scripts like Textual Inversion and LoRA training. -You may personalize the generated images to provide your own styles or objects -by training a new LDM checkpoint and introducing a new vocabulary to the fixed -model as a (.pt) embeddings file. Alternatively, you may use or train -HuggingFace Concepts embeddings files (.bin) from - and its associated -notebooks. - -## **Hardware and Software Requirements** - -You will need a GPU to perform training in a reasonable length of -time, and at least 12 GB of VRAM. We recommend using the [`xformers` -library](../installation/070_INSTALL_XFORMERS.md) to accelerate the -training process further. During training, about ~8 GB is temporarily -needed in order to store intermediate models, checkpoints and logs. - -## **Preparing for Training** - -To train, prepare a folder that contains 3-5 images that illustrate -the object or concept. It is good to provide a variety of examples or -poses to avoid overtraining the system. Format these images as PNG -(preferred) or JPG. You do not need to resize or crop the images in -advance, but for more control you may wish to do so. - -Place the training images in a directory on the machine InvokeAI runs -on. We recommend placing them in a subdirectory of the -`text-inversion-training-data` folder located in the InvokeAI root -directory, ordinarily `~/invokeai` (Linux/Mac), or -`C:\Users\your_name\invokeai` (Windows). For example, to create an -embedding for the "psychedelic" style, you'd place the training images -into the directory -`~invokeai/text-inversion-training-data/psychedelic`. - -## **Launching Training Using the Console Front End** - -InvokeAI 2.3 and higher comes with a text console-based training front -end. From within the `invoke.sh`/`invoke.bat` Invoke launcher script, -start training tool selecting choice (3): - -```sh -1 "Generate images with a browser-based interface" -2 "Explore InvokeAI nodes using a command-line interface" -3 "Textual inversion training" -4 "Merge models (diffusers type only)" -5 "Download and install models" -6 "Change InvokeAI startup options" -7 "Re-run the configure script to fix a broken install or to complete a major upgrade" -8 "Open the developer console" -9 "Update InvokeAI" -``` - -Alternatively, you can select option (8) or from the command line, with the InvokeAI virtual environment active, -you can then launch the front end with the command `invokeai-ti --gui`. - -This will launch a text-based front end that will look like this: - -
-![ti-frontend](../assets/textual-inversion/ti-frontend.png) -
- -The interface is keyboard-based. Move from field to field using -control-N (^N) to move to the next field and control-P (^P) to the -previous one. and work as well. Once a field is -active, use the cursor keys. In a checkbox group, use the up and down -cursor keys to move from choice to choice, and to select a -choice. In a scrollbar, use the left and right cursor keys to increase -and decrease the value of the scroll. In textfields, type the desired -values. - -The number of parameters may look intimidating, but in most cases the -predefined defaults work fine. The red circled fields in the above -illustration are the ones you will adjust most frequently. - -### Model Name - -This will list all the diffusers models that are currently -installed. Select the one you wish to use as the basis for your -embedding. Be aware that if you use a SD-1.X-based model for your -training, you will only be able to use this embedding with other -SD-1.X-based models. Similarly, if you train on SD-2.X, you will only -be able to use the embeddings with models based on SD-2.X. - -### Trigger Term - -This is the prompt term you will use to trigger the embedding. Type a -single word or phrase you wish to use as the trigger, example -"psychedelic" (without angle brackets). Within InvokeAI, you will then -be able to activate the trigger using the syntax ``. - -### Initializer - -This is a single character that is used internally during the training -process as a placeholder for the trigger term. It defaults to "*" and -can usually be left alone. - -### Resume from last saved checkpoint - -As training proceeds, textual inversion will write a series of -intermediate files that can be used to resume training from where it -was left off in the case of an interruption. This checkbox will be -automatically selected if you provide a previously used trigger term -and at least one checkpoint file is found on disk. - -Note that as of 20 January 2023, resume does not seem to be working -properly due to an issue with the upstream code. - -### Data Training Directory - -This is the location of the images to be used for training. When you -select a trigger term like "my-trigger", the frontend will prepopulate -this field with `~/invokeai/text-inversion-training-data/my-trigger`, -but you can change the path to wherever you want. - -### Output Destination Directory - -This is the location of the logs, checkpoint files, and embedding -files created during training. When you select a trigger term like -"my-trigger", the frontend will prepopulate this field with -`~/invokeai/text-inversion-output/my-trigger`, but you can change the -path to wherever you want. - -### Image resolution - -The images in the training directory will be automatically scaled to -the value you use here. For best results, you will want to use the -same default resolution of the underlying model (512 pixels for -SD-1.5, 768 for the larger version of SD-2.1). - -### Center crop images - -If this is selected, your images will be center cropped to make them -square before resizing them to the desired resolution. Center cropping -can indiscriminately cut off the top of subjects' heads for portrait -aspect images, so if you have images like this, you may wish to use a -photoeditor to manually crop them to a square aspect ratio. - -### Mixed precision - -Select the floating point precision for the embedding. "no" will -result in a full 32-bit precision, "fp16" will provide 16-bit -precision, and "bf16" will provide mixed precision (only available -when XFormers is used). - -### Max training steps - -How many steps the training will take before the model converges. Most -training sets will converge with 2000-3000 steps. - -### Batch size - -This adjusts how many training images are processed simultaneously in -each step. Higher values will cause the training process to run more -quickly, but use more memory. The default size will run with GPUs with -as little as 12 GB. - -### Learning rate - -The rate at which the system adjusts its internal weights during -training. Higher values risk overtraining (getting the same image each -time), and lower values will take more steps to train a good -model. The default of 0.0005 is conservative; you may wish to increase -it to 0.005 to speed up training. - -### Scale learning rate by number of GPUs, steps and batch size - -If this is selected (the default) the system will adjust the provided -learning rate to improve performance. - -### Use xformers acceleration - -This will activate XFormers memory-efficient attention. You need to -have XFormers installed for this to have an effect. - -### Learning rate scheduler - -This adjusts how the learning rate changes over the course of -training. The default "constant" means to use a constant learning rate -for the entire training session. The other values scale the learning -rate according to various formulas. - -Only "constant" is supported by the XFormers library. - -### Gradient accumulation steps - -This is a parameter that allows you to use bigger batch sizes than -your GPU's VRAM would ordinarily accommodate, at the cost of some -performance. - -### Warmup steps - -If "constant_with_warmup" is selected in the learning rate scheduler, -then this provides the number of warmup steps. Warmup steps have a -very low learning rate, and are one way of preventing early -overtraining. - -## The training run - -Start the training run by advancing to the OK button (bottom right) -and pressing . A series of progress messages will be displayed -as the training process proceeds. This may take an hour or two, -depending on settings and the speed of your system. Various log and -checkpoint files will be written into the output directory (ordinarily -`~/invokeai/text-inversion-output/my-model/`) - -At the end of successful training, the system will copy the file -`learned_embeds.bin` into the InvokeAI root directory's `embeddings` -directory, using a subdirectory named after the trigger token. For -example, if the trigger token was `psychedelic`, then look for the -embeddings file in -`~/invokeai/embeddings/psychedelic/learned_embeds.bin` - -You may now launch InvokeAI and try out a prompt that uses the trigger -term. For example `a plate of banana sushi in style`. - -## **Training with the Command-Line Script** - -Training can also be done using a traditional command-line script. It -can be launched from within the "developer's console", or from the -command line after activating InvokeAI's virtual environment. - -It accepts a large number of arguments, which can be summarized by -passing the `--help` argument: - -```sh -invokeai-ti --help -``` - -Typical usage is shown here: -```sh -invokeai-ti \ - --model=stable-diffusion-1.5 \ - --resolution=512 \ - --learnable_property=style \ - --initializer_token='*' \ - --placeholder_token='' \ - --train_data_dir=/home/lstein/invokeai/training-data/psychedelic \ - --output_dir=/home/lstein/invokeai/text-inversion-training/psychedelic \ - --scale_lr \ - --train_batch_size=8 \ - --gradient_accumulation_steps=4 \ - --max_train_steps=3000 \ - --learning_rate=0.0005 \ - --resume_from_checkpoint=latest \ - --lr_scheduler=constant \ - --mixed_precision=fp16 \ - --only_save_embeds -``` - -## Troubleshooting - -### `Cannot load embedding for . It was trained on a model with token dimension 1024, but the current model has token dimension 768` - -Messages like this indicate you trained the embedding on a different base model than the currently selected one. - -For example, in the error above, the training was done on SD2.1 (768x768) but it was used on SD1.5 (512x512). - -## Reading - -For more information on textual inversion, please see the following -resources: - -* The [textual inversion repository](https://github.com/rinongal/textual_inversion) and - associated paper for details and limitations. -* [HuggingFace's textual inversion training - page](https://huggingface.co/docs/diffusers/training/text_inversion) -* [HuggingFace example script - documentation](https://github.com/huggingface/diffusers/tree/main/examples/textual_inversion) - (Note that this script is similar to, but not identical, to - `textual_inversion`, but produces embed files that are completely compatible. - ---- - -copyright (c) 2023, Lincoln Stein and the InvokeAI Development Team +You can find more by visiting the repo at https://github.com/invoke-ai/invoke-training From af868b0ea60bd8cdd5b19aa0af4eaa3d020db2f9 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 30 Apr 2024 21:45:10 -0400 Subject: [PATCH 02/27] Update 010_INSTALL_AUTOMATED.md --- docs/installation/010_INSTALL_AUTOMATED.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/installation/010_INSTALL_AUTOMATED.md b/docs/installation/010_INSTALL_AUTOMATED.md index 5e2db65d7b..9eb8620321 100644 --- a/docs/installation/010_INSTALL_AUTOMATED.md +++ b/docs/installation/010_INSTALL_AUTOMATED.md @@ -1,8 +1,10 @@ -# Automatic Install +# Automatic Install & Updates -The installer is used for both new installs and updates. +**The same packaged installer file can be used for both new installs and updates.** +Using the installer for updates will leave everything you've added since installation, and just update the core libraries used to run Invoke. +Simply use the same path you installed to originally. -Both release and pre-release versions can be installed using it. It also supports install a wheel if needed. +Both release and pre-release versions can be installed using the installer. It also supports install through a wheel if needed. Be sure to review the [installation requirements] and ensure your system has everything it needs to install Invoke. From ab87511a0378ab7b33813550b1682836c956822d Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 30 Apr 2024 21:49:46 -0400 Subject: [PATCH 03/27] Update INSTALLATION.md --- docs/installation/INSTALLATION.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/installation/INSTALLATION.md b/docs/installation/INSTALLATION.md index 5c121e6170..267376f197 100644 --- a/docs/installation/INSTALLATION.md +++ b/docs/installation/INSTALLATION.md @@ -1,4 +1,4 @@ -# Installation Overview +# Installation and Updating Overview Before installing, review the [installation requirements] to ensure your system is set up properly. @@ -6,14 +6,21 @@ See the [FAQ] for frequently-encountered installation issues. If you need more help, join our [discord] or [create an issue]. -

Automatic Install

+

Automatic Install & Updates

✅ The automatic install is the best way to run InvokeAI. Check out the [installation guide] to get started. +⬆️ The same installer is also the best way to update InvokeAI - Simply rerun it for the same folder you installed to. + +The installation process simply manages installation for the core libraries & application dependencies that run Invoke. +Any models, images, or other assets in the Invoke root folder won't be affected by the installation process. +

Manual Install

If you are familiar with python and want more control over the packages that are installed, you can [install InvokeAI manually via PyPI]. +Updates are managed by reinstalling the latest version through PyPi. +

Developer Install

If you want to contribute to InvokeAI, consult the [developer install guide]. From 3cba53533dd7b84e8e4c1564ef84f37b70e216ea Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 30 Apr 2024 21:50:12 -0400 Subject: [PATCH 04/27] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f540e7be75..41de4882ee 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Invoke is a leading creative engine built to empower professionals and enthusiasts alike. Generate and create stunning visual media using the latest AI-driven technologies. Invoke offers an industry leading web-based UI, and serves as the foundation for multiple commercial products. -[Installation][installation docs] - [Documentation and Tutorials][docs home] - [Bug Reports][github issues] - [Contributing][contributing docs] +[Installation and Updates][installation docs] - [Documentation and Tutorials][docs home] - [Bug Reports][github issues] - [Contributing][contributing docs]
From af9f0e0963be42be97907f4ed2047dd903f98c64 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 3 May 2024 18:43:45 +1000 Subject: [PATCH 05/27] feat(ui): cache control layer mask images When invoking with control layers, we were creating and uploading the mask images on every enqueue, even when the mask didn't change. The mask image can be cached to greatly reduce the number of uploads. With this change, we are a bit smarter about the mask images: - Check if there is an uploaded mask image name - If so, attempt to retrieve its DTO. Typically it will be in the RTKQ cache, so there is no network request, but it will make a network request if not cached to confirm the image actually exists on the server. - If we don't have an uploaded mask image name, or the request fails, we go ahead and upload the generated blob - Update the layer's state with a reference to this uploaded image for next time - Continue as before Any time we modify the mask (drawing/erasing, resetting the layer), we invalidate that cached image name (set it to null). We now only upload images when we need to and generation starts faster. --- .../controlLayers/store/controlLayersSlice.ts | 12 +++++ .../src/features/controlLayers/store/types.ts | 1 + .../util/graph/addControlLayersToGraph.ts | 49 ++++++++++++------- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts index a1b5e0ebc8..50023b1399 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts @@ -86,6 +86,7 @@ const resetLayer = (layer: Layer) => { layer.isEnabled = true; layer.needsPixelBbox = false; layer.bboxNeedsUpdate = false; + layer.uploadedMaskImage = null; return; } }; @@ -173,6 +174,7 @@ export const controlLayersSlice = createSlice({ if (bbox === null && layer.type === 'regional_guidance_layer') { // The layer was fully erased, empty its objects to prevent accumulation of invisible objects layer.maskObjects = []; + layer.uploadedMaskImage = null; layer.needsPixelBbox = false; } } @@ -456,6 +458,7 @@ export const controlLayersSlice = createSlice({ negativePrompt: null, ipAdapters: [], isSelected: true, + uploadedMaskImage: null, }; state.layers.push(layer); state.selectedLayerId = layer.id; @@ -505,6 +508,7 @@ export const controlLayersSlice = createSlice({ strokeWidth: state.brushSize, }); layer.bboxNeedsUpdate = true; + layer.uploadedMaskImage = null; if (!layer.needsPixelBbox && tool === 'eraser') { layer.needsPixelBbox = true; } @@ -524,6 +528,7 @@ export const controlLayersSlice = createSlice({ // TODO: Handle this in the event listener lastLine.points.push(point[0] - layer.x, point[1] - layer.y); layer.bboxNeedsUpdate = true; + layer.uploadedMaskImage = null; }, rgLayerRectAdded: { reducer: (state, action: PayloadAction<{ layerId: string; rect: IRect; rectUuid: string }>) => { @@ -543,9 +548,15 @@ export const controlLayersSlice = createSlice({ height: rect.height, }); layer.bboxNeedsUpdate = true; + layer.uploadedMaskImage = null; }, prepare: (payload: { layerId: string; rect: IRect }) => ({ payload: { ...payload, rectUuid: uuidv4() } }), }, + rgLayerMaskImageUploaded: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO }>) => { + const { layerId, imageDTO } = action.payload; + const layer = selectRGLayerOrThrow(state, layerId); + layer.uploadedMaskImage = imageDTOToImageWithDims(imageDTO); + }, rgLayerAutoNegativeChanged: ( state, action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }> @@ -825,6 +836,7 @@ export const { rgLayerLineAdded, rgLayerPointsAdded, rgLayerRectAdded, + rgLayerMaskImageUploaded, rgLayerAutoNegativeChanged, rgLayerIPAdapterAdded, rgLayerIPAdapterDeleted, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index cbb986bde2..afb04aae37 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -72,6 +72,7 @@ export type RegionalGuidanceLayer = RenderableLayerBase & { previewColor: RgbColor; autoNegative: ParameterAutoNegative; needsPixelBbox: boolean; // Needs the slower pixel-based bbox calculation - set to true when an there is an eraser object + uploadedMaskImage: ImageWithDims | null; }; export type InitialImageLayer = RenderableLayerBase & { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts index da13fed9f5..30c15fae10 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/addControlLayersToGraph.ts @@ -4,7 +4,9 @@ import { isControlAdapterLayer, isIPAdapterLayer, isRegionalGuidanceLayer, + rgLayerMaskImageUploaded, } from 'features/controlLayers/store/controlLayersSlice'; +import type { RegionalGuidanceLayer } from 'features/controlLayers/store/types'; import { type ControlNetConfigV2, type ImageWithDims, @@ -32,12 +34,13 @@ import { } from 'features/nodes/util/graph/constants'; import { upsertMetadata } from 'features/nodes/util/graph/metadata'; import { size } from 'lodash-es'; -import { imagesApi } from 'services/api/endpoints/images'; +import { getImageDTO, imagesApi } from 'services/api/endpoints/images'; import type { CollectInvocation, ControlNetInvocation, CoreMetadataInvocation, Edge, + ImageDTO, IPAdapterInvocation, NonNullableGraph, S, @@ -337,7 +340,6 @@ const addGlobalIPAdaptersToGraph = async ( }; export const addControlLayersToGraph = async (state: RootState, graph: NonNullableGraph, denoiseNodeId: string) => { - const { dispatch } = getStore(); const mainModel = state.generation.model; assert(mainModel, 'Missing main model when building graph'); const isSDXL = mainModel.base === 'sdxl'; @@ -404,10 +406,6 @@ export const addControlLayersToGraph = async (state: RootState, graph: NonNullab return hasTextPrompt || hasIPAdapter; }); - const layerIds = rgLayers.map((l) => l.id); - const blobs = await getRegionalPromptLayerBlobs(layerIds); - assert(size(blobs) === size(layerIds), 'Mismatch between layer IDs and blobs'); - // TODO: We should probably just use conditioning collectors by default, and skip all this fanagling with re-routing // the existing conditioning nodes. @@ -470,22 +468,15 @@ export const addControlLayersToGraph = async (state: RootState, graph: NonNullab }, }); - // Upload the blobs to the backend, add each to graph - // TODO: Store the uploaded image names in redux to reuse them, so long as the layer hasn't otherwise changed. This - // would be a great perf win - not only would we skip re-uploading the same image, but we'd be able to use the node - // cache (currently, when we re-use the same mask data, since it is a different image, the node cache is not used). + const layerIds = rgLayers.map((l) => l.id); + const blobs = await getRegionalPromptLayerBlobs(layerIds); + assert(size(blobs) === size(layerIds), 'Mismatch between layer IDs and blobs'); + for (const layer of rgLayers) { const blob = blobs[layer.id]; assert(blob, `Blob for layer ${layer.id} not found`); - - const file = new File([blob], `${layer.id}_mask.png`, { type: 'image/png' }); - const req = dispatch( - imagesApi.endpoints.uploadImage.initiate({ file, image_category: 'mask', is_intermediate: true }) - ); - req.reset(); - - // TODO: This will raise on network error - const { image_name } = await req.unwrap(); + // Upload the mask image, or get the cached image if it exists + const { image_name } = await getMaskImage(layer, blob); // The main mask-to-tensor node const maskToTensorNode: S['AlphaMaskToTensorInvocation'] = { @@ -679,3 +670,23 @@ export const addControlLayersToGraph = async (state: RootState, graph: NonNullab } } }; + +const getMaskImage = async (layer: RegionalGuidanceLayer, blob: Blob): Promise => { + if (layer.uploadedMaskImage) { + const imageDTO = await getImageDTO(layer.uploadedMaskImage.imageName); + if (imageDTO) { + return imageDTO; + } + } + const { dispatch } = getStore(); + // No cached mask, or the cached image no longer exists - we need to upload the mask image + const file = new File([blob], `${layer.id}_mask.png`, { type: 'image/png' }); + const req = dispatch( + imagesApi.endpoints.uploadImage.initiate({ file, image_category: 'mask', is_intermediate: true }) + ); + req.reset(); + + const imageDTO = await req.unwrap(); + dispatch(rgLayerMaskImageUploaded({ layerId: layer.id, imageDTO })); + return imageDTO; +}; From be7eeb576b534b45f70f5b50d50732c9a3cfaa95 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 3 May 2024 20:36:31 +1000 Subject: [PATCH 06/27] fix(ui): fix viewer getting stuck when spamming toggle --- .../features/gallery/components/ImageViewer/ImageViewer.tsx | 3 ++- .../frontend/web/src/features/ui/components/InvokeTabs.tsx | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx index 874464f938..949e72fad1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx @@ -42,8 +42,9 @@ export const ImageViewer = memo(() => { useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]); useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]); + // The AnimatePresence mode must be wait - else framer can get confused if you spam the toggle button return ( - + {shouldShowViewer && ( { /> )} - - + + {tabPanels} + - {shouldShowGalleryPanel && ( <> From f4dde883cae92001df3f4d1633929f66c7db8ea6 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Fri, 3 May 2024 22:20:00 +0530 Subject: [PATCH 07/27] feat: improve the switch states of the control layers / viewer area --- invokeai/frontend/web/public/locales/en.json | 1 + .../components/ImageViewer/EditorButton.tsx | 30 ++++++++++++------- .../components/ImageViewer/ViewerButton.tsx | 21 +++++++++++-- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index a6d60fa281..826bd8ac01 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -143,6 +143,7 @@ "alpha": "Alpha", "selected": "Selected", "viewer": "Viewer", + "controlLayers": "Control Layers", "tab": "Tab" }, "controlnet": { diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx index 2e10d057f8..2e50d33da7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx @@ -1,19 +1,21 @@ +import { IconButton } from '@chakra-ui/react'; import { Button } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import type { InvokeTabName } from 'features/ui/store/tabMap'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { PiArrowsDownUpBold } from 'react-icons/pi'; import { useImageViewer } from './useImageViewer'; -const TAB_NAME_TO_TKEY: Record = { - generation: 'ui.tabs.generationTab', - canvas: 'ui.tabs.canvasTab', - workflows: 'ui.tabs.workflowsTab', - models: 'ui.tabs.modelsTab', - queue: 'ui.tabs.queueTab', -}; +// const TAB_NAME_TO_TKEY: Record = { +// generation: 'ui.tabs.generationTab', +// canvas: 'ui.tabs.canvasTab', +// workflows: 'ui.tabs.workflowsTab', +// models: 'ui.tabs.modelsTab', +// queue: 'ui.tabs.queueTab', +// }; const TAB_NAME_TO_TKEY_SHORT: Record = { generation: 'ui.tabs.generation', @@ -27,11 +29,19 @@ export const EditorButton = () => { const { t } = useTranslation(); const { onClose } = useImageViewer(); const activeTabName = useAppSelector(activeTabNameSelector); - const tooltip = useMemo(() => t('gallery.switchTo', { tab: t(TAB_NAME_TO_TKEY[activeTabName]) }), [t, activeTabName]); + + const tooltip = useMemo( + () => + t('gallery.switchTo', { + tab: activeTabName === 'generation' ? t('common.controlLayers') : t(TAB_NAME_TO_TKEY_SHORT[activeTabName]), + }), + [t, activeTabName] + ); return ( - ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx index a57ae9d1ee..2492c8cde3 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx @@ -1,16 +1,33 @@ +import { IconButton } from '@chakra-ui/react'; import { Button } from '@invoke-ai/ui-library'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { PiArrowsDownUpBold } from 'react-icons/pi'; import { useImageViewer } from './useImageViewer'; export const ViewerButton = () => { const { t } = useTranslation(); const { onOpen } = useImageViewer(); - const tooltip = useMemo(() => t('gallery.switchTo', { tab: t('common.viewer') }), [t]); + + const tooltip = useMemo( + () => + t('gallery.switchTo', { + tab: t('common.viewer'), + }), + [t] + ); return ( - ); From 68d1458c8356303a2dcdd9772bba53eb0cbba0f8 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 4 May 2024 08:37:59 +1000 Subject: [PATCH 08/27] fix(ui): address feedback --- invokeai/frontend/web/public/locales/en.json | 3 +- .../components/ImageViewer/EditorButton.tsx | 28 +++++++------------ .../components/ImageViewer/ViewerButton.tsx | 13 ++------- 3 files changed, 13 insertions(+), 31 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 826bd8ac01..37a2a7a5da 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -143,7 +143,6 @@ "alpha": "Alpha", "selected": "Selected", "viewer": "Viewer", - "controlLayers": "Control Layers", "tab": "Tab" }, "controlnet": { @@ -1535,7 +1534,7 @@ "moveForward": "Move Forward", "moveBackward": "Move Backward", "brushSize": "Brush Size", - "controlLayers": "Control Layers (BETA)", + "controlLayers": "Control Layers", "globalMaskOpacity": "Global Mask Opacity", "autoNegative": "Auto Negative", "toggleVisibility": "Toggle Layer Visibility", diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx index 2e50d33da7..cc2aa8c543 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/EditorButton.tsx @@ -1,4 +1,3 @@ -import { IconButton } from '@chakra-ui/react'; import { Button } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import type { InvokeTabName } from 'features/ui/store/tabMap'; @@ -9,16 +8,8 @@ import { PiArrowsDownUpBold } from 'react-icons/pi'; import { useImageViewer } from './useImageViewer'; -// const TAB_NAME_TO_TKEY: Record = { -// generation: 'ui.tabs.generationTab', -// canvas: 'ui.tabs.canvasTab', -// workflows: 'ui.tabs.workflowsTab', -// models: 'ui.tabs.modelsTab', -// queue: 'ui.tabs.queueTab', -// }; - const TAB_NAME_TO_TKEY_SHORT: Record = { - generation: 'ui.tabs.generation', + generation: 'controlLayers.controlLayers', canvas: 'ui.tabs.canvas', workflows: 'ui.tabs.workflows', models: 'ui.tabs.models', @@ -29,19 +20,20 @@ export const EditorButton = () => { const { t } = useTranslation(); const { onClose } = useImageViewer(); const activeTabName = useAppSelector(activeTabNameSelector); - const tooltip = useMemo( - () => - t('gallery.switchTo', { - tab: activeTabName === 'generation' ? t('common.controlLayers') : t(TAB_NAME_TO_TKEY_SHORT[activeTabName]), - }), + () => t('gallery.switchTo', { tab: t(TAB_NAME_TO_TKEY_SHORT[activeTabName]) }), [t, activeTabName] ); return ( - ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx index 2492c8cde3..edceb5099c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerButton.tsx @@ -1,4 +1,3 @@ -import { IconButton } from '@chakra-ui/react'; import { Button } from '@invoke-ai/ui-library'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -9,14 +8,7 @@ import { useImageViewer } from './useImageViewer'; export const ViewerButton = () => { const { t } = useTranslation(); const { onOpen } = useImageViewer(); - - const tooltip = useMemo( - () => - t('gallery.switchTo', { - tab: t('common.viewer'), - }), - [t] - ); + const tooltip = useMemo(() => t('gallery.switchTo', { tab: t('common.viewer') }), [t]); return ( ); From 4beccea6e7008cb55810229d0d47affbf12525c9 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 4 May 2024 09:02:27 +1000 Subject: [PATCH 09/27] fix(ui): do not run HRO if using an initial image --- .../nodes/util/graph/addInitialImageToLinearGraph.ts | 9 +++++++-- .../features/nodes/util/graph/buildGenerationTabGraph.ts | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts index 603708f15b..eae45acc5b 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/addInitialImageToLinearGraph.ts @@ -6,11 +6,14 @@ import { assert } from 'tsafe'; import { IMAGE_TO_LATENTS, NOISE, RESIZE } from './constants'; +/** + * Returns true if an initial image was added, false if not. + */ export const addInitialImageToLinearGraph = ( state: RootState, graph: NonNullableGraph, denoiseNodeId: string -): void => { +): boolean => { // Remove Existing UNet Connections const { img2imgStrength, vaePrecision, model } = state.generation; const { refinerModel, refinerStart } = state.sdxl; @@ -19,7 +22,7 @@ export const addInitialImageToLinearGraph = ( const initialImage = initialImageLayer?.isEnabled ? initialImageLayer?.image : null; if (!initialImage) { - return; + return false; } const isSDXL = model?.base === 'sdxl'; @@ -122,4 +125,6 @@ export const addInitialImageToLinearGraph = ( strength: img2imgStrength, init_image: initialImage.imageName, }); + + return true; }; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabGraph.ts index 6c04b25770..41f9f4f748 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildGenerationTabGraph.ts @@ -232,7 +232,7 @@ export const buildGenerationTabGraph = async (state: RootState): Promise Date: Sat, 4 May 2024 09:07:24 +1000 Subject: [PATCH 10/27] fix(ui): invalidate mask cache when moving layer --- .../web/src/features/controlLayers/store/controlLayersSlice.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts index 50023b1399..8adbae6d80 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts @@ -164,6 +164,9 @@ export const controlLayersSlice = createSlice({ layer.x = x; layer.y = y; } + if (isRegionalGuidanceLayer(layer)) { + layer.uploadedMaskImage = null; + } }, layerBboxChanged: (state, action: PayloadAction<{ layerId: string; bbox: IRect | null }>) => { const { layerId, bbox } = action.payload; From 6d2fe3b691d67ffde33b1d612b37ad5b949019d1 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 4 May 2024 09:08:45 +1000 Subject: [PATCH 11/27] tidy(ui): clean up layer reset logic --- .../controlLayers/components/ToolChooser.tsx | 10 +++++-- .../controlLayers/store/controlLayersSlice.ts | 28 ++++++------------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx index 53535b4248..f97a0f35e5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx @@ -4,9 +4,9 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { $tool, + layerReset, selectControlLayersSlice, selectedLayerDeleted, - selectedLayerReset, } from 'features/controlLayers/store/controlLayersSlice'; import { useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -22,6 +22,7 @@ export const ToolChooser: React.FC = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const isDisabled = useAppSelector(selectIsDisabled); + const selectedLayerId = useAppSelector((s) => s.controlLayers.present.selectedLayerId); const tool = useStore($tool); const setToolToBrush = useCallback(() => { @@ -42,8 +43,11 @@ export const ToolChooser: React.FC = () => { useHotkeys('v', setToolToMove, { enabled: !isDisabled }, [isDisabled]); const resetSelectedLayer = useCallback(() => { - dispatch(selectedLayerReset()); - }, [dispatch]); + if (selectedLayerId === null) { + return; + } + dispatch(layerReset(selectedLayerId)); + }, [dispatch, selectedLayerId]); useHotkeys('shift+c', resetSelectedLayer); const deleteSelectedLayer = useCallback(() => { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts index 8adbae6d80..f6a6f0b38d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts @@ -79,17 +79,6 @@ export const isRenderableLayer = ( layer?.type === 'regional_guidance_layer' || layer?.type === 'control_adapter_layer' || layer?.type === 'initial_image_layer'; -const resetLayer = (layer: Layer) => { - if (layer.type === 'regional_guidance_layer') { - layer.maskObjects = []; - layer.bbox = null; - layer.isEnabled = true; - layer.needsPixelBbox = false; - layer.bboxNeedsUpdate = false; - layer.uploadedMaskImage = null; - return; - } -}; export const selectCALayerOrThrow = (state: ControlLayersState, layerId: string): ControlAdapterLayer => { const layer = state.layers.find((l) => l.id === layerId); @@ -184,8 +173,14 @@ export const controlLayersSlice = createSlice({ }, layerReset: (state, action: PayloadAction) => { const layer = state.layers.find((l) => l.id === action.payload); - if (layer) { - resetLayer(layer); + // TODO(psyche): Should other layer types also have reset functionality? + if (isRegionalGuidanceLayer(layer)) { + layer.maskObjects = []; + layer.bbox = null; + layer.isEnabled = true; + layer.needsPixelBbox = false; + layer.bboxNeedsUpdate = false; + layer.uploadedMaskImage = null; } }, layerDeleted: (state, action: PayloadAction) => { @@ -218,12 +213,6 @@ export const controlLayersSlice = createSlice({ moveToFront(renderableLayers, cb); state.layers = [...ipAdapterLayers, ...renderableLayers]; }, - selectedLayerReset: (state) => { - const layer = state.layers.find((l) => l.id === state.selectedLayerId); - if (layer) { - resetLayer(layer); - } - }, selectedLayerDeleted: (state) => { state.layers = state.layers.filter((l) => l.id !== state.selectedLayerId); state.selectedLayerId = state.layers[0]?.id ?? null; @@ -806,7 +795,6 @@ export const { layerMovedToFront, layerMovedBackward, layerMovedToBack, - selectedLayerReset, selectedLayerDeleted, allLayersDeleted, // CA Layers From 26613f10c724a7b4520a0a95bb387169aab22a4e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 4 May 2024 09:52:41 +1000 Subject: [PATCH 12/27] feat(ui): close viewer when user switches tabs --- .../frontend/web/src/features/gallery/store/gallerySlice.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 373d946469..60ed692ba2 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -1,6 +1,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice, isAnyOf } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; +import { setActiveTab } from 'features/ui/store/uiSlice'; import { uniqBy } from 'lodash-es'; import { boardsApi } from 'services/api/endpoints/boards'; import { imagesApi } from 'services/api/endpoints/images'; @@ -83,6 +84,9 @@ export const gallerySlice = createSlice({ }, }, extraReducers: (builder) => { + builder.addCase(setActiveTab, (state) => { + state.isImageViewerOpen = false; + }); builder.addMatcher(isAnyBoardDeleted, (state, action) => { const deletedBoardId = action.meta.arg.originalArgs; if (deletedBoardId === state.selectedBoardId) { From 6bdded85dafbe73be73120c9457eb40cd0c6ef10 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 4 May 2024 10:05:42 +1000 Subject: [PATCH 13/27] fix(ui): do not auto-hide next/prev image buttons --- .../components/ImageViewer/CurrentImagePreview.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 37fada0b78..35abf07965 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -52,17 +52,20 @@ const CurrentImagePreview = () => { // Show and hide the next/prev buttons on mouse move const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState(false); const timeoutId = useRef(0); - const onMouseMove = useCallback(() => { + const onMouseOver = useCallback(() => { setShouldShowNextPrevButtons(true); window.clearTimeout(timeoutId.current); + }, []); + const onMouseOut = useCallback(() => { timeoutId.current = window.setTimeout(() => { setShouldShowNextPrevButtons(false); - }, 1000); + }, 500); }, []); return ( Date: Sat, 4 May 2024 10:16:00 +1000 Subject: [PATCH 14/27] fix(ui): save upscaled images to gallery on canvas tab --- .../src/features/nodes/util/graph/buildAdHocUpscaleGraph.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildAdHocUpscaleGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildAdHocUpscaleGraph.ts index 52c09b1db0..6c90dafd25 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildAdHocUpscaleGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildAdHocUpscaleGraph.ts @@ -1,5 +1,5 @@ import type { RootState } from 'app/store/store'; -import { getBoardField, getIsIntermediate } from 'features/nodes/util/graph/graphBuilderUtils'; +import { getBoardField } from 'features/nodes/util/graph/graphBuilderUtils'; import type { ESRGANInvocation, Graph, NonNullableGraph } from 'services/api/types'; import { ESRGAN } from './constants'; @@ -18,7 +18,7 @@ export const buildAdHocUpscaleGraph = ({ image_name, state }: Arg): Graph => { type: 'esrgan', image: { image_name }, model_name: esrganModelName, - is_intermediate: getIsIntermediate(state), + is_intermediate: false, board: getBoardField(state), }; From 5cb1ff8679c075be0e2286cb1ab6de5db2d9e528 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 4 May 2024 14:42:29 +1000 Subject: [PATCH 15/27] fix(ui): open viewer on image click, not select --- .../listenerMiddleware/listeners/galleryImageClicked.ts | 3 ++- .../frontend/web/src/features/gallery/store/gallerySlice.ts | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts index 67c6d076ee..6b8c9b4ea3 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts @@ -1,7 +1,7 @@ import { createAction } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; -import { selectionChanged } from 'features/gallery/store/gallerySlice'; +import { isImageViewerOpenChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; import { imagesSelectors } from 'services/api/util'; @@ -62,6 +62,7 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen } else { dispatch(selectionChanged([imageDTO])); } + dispatch(isImageViewerOpenChanged(true)); }, }); }; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 60ed692ba2..5248977825 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -31,11 +31,9 @@ export const gallerySlice = createSlice({ reducers: { imageSelected: (state, action: PayloadAction) => { state.selection = action.payload ? [action.payload] : []; - state.isImageViewerOpen = true; }, selectionChanged: (state, action: PayloadAction) => { state.selection = uniqBy(action.payload, (i) => i.image_name); - state.isImageViewerOpen = true; }, shouldAutoSwitchChanged: (state, action: PayloadAction) => { state.shouldAutoSwitch = action.payload; From 7ca613d41cc7d930ea9a61ea97e24fa1e4bf0f02 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 4 May 2024 18:41:14 +1000 Subject: [PATCH 16/27] feat(ui): snap cursor pos when drawing rects - Rects snap to stage edge when within a threshold (10 screen pixels) - When mouse leaves stage, set last mousedown pos to null, preventing nonfunctional rect outlines Partially addresses #6306. There's a technical challenge to fully address the issue - mouse event are not fired when the mouse is outside the stage. While we could draw the rect even if the mouse leaves, we cannot update the rect's dimensions on mouse move, or complete the drawing on mouse up. To fully address the issue, we'd need to a way to forward window events back to the stage, or at least handle window events. We can explore this later. --- .../controlLayers/hooks/mouseEventHooks.ts | 39 ++++++++++++++++--- .../features/controlLayers/util/renderers.ts | 11 +++--- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts index 889d2c0c2e..03595fb82d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts @@ -22,10 +22,33 @@ const getIsFocused = (stage: Konva.Stage) => { }; const getIsMouseDown = (e: KonvaEventObject) => e.evt.buttons === 1; +const SNAP_PX = 10; + +export const snapPosToStage = (pos: Vector2d, stage: Konva.Stage) => { + const snappedPos = { ...pos }; + // Get the normalized threshold for snapping to the edge of the stage + const thresholdX = SNAP_PX / stage.scaleX(); + const thresholdY = SNAP_PX / stage.scaleY(); + const stageWidth = stage.width() / stage.scaleX(); + const stageHeight = stage.height() / stage.scaleY(); + // Snap to the edge of the stage if within threshold + if (pos.x - thresholdX < 0) { + snappedPos.x = 0; + } else if (pos.x + thresholdX > stageWidth) { + snappedPos.x = Math.floor(stageWidth); + } + if (pos.y - thresholdY < 0) { + snappedPos.y = 0; + } else if (pos.y + thresholdY > stageHeight) { + snappedPos.y = Math.floor(stageHeight); + } + return snappedPos; +}; + export const getScaledFlooredCursorPosition = (stage: Konva.Stage) => { const pointerPosition = stage.getPointerPosition(); const stageTransform = stage.getAbsoluteTransform().copy(); - if (!pointerPosition || !stageTransform) { + if (!pointerPosition) { return; } const scaledCursorPosition = stageTransform.invert().point(pointerPosition); @@ -71,7 +94,6 @@ export const useMouseEvents = () => { if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') { return; } - $lastMouseDownPos.set(pos); if (tool === 'brush' || tool === 'eraser') { dispatch( rgLayerLineAdded({ @@ -81,6 +103,9 @@ export const useMouseEvents = () => { }) ); $isDrawing.set(true); + $lastMouseDownPos.set(pos); + } else if (tool === 'rect') { + $lastMouseDownPos.set(snapPosToStage(pos, stage)); } }, [dispatch, selectedLayerId, selectedLayerType, tool] @@ -99,14 +124,15 @@ export const useMouseEvents = () => { const lastPos = $lastMouseDownPos.get(); const tool = $tool.get(); if (lastPos && selectedLayerId && tool === 'rect') { + const snappedPos = snapPosToStage(pos, stage); dispatch( rgLayerRectAdded({ layerId: selectedLayerId, rect: { - x: Math.min(pos.x, lastPos.x), - y: Math.min(pos.y, lastPos.y), - width: Math.abs(pos.x - lastPos.x), - height: Math.abs(pos.y - lastPos.y), + x: Math.min(snappedPos.x, lastPos.x), + y: Math.min(snappedPos.y, lastPos.y), + width: Math.abs(snappedPos.x - lastPos.x), + height: Math.abs(snappedPos.y - lastPos.y), }, }) ); @@ -163,6 +189,7 @@ export const useMouseEvents = () => { } $isDrawing.set(false); $cursorPosition.set(null); + $lastMouseDownPos.set(null); }, [selectedLayerId, selectedLayerType, tool, dispatch] ); diff --git a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts index 36083d2d92..9f24232240 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts @@ -1,6 +1,6 @@ import { getStore } from 'app/store/nanostores/store'; import { rgbaColorToString, rgbColorToString } from 'features/canvas/util/colorToString'; -import { getScaledFlooredCursorPosition } from 'features/controlLayers/hooks/mouseEventHooks'; +import { getScaledFlooredCursorPosition, snapPosToStage } from 'features/controlLayers/hooks/mouseEventHooks'; import { $tool, BACKGROUND_LAYER_ID, @@ -211,12 +211,13 @@ const renderToolPreview = ( } if (cursorPos && lastMouseDownPos && tool === 'rect') { + const snappedPos = snapPosToStage(cursorPos, stage); const rectPreview = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_RECT_ID}`); rectPreview?.setAttrs({ - x: Math.min(cursorPos.x, lastMouseDownPos.x), - y: Math.min(cursorPos.y, lastMouseDownPos.y), - width: Math.abs(cursorPos.x - lastMouseDownPos.x), - height: Math.abs(cursorPos.y - lastMouseDownPos.y), + x: Math.min(snappedPos.x, lastMouseDownPos.x), + y: Math.min(snappedPos.y, lastMouseDownPos.y), + width: Math.abs(snappedPos.x - lastMouseDownPos.x), + height: Math.abs(snappedPos.y - lastMouseDownPos.y), }); rectPreview?.visible(true); } else { From ac0b9ba290cfe96597936a470a48066c63798ae4 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 4 May 2024 19:01:18 +1000 Subject: [PATCH 17/27] tidy(ui): `$cursorPosition` -> `$lastCursorPos` --- .../features/controlLayers/components/StageComponent.tsx | 8 ++++---- .../src/features/controlLayers/hooks/mouseEventHooks.ts | 8 ++++---- .../features/controlLayers/store/controlLayersSlice.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx index c66c15d61b..d0d693a5f2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx @@ -6,7 +6,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useMouseEvents } from 'features/controlLayers/hooks/mouseEventHooks'; import { - $cursorPosition, + $lastCursorPos, $lastMouseDownPos, $tool, isRegionalGuidanceLayer, @@ -48,7 +48,7 @@ const useStageRenderer = ( const state = useAppSelector((s) => s.controlLayers.present); const tool = useStore($tool); const mouseEventHandlers = useMouseEvents(); - const cursorPosition = useStore($cursorPosition); + const lastCursorPos = useStore($lastCursorPos); const lastMouseDownPos = useStore($lastMouseDownPos); const selectedLayerIdColor = useAppSelector(selectSelectedLayerColor); const selectedLayerType = useAppSelector(selectSelectedLayerType); @@ -141,7 +141,7 @@ const useStageRenderer = ( selectedLayerIdColor, selectedLayerType, state.globalMaskLayerOpacity, - cursorPosition, + lastCursorPos, lastMouseDownPos, state.brushSize ); @@ -152,7 +152,7 @@ const useStageRenderer = ( selectedLayerIdColor, selectedLayerType, state.globalMaskLayerOpacity, - cursorPosition, + lastCursorPos, lastMouseDownPos, state.brushSize, renderers, diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts index 03595fb82d..b9716ba217 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts @@ -3,8 +3,8 @@ import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { calculateNewBrushSize } from 'features/canvas/hooks/useCanvasZoom'; import { - $cursorPosition, $isDrawing, + $lastCursorPos, $lastMouseDownPos, $tool, brushSizeChanged, @@ -63,7 +63,7 @@ const syncCursorPos = (stage: Konva.Stage): Vector2d | null => { if (!pos) { return null; } - $cursorPosition.set(pos); + $lastCursorPos.set(pos); return pos; }; @@ -117,7 +117,7 @@ export const useMouseEvents = () => { if (!stage) { return; } - const pos = $cursorPosition.get(); + const pos = $lastCursorPos.get(); if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') { return; } @@ -188,7 +188,7 @@ export const useMouseEvents = () => { dispatch(rgLayerPointsAdded({ layerId: selectedLayerId, point: [pos.x, pos.y] })); } $isDrawing.set(false); - $cursorPosition.set(null); + $lastCursorPos.set(null); $lastMouseDownPos.set(null); }, [selectedLayerId, selectedLayerType, tool, dispatch] diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts index f6a6f0b38d..fc1887f425 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts @@ -866,7 +866,7 @@ const migrateControlLayersState = (state: any): any => { export const $isDrawing = atom(false); export const $lastMouseDownPos = atom(null); export const $tool = atom('brush'); -export const $cursorPosition = atom(null); +export const $lastCursorPos = atom(null); // IDs for singleton Konva layers and objects export const TOOL_PREVIEW_LAYER_ID = 'tool_preview_layer'; From 806a8f69c582dcb4c4ba54d73ad09ff4b2a64f2a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 4 May 2024 21:34:51 +1000 Subject: [PATCH 18/27] perf(ui): rerender of opacity sliders --- .../web/src/features/controlLayers/hooks/layerStateHooks.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts index b4880d1dc6..f2054779d4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts @@ -1,4 +1,5 @@ import { createSelector } from '@reduxjs/toolkit'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { isControlAdapterLayer, @@ -69,7 +70,7 @@ export const useLayerType = (layerId: string) => { export const useLayerOpacity = (layerId: string) => { const selectLayer = useMemo( () => - createSelector(selectControlLayersSlice, (controlLayers) => { + createMemoizedSelector(selectControlLayersSlice, (controlLayers) => { const layer = controlLayers.present.layers.filter(isControlAdapterLayer).find((l) => l.id === layerId); assert(layer, `Layer ${layerId} not found`); return { opacity: Math.round(layer.opacity * 100), isFilterEnabled: layer.isFilterEnabled }; From b5b6a96d9465b96013859cd4b5a1bce6e278e142 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 4 May 2024 23:41:11 +1000 Subject: [PATCH 19/27] feat(ui): dynamic brush spacing Scaled to 10% of brush size, clamped between 5px and 15px. This makes drawing feel a bit smoother, but maintains reasonable performance. --- .../controlLayers/hooks/mouseEventHooks.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts index b9716ba217..8f69c165ca 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts @@ -15,7 +15,8 @@ import { import type Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import type { Vector2d } from 'konva/lib/types'; -import { useCallback, useRef } from 'react'; +import { clamp } from 'lodash-es'; +import { useCallback, useMemo, useRef } from 'react'; const getIsFocused = (stage: Konva.Stage) => { return stage.container().contains(document.activeElement); @@ -67,7 +68,9 @@ const syncCursorPos = (stage: Konva.Stage): Vector2d | null => { return pos; }; -const BRUSH_SPACING = 20; +const BRUSH_SPACING_PCT = 10; +const MIN_BRUSH_SPACING_PX = 5; +const MAX_BRUSH_SPACING_PX = 15; export const useMouseEvents = () => { const dispatch = useAppDispatch(); @@ -83,6 +86,10 @@ export const useMouseEvents = () => { const lastCursorPosRef = useRef<[number, number] | null>(null); const shouldInvertBrushSizeScrollDirection = useAppSelector((s) => s.canvas.shouldInvertBrushSizeScrollDirection); const brushSize = useAppSelector((s) => s.controlLayers.present.brushSize); + const brushSpacingPx = useMemo( + () => clamp(brushSize / BRUSH_SPACING_PCT, MIN_BRUSH_SPACING_PX, MAX_BRUSH_SPACING_PX), + [brushSize] + ); const onMouseDown = useCallback( (e: KonvaEventObject) => { @@ -158,7 +165,7 @@ export const useMouseEvents = () => { // Continue the last line if (lastCursorPosRef.current) { // Dispatching redux events impacts perf substantially - using brush spacing keeps dispatches to a reasonable number - if (Math.hypot(lastCursorPosRef.current[0] - pos.x, lastCursorPosRef.current[1] - pos.y) < BRUSH_SPACING) { + if (Math.hypot(lastCursorPosRef.current[0] - pos.x, lastCursorPosRef.current[1] - pos.y) < brushSpacingPx) { return; } } @@ -171,7 +178,7 @@ export const useMouseEvents = () => { $isDrawing.set(true); } }, - [dispatch, selectedLayerId, selectedLayerType, tool] + [brushSpacingPx, dispatch, selectedLayerId, selectedLayerType, tool] ); const onMouseLeave = useCallback( From e4a640f0a761150ca91db4250453697c47270805 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 4 May 2024 23:57:42 +1000 Subject: [PATCH 20/27] feat(ui): optimized rendering of selected layer Instead of caching on every stroke, we can use a compositing rect when the layer is being drawn to improve performance. --- .../controlLayers/store/controlLayersSlice.ts | 1 + .../src/features/controlLayers/util/bbox.ts | 2 +- .../features/controlLayers/util/renderers.ts | 55 +++++++++++++++++-- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts index fc1887f425..1ef90ead3a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts @@ -889,6 +889,7 @@ export const RG_LAYER_RECT_NAME = 'regional_guidance_layer.rect'; export const INITIAL_IMAGE_LAYER_NAME = 'initial_image_layer'; export const INITIAL_IMAGE_LAYER_IMAGE_NAME = 'initial_image_layer.image'; export const LAYER_BBOX_NAME = 'layer.bbox'; +export const COMPOSITING_RECT_NAME = 'compositing-rect'; // Getters for non-singleton layer and object IDs const getRGLayerId = (layerId: string) => `${RG_LAYER_NAME}_${layerId}`; diff --git a/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts b/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts index a4c7be6886..72aefe1eb4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/bbox.ts @@ -123,7 +123,7 @@ export const getLayerBboxPixels = (layer: KonvaLayerType, preview: boolean = fal return correctedLayerBbox; }; -export const getLayerBboxFast = (layer: KonvaLayerType): IRect | null => { +export const getLayerBboxFast = (layer: KonvaLayerType): IRect => { const bbox = layer.getClientRect(GET_CLIENT_RECT_CONFIG); return { x: Math.floor(bbox.x), diff --git a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts index 9f24232240..f58b1e3b74 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/renderers.ts @@ -7,6 +7,7 @@ import { BACKGROUND_RECT_ID, CA_LAYER_IMAGE_NAME, CA_LAYER_NAME, + COMPOSITING_RECT_NAME, getCALayerImageId, getIILayerImageId, getLayerBboxId, @@ -324,6 +325,12 @@ const createVectorMaskRect = (reduxObject: VectorMaskRect, konvaGroup: Konva.Gro return vectorMaskRect; }; +const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => { + const compositingRect = new Konva.Rect({ name: COMPOSITING_RECT_NAME, listening: false }); + konvaLayer.add(compositingRect); + return compositingRect; +}; + /** * Renders a vector mask layer. * @param stage The konva stage to render on. @@ -401,15 +408,53 @@ const renderRegionalGuidanceLayer = ( groupNeedsCache = true; } - if (konvaObjectGroup.children.length === 0) { + if (konvaObjectGroup.getChildren().length === 0) { // No objects - clear the cache to reset the previous pixel data konvaObjectGroup.clearCache(); - } else if (groupNeedsCache) { - konvaObjectGroup.cache(); + return; } - // Updating group opacity does not require re-caching - if (konvaObjectGroup.opacity() !== globalMaskLayerOpacity) { + const compositingRect = + konvaLayer.findOne(`.${COMPOSITING_RECT_NAME}`) ?? createCompositingRect(konvaLayer); + + /** + * When the group is selected, we use a rect of the selected preview color, composited over the shapes. This allows + * shapes to render as a "raster" layer with all pixels drawn at the same color and opacity. + * + * Without this special handling, each shape is drawn individually with the given opacity, atop the other shapes. The + * effect is like if you have a Photoshop Group consisting of many shapes, each of which has the given opacity. + * Overlapping shapes will have their colors blended together, and the final color is the result of all the shapes. + * + * Instead, with the special handling, the effect is as if you drew all the shapes at 100% opacity, flattened them to + * a single raster image, and _then_ applied the 50% opacity. + */ + if (reduxLayer.isSelected && tool !== 'move') { + // We must clear the cache first so Konva will re-draw the group with the new compositing rect + if (konvaObjectGroup.isCached()) { + konvaObjectGroup.clearCache(); + } + // The user is allowed to reduce mask opacity to 0, but we need the opacity for the compositing rect to work + konvaObjectGroup.opacity(1); + + compositingRect.setAttrs({ + // The rect should be the size of the layer - use the fast method bc it's OK if the rect is larger + ...getLayerBboxFast(konvaLayer), + fill: rgbColor, + opacity: globalMaskLayerOpacity, + // Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes) + globalCompositeOperation: 'source-in', + visible: true, + // This rect must always be on top of all other shapes + zIndex: konvaObjectGroup.getChildren().length, + }); + } else { + // The compositing rect should only be shown when the layer is selected. + compositingRect.visible(false); + // Cache only if needed - or if we are on this code path and _don't_ have a cache + if (groupNeedsCache || !konvaObjectGroup.isCached()) { + konvaObjectGroup.cache(); + } + // Updating group opacity does not require re-caching konvaObjectGroup.opacity(globalMaskLayerOpacity); } }; From 26847895b9027d1c2606ea08b825ddbc1661c712 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 6 May 2024 15:35:12 +1000 Subject: [PATCH 21/27] fix(ui): update hotkeys for viewer --- invokeai/frontend/web/public/locales/en.json | 10 +++------- .../system/components/HotkeysModal/useHotkeyData.ts | 11 +++-------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 37a2a7a5da..97f52e5f5a 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -589,13 +589,9 @@ "desc": "Upscale the current image", "title": "Upscale" }, - "backToEditor": { - "desc": "Closes the Image Viewer and shows the Editor View (Text to Image tab only)", - "title": "Back to Editor" - }, - "openImageViewer": { - "desc": "Opens the Image Viewer (Text to Image tab only)", - "title": "Open Image Viewer" + "toggleViewer": { + "desc": "Switches between the Image Viewer and workspace for the current tab.", + "title": "Toggle Image Viewer" } }, "metadata": { diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts index 806b85ca59..79d957d7d3 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts @@ -141,14 +141,9 @@ export const useHotkeyData = (): HotkeyGroup[] => { hotkeys: [['Arrow Right']], }, { - title: t('hotkeys.openImageViewer.title'), - desc: t('hotkeys.openImageViewer.desc'), - hotkeys: [['I']], - }, - { - title: t('hotkeys.backToEditor.title'), - desc: t('hotkeys.backToEditor.desc'), - hotkeys: [['Esc']], + title: t('hotkeys.toggleViewer.title'), + desc: t('hotkeys.toggleViewer.desc'), + hotkeys: [['Z']], }, ], }), From 44ecddae2e50e1d0066abd4b230e540720befcea Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 6 May 2024 15:50:47 +1000 Subject: [PATCH 22/27] feat(ui): style Settings/Control Layers tabs like tabs --- .../components/ParametersPanelTextToImage.tsx | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx index abd78d00e4..3e02e1e132 100644 --- a/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx +++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanelTextToImage.tsx @@ -24,27 +24,16 @@ const overlayScrollbarsStyles: CSSProperties = { width: '100%', }; -const unselectedStyles: ChakraProps['sx'] = { - bg: 'none', - color: 'base.300', +const baseStyles: ChakraProps['sx'] = { fontWeight: 'semibold', fontSize: 'sm', - w: '50%', - borderWidth: 1, - borderRadius: 'base', + color: 'base.300', }; const selectedStyles: ChakraProps['sx'] = { + borderColor: 'base.800', + borderBottomColor: 'base.900', color: 'invokeBlue.300', - borderColor: 'invokeBlueAlpha.400', - _hover: { - color: 'invokeBlue.200', - }, -}; - -const hoverStyles: ChakraProps['sx'] = { - bg: 'base.850', - color: 'base.100', }; const ParametersPanelTextToImage = () => { @@ -61,12 +50,12 @@ const ParametersPanelTextToImage = () => { {isSDXL ? : } - - - + + + {t('common.settingsLabel')} - + {controlLayersTitle} From c5b948bc3f9de2e856b7319eb4588fa79718685a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 6 May 2024 16:36:29 +1000 Subject: [PATCH 23/27] feat(ui): fade layer selection color --- .../controlLayers/components/LayerCommon/LayerWrapper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx index 9d5fb6ea4b..a22f96b750 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx @@ -10,7 +10,7 @@ type Props = PropsWithChildren<{ export const LayerWrapper = memo(({ onClick, borderColor, children }: Props) => { return ( - + {children} From aab152a7e9b29d3ee0b8624c22515cb01381fc65 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 6 May 2024 16:50:58 +1000 Subject: [PATCH 24/27] fix(ui): track mouse out flags correctly --- .../web/src/features/controlLayers/hooks/mouseEventHooks.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts index 8f69c165ca..fa49fdb473 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/mouseEventHooks.ts @@ -188,15 +188,15 @@ export const useMouseEvents = () => { return; } const pos = syncCursorPos(stage); + $isDrawing.set(false); + $lastCursorPos.set(null); + $lastMouseDownPos.set(null); if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') { return; } if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) { dispatch(rgLayerPointsAdded({ layerId: selectedLayerId, point: [pos.x, pos.y] })); } - $isDrawing.set(false); - $lastCursorPos.set(null); - $lastMouseDownPos.set(null); }, [selectedLayerId, selectedLayerType, tool, dispatch] ); From cce3144c7416b8d2ac50de98a64670e56d7fe1b1 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 6 May 2024 18:29:13 +1000 Subject: [PATCH 25/27] feat(ui): add floating image viewer --- invokeai/frontend/web/package.json | 1 + invokeai/frontend/web/pnpm-lock.yaml | 43 +++++ invokeai/frontend/web/public/locales/en.json | 7 +- .../frontend/web/src/app/components/App.tsx | 2 + .../ImageViewer/CurrentImagePreview.tsx | 12 +- .../ImageViewer/FloatingImageViewer.tsx | 178 ++++++++++++++++++ .../features/gallery/store/gallerySlice.ts | 5 + .../web/src/features/gallery/store/types.ts | 1 + .../src/features/ui/components/InvokeTabs.tsx | 2 + 9 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/FloatingImageViewer.tsx diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 96db090386..25a77cf918 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -89,6 +89,7 @@ "react-konva": "^18.2.10", "react-redux": "9.1.0", "react-resizable-panels": "^2.0.16", + "react-rnd": "^10.4.10", "react-select": "5.8.0", "react-use": "^17.5.0", "react-virtuoso": "^4.7.5", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 2e5442479f..3d688dddce 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -122,6 +122,9 @@ dependencies: react-resizable-panels: specifier: ^2.0.16 version: 2.0.16(react-dom@18.2.0)(react@18.2.0) + react-rnd: + specifier: ^10.4.10 + version: 10.4.10(react-dom@18.2.0)(react@18.2.0) react-select: specifier: 5.8.0 version: 5.8.0(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0) @@ -7385,6 +7388,11 @@ packages: requiresBuild: true dev: true + /clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -11200,6 +11208,16 @@ packages: unpipe: 1.0.0 dev: true + /re-resizable@6.9.14(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-2UbPrpezMr6gkHKNCRA/N6QGGU237SKOZ78yMHId204A/oXWSAREAIuGZNQ9qlrJosewzcsv2CphZH3u7hC6ng==} + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-clientside-effect@1.2.6(react@18.2.0): resolution: {integrity: sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==} peerDependencies: @@ -11253,6 +11271,18 @@ packages: react: 18.2.0 scheduler: 0.23.0 + /react-draggable@4.4.6(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + dependencies: + clsx: 1.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-dropzone@14.2.3(react@18.2.0): resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==} engines: {node: '>= 10.13'} @@ -11466,6 +11496,19 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /react-rnd@10.4.10(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-YjQAgEeSbNUoOXSD9ZBvIiLVizFb+bNhpDk8DbIRHA557NW02CXbwsAeOTpJQnsdhEL+NP2I+Ssrwejqcodtjg==} + peerDependencies: + react: '>=16.3.0' + react-dom: '>=16.3.0' + dependencies: + re-resizable: 6.9.14(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-draggable: 4.4.6(react-dom@18.2.0)(react@18.2.0) + tslib: 2.6.2 + dev: false + /react-select@5.7.7(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-HhashZZJDRlfF/AKj0a0Lnfs3sRdw/46VJIRd8IbB9/Ovr74+ZIwkAdSBjSPXsFMG+u72c5xShqwLSKIJllzqw==} peerDependencies: diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 97f52e5f5a..83e80e8a81 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -143,7 +143,8 @@ "alpha": "Alpha", "selected": "Selected", "viewer": "Viewer", - "tab": "Tab" + "tab": "Tab", + "close": "Close" }, "controlnet": { "controlAdapter_one": "Control Adapter", @@ -365,7 +366,9 @@ "bulkDownloadFailed": "Download Failed", "problemDeletingImages": "Problem Deleting Images", "problemDeletingImagesDesc": "One or more images could not be deleted", - "switchTo": "Switch to {{ tab }} (Z)" + "switchTo": "Switch to {{ tab }} (Z)", + "openFloatingViewer": "Open Floating Viewer", + "closeFloatingViewer": "Close Floating Viewer" }, "hotkeys": { "searchHotkeys": "Search Hotkeys", diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 30d8f41200..03c854bb48 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -12,6 +12,7 @@ import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys'; import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal'; import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal'; import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal'; +import { FloatingImageViewer } from 'features/gallery/components/ImageViewer/FloatingImageViewer'; import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast'; import { configChanged } from 'features/system/store/configSlice'; import { languageSelector } from 'features/system/store/systemSelectors'; @@ -96,6 +97,7 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage }: Props) => { + ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 35abf07965..840800b897 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -22,7 +22,13 @@ const selectLastSelectedImageName = createSelector( (lastSelectedImage) => lastSelectedImage?.image_name ); -const CurrentImagePreview = () => { +type Props = { + isDragDisabled?: boolean; + isDropDisabled?: boolean; + withNextPrevButtons?: boolean; +}; + +const CurrentImagePreview = ({ isDragDisabled = false, isDropDisabled = false, withNextPrevButtons = true }: Props) => { const { t } = useTranslation(); const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails); const imageName = useAppSelector(selectLastSelectedImageName); @@ -79,6 +85,8 @@ const CurrentImagePreview = () => { imageDTO={imageDTO} droppableData={droppableData} draggableData={draggableData} + isDragDisabled={isDragDisabled} + isDropDisabled={isDropDisabled} isUploadDisabled={true} fitContainer useThumbailFallback @@ -106,7 +114,7 @@ const CurrentImagePreview = () => { )} - {shouldShowNextPrevButtons && imageDTO && ( + {withNextPrevButtons && shouldShowNextPrevButtons && imageDTO && ( { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const shift = useShiftModifier(); + const rndRef = useRef(null); + const imagePreviewRef = useRef(null); + const onClose = useCallback(() => { + dispatch(isFloatingImageViewerOpenChanged(false)); + }, [dispatch]); + + const fitToScreen = useCallback(() => { + if (!imagePreviewRef.current || !rndRef.current) { + return; + } + const el = imagePreviewRef.current; + const rnd = rndRef.current; + + const { top, right, bottom, left, width, height } = el.getBoundingClientRect(); + const { innerWidth, innerHeight } = window; + + const newPosition = rnd.getDraggablePosition(); + + if (top < 0) { + newPosition.y = 0; + } + if (left < 0) { + newPosition.x = 0; + } + if (bottom > innerHeight) { + newPosition.y = innerHeight - height; + } + if (right > innerWidth) { + newPosition.x = innerWidth - width; + } + rnd.updatePosition(newPosition); + }, []); + + const onDoubleClick = useCallback(() => { + if (!rndRef.current || !imagePreviewRef.current) { + return; + } + const { width, height } = imagePreviewRef.current.getBoundingClientRect(); + if (width === defaultSize.width && height === defaultSize.height) { + rndRef.current.updateSize(maxSize); + } else { + rndRef.current.updateSize(defaultSize); + } + flushSync(fitToScreen); + }, [fitToScreen]); + + useLayoutEffect(() => { + window.addEventListener('resize', fitToScreen); + return () => { + window.removeEventListener('resize', fitToScreen); + }; + }, [fitToScreen]); + + useLayoutEffect(() => { + // Set the initial position + if (!imagePreviewRef.current || !rndRef.current) { + return; + } + + const { width, height } = imagePreviewRef.current.getBoundingClientRect(); + + const initialPosition = { + // 54 = width of left-hand vertical bar of tab icons + // 430 = width of parameters panel + x: 54 + 430 / 2 - width / 2, + // 16 = just a reasonable bottom padding + y: window.innerHeight - height - 16, + }; + + rndRef.current.updatePosition(initialPosition); + }, [fitToScreen]); + + return ( + + + + + {t('common.viewer')} + + + } size="sm" variant="link" onClick={onClose} /> + + + + + + + ); +}; + +export const FloatingImageViewer = () => { + const isOpen = useAppSelector((s) => s.gallery.isFloatingImageViewerOpen); + + if (!isOpen) { + return null; + } + + return ; +}; + +export const ToggleFloatingImageViewerButton = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const isOpen = useAppSelector((s) => s.gallery.isFloatingImageViewerOpen); + + const onToggle = useCallback(() => { + dispatch(isFloatingImageViewerOpenChanged(!isOpen)); + }, [dispatch, isOpen]); + + return ( + } + size="sm" + onClick={onToggle} + variant="link" + colorScheme={isOpen ? 'invokeBlue' : 'base'} + boxSize={8} + /> + ); +}; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 5248977825..892c5c954d 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -23,6 +23,7 @@ const initialGalleryState: GalleryState = { limit: INITIAL_IMAGE_LIMIT, offset: 0, isImageViewerOpen: false, + isFloatingImageViewerOpen: false, }; export const gallerySlice = createSlice({ @@ -80,6 +81,9 @@ export const gallerySlice = createSlice({ isImageViewerOpenChanged: (state, action: PayloadAction) => { state.isImageViewerOpen = action.payload; }, + isFloatingImageViewerOpenChanged: (state, action: PayloadAction) => { + state.isFloatingImageViewerOpen = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(setActiveTab, (state) => { @@ -121,6 +125,7 @@ export const { moreImagesLoaded, alwaysShowImageSizeBadgeChanged, isImageViewerOpenChanged, + isFloatingImageViewerOpenChanged, } = gallerySlice.actions; const isAnyBoardDeleted = isAnyOf( diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts index 0e86d2d4be..9c258060c9 100644 --- a/invokeai/frontend/web/src/features/gallery/store/types.ts +++ b/invokeai/frontend/web/src/features/gallery/store/types.ts @@ -21,4 +21,5 @@ export type GalleryState = { limit: number; alwaysShowImageSizeBadge: boolean; isImageViewerOpen: boolean; + isFloatingImageViewerOpen: boolean; }; diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx index 42df03872c..4152f4065b 100644 --- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx +++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx @@ -4,6 +4,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { $customNavComponent } from 'app/store/nanostores/customNavComponent'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent'; +import { ToggleFloatingImageViewerButton } from 'features/gallery/components/ImageViewer/FloatingImageViewer'; import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer'; import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup'; import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent'; @@ -223,6 +224,7 @@ const InvokeTabs = () => { + {customNavComponent ? customNavComponent : } Date: Mon, 6 May 2024 19:24:27 +1000 Subject: [PATCH 26/27] feat(ui): floating viewer always shows progress, never shows metadata --- .../components/ImageViewer/CurrentImagePreview.tsx | 14 +++++++++++--- .../components/ImageViewer/FloatingImageViewer.tsx | 8 +++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 840800b897..8e6eccbe73 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -26,9 +26,17 @@ type Props = { isDragDisabled?: boolean; isDropDisabled?: boolean; withNextPrevButtons?: boolean; + withMetadata?: boolean; + alwaysShowProgress?: boolean; }; -const CurrentImagePreview = ({ isDragDisabled = false, isDropDisabled = false, withNextPrevButtons = true }: Props) => { +const CurrentImagePreview = ({ + isDragDisabled = false, + isDropDisabled = false, + withNextPrevButtons = true, + withMetadata = true, + alwaysShowProgress = false, +}: Props) => { const { t } = useTranslation(); const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails); const imageName = useAppSelector(selectLastSelectedImageName); @@ -78,7 +86,7 @@ const CurrentImagePreview = ({ isDragDisabled = false, isDropDisabled = false, w justifyContent="center" position="relative" > - {hasDenoiseProgress && shouldShowProgressInViewer ? ( + {hasDenoiseProgress && (shouldShowProgressInViewer || alwaysShowProgress) ? ( ) : ( )} - {shouldShowImageDetails && imageDTO && ( + {shouldShowImageDetails && imageDTO && withMetadata && ( { } size="sm" variant="link" onClick={onClose} /> - + From 6b98dba71dc434ee235f388ea729835db0947be1 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 6 May 2024 19:28:57 +1000 Subject: [PATCH 27/27] chore(ui): lint --- .../components/LayerCommon/LayerWrapper.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx index a22f96b750..9757cf3972 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerWrapper.tsx @@ -10,7 +10,16 @@ type Props = PropsWithChildren<{ export const LayerWrapper = memo(({ onClick, borderColor, children }: Props) => { return ( - + {children}