Merge branch 'invoke-ai:main' into main

This commit is contained in:
Millun Atluri 2023-07-20 09:22:26 +10:00 committed by GitHub
commit 899aa1d251
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
128 changed files with 3551 additions and 3079 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 637 KiB

After

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
docs/assets/upscaling.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

View File

@ -1,8 +1,8 @@
--- ---
title: Concepts title: Textual Inversion Embeddings and LoRAs
--- ---
# :material-library-shelves: The Hugging Face Concepts Library and Importing Textual Inversion files # :material-library-shelves: Textual Inversions and LoRAs
With the advances in research, many new capabilities are available to customize the knowledge and understanding of novel concepts not originally contained in the base model. With the advances in research, many new capabilities are available to customize the knowledge and understanding of novel concepts not originally contained in the base model.
@ -64,21 +64,25 @@ select the embedding you'd like to use. This UI has type-ahead support, so you c
## Using LoRAs ## Using LoRAs
LoRA files are models that customize the output of Stable Diffusion image generation. LoRA files are models that customize the output of Stable Diffusion
Larger than embeddings, but much smaller than full models, they augment SD with improved image generation. Larger than embeddings, but much smaller than full
understanding of subjects and artistic styles. models, they augment SD with improved understanding of subjects and
artistic styles.
Unlike TI files, LoRAs do not introduce novel vocabulary into the model's known tokens. Instead, Unlike TI files, LoRAs do not introduce novel vocabulary into the
LoRAs augment the model's weights that are applied to generate imagery. LoRAs may be supplied model's known tokens. Instead, LoRAs augment the model's weights that
with a "trigger" word that they have been explicitly trained on, or may simply apply their are applied to generate imagery. LoRAs may be supplied with a
effect without being triggered. "trigger" word that they have been explicitly trained on, or may
simply apply their effect without being triggered.
LoRAs are typically stored in .safetensors files, which are the most secure way to store and transmit LoRAs are typically stored in .safetensors files, which are the most
these types of weights. You may install any number of `.safetensors` LoRA files simply by copying them into secure way to store and transmit these types of weights. You may
the `lora` directory of the corresponding InvokeAI models directory (usually `invokeai` install any number of `.safetensors` LoRA files simply by copying them
in your home directory). For example, you can simply move a Stable Diffusion 1.5 LoRA file to into the `autoimport/lora` directory of the corresponding InvokeAI models
the `sd-1/lora` folder. directory (usually `invokeai` in your home directory).
To use these when generating, open the LoRA menu item in the options panel, select the LoRAs you want to apply To use these when generating, open the LoRA menu item in the options
and ensure that they have the appropriate weight recommended by the model provider. Typically, most LoRAs perform best at a weight of .75-1. panel, select the LoRAs you want to apply and ensure that they have
the appropriate weight recommended by the model provider. Typically,
most LoRAs perform best at a weight of .75-1.

View File

@ -8,20 +8,64 @@ title: ControlNet
ControlNet ControlNet
ControlNet is a powerful set of features developed by the open-source community (notably, Stanford researcher [**@ilyasviel**](https://github.com/lllyasviel)) that allows you to apply a secondary neural network model to your image generation process in Invoke. ControlNet is a powerful set of features developed by the open-source
community (notably, Stanford researcher
[**@ilyasviel**](https://github.com/lllyasviel)) that allows you to
apply a secondary neural network model to your image generation
process in Invoke.
With ControlNet, you can get more control over the output of your image generation, providing you with a way to direct the network towards generating images that better fit your desired style or outcome. With ControlNet, you can get more control over the output of your
image generation, providing you with a way to direct the network
towards generating images that better fit your desired style or
outcome.
### How it works ### How it works
ControlNet works by analyzing an input image, pre-processing that image to identify relevant information that can be interpreted by each specific ControlNet model, and then inserting that control information into the generation process. This can be used to adjust the style, composition, or other aspects of the image to better achieve a specific result. ControlNet works by analyzing an input image, pre-processing that
image to identify relevant information that can be interpreted by each
specific ControlNet model, and then inserting that control information
into the generation process. This can be used to adjust the style,
composition, or other aspects of the image to better achieve a
specific result.
### Models ### Models
As part of the model installation, ControlNet models can be selected including a variety of pre-trained models that have been added to achieve different effects or styles in your generated images. Further ControlNet models may require additional code functionality to also be incorporated into Invoke's Invocations folder. You should expect to follow any installation instructions for ControlNet models loaded outside the default models provided by Invoke. The default models include: InvokeAI provides access to a series of ControlNet models that provide
different effects or styles in your generated images. Currently
InvokeAI only supports "diffuser" style ControlNet models. These are
folders that contain the files `config.json` and/or
`diffusion_pytorch_model.safetensors` and
`diffusion_pytorch_model.fp16.safetensors`. The name of the folder is
the name of the model.
***InvokeAI does not currently support checkpoint-format
ControlNets. These come in the form of a single file with the
extension `.safetensors`.***
Diffuser-style ControlNet models are available at HuggingFace
(http://huggingface.co) and accessed via their repo IDs (identifiers
in the format "author/modelname"). The easiest way to install them is
to use the InvokeAI model installer application. Use the
`invoke.sh`/`invoke.bat` launcher to select item [5] and then navigate
to the CONTROLNETS section. Select the models you wish to install and
press "APPLY CHANGES". You may also enter additional HuggingFace
repo_ids in the "Additional models" textbox:
![Model Installer -
Controlnetl](../assets/installing-models/model-installer-controlnet.png){:width="640px"}
Command-line users can launch the model installer using the command
`invokeai-model-install`.
_Be aware that some ControlNet models require additional code
functionality in order to work properly, so just installing a
third-party ControlNet model may not have the desired effect._ Please
read and follow the documentation for installing a third party model
not currently included among InvokeAI's default list.
The models currently supported include:
**Canny**: **Canny**:

View File

@ -4,15 +4,19 @@ title: InvokeAI Web Server
# :material-web: InvokeAI Web Server # :material-web: InvokeAI Web Server
As of version 2.0.0, this distribution comes with a full-featured web server ## Quick guided walkthrough of the WebUI's features
(see screenshot).
To use it, launch the `invoke.sh`/`invoke.bat` script and select While most of the WebUI's features are intuitive, here is a guided walkthrough
option (2). Alternatively, with the InvokeAI environment active, run through its various components.
the `invokeai` script by adding the `--web` option:
### Launching the WebUI
To run the InvokeAI web server, start the `invoke.sh`/`invoke.bat`
script and select option (1). Alternatively, with the InvokeAI
environment active, run `invokeai-web`:
```bash ```bash
invokeai --web invokeai-web
``` ```
You can then connect to the server by pointing your web browser at You can then connect to the server by pointing your web browser at
@ -28,33 +32,32 @@ invoke.sh --host 0.0.0.0
or or
```bash ```bash
invokeai --web --host 0.0.0.0 invokeai-web --host 0.0.0.0
``` ```
## Quick guided walkthrough of the WebUI's features ### The InvokeAI Web Interface
While most of the WebUI's features are intuitive, here is a guided walkthrough
through its various components.
![Invoke Web Server - Major Components](../assets/invoke-web-server-1.png){:width="640px"} ![Invoke Web Server - Major Components](../assets/invoke-web-server-1.png){:width="640px"}
The screenshot above shows the Text to Image tab of the WebUI. There are three The screenshot above shows the Text to Image tab of the WebUI. There are three
main sections: main sections:
1. A **control panel** on the left, which contains various settings for text to 1. A **control panel** on the left, which contains various settings
image generation. The most important part is the text field (currently for text to image generation. The most important part is the text
showing `strawberry sushi`) for entering the text prompt, and the camera icon field (currently showing `fantasy painting, horned demon`) for
directly underneath that will render the image. We'll call this the _Invoke_ entering the positive text prompt, another text field right below it for an
button from now on. optional negative text prompt (concepts to exclude), and a _Invoke_ button
to begin the image rendering process.
2. The **current image** section in the middle, which shows a large format 2. The **current image** section in the middle, which shows a large
version of the image you are currently working on. A series of buttons at the format version of the image you are currently working on. A series
top ("image to image", "Use All", "Use Seed", etc) lets you modify the image of buttons at the top lets you modify and manipulate the image in
in various ways. various ways.
3. A \*_gallery_ section on the left that contains a history of the images you 3. A **gallery** section on the left that contains a history of the images you
have generated. These images are read and written to the directory specified have generated. These images are read and written to the directory specified
at launch time in `--outdir`. in the `INVOKEAIROOT/invokeai.yaml` initialization file, usually a directory
named `outputs` in `INVOKEAIROOT`.
In addition to these three elements, there are a series of icons for changing In addition to these three elements, there are a series of icons for changing
global settings, reporting bugs, and changing the theme on the upper right. global settings, reporting bugs, and changing the theme on the upper right.
@ -76,15 +79,11 @@ From top to bottom, these are:
with outpainting,and modify interior portions of the image with with outpainting,and modify interior portions of the image with
inpainting, erase portions of a starting image and have the AI fill in inpainting, erase portions of a starting image and have the AI fill in
the erased region from a text prompt. the erased region from a text prompt.
4. Node Editor - this panel allows you to create 4. Node Editor - (experimental) this panel allows you to create
pipelines of common operations and combine them into workflows. pipelines of common operations and combine them into workflows.
5. Model Manager - this panel allows you to import and configure new 5. Model Manager - this panel allows you to import and configure new
models using URLs, local paths, or HuggingFace diffusers repo_ids. models using URLs, local paths, or HuggingFace diffusers repo_ids.
The inpainting, outpainting and postprocessing tabs are currently in
development. However, limited versions of their features can already be accessed
through the Text to Image and Image to Image tabs.
## Walkthrough ## Walkthrough
The following walkthrough will exercise most (but not all) of the WebUI's The following walkthrough will exercise most (but not all) of the WebUI's
@ -92,43 +91,54 @@ feature set.
### Text to Image ### Text to Image
1. Launch the WebUI using `python scripts/invoke.py --web` and connect to it 1. Launch the WebUI using launcher option [1] and connect to it with
with your browser by accessing `http://localhost:9090`. If the browser and your browser by accessing `http://localhost:9090`. If the browser
server are running on different machines on your LAN, add the option and server are running on different machines on your LAN, add the
`--host 0.0.0.0` to the launch command line and connect to the machine option `--host 0.0.0.0` to the `invoke.sh` launch command line and connect to
hosting the web server using its IP address or domain name. the machine hosting the web server using its IP address or domain
name.
2. If all goes well, the WebUI should come up and you'll see a green 2. If all goes well, the WebUI should come up and you'll see a green dot
`connected` message on the upper right. meaning `connected` on the upper right.
![Invoke Web Server - Control Panel](../assets/invoke-control-panel-1.png){ align=right width=300px }
#### Basics #### Basics
1. Generate an image by typing _strawberry sushi_ into the large prompt field 1. Generate an image by typing _bluebird_ into the large prompt field
on the upper left and then clicking on the Invoke button (the one with the on the upper left and then clicking on the Invoke button or pressing
Camera icon). After a short wait, you'll see a large image of sushi in the the return button.
After a short wait, you'll see a large image of a bluebird in the
image panel, and a new thumbnail in the gallery on the right. image panel, and a new thumbnail in the gallery on the right.
If you need more room on the screen, you can turn the gallery off by If you need more room on the screen, you can turn the gallery off
clicking on the **x** to the right of "Your Invocations". You can turn it by typing the **g** hotkey. You can turn it back on later by clicking the
back on later by clicking the image icon that appears in the gallery's image icon that appears in the gallery's place. The list of hotkeys can
place. be found by clicking on the keyboard icon above the image gallery.
The images are written into the directory indicated by the `--outdir` option 2. Generate a bunch of bluebird images by increasing the number of
provided at script launch time. By default, this is `outputs/img-samples` requested images by adjusting the Images counter just below the Invoke
under the InvokeAI directory.
2. Generate a bunch of strawberry sushi images by increasing the number of
requested images by adjusting the Images counter just below the Camera
button. As each is generated, it will be added to the gallery. You can button. As each is generated, it will be added to the gallery. You can
switch the active image by clicking on the gallery thumbnails. switch the active image by clicking on the gallery thumbnails.
3. Try playing with different settings, including image width and height, the If you'd like to watch the image generation progress, click the hourglass
Sampler, the Steps and the CFG scale. icon above the main image area. As generation progresses, you'll see
increasingly detailed versions of the ultimate image.
3. Try playing with different settings, including changing the main
model, the image width and height, the Scheduler, the Steps and
the CFG scale.
The _Model_ changes the main model. Thousands of custom models are
now available, which generate a variety of image styles and
subjects. While InvokeAI comes with a few starter models, it is
easy to import new models into the application. See [Installing
Models](../installation/050_INSTALLING_MODELS.md) for more details.
Image _Width_ and _Height_ do what you'd expect. However, be aware that Image _Width_ and _Height_ do what you'd expect. However, be aware that
larger images consume more VRAM memory and take longer to generate. larger images consume more VRAM memory and take longer to generate.
The _Sampler_ controls how the AI selects the image to display. Some The _Scheduler_ controls how the AI selects the image to display. Some
samplers are more "creative" than others and will produce a wider range of samplers are more "creative" than others and will produce a wider range of
variations (see next section). Some samplers run faster than others. variations (see next section). Some samplers run faster than others.
@ -142,17 +152,27 @@ feature set.
to the input prompt. You can go as high or low as you like, but generally to the input prompt. You can go as high or low as you like, but generally
values greater than 20 won't improve things much, and values lower than 5 values greater than 20 won't improve things much, and values lower than 5
will produce unexpected images. There are complex interactions between will produce unexpected images. There are complex interactions between
_Steps_, _CFG Scale_ and the _Sampler_, so experiment to find out what works _Steps_, _CFG Scale_ and the _Scheduler_, so experiment to find out what works
for you. for you.
4. To regenerate a previously-generated image, select the image you want and The _Seed_ controls the series of values returned by InvokeAI's
click _Use All_. This loads the text prompt and other original settings into random number generator. Each unique seed value will generate a different
the control panel. If you then press _Invoke_ it will regenerate the image image. To regenerate a previous image, simply use the original image's
exactly. You can also selectively modify the prompt or other settings to seed value. A slider to the right of the _Seed_ field will change the
tweak the image. seed each time an image is generated.
Alternatively, you may click on _Use Seed_ to load just the image's seed, ![Invoke Web Server - Control Panel 2](../assets/control-panel-2.png){ align=right width=400px }
and leave other settings unchanged.
4. To regenerate a previously-generated image, select the image you
want and click the asterisk ("*") button at the top of the
image. This loads the text prompt and other original settings into
the control panel. If you then press _Invoke_ it will regenerate
the image exactly. You can also selectively modify the prompt or
other settings to tweak the image.
Alternatively, you may click on the "sprouting plant icon" to load
just the image's seed, and leave other settings unchanged or the
quote icon to load just the positive and negative prompts.
5. To regenerate a Stable Diffusion image that was generated by another SD 5. To regenerate a Stable Diffusion image that was generated by another SD
package, you need to know its text prompt and its _Seed_. Copy-paste the package, you need to know its text prompt and its _Seed_. Copy-paste the
@ -162,61 +182,21 @@ feature set.
not be exact unless you also set the correct values for the original not be exact unless you also set the correct values for the original
sampler, CFG, steps and dimensions, but it will (usually) be close. sampler, CFG, steps and dimensions, but it will (usually) be close.
#### Variations on a theme 6. To save an image, right click on it to bring up a menu that will
let you download the image, save it to a named image gallery, and
copy it to the clipboard, among other things.
1. Let's try generating some variations. Select your favorite sushi image from #### Upscaling
the gallery to load it. Then select "Use All" from the list of buttons
above. This will load up all the settings used to generate this image,
including its unique seed.
Go down to the Variations section of the Control Panel and set the button to ![Invoke Web Server - Upscaling](../assets/upscaling.png){ align=right width=400px }
On. Set Variation Amount to 0.2 to generate a modest number of variations on
the image, and also set the Image counter to `4`. Press the `invoke` button.
This will generate a series of related images. To obtain smaller variations,
just lower the Variation Amount. You may also experiment with changing the
Sampler. Some samplers generate more variability than others. _k_euler_a_ is
particularly creative, while _ddim_ is pretty conservative.
2. For even more variations, experiment with increasing the setting for "Upscaling" is the process of increasing the size of an image while
_Perlin_. This adds a bit of noise to the image generation process. Note retaining the sharpness. InvokeAI uses an external library called
that values of Perlin noise greater than 0.15 produce poor images for "ESRGAN" to do this. To invoke upscaling, simply select an image
several of the samplers. and press the "expanding arrows" button above it. You can select
between 2X and 4X upscaling, and adjust the upscaling strength,
#### Facial reconstruction and upscaling which has much the same meaning as in facial reconstruction. Try
running this on one of your previously-generated images.
Stable Diffusion frequently produces mangled faces, particularly when there are
multiple figures in the same scene. Stable Diffusion has particular issues with
generating reallistic eyes. InvokeAI provides the ability to reconstruct faces
using either the GFPGAN or CodeFormer libraries. For more information see
[POSTPROCESS](POSTPROCESS.md).
1. Invoke a prompt that generates a mangled face. A prompt that often gives
this is "portrait of a lawyer, 3/4 shot" (this is not intended as a slur
against lawyers!) Once you have an image that needs some touching up, load
it into the Image panel, and press the button with the face icon
(highlighted in the first screenshot below). A dialog box will appear. Leave
_Strength_ at 0.8 and press \*Restore Faces". If all goes well, the eyes and
other aspects of the face will be improved (see the second screenshot)
![Invoke Web Server - Original Image](../assets/invoke-web-server-3.png)
![Invoke Web Server - Retouched Image](../assets/invoke-web-server-4.png)
The facial reconstruction _Strength_ field adjusts how aggressively the face
library will try to alter the face. It can be as high as 1.0, but be aware
that this often softens the face airbrush style, losing some details. The
default 0.8 is usually sufficient.
2. "Upscaling" is the process of increasing the size of an image while
retaining the sharpness. InvokeAI uses an external library called "ESRGAN"
to do this. To invoke upscaling, simply select an image and press the _HD_
button above it. You can select between 2X and 4X upscaling, and adjust the
upscaling strength, which has much the same meaning as in facial
reconstruction. Try running this on one of your previously-generated images.
3. Finally, you can run facial reconstruction and/or upscaling automatically
after each Invocation. Go to the Advanced Options section of the Control
Panel and turn on _Restore Face_ and/or _Upscale_.
### Image to Image ### Image to Image
@ -224,24 +204,14 @@ InvokeAI lets you take an existing image and use it as the basis for a new
creation. You can use any sort of image, including a photograph, a scanned creation. You can use any sort of image, including a photograph, a scanned
sketch, or a digital drawing, as long as it is in PNG or JPEG format. sketch, or a digital drawing, as long as it is in PNG or JPEG format.
For this tutorial, we'll use files named For this tutorial, we'll use the file named
[Lincoln-and-Parrot-512.png](../assets/Lincoln-and-Parrot-512.png), and [Lincoln-and-Parrot-512.png](../assets/Lincoln-and-Parrot-512.png).
[Lincoln-and-Parrot-512-transparent.png](../assets/Lincoln-and-Parrot-512-transparent.png).
Download these images to your local machine now to continue with the
walkthrough.
1. Click on the _Image to Image_ tab icon, which is the second icon from the 1. Click on the _Image to Image_ tab icon, which is the second icon
top on the left-hand side of the screen: from the top on the left-hand side of the screen. This will bring
you to a screen similar to the one shown here:
<figure markdown> ![Invoke Web Server - Image to Image Tab](../assets/invoke-web-server-6.png){ width="640px" }
![Invoke Web Server - Image to Image Icon](../assets/invoke-web-server-5.png)
</figure>
This will bring you to a screen similar to the one shown here:
<figure markdown>
![Invoke Web Server - Image to Image Tab](../assets/invoke-web-server-6.png){:width="640px"}
</figure>
2. Drag-and-drop the Lincoln-and-Parrot image into the Image panel, or click 2. Drag-and-drop the Lincoln-and-Parrot image into the Image panel, or click
the blank area to get an upload dialog. The image will load into an area the blank area to get an upload dialog. The image will load into an area
@ -255,120 +225,99 @@ walkthrough.
![Invoke Web Server - Image to Image example](../assets/invoke-web-server-7.png){:width="640px"} ![Invoke Web Server - Image to Image example](../assets/invoke-web-server-7.png){:width="640px"}
4. Experiment with the different settings. The most influential one in Image to 4. Experiment with the different settings. The most influential one in Image to
Image is _Image to Image Strength_ located about midway down the control Image is _Denoising Strength_ located about midway down the control
panel. By default it is set to 0.75, but can range from 0.0 to 0.99. The panel. By default it is set to 0.75, but can range from 0.0 to 0.99. The
higher the value, the more of the original image the AI will replace. A higher the value, the more of the original image the AI will replace. A
value of 0 will leave the initial image completely unchanged, while 0.99 value of 0 will leave the initial image completely unchanged, while 0.99
will replace it completely. However, the Sampler and CFG Scale also will replace it completely. However, the _Scheduler_ and _CFG Scale_ also
influence the final result. You can also generate variations in the same way influence the final result. You can also generate variations in the same way
as described in Text to Image. as described in Text to Image.
5. What if we only want to change certain part(s) of the image and leave the 5. What if we only want to change certain part(s) of the image and
rest intact? This is called Inpainting, and a future version of the InvokeAI leave the rest intact? This is called Inpainting, and you can do
web server will provide an interactive painting canvas on which you can it in the [Unified Canvas](UNIFIED_CANVAS.md). The Unified Canvas
directly draw the areas you wish to Inpaint into. For now, you can achieve also allows you to extend borders of the image and fill in the
this effect by using an external photoeditor tool to make one or more blank areas, a process called outpainting.
regions of the image transparent as described in [INPAINTING.md] and
uploading that.
The file
[Lincoln-and-Parrot-512-transparent.png](../assets/Lincoln-and-Parrot-512-transparent.png)
is a version of the earlier image in which the area around the parrot has
been replaced with transparency. Click on the "x" in the upper right of the
Initial Image and upload the transparent version. Using the same prompt "old
sea captain with raven on shoulder" try Invoking an image. This time, only
the parrot will be replaced, leaving the rest of the original image intact:
<figure markdown>
![Invoke Web Server - Inpainting](../assets/invoke-web-server-8.png){:width="640px"}
</figure>
6. Would you like to modify a previously-generated image using the Image to 6. Would you like to modify a previously-generated image using the Image to
Image facility? Easy! While in the Image to Image panel, hover over any of Image facility? Easy! While in the Image to Image panel, drag and drop any
the gallery images to see a little menu of icons pop up. Click the picture image in the gallery into the Initial Image area, and it will be ready for
icon to instantly send the selected image to Image to Image as the initial use. You can do the same thing with the main image display. Click on the
image. _Send to_ icon to get a menu of
commands and choose "Send to Image to Image".
You can do the same from the Text to Image tab by clicking on the picture icon ![Send To Icon](../assets/send-to-icon.png)
above the central image panel. The screenshot below shows where the "use as
initial image" icons are located.
![Invoke Web Server - Use as Image Links](../assets/invoke-web-server-9.png){:width="640px"} ### Textual Inversion, LoRA and ControlNet
### Unified Canvas InvokeAI supports several different types of model files that
extending the capabilities of the main model by adding artistic
styles, special effects, or subjects. By mixing and matching textual
inversion, LoRA and ControlNet models, you can achieve many
interesting and beautiful effects.
See the [Unified Canvas Guide](UNIFIED_CANVAS.md) We will give an example using a LoRA model named "Ink Scenery". This
LoRA, which can be downloaded from Civitai (civitai.com), is
specialized to paint landscapes that look like they were made with
dripping india ink. To install this LoRA, we first download it and
put it into the `autoimport/lora` folder located inside the
`invokeai` root directory. After restarting the web server, the
LoRA will now become available for use.
## Reference To see this LoRA at work, we'll first generate an image without it
using the standard `stable-diffusion-v1-5` model. Choose this
model and enter the prompt "mountains, ink". Here is a typical
generated image, a mountain range rendered in ink and watercolor
wash:
### Additional Options ![Ink Scenery without LoRA](../assets/lora-example-0.png){ width=512px }
| parameter <img width=160 align="right"> | effect | Now let's install and activate the Ink Scenery LoRA. Go to
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | https://civitai.com/models/78605/ink-scenery-or and download the LoRA
| `--web_develop` | Starts the web server in development mode. | model file to `invokeai/autoimport/lora` and restart the web
| `--web_verbose` | Enables verbose logging | server. (Alternatively, you can use [InvokeAI's Web Model
| `--cors [CORS ...]` | Additional allowed origins, comma-separated | Manager](../installation/050_INSTALLING_MODELS.md) to download and
| `--host HOST` | Web server: Host or IP to listen on. Set to 0.0.0.0 to accept traffic from other devices on your network. | install the LoRA directly by typing its URL into the _Import
| `--port PORT` | Web server: Port to listen on | Models_->_Location_ field).
| `--certfile CERTFILE` | Web server: Path to certificate file to use for SSL. Use together with --keyfile |
| `--keyfile KEYFILE` | Web server: Path to private key file to use for SSL. Use together with --certfile' |
| `--gui` | Start InvokeAI GUI - This is the "desktop mode" version of the web app. It uses Flask to create a desktop app experience of the webserver. |
### Web Specific Features Scroll down the control panel until you get to the LoRA accordion
section, and open it:
The web experience offers an incredibly easy-to-use experience for interacting ![LoRA Section](../assets/lora-example-1.png){ width=512px }
with the InvokeAI toolkit. For detailed guidance on individual features, see the
Feature-specific help documents available in this directory. Note that the
latest functionality available in the CLI may not always be available in the Web
interface.
#### Dark Mode & Light Mode Click the popup menu and select "Ink scenery". (If it isn't there, then
the model wasn't installed to the right place, or perhaps you forgot
to restart the web server.) The LoRA section will change to look like this:
The InvokeAI interface is available in a nano-carbon black & purple Dark Mode, ![LoRA Section Loaded](../assets/lora-example-2.png){ width=512px }
and a "burn your eyes out Nosferatu" Light Mode. These can be toggled by
clicking the Sun/Moon icons at the top right of the interface.
![InvokeAI Web Server - Dark Mode](../assets/invoke_web_dark.png) Note that there is now a slider control for _Ink scenery_. The slider
controls how much influence the LoRA model will have on the generated
image.
![InvokeAI Web Server - Light Mode](../assets/invoke_web_light.png) Run the "mountains, ink" prompt again and observe the change in style:
#### Invocation Toolbar ![Ink Scenery](../assets/lora-example-3.png){ width=512px }
The left side of the InvokeAI interface is available for customizing the prompt Try adjusting the weight slider for larger and smaller weights and
and the settings used for invoking your new image. Typing your prompt into the generate the image after each adjustment. The higher the weight, the
open text field and clicking the Invoke button will produce the image based on more influence the LoRA will have.
the settings configured in the toolbar.
See below for additional documentation related to each feature: To remove the LoRA completely, just click on its trash can icon.
- [Variations](./VARIATIONS.md) Multiple LoRAs can be added simultaneously and combined with textual
- [Upscaling](./POSTPROCESS.md#upscaling) inversions and ControlNet models. Please see [Textual Inversions and
- [Image to Image](./IMG2IMG.md) LoRAs](CONCEPTS.md) and [Using ControlNet](CONTROLNET.md) for details.
- [Other](./OTHER.md)
#### Invocation Gallery ## Summary
The currently selected --outdir (or the default outputs folder) will display all This walkthrough just skims the surface of the many things InvokeAI
previously generated files on load. As new invocations are generated, these will can do. Please see [Features](index.md) for more detailed reference
be dynamically added to the gallery, and can be previewed by selecting them. guides.
Each image also has a simple set of actions (e.g., Delete, Use Seed, Use All
Parameters, etc.) that can be accessed by hovering over the image.
#### Image Workspace
When an image from the Invocation Gallery is selected, or is generated, the
image will be displayed within the center of the interface. A quickbar of common
image interactions are displayed along the top of the image, including:
- Use image in the `Image to Image` workflow
- Initialize Face Restoration on the selected file
- Initialize Upscaling on the selected file
- View File metadata and details
- Delete the file
## Acknowledgements ## Acknowledgements
A huge shout-out to the core team working to make this vision a reality, A huge shout-out to the core team working to make the Web GUI a reality,
including [psychedelicious](https://github.com/psychedelicious), including [psychedelicious](https://github.com/psychedelicious),
[Kyle0654](https://github.com/Kyle0654) and [Kyle0654](https://github.com/Kyle0654) and
[blessedcoolant](https://github.com/blessedcoolant). [blessedcoolant](https://github.com/blessedcoolant).

View File

@ -17,8 +17,12 @@ a single convenient digital artist-optimized user interface.
### * [Prompt Engineering](PROMPTS.md) ### * [Prompt Engineering](PROMPTS.md)
Get the images you want with the InvokeAI prompt engineering language. Get the images you want with the InvokeAI prompt engineering language.
## * The [Concepts Library](CONCEPTS.md) ### * The [LoRA, LyCORIS and Textual Inversion Models](CONCEPTS.md)
Add custom subjects and styles using HuggingFace's repository of embeddings. Add custom subjects and styles using a variety of fine-tuned models.
### * [ControlNet](CONTROLNET.md)
Learn how to install and use ControlNet models for fine control over
image output.
### * [Image-to-Image Guide](IMG2IMG.md) ### * [Image-to-Image Guide](IMG2IMG.md)
Use a seed image to build new creations in the CLI. Use a seed image to build new creations in the CLI.
@ -29,26 +33,28 @@ are the ticket.
## Model Management ## Model Management
## * [Model Installation](../installation/050_INSTALLING_MODELS.md) ### * [Model Installation](../installation/050_INSTALLING_MODELS.md)
Learn how to import third-party models and switch among them. This Learn how to import third-party models and switch among them. This
guide also covers optimizing models to load quickly. guide also covers optimizing models to load quickly.
## * [Merging Models](MODEL_MERGING.md) ### * [Merging Models](MODEL_MERGING.md)
Teach an old model new tricks. Merge 2-3 models together to create a Teach an old model new tricks. Merge 2-3 models together to create a
new model that combines characteristics of the originals. new model that combines characteristics of the originals.
## * [Textual Inversion](TRAINING.md) ### * [Textual Inversion](TRAINING.md)
Personalize models by adding your own style or subjects. Personalize models by adding your own style or subjects.
# Other Features ## Other Features
## * [The NSFW Checker](NSFW.md) ### * [The NSFW Checker](NSFW.md)
Prevent InvokeAI from displaying unwanted racy images. Prevent InvokeAI from displaying unwanted racy images.
## * [Controlling Logging](LOGGING.md) ### * [Controlling Logging](LOGGING.md)
Control how InvokeAI logs status messages. Control how InvokeAI logs status messages.
## * [Miscellaneous](OTHER.md) <!-- OUT OF DATE
### * [Miscellaneous](OTHER.md)
Run InvokeAI on Google Colab, generate images with repeating patterns, Run InvokeAI on Google Colab, generate images with repeating patterns,
batch process a file of prompts, increase the "creativity" of image batch process a file of prompts, increase the "creativity" of image
generation by adding initial noise, and more! generation by adding initial noise, and more!
-->

View File

@ -145,6 +145,7 @@ This method is recommended for those familiar with running Docker containers
### Model Management ### Model Management
- [Installing](installation/050_INSTALLING_MODELS.md) - [Installing](installation/050_INSTALLING_MODELS.md)
- [Model Merging](features/MODEL_MERGING.md) - [Model Merging](features/MODEL_MERGING.md)
- [ControlNet Models](features/CONTROLNET.md)
- [Style/Subject Concepts and Embeddings](features/CONCEPTS.md) - [Style/Subject Concepts and Embeddings](features/CONCEPTS.md)
- [Not Safe for Work (NSFW) Checker](features/NSFW.md) - [Not Safe for Work (NSFW) Checker](features/NSFW.md)
<!-- seperator --> <!-- seperator -->

View File

@ -24,11 +24,14 @@ async def create_board_image(
): ):
"""Creates a board_image""" """Creates a board_image"""
try: try:
result = ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name) result = ApiDependencies.invoker.services.board_images.add_image_to_board(
board_id=board_id, image_name=image_name
)
return result return result
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail="Failed to add to board") raise HTTPException(status_code=500, detail="Failed to add to board")
@board_images_router.delete( @board_images_router.delete(
"/", "/",
operation_id="remove_board_image", operation_id="remove_board_image",
@ -43,27 +46,10 @@ async def remove_board_image(
): ):
"""Deletes a board_image""" """Deletes a board_image"""
try: try:
result = ApiDependencies.invoker.services.board_images.remove_image_from_board(board_id=board_id, image_name=image_name) result = ApiDependencies.invoker.services.board_images.remove_image_from_board(
board_id=board_id, image_name=image_name
)
return result return result
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail="Failed to update board") raise HTTPException(status_code=500, detail="Failed to update board")
@board_images_router.get(
"/{board_id}",
operation_id="list_board_images",
response_model=OffsetPaginatedResults[ImageDTO],
)
async def list_board_images(
board_id: str = Path(description="The id of the board"),
offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of boards per page"),
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets a list of images for a board"""
results = ApiDependencies.invoker.services.board_images.get_images_for_board(
board_id,
)
return results

View File

@ -1,16 +1,28 @@
from typing import Optional, Union from typing import Optional, Union
from fastapi import Body, HTTPException, Path, Query from fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from pydantic import BaseModel, Field
from invokeai.app.services.board_record_storage import BoardChanges from invokeai.app.services.board_record_storage import BoardChanges
from invokeai.app.services.image_record_storage import OffsetPaginatedResults from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.board_record import BoardDTO from invokeai.app.services.models.board_record import BoardDTO
from ..dependencies import ApiDependencies from ..dependencies import ApiDependencies
boards_router = APIRouter(prefix="/v1/boards", tags=["boards"]) boards_router = APIRouter(prefix="/v1/boards", tags=["boards"])
class DeleteBoardResult(BaseModel):
board_id: str = Field(description="The id of the board that was deleted.")
deleted_board_images: list[str] = Field(
description="The image names of the board-images relationships that were deleted."
)
deleted_images: list[str] = Field(
description="The names of the images that were deleted."
)
@boards_router.post( @boards_router.post(
"/", "/",
operation_id="create_board", operation_id="create_board",
@ -69,25 +81,42 @@ async def update_board(
raise HTTPException(status_code=500, detail="Failed to update board") raise HTTPException(status_code=500, detail="Failed to update board")
@boards_router.delete("/{board_id}", operation_id="delete_board") @boards_router.delete(
"/{board_id}", operation_id="delete_board", response_model=DeleteBoardResult
)
async def delete_board( async def delete_board(
board_id: str = Path(description="The id of board to delete"), board_id: str = Path(description="The id of board to delete"),
include_images: Optional[bool] = Query( include_images: Optional[bool] = Query(
description="Permanently delete all images on the board", default=False description="Permanently delete all images on the board", default=False
), ),
) -> None: ) -> DeleteBoardResult:
"""Deletes a board""" """Deletes a board"""
try: try:
if include_images is True: if include_images is True:
deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id=board_id
)
ApiDependencies.invoker.services.images.delete_images_on_board( ApiDependencies.invoker.services.images.delete_images_on_board(
board_id=board_id board_id=board_id
) )
ApiDependencies.invoker.services.boards.delete(board_id=board_id) ApiDependencies.invoker.services.boards.delete(board_id=board_id)
return DeleteBoardResult(
board_id=board_id,
deleted_board_images=[],
deleted_images=deleted_images,
)
else: else:
deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id=board_id
)
ApiDependencies.invoker.services.boards.delete(board_id=board_id) ApiDependencies.invoker.services.boards.delete(board_id=board_id)
return DeleteBoardResult(
board_id=board_id,
deleted_board_images=deleted_board_images,
deleted_images=[],
)
except Exception as e: except Exception as e:
# TODO: Does this need any exception handling at all? raise HTTPException(status_code=500, detail="Failed to delete board")
pass
@boards_router.get( @boards_router.get(
@ -115,3 +144,19 @@ async def list_boards(
status_code=400, status_code=400,
detail="Invalid request: Must provide either 'all' or both 'offset' and 'limit'", detail="Invalid request: Must provide either 'all' or both 'offset' and 'limit'",
) )
@boards_router.get(
"/{board_id}/image_names",
operation_id="list_all_board_image_names",
response_model=list[str],
)
async def list_all_board_image_names(
board_id: str = Path(description="The id of the board"),
) -> list[str]:
"""Gets a list of images for a board"""
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id,
)
return image_names

View File

@ -84,6 +84,17 @@ async def delete_image(
# TODO: Does this need any exception handling at all? # TODO: Does this need any exception handling at all?
pass pass
@images_router.post("/clear-intermediates", operation_id="clear_intermediates")
async def clear_intermediates() -> int:
"""Clears first 100 intermediates"""
try:
count_deleted = ApiDependencies.invoker.services.images.delete_many(is_intermediate=True)
return count_deleted
except Exception as e:
# TODO: Does this need any exception handling at all?
pass
@images_router.patch( @images_router.patch(
"/{image_name}", "/{image_name}",
@ -234,16 +245,16 @@ async def get_image_urls(
) )
async def list_image_dtos( async def list_image_dtos(
image_origin: Optional[ResourceOrigin] = Query( image_origin: Optional[ResourceOrigin] = Query(
default=None, description="The origin of images to list" default=None, description="The origin of images to list."
), ),
categories: Optional[list[ImageCategory]] = Query( categories: Optional[list[ImageCategory]] = Query(
default=None, description="The categories of image to include" default=None, description="The categories of image to include."
), ),
is_intermediate: Optional[bool] = Query( is_intermediate: Optional[bool] = Query(
default=None, description="Whether to list intermediate images" default=None, description="Whether to list intermediate images."
), ),
board_id: Optional[str] = Query( board_id: Optional[str] = Query(
default=None, description="The board id to filter by" default=None, description="The board id to filter by. Use 'none' to find images without a board."
), ),
offset: int = Query(default=0, description="The page offset"), offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of images per page"), limit: int = Query(default=10, description="The number of images per page"),

View File

@ -4,6 +4,7 @@ import sys
from inspect import signature from inspect import signature
import uvicorn import uvicorn
import socket
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -193,9 +194,22 @@ app.mount("/",
) )
def invoke_api(): def invoke_api():
def find_port(port: int):
"""Find a port not in use starting at given port"""
# Taken from https://waylonwalker.com/python-find-available-port/, thanks Waylon!
# https://github.com/WaylonWalker
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(("localhost", port)) == 0:
return find_port(port=port + 1)
else:
return port
port = find_port(app_config.port)
if port != app_config.port:
logger.warn(f"Port {app_config.port} in use, using port {port}")
# Start our own event loop for eventing usage # Start our own event loop for eventing usage
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
config = uvicorn.Config(app=app, host=app_config.host, port=app_config.port, loop=loop) config = uvicorn.Config(app=app, host=app_config.host, port=port, loop=loop)
# Use access_log to turn off logging # Use access_log to turn off logging
server = uvicorn.Server(config) server = uvicorn.Server(config)
loop.run_until_complete(server.serve()) loop.run_until_complete(server.serve())

View File

@ -22,7 +22,7 @@ from ...backend.stable_diffusion.diffusers_pipeline import (
from ...backend.stable_diffusion.diffusion.shared_invokeai_diffusion import \ from ...backend.stable_diffusion.diffusion.shared_invokeai_diffusion import \
PostprocessingSettings PostprocessingSettings
from ...backend.stable_diffusion.schedulers import SCHEDULER_MAP from ...backend.stable_diffusion.schedulers import SCHEDULER_MAP
from ...backend.util.devices import choose_torch_device, torch_dtype from ...backend.util.devices import choose_torch_device, torch_dtype, choose_precision
from ..models.image import ImageCategory, ImageField, ResourceOrigin from ..models.image import ImageCategory, ImageField, ResourceOrigin
from .baseinvocation import (BaseInvocation, BaseInvocationOutput, from .baseinvocation import (BaseInvocation, BaseInvocationOutput,
InvocationConfig, InvocationContext) InvocationConfig, InvocationContext)
@ -38,6 +38,10 @@ from diffusers.models.attention_processor import (
XFormersAttnProcessor, XFormersAttnProcessor,
) )
DEFAULT_PRECISION = choose_precision(choose_torch_device())
class LatentsField(BaseModel): class LatentsField(BaseModel):
"""A latents field used for passing latents between invocations""" """A latents field used for passing latents between invocations"""
@ -492,7 +496,7 @@ class LatentsToImageInvocation(BaseInvocation):
tiled: bool = Field( tiled: bool = Field(
default=False, default=False,
description="Decode latents by overlaping tiles(less memory consumption)") description="Decode latents by overlaping tiles(less memory consumption)")
fp32: bool = Field(False, description="Decode in full precision") fp32: bool = Field(DEFAULT_PRECISION=='float32', description="Decode in full precision")
metadata: Optional[CoreMetadata] = Field(default=None, description="Optional core metadata to be written to the image") metadata: Optional[CoreMetadata] = Field(default=None, description="Optional core metadata to be written to the image")
# Schema customisation # Schema customisation
@ -686,7 +690,7 @@ class ImageToLatentsInvocation(BaseInvocation):
tiled: bool = Field( tiled: bool = Field(
default=False, default=False,
description="Encode latents by overlaping tiles(less memory consumption)") description="Encode latents by overlaping tiles(less memory consumption)")
fp32: bool = Field(False, description="Decode in full precision") fp32: bool = Field(DEFAULT_PRECISION=='float32', description="Decode in full precision")
# Schema customisation # Schema customisation

View File

@ -32,11 +32,11 @@ class BoardImageRecordStorageBase(ABC):
pass pass
@abstractmethod @abstractmethod
def get_images_for_board( def get_all_board_image_names_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageRecord]: ) -> list[str]:
"""Gets images for a board.""" """Gets all board images for a board, as a list of the image names."""
pass pass
@abstractmethod @abstractmethod
@ -211,6 +211,26 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
items=images, offset=offset, limit=limit, total=count items=images, offset=offset, limit=limit, total=count
) )
def get_all_board_image_names_for_board(self, board_id: str) -> list[str]:
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
SELECT image_name
FROM board_images
WHERE board_id = ?;
""",
(board_id,),
)
result = cast(list[sqlite3.Row], self._cursor.fetchall())
image_names = list(map(lambda r: r[0], result))
return image_names
except sqlite3.Error as e:
self._conn.rollback()
raise e
finally:
self._lock.release()
def get_board_for_image( def get_board_for_image(
self, self,
image_name: str, image_name: str,

View File

@ -38,11 +38,11 @@ class BoardImagesServiceABC(ABC):
pass pass
@abstractmethod @abstractmethod
def get_images_for_board( def get_all_board_image_names_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageDTO]: ) -> list[str]:
"""Gets images for a board.""" """Gets all board images for a board, as a list of the image names."""
pass pass
@abstractmethod @abstractmethod
@ -98,30 +98,13 @@ class BoardImagesService(BoardImagesServiceABC):
) -> None: ) -> None:
self._services.board_image_records.remove_image_from_board(board_id, image_name) self._services.board_image_records.remove_image_from_board(board_id, image_name)
def get_images_for_board( def get_all_board_image_names_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageDTO]: ) -> list[str]:
image_records = self._services.board_image_records.get_images_for_board( return self._services.board_image_records.get_all_board_image_names_for_board(
board_id board_id
) )
image_dtos = list(
map(
lambda r: image_record_to_dto(
r,
self._services.urls.get_image_url(r.image_name),
self._services.urls.get_image_url(r.image_name, True),
board_id,
),
image_records.items,
)
)
return OffsetPaginatedResults[ImageDTO](
items=image_dtos,
offset=image_records.offset,
limit=image_records.limit,
total=image_records.total,
)
def get_board_for_image( def get_board_for_image(
self, self,
@ -136,7 +119,7 @@ def board_record_to_dto(
) -> BoardDTO: ) -> BoardDTO:
"""Converts a board record to a board DTO.""" """Converts a board record to a board DTO."""
return BoardDTO( return BoardDTO(
**board_record.dict(exclude={'cover_image_name'}), **board_record.dict(exclude={"cover_image_name"}),
cover_image_name=cover_image_name, cover_image_name=cover_image_name,
image_count=image_count, image_count=image_count,
) )

View File

@ -141,7 +141,7 @@ class EventServiceBase:
model_type=model_type, model_type=model_type,
submodel=submodel, submodel=submodel,
hash=model_info.hash, hash=model_info.hash,
location=model_info.location, location=str(model_info.location),
precision=str(model_info.precision), precision=str(model_info.precision),
), ),
) )

View File

@ -10,7 +10,10 @@ from pydantic.generics import GenericModel
from invokeai.app.models.image import ImageCategory, ResourceOrigin from invokeai.app.models.image import ImageCategory, ResourceOrigin
from invokeai.app.services.models.image_record import ( from invokeai.app.services.models.image_record import (
ImageRecord, ImageRecordChanges, deserialize_image_record) ImageRecord,
ImageRecordChanges,
deserialize_image_record,
)
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)
@ -97,8 +100,8 @@ class ImageRecordStorageBase(ABC):
@abstractmethod @abstractmethod
def get_many( def get_many(
self, self,
offset: int = 0, offset: Optional[int] = None,
limit: int = 10, limit: Optional[int] = None,
image_origin: Optional[ResourceOrigin] = None, image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None, categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None, is_intermediate: Optional[bool] = None,
@ -322,8 +325,8 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
def get_many( def get_many(
self, self,
offset: int = 0, offset: Optional[int] = None,
limit: int = 10, limit: Optional[int] = None,
image_origin: Optional[ResourceOrigin] = None, image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None, categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None, is_intermediate: Optional[bool] = None,
@ -377,11 +380,15 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
query_params.append(is_intermediate) query_params.append(is_intermediate)
if board_id is not None: # board_id of "none" is reserved for images without a board
if board_id == "none":
query_conditions += """--sql
AND board_images.board_id IS NULL
"""
elif board_id is not None:
query_conditions += """--sql query_conditions += """--sql
AND board_images.board_id = ? AND board_images.board_id = ?
""" """
query_params.append(board_id) query_params.append(board_id)
query_pagination = """--sql query_pagination = """--sql
@ -392,8 +399,12 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
images_query += query_conditions + query_pagination + ";" images_query += query_conditions + query_pagination + ";"
# Add all the parameters # Add all the parameters
images_params = query_params.copy() images_params = query_params.copy()
if limit is not None:
images_params.append(limit) images_params.append(limit)
if offset is not None:
images_params.append(offset) images_params.append(offset)
# Build the list of images, deserializing each row # Build the list of images, deserializing each row
self._cursor.execute(images_query, images_params) self._cursor.execute(images_query, images_params)
result = cast(list[sqlite3.Row], self._cursor.fetchall()) result = cast(list[sqlite3.Row], self._cursor.fetchall())

View File

@ -11,7 +11,6 @@ from invokeai.app.models.image import (ImageCategory,
InvalidOriginException, ResourceOrigin) InvalidOriginException, ResourceOrigin)
from invokeai.app.services.board_image_record_storage import \ from invokeai.app.services.board_image_record_storage import \
BoardImageRecordStorageBase BoardImageRecordStorageBase
from invokeai.app.services.graph import Graph
from invokeai.app.services.image_file_storage import ( from invokeai.app.services.image_file_storage import (
ImageFileDeleteException, ImageFileNotFoundException, ImageFileDeleteException, ImageFileNotFoundException,
ImageFileSaveException, ImageFileStorageBase) ImageFileSaveException, ImageFileStorageBase)
@ -109,6 +108,13 @@ class ImageServiceABC(ABC):
"""Deletes an image.""" """Deletes an image."""
pass pass
@abstractmethod
def delete_many(self, is_intermediate: bool) -> int:
"""Deletes many images."""
pass
@abstractmethod @abstractmethod
def delete_images_on_board(self, board_id: str): def delete_images_on_board(self, board_id: str):
"""Deletes all images on a board.""" """Deletes all images on a board."""
@ -378,16 +384,39 @@ class ImageService(ImageServiceABC):
def delete_images_on_board(self, board_id: str): def delete_images_on_board(self, board_id: str):
try: try:
images = self._services.board_image_records.get_images_for_board(board_id) image_names = (
image_name_list = list( self._services.board_image_records.get_all_board_image_names_for_board(
map( board_id
lambda r: r.image_name,
images.items,
) )
) )
for image_name in image_name_list: for image_name in image_names:
self._services.image_files.delete(image_name) self._services.image_files.delete(image_name)
self._services.image_records.delete_many(image_name_list) self._services.image_records.delete_many(image_names)
except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image records")
raise
except ImageFileDeleteException:
self._services.logger.error(f"Failed to delete image files")
raise
except Exception as e:
self._services.logger.error("Problem deleting image records and files")
raise e
def delete_many(self, is_intermediate: bool):
try:
# only clears 100 at a time
images = self._services.image_records.get_many(offset=0, limit=100, is_intermediate=is_intermediate,)
count = len(images.items)
image_name_list = list(
map(
lambda r: r.image_name,
images.items,
)
)
for image_name in image_name_list:
self._services.image_files.delete(image_name)
self._services.image_records.delete_many(image_name_list)
return count
except ImageRecordDeleteException: except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image records") self._services.logger.error(f"Failed to delete image records")
raise raise

View File

@ -560,7 +560,6 @@ def edit_opts(program_opts: Namespace, invokeai_opts: Namespace) -> argparse.Nam
editApp.run() editApp.run()
return editApp.new_opts() return editApp.new_opts()
def default_startup_options(init_file: Path) -> Namespace: def default_startup_options(init_file: Path) -> Namespace:
opts = InvokeAIAppConfig.get_config() opts = InvokeAIAppConfig.get_config()
if not init_file.exists(): if not init_file.exists():
@ -664,6 +663,9 @@ def write_opts(opts: Namespace, init_file: Path):
with open(init_file,'w', encoding='utf-8') as file: with open(init_file,'w', encoding='utf-8') as file:
file.write(new_config.to_yaml()) file.write(new_config.to_yaml())
if opts.hf_token:
HfLogin(opts.hf_token)
# ------------------------------------- # -------------------------------------
def default_output_dir() -> Path: def default_output_dir() -> Path:
return config.root_path / "outputs" return config.root_path / "outputs"

View File

@ -21,6 +21,7 @@ import re
import warnings import warnings
from pathlib import Path from pathlib import Path
from typing import Union from typing import Union
from packaging import version
import torch import torch
from safetensors.torch import load_file from safetensors.torch import load_file
@ -63,6 +64,7 @@ from diffusers.pipelines.stable_diffusion.safety_checker import (
StableDiffusionSafetyChecker, StableDiffusionSafetyChecker,
) )
from diffusers.utils import is_safetensors_available from diffusers.utils import is_safetensors_available
import transformers
from transformers import ( from transformers import (
AutoFeatureExtractor, AutoFeatureExtractor,
BertTokenizerFast, BertTokenizerFast,
@ -841,6 +843,15 @@ def convert_ldm_clip_checkpoint(checkpoint):
key key
] ]
# transformers 4.31.0 and higher - this key no longer in state dict
if version.parse(transformers.__version__) >= version.parse("4.31.0"):
position_ids = text_model_dict.pop("text_model.embeddings.position_ids", None)
text_model.load_state_dict(text_model_dict)
if position_ids is not None:
text_model.text_model.embeddings.position_ids.copy_(position_ids)
# transformers 4.30.2 and lower - position_ids is part of state_dict
else:
text_model.load_state_dict(text_model_dict) text_model.load_state_dict(text_model_dict)
return text_model return text_model
@ -947,6 +958,15 @@ def convert_open_clip_checkpoint(checkpoint):
text_model_dict[new_key] = checkpoint[key] text_model_dict[new_key] = checkpoint[key]
# transformers 4.31.0 and higher - this key no longer in state dict
if version.parse(transformers.__version__) >= version.parse("4.31.0"):
position_ids = text_model_dict.pop("text_model.embeddings.position_ids", None)
text_model.load_state_dict(text_model_dict)
if position_ids is not None:
text_model.text_model.embeddings.position_ids.copy_(position_ids)
# transformers 4.30.2 and lower - position_ids is part of state_dict
else:
text_model.load_state_dict(text_model_dict) text_model.load_state_dict(text_model_dict)
return text_model return text_model

View File

@ -938,20 +938,29 @@ class ModelManager(object):
def models_found(self): def models_found(self):
return self.new_models_found return self.new_models_found
config = self.app_config
# LS: hacky
# Patch in the SD VAE from core so that it is available for use by the UI
try:
self.heuristic_import({config.root_path / 'models/core/convert/sd-vae-ft-mse'})
except:
pass
installer = ModelInstall(config = self.app_config, installer = ModelInstall(config = self.app_config,
model_manager = self, model_manager = self,
prediction_type_helper = ask_user_for_prediction_type, prediction_type_helper = ask_user_for_prediction_type,
) )
config = self.app_config
known_paths = {config.root_path / x['path'] for x in self.list_models()} known_paths = {config.root_path / x['path'] for x in self.list_models()}
directories = {config.root_path / x for x in [config.autoimport_dir, directories = {config.root_path / x for x in [config.autoimport_dir,
config.lora_dir, config.lora_dir,
config.embedding_dir, config.embedding_dir,
config.controlnet_dir] config.controlnet_dir,
]
} }
scanner = ScanAndImport(directories, self.logger, ignore=known_paths, installer=installer) scanner = ScanAndImport(directories, self.logger, ignore=known_paths, installer=installer)
scanner.search() scanner.search()
return scanner.models_found() return scanner.models_found()
def heuristic_import(self, def heuristic_import(self,

View File

@ -15,7 +15,6 @@ import InvokeTabs from 'features/ui/components/InvokeTabs';
import ParametersDrawer from 'features/ui/components/ParametersDrawer'; import ParametersDrawer from 'features/ui/components/ParametersDrawer';
import i18n from 'i18n'; import i18n from 'i18n';
import { ReactNode, memo, useEffect } from 'react'; import { ReactNode, memo, useEffect } from 'react';
import DeleteBoardImagesModal from '../../features/gallery/components/Boards/DeleteBoardImagesModal';
import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal'; import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal';
import GlobalHotkeys from './GlobalHotkeys'; import GlobalHotkeys from './GlobalHotkeys';
import Toaster from './Toaster'; import Toaster from './Toaster';
@ -84,7 +83,6 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
</Grid> </Grid>
<DeleteImageModal /> <DeleteImageModal />
<UpdateImageBoardModal /> <UpdateImageBoardModal />
<DeleteBoardImagesModal />
<Toaster /> <Toaster />
<GlobalHotkeys /> <GlobalHotkeys />
</> </>

View File

@ -15,10 +15,7 @@ const STYLES: ChakraProps['sx'] = {
maxH: BOX_SIZE, maxH: BOX_SIZE,
shadow: 'dark-lg', shadow: 'dark-lg',
borderRadius: 'lg', borderRadius: 'lg',
borderWidth: 2, opacity: 0.3,
borderStyle: 'dashed',
borderColor: 'base.100',
opacity: 0.5,
bg: 'base.800', bg: 'base.800',
color: 'base.50', color: 'base.50',
_dark: { _dark: {

View File

@ -28,6 +28,7 @@ const ImageDndContext = (props: ImageDndContextProps) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
console.log('dragStart', event.active.data.current);
const activeData = event.active.data.current; const activeData = event.active.data.current;
if (!activeData) { if (!activeData) {
return; return;
@ -37,15 +38,16 @@ const ImageDndContext = (props: ImageDndContextProps) => {
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
console.log('dragEnd', event.active.data.current);
const activeData = event.active.data.current; const activeData = event.active.data.current;
const overData = event.over?.data.current; const overData = event.over?.data.current;
if (!activeData || !overData) { if (!activeDragData || !overData) {
return; return;
} }
dispatch(dndDropped({ overData, activeData })); dispatch(dndDropped({ overData, activeData: activeDragData }));
setActiveDragData(null); setActiveDragData(null);
}, },
[dispatch] [activeDragData, dispatch]
); );
const mouseSensor = useSensor(MouseSensor, { const mouseSensor = useSensor(MouseSensor, {

View File

@ -11,6 +11,7 @@ import {
useDraggable as useOriginalDraggable, useDraggable as useOriginalDraggable,
useDroppable as useOriginalDroppable, useDroppable as useOriginalDroppable,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { BoardId } from 'features/gallery/store/gallerySlice';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
type BaseDropData = { type BaseDropData = {
@ -55,7 +56,7 @@ export type AddToBatchDropData = BaseDropData & {
export type MoveBoardDropData = BaseDropData & { export type MoveBoardDropData = BaseDropData & {
actionType: 'MOVE_BOARD'; actionType: 'MOVE_BOARD';
context: { boardId: string | null }; context: { boardId: BoardId };
}; };
export type TypesafeDroppableData = export type TypesafeDroppableData =
@ -158,8 +159,36 @@ export const isValidDrop = (
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'ADD_TO_BATCH': case 'ADD_TO_BATCH':
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'MOVE_BOARD': case 'MOVE_BOARD': {
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; // If the board is the same, don't allow the drop
// Check the payload types
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
if (!isPayloadValid) {
return false;
}
// Check if the image's board is the board we are dragging onto
if (payloadType === 'IMAGE_DTO') {
const { imageDTO } = active.data.current.payload;
const currentBoard = imageDTO.board_id;
const destinationBoard = overData.context.boardId;
const isSameBoard = currentBoard === destinationBoard;
const isDestinationValid = !currentBoard
? destinationBoard !== 'no_board'
: true;
return !isSameBoard && isDestinationValid;
}
if (payloadType === 'IMAGE_NAMES') {
// TODO (multi-select)
return false;
}
return true;
}
default: default:
return false; return false;
} }

View File

@ -18,7 +18,6 @@ import { Middleware } from '@reduxjs/toolkit';
import ImageDndContext from './ImageDnd/ImageDndContext'; import ImageDndContext from './ImageDnd/ImageDndContext';
import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext'; import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext';
import { $authToken, $baseUrl } from 'services/api/client'; import { $authToken, $baseUrl } from 'services/api/client';
import { DeleteBoardImagesContextProvider } from '../contexts/DeleteBoardImagesContext';
const App = lazy(() => import('./App')); const App = lazy(() => import('./App'));
const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider')); const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider'));
@ -78,9 +77,7 @@ const InvokeAIUI = ({
<ThemeLocaleProvider> <ThemeLocaleProvider>
<ImageDndContext> <ImageDndContext>
<AddImageToBoardContextProvider> <AddImageToBoardContextProvider>
<DeleteBoardImagesContextProvider>
<App config={config} headerComponent={headerComponent} /> <App config={config} headerComponent={headerComponent} />
</DeleteBoardImagesContextProvider>
</AddImageToBoardContextProvider> </AddImageToBoardContextProvider>
</ImageDndContext> </ImageDndContext>
</ThemeLocaleProvider> </ThemeLocaleProvider>

View File

@ -1,7 +1,8 @@
import { useDisclosure } from '@chakra-ui/react'; import { useDisclosure } from '@chakra-ui/react';
import { PropsWithChildren, createContext, useCallback, useState } from 'react'; import { PropsWithChildren, createContext, useCallback, useState } from 'react';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { useAddImageToBoardMutation } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { useAppDispatch } from '../store/storeHooks';
export type ImageUsage = { export type ImageUsage = {
isInitialImage: boolean; isInitialImage: boolean;
@ -40,8 +41,7 @@ type Props = PropsWithChildren;
export const AddImageToBoardContextProvider = (props: Props) => { export const AddImageToBoardContextProvider = (props: Props) => {
const [imageToMove, setImageToMove] = useState<ImageDTO>(); const [imageToMove, setImageToMove] = useState<ImageDTO>();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
const [addImageToBoard, result] = useAddImageToBoardMutation();
// Clean up after deleting or dismissing the modal // Clean up after deleting or dismissing the modal
const closeAndClearImageToDelete = useCallback(() => { const closeAndClearImageToDelete = useCallback(() => {
@ -63,14 +63,16 @@ export const AddImageToBoardContextProvider = (props: Props) => {
const handleAddToBoard = useCallback( const handleAddToBoard = useCallback(
(boardId: string) => { (boardId: string) => {
if (imageToMove) { if (imageToMove) {
addImageToBoard({ dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
imageDTO: imageToMove,
board_id: boardId, board_id: boardId,
image_name: imageToMove.image_name, })
}); );
closeAndClearImageToDelete(); closeAndClearImageToDelete();
} }
}, },
[addImageToBoard, closeAndClearImageToDelete, imageToMove] [dispatch, closeAndClearImageToDelete, imageToMove]
); );
return ( return (

View File

@ -1,170 +0,0 @@
import { useDisclosure } from '@chakra-ui/react';
import { PropsWithChildren, createContext, useCallback, useState } from 'react';
import { BoardDTO } from 'services/api/types';
import { useDeleteBoardMutation } from '../../services/api/endpoints/boards';
import { defaultSelectorOptions } from '../store/util/defaultMemoizeOptions';
import { createSelector } from '@reduxjs/toolkit';
import { some } from 'lodash-es';
import { canvasSelector } from 'features/canvas/store/canvasSelectors';
import { controlNetSelector } from 'features/controlNet/store/controlNetSlice';
import { selectImagesById } from 'features/gallery/store/gallerySlice';
import { nodesSelector } from 'features/nodes/store/nodesSlice';
import { generationSelector } from 'features/parameters/store/generationSelectors';
import { RootState } from '../store/store';
import { useAppDispatch, useAppSelector } from '../store/storeHooks';
import { ImageUsage } from './DeleteImageContext';
import { requestedBoardImagesDeletion } from 'features/gallery/store/actions';
export const selectBoardImagesUsage = createSelector(
[
(state: RootState) => state,
generationSelector,
canvasSelector,
nodesSelector,
controlNetSelector,
(state: RootState, board_id?: string) => board_id,
],
(state, generation, canvas, nodes, controlNet, board_id) => {
const initialImage = generation.initialImage
? selectImagesById(state, generation.initialImage.imageName)
: undefined;
const isInitialImage = initialImage?.board_id === board_id;
const isCanvasImage = canvas.layerState.objects.some((obj) => {
if (obj.kind === 'image') {
const image = selectImagesById(state, obj.imageName);
return image?.board_id === board_id;
}
return false;
});
const isNodesImage = nodes.nodes.some((node) => {
return some(node.data.inputs, (input) => {
if (input.type === 'image' && input.value) {
const image = selectImagesById(state, input.value.image_name);
return image?.board_id === board_id;
}
return false;
});
});
const isControlNetImage = some(controlNet.controlNets, (c) => {
const controlImage = c.controlImage
? selectImagesById(state, c.controlImage)
: undefined;
const processedControlImage = c.processedControlImage
? selectImagesById(state, c.processedControlImage)
: undefined;
return (
controlImage?.board_id === board_id ||
processedControlImage?.board_id === board_id
);
});
const imageUsage: ImageUsage = {
isInitialImage,
isCanvasImage,
isNodesImage,
isControlNetImage,
};
return imageUsage;
},
defaultSelectorOptions
);
type DeleteBoardImagesContextValue = {
/**
* Whether the move image dialog is open.
*/
isOpen: boolean;
/**
* Closes the move image dialog.
*/
onClose: () => void;
imagesUsage?: ImageUsage;
board?: BoardDTO;
onClickDeleteBoardImages: (board: BoardDTO) => void;
handleDeleteBoardImages: (boardId: string) => void;
handleDeleteBoardOnly: (boardId: string) => void;
};
export const DeleteBoardImagesContext =
createContext<DeleteBoardImagesContextValue>({
isOpen: false,
onClose: () => undefined,
onClickDeleteBoardImages: () => undefined,
handleDeleteBoardImages: () => undefined,
handleDeleteBoardOnly: () => undefined,
});
type Props = PropsWithChildren;
export const DeleteBoardImagesContextProvider = (props: Props) => {
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
// Check where the board images to be deleted are used (eg init image, controlnet, etc.)
const imagesUsage = useAppSelector((state) =>
selectBoardImagesUsage(state, boardToDelete?.board_id)
);
const [deleteBoard] = useDeleteBoardMutation();
// Clean up after deleting or dismissing the modal
const closeAndClearBoardToDelete = useCallback(() => {
setBoardToDelete(undefined);
onClose();
}, [onClose]);
const onClickDeleteBoardImages = useCallback(
(board?: BoardDTO) => {
console.log({ board });
if (!board) {
return;
}
setBoardToDelete(board);
onOpen();
},
[setBoardToDelete, onOpen]
);
const handleDeleteBoardImages = useCallback(
(boardId: string) => {
if (boardToDelete) {
dispatch(
requestedBoardImagesDeletion({ board: boardToDelete, imagesUsage })
);
closeAndClearBoardToDelete();
}
},
[dispatch, closeAndClearBoardToDelete, boardToDelete, imagesUsage]
);
const handleDeleteBoardOnly = useCallback(
(boardId: string) => {
if (boardToDelete) {
deleteBoard(boardId);
closeAndClearBoardToDelete();
}
},
[deleteBoard, closeAndClearBoardToDelete, boardToDelete]
);
return (
<DeleteBoardImagesContext.Provider
value={{
isOpen,
board: boardToDelete,
onClose: closeAndClearBoardToDelete,
onClickDeleteBoardImages,
handleDeleteBoardImages,
handleDeleteBoardOnly,
imagesUsage,
}}
>
{props.children}
</DeleteBoardImagesContext.Provider>
);
};

View File

@ -11,7 +11,7 @@ import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingA
import { addAppConfigReceivedListener } from './listeners/appConfigReceived'; import { addAppConfigReceivedListener } from './listeners/appConfigReceived';
import { addAppStartedListener } from './listeners/appStarted'; import { addAppStartedListener } from './listeners/appStarted';
import { addBoardIdSelectedListener } from './listeners/boardIdSelected'; import { addBoardIdSelectedListener } from './listeners/boardIdSelected';
import { addRequestedBoardImageDeletionListener } from './listeners/boardImagesDeleted'; import { addDeleteBoardAndImagesFulfilledListener } from './listeners/boardAndImagesDeleted';
import { addCanvasCopiedToClipboardListener } from './listeners/canvasCopiedToClipboard'; import { addCanvasCopiedToClipboardListener } from './listeners/canvasCopiedToClipboard';
import { addCanvasDownloadedAsImageListener } from './listeners/canvasDownloadedAsImage'; import { addCanvasDownloadedAsImageListener } from './listeners/canvasDownloadedAsImage';
import { addCanvasMergedListener } from './listeners/canvasMerged'; import { addCanvasMergedListener } from './listeners/canvasMerged';
@ -29,10 +29,6 @@ import {
addRequestedImageDeletionListener, addRequestedImageDeletionListener,
} from './listeners/imageDeleted'; } from './listeners/imageDeleted';
import { addImageDroppedListener } from './listeners/imageDropped'; import { addImageDroppedListener } from './listeners/imageDropped';
import {
addImageMetadataReceivedFulfilledListener,
addImageMetadataReceivedRejectedListener,
} from './listeners/imageMetadataReceived';
import { import {
addImageRemovedFromBoardFulfilledListener, addImageRemovedFromBoardFulfilledListener,
addImageRemovedFromBoardRejectedListener, addImageRemovedFromBoardRejectedListener,
@ -46,18 +42,10 @@ import {
addImageUploadedFulfilledListener, addImageUploadedFulfilledListener,
addImageUploadedRejectedListener, addImageUploadedRejectedListener,
} from './listeners/imageUploaded'; } from './listeners/imageUploaded';
import {
addImageUrlsReceivedFulfilledListener,
addImageUrlsReceivedRejectedListener,
} from './listeners/imageUrlsReceived';
import { addInitialImageSelectedListener } from './listeners/initialImageSelected'; import { addInitialImageSelectedListener } from './listeners/initialImageSelected';
import { addModelSelectedListener } from './listeners/modelSelected'; import { addModelSelectedListener } from './listeners/modelSelected';
import { addModelsLoadedListener } from './listeners/modelsLoaded'; import { addModelsLoadedListener } from './listeners/modelsLoaded';
import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema'; import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema';
import {
addReceivedPageOfImagesFulfilledListener,
addReceivedPageOfImagesRejectedListener,
} from './listeners/receivedPageOfImages';
import { import {
addSessionCanceledFulfilledListener, addSessionCanceledFulfilledListener,
addSessionCanceledPendingListener, addSessionCanceledPendingListener,
@ -91,6 +79,7 @@ import { addUserInvokedTextToImageListener } from './listeners/userInvokedTextTo
import { addModelLoadStartedEventListener } from './listeners/socketio/socketModelLoadStarted'; import { addModelLoadStartedEventListener } from './listeners/socketio/socketModelLoadStarted';
import { addModelLoadCompletedEventListener } from './listeners/socketio/socketModelLoadCompleted'; import { addModelLoadCompletedEventListener } from './listeners/socketio/socketModelLoadCompleted';
import { addUpscaleRequestedListener } from './listeners/upscaleRequested'; import { addUpscaleRequestedListener } from './listeners/upscaleRequested';
import { addFirstListImagesListener } from './listeners/addFirstListImagesListener.ts';
export const listenerMiddleware = createListenerMiddleware(); export const listenerMiddleware = createListenerMiddleware();
@ -132,17 +121,9 @@ addRequestedImageDeletionListener();
addImageDeletedPendingListener(); addImageDeletedPendingListener();
addImageDeletedFulfilledListener(); addImageDeletedFulfilledListener();
addImageDeletedRejectedListener(); addImageDeletedRejectedListener();
addRequestedBoardImageDeletionListener(); addDeleteBoardAndImagesFulfilledListener();
addImageToDeleteSelectedListener(); addImageToDeleteSelectedListener();
// Image metadata
addImageMetadataReceivedFulfilledListener();
addImageMetadataReceivedRejectedListener();
// Image URLs
addImageUrlsReceivedFulfilledListener();
addImageUrlsReceivedRejectedListener();
// User Invoked // User Invoked
addUserInvokedCanvasListener(); addUserInvokedCanvasListener();
addUserInvokedNodesListener(); addUserInvokedNodesListener();
@ -198,17 +179,10 @@ addSessionCanceledPendingListener();
addSessionCanceledFulfilledListener(); addSessionCanceledFulfilledListener();
addSessionCanceledRejectedListener(); addSessionCanceledRejectedListener();
// Fetching images
addReceivedPageOfImagesFulfilledListener();
addReceivedPageOfImagesRejectedListener();
// ControlNet // ControlNet
addControlNetImageProcessedListener(); addControlNetImageProcessedListener();
addControlNetAutoProcessListener(); addControlNetAutoProcessListener();
// Update image URLs on connect
// addUpdateImageUrlsOnConnectListener();
// Boards // Boards
addImageAddedToBoardFulfilledListener(); addImageAddedToBoardFulfilledListener();
addImageAddedToBoardRejectedListener(); addImageAddedToBoardRejectedListener();
@ -229,5 +203,7 @@ addModelSelectedListener();
addAppStartedListener(); addAppStartedListener();
addModelsLoadedListener(); addModelsLoadedListener();
addAppConfigReceivedListener(); addAppConfigReceivedListener();
addFirstListImagesListener();
// Ad-hoc upscale workflwo
addUpscaleRequestedListener(); addUpscaleRequestedListener();

View File

@ -0,0 +1,43 @@
import { createAction } from '@reduxjs/toolkit';
import {
IMAGE_CATEGORIES,
imageSelected,
} from 'features/gallery/store/gallerySlice';
import {
ImageCache,
getListImagesUrl,
imagesApi,
} from 'services/api/endpoints/images';
import { startAppListening } from '..';
export const appStarted = createAction('app/appStarted');
export const addFirstListImagesListener = () => {
startAppListening({
matcher: imagesApi.endpoints.listImages.matchFulfilled,
effect: async (
action,
{ getState, dispatch, unsubscribe, cancelActiveListeners }
) => {
// Only run this listener on the first listImages request for `images` categories
if (
action.meta.arg.queryCacheKey !==
getListImagesUrl({ categories: IMAGE_CATEGORIES })
) {
return;
}
// this should only run once
cancelActiveListeners();
unsubscribe();
// TODO: figure out how to type the predicate
const data = action.payload as ImageCache;
if (data.ids.length > 0) {
// Select the first image
dispatch(imageSelected(data.ids[0] as string));
}
},
});
};

View File

@ -1,11 +1,4 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
INITIAL_IMAGE_LIMIT,
isLoadingChanged,
} from 'features/gallery/store/gallerySlice';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..'; import { startAppListening } from '..';
export const appStarted = createAction('app/appStarted'); export const appStarted = createAction('app/appStarted');
@ -17,29 +10,9 @@ export const addAppStartedListener = () => {
action, action,
{ getState, dispatch, unsubscribe, cancelActiveListeners } { getState, dispatch, unsubscribe, cancelActiveListeners }
) => { ) => {
// this should only run once
cancelActiveListeners(); cancelActiveListeners();
unsubscribe(); unsubscribe();
// fill up the gallery tab with images
await dispatch(
receivedPageOfImages({
categories: IMAGE_CATEGORIES,
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
})
);
// fill up the assets tab with images
await dispatch(
receivedPageOfImages({
categories: ASSETS_CATEGORIES,
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
})
);
dispatch(isLoadingChanged(false));
}, },
}); });
}; };

View File

@ -0,0 +1,48 @@
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { getImageUsage } from 'features/imageDeletion/store/imageDeletionSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { startAppListening } from '..';
import { boardsApi } from '../../../../../services/api/endpoints/boards';
export const addDeleteBoardAndImagesFulfilledListener = () => {
startAppListening({
matcher: boardsApi.endpoints.deleteBoardAndImages.matchFulfilled,
effect: async (action, { dispatch, getState, condition }) => {
const { board_id, deleted_board_images, deleted_images } = action.payload;
// Remove all deleted images from the UI
let wasInitialImageReset = false;
let wasCanvasReset = false;
let wasNodeEditorReset = false;
let wasControlNetReset = false;
const state = getState();
deleted_images.forEach((image_name) => {
const imageUsage = getImageUsage(state, image_name);
if (imageUsage.isInitialImage && !wasInitialImageReset) {
dispatch(clearInitialImage());
wasInitialImageReset = true;
}
if (imageUsage.isCanvasImage && !wasCanvasReset) {
dispatch(resetCanvas());
wasCanvasReset = true;
}
if (imageUsage.isNodesImage && !wasNodeEditorReset) {
dispatch(nodeEditorReset());
wasNodeEditorReset = true;
}
if (imageUsage.isControlNetImage && !wasControlNetReset) {
dispatch(controlNetReset());
wasControlNetReset = true;
}
});
},
});
};

View File

@ -1,17 +1,13 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import { import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
boardIdSelected, boardIdSelected,
imageSelected, imageSelected,
selectImagesAll,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { boardsApi } from 'services/api/endpoints/boards';
import { import {
IMAGES_PER_PAGE, getBoardIdQueryParamForBoard,
receivedPageOfImages, getCategoriesQueryParamForBoard,
} from 'services/api/thunks/image'; } from 'features/gallery/store/util';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' }); const moduleLog = log.child({ namespace: 'boards' });
@ -19,54 +15,44 @@ const moduleLog = log.child({ namespace: 'boards' });
export const addBoardIdSelectedListener = () => { export const addBoardIdSelectedListener = () => {
startAppListening({ startAppListening({
actionCreator: boardIdSelected, actionCreator: boardIdSelected,
effect: (action, { getState, dispatch }) => { effect: async (
const board_id = action.payload; action,
{ getState, dispatch, condition, cancelActiveListeners }
) => {
// Cancel any in-progress instances of this listener, we don't want to select an image from a previous board
cancelActiveListeners();
// we need to check if we need to fetch more images const _board_id = action.payload;
// when a board is selected, we need to wait until the board has loaded *some* images, then select the first one
const state = getState(); const categories = getCategoriesQueryParamForBoard(_board_id);
const allImages = selectImagesAll(state); const board_id = getBoardIdQueryParamForBoard(_board_id);
const queryArgs = { board_id, categories };
if (board_id === 'all') { // wait until the board has some images - maybe it already has some from a previous fetch
// Selected all images // must use getState() to ensure we do not have stale state
dispatch(imageSelected(allImages[0]?.image_name ?? null)); const isSuccess = await condition(
return; () =>
} imagesApi.endpoints.listImages.select(queryArgs)(getState())
.isSuccess,
if (board_id === 'batch') { 1000
// Selected the batch
dispatch(imageSelected(state.gallery.batchImageNames[0] ?? null));
return;
}
const filteredImages = selectFilteredImages(state);
const categories =
state.gallery.galleryView === 'images'
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES;
// get the board from the cache
const { data: boards } =
boardsApi.endpoints.listAllBoards.select()(state);
const board = boards?.find((b) => b.board_id === board_id);
if (!board) {
// can't find the board in cache...
dispatch(boardIdSelected('all'));
return;
}
dispatch(imageSelected(board.cover_image_name ?? null));
// if we haven't loaded one full page of images from this board, load more
if (
filteredImages.length < board.image_count &&
filteredImages.length < IMAGES_PER_PAGE
) {
dispatch(
receivedPageOfImages({ categories, board_id, is_intermediate: false })
); );
if (isSuccess) {
// the board was just changed - we can select the first image
const { data: boardImagesData } = imagesApi.endpoints.listImages.select(
queryArgs
)(getState());
if (boardImagesData?.ids.length) {
dispatch(imageSelected((boardImagesData.ids[0] as string) ?? null));
} else {
// board has no images - deselect
dispatch(imageSelected(null));
}
} else {
// fallback - deselect
dispatch(imageSelected(null));
} }
}, },
}); });

View File

@ -1,82 +0,0 @@
import { requestedBoardImagesDeletion } from 'features/gallery/store/actions';
import { startAppListening } from '..';
import {
imageSelected,
imagesRemoved,
selectImagesAll,
selectImagesById,
} from 'features/gallery/store/gallerySlice';
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { LIST_TAG, api } from 'services/api';
import { boardsApi } from '../../../../../services/api/endpoints/boards';
export const addRequestedBoardImageDeletionListener = () => {
startAppListening({
actionCreator: requestedBoardImagesDeletion,
effect: async (action, { dispatch, getState, condition }) => {
const { board, imagesUsage } = action.payload;
const { board_id } = board;
const state = getState();
const selectedImageName =
state.gallery.selection[state.gallery.selection.length - 1];
const selectedImage = selectedImageName
? selectImagesById(state, selectedImageName)
: undefined;
if (selectedImage && selectedImage.board_id === board_id) {
dispatch(imageSelected(null));
}
// We need to reset the features where the board images are in use - none of these work if their image(s) don't exist
if (imagesUsage.isCanvasImage) {
dispatch(resetCanvas());
}
if (imagesUsage.isControlNetImage) {
dispatch(controlNetReset());
}
if (imagesUsage.isInitialImage) {
dispatch(clearInitialImage());
}
if (imagesUsage.isNodesImage) {
dispatch(nodeEditorReset());
}
// Preemptively remove from gallery
const images = selectImagesAll(state).reduce((acc: string[], img) => {
if (img.board_id === board_id) {
acc.push(img.image_name);
}
return acc;
}, []);
dispatch(imagesRemoved(images));
// Delete from server
dispatch(boardsApi.endpoints.deleteBoardAndImages.initiate(board_id));
const result =
boardsApi.endpoints.deleteBoardAndImages.select(board_id)(state);
const { isSuccess } = result;
// Wait for successful deletion, then trigger boards to re-fetch
const wasBoardDeleted = await condition(() => !!isSuccess, 30000);
if (wasBoardDeleted) {
dispatch(
api.util.invalidateTags([
{ type: 'Board', id: board_id },
{ type: 'Image', id: LIST_TAG },
])
);
}
},
});
};

View File

@ -1,11 +1,11 @@
import { canvasMerged } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { addToast } from 'features/system/store/systemSlice'; import { canvasMerged } from 'features/canvas/store/actions';
import { imageUploaded } from 'services/api/thunks/image';
import { setMergedCanvas } from 'features/canvas/store/canvasSlice'; import { setMergedCanvas } from 'features/canvas/store/canvasSlice';
import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob'; import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob';
import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' }); const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' });
@ -46,27 +46,28 @@ export const addCanvasMergedListener = () => {
}); });
const imageUploadedRequest = dispatch( const imageUploadedRequest = dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([blob], 'mergedCanvas.png', { file: new File([blob], 'mergedCanvas.png', {
type: 'image/png', type: 'image/png',
}), }),
image_category: 'general', image_category: 'general',
is_intermediate: true, is_intermediate: true,
postUploadAction: { postUploadAction: {
type: 'TOAST_CANVAS_MERGED', type: 'TOAST',
toastOptions: { title: 'Canvas Merged' },
}, },
}) })
); );
const [{ payload }] = await take( const [{ payload }] = await take(
( (uploadedImageAction) =>
uploadedImageAction imagesApi.endpoints.uploadImage.matchFulfilled(uploadedImageAction) &&
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(uploadedImageAction) &&
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
); );
const { image_name } = payload; // TODO: I can't figure out how to do the type narrowing in the `take()` so just brute forcing it here
const { image_name } =
payload as typeof imagesApi.endpoints.uploadImage.Types.ResultType;
dispatch( dispatch(
setMergedCanvas({ setMergedCanvas({
@ -76,13 +77,6 @@ export const addCanvasMergedListener = () => {
...baseLayerRect, ...baseLayerRect,
}) })
); );
dispatch(
addToast({
title: 'Canvas Merged',
status: 'success',
})
);
}, },
}); });
}; };

View File

@ -1,10 +1,9 @@
import { canvasSavedToGallery } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imageUploaded } from 'services/api/thunks/image'; import { canvasSavedToGallery } from 'features/canvas/store/actions';
import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imageUpserted } from 'features/gallery/store/gallerySlice'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' }); const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' });
@ -28,28 +27,19 @@ export const addCanvasSavedToGalleryListener = () => {
return; return;
} }
const imageUploadedRequest = dispatch( dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([blob], 'savedCanvas.png', { file: new File([blob], 'savedCanvas.png', {
type: 'image/png', type: 'image/png',
}), }),
image_category: 'general', image_category: 'general',
is_intermediate: false, is_intermediate: false,
postUploadAction: { postUploadAction: {
type: 'TOAST_CANVAS_SAVED_TO_GALLERY', type: 'TOAST',
toastOptions: { title: 'Canvas Saved to Gallery' },
}, },
}) })
); );
const [{ payload: uploadedImageDTO }] = await take(
(
uploadedImageAction
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(uploadedImageAction) &&
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
);
dispatch(imageUpserted(uploadedImageDTO));
}, },
}); });
}; };

View File

@ -2,10 +2,10 @@ import { log } from 'app/logging/useLogger';
import { controlNetImageProcessed } from 'features/controlNet/store/actions'; import { controlNetImageProcessed } from 'features/controlNet/store/actions';
import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice'; import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice';
import { sessionReadyToInvoke } from 'features/system/store/actions'; import { sessionReadyToInvoke } from 'features/system/store/actions';
import { imagesApi } from 'services/api/endpoints/images';
import { isImageOutput } from 'services/api/guards'; import { isImageOutput } from 'services/api/guards';
import { imageDTOReceived } from 'services/api/thunks/image';
import { sessionCreated } from 'services/api/thunks/session'; import { sessionCreated } from 'services/api/thunks/session';
import { Graph } from 'services/api/types'; import { Graph, ImageDTO } from 'services/api/types';
import { socketInvocationComplete } from 'services/events/actions'; import { socketInvocationComplete } from 'services/events/actions';
import { startAppListening } from '..'; import { startAppListening } from '..';
@ -62,12 +62,13 @@ export const addControlNetImageProcessedListener = () => {
invocationCompleteAction.payload.data.result.image; invocationCompleteAction.payload.data.result.image;
// Wait for the ImageDTO to be received // Wait for the ImageDTO to be received
const [imageMetadataReceivedAction] = await take( const [{ payload }] = await take(
(action): action is ReturnType<typeof imageDTOReceived.fulfilled> => (action) =>
imageDTOReceived.fulfilled.match(action) && imagesApi.endpoints.getImageDTO.matchFulfilled(action) &&
action.payload.image_name === image_name action.payload.image_name === image_name
); );
const processedControlImage = imageMetadataReceivedAction.payload;
const processedControlImage = payload as ImageDTO;
moduleLog.debug( moduleLog.debug(
{ data: { arg: action.payload, processedControlImage } }, { data: { arg: action.payload, processedControlImage } },

View File

@ -1,31 +1,30 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' }); const moduleLog = log.child({ namespace: 'boards' });
export const addImageAddedToBoardFulfilledListener = () => { export const addImageAddedToBoardFulfilledListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchFulfilled, matcher: imagesApi.endpoints.addImageToBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, imageDTO } = action.meta.arg.originalArgs;
moduleLog.debug( // TODO: update listImages cache for this board
{ data: { board_id, image_name } },
'Image added to board' moduleLog.debug({ data: { board_id, imageDTO } }, 'Image added to board');
);
}, },
}); });
}; };
export const addImageAddedToBoardRejectedListener = () => { export const addImageAddedToBoardRejectedListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchRejected, matcher: imagesApi.endpoints.addImageToBoard.matchRejected,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, imageDTO } = action.meta.arg.originalArgs;
moduleLog.debug( moduleLog.debug(
{ data: { board_id, image_name } }, { data: { board_id, imageDTO } },
'Problem adding image to board' 'Problem adding image to board'
); );
}, },

View File

@ -1,19 +1,17 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { resetCanvas } from 'features/canvas/store/canvasSlice'; import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { selectNextImageToSelect } from 'features/gallery/store/gallerySelectors'; import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { import { imageSelected } from 'features/gallery/store/gallerySlice';
imageRemoved,
imageSelected,
} from 'features/gallery/store/gallerySlice';
import { import {
imageDeletionConfirmed, imageDeletionConfirmed,
isModalOpenChanged, isModalOpenChanged,
} from 'features/imageDeletion/store/imageDeletionSlice'; } from 'features/imageDeletion/store/imageDeletionSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice'; import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { clamp } from 'lodash-es';
import { api } from 'services/api'; import { api } from 'services/api';
import { imageDeleted } from 'services/api/thunks/image'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
@ -36,10 +34,28 @@ export const addRequestedImageDeletionListener = () => {
state.gallery.selection[state.gallery.selection.length - 1]; state.gallery.selection[state.gallery.selection.length - 1];
if (lastSelectedImage === image_name) { if (lastSelectedImage === image_name) {
const newSelectedImageId = selectNextImageToSelect(state, image_name); const baseQueryArgs = selectListImagesBaseQueryArgs(state);
const { data } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const ids = data?.ids ?? [];
const deletedImageIndex = ids.findIndex(
(result) => result.toString() === image_name
);
const filteredIds = ids.filter((id) => id.toString() !== image_name);
const newSelectedImageIndex = clamp(
deletedImageIndex,
0,
filteredIds.length - 1
);
const newSelectedImageId = filteredIds[newSelectedImageIndex];
if (newSelectedImageId) { if (newSelectedImageId) {
dispatch(imageSelected(newSelectedImageId)); dispatch(imageSelected(newSelectedImageId as string));
} else { } else {
dispatch(imageSelected(null)); dispatch(imageSelected(null));
} }
@ -63,16 +79,15 @@ export const addRequestedImageDeletionListener = () => {
dispatch(nodeEditorReset()); dispatch(nodeEditorReset());
} }
// Preemptively remove from gallery
dispatch(imageRemoved(image_name));
// Delete from server // Delete from server
const { requestId } = dispatch(imageDeleted({ image_name })); const { requestId } = dispatch(
imagesApi.endpoints.deleteImage.initiate(imageDTO)
);
// Wait for successful deletion, then trigger boards to re-fetch // Wait for successful deletion, then trigger boards to re-fetch
const wasImageDeleted = await condition( const wasImageDeleted = await condition(
(action): action is ReturnType<typeof imageDeleted.fulfilled> => (action) =>
imageDeleted.fulfilled.match(action) && imagesApi.endpoints.deleteImage.matchFulfilled(action) &&
action.meta.requestId === requestId, action.meta.requestId === requestId,
30000 30000
); );
@ -91,7 +106,7 @@ export const addRequestedImageDeletionListener = () => {
*/ */
export const addImageDeletedPendingListener = () => { export const addImageDeletedPendingListener = () => {
startAppListening({ startAppListening({
actionCreator: imageDeleted.pending, matcher: imagesApi.endpoints.deleteImage.matchPending,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
// //
}, },
@ -103,9 +118,12 @@ export const addImageDeletedPendingListener = () => {
*/ */
export const addImageDeletedFulfilledListener = () => { export const addImageDeletedFulfilledListener = () => {
startAppListening({ startAppListening({
actionCreator: imageDeleted.fulfilled, matcher: imagesApi.endpoints.deleteImage.matchFulfilled,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
moduleLog.debug({ data: { image: action.meta.arg } }, 'Image deleted'); moduleLog.debug(
{ data: { image: action.meta.arg.originalArgs } },
'Image deleted'
);
}, },
}); });
}; };
@ -115,10 +133,10 @@ export const addImageDeletedFulfilledListener = () => {
*/ */
export const addImageDeletedRejectedListener = () => { export const addImageDeletedRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageDeleted.rejected, matcher: imagesApi.endpoints.deleteImage.matchRejected,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
moduleLog.debug( moduleLog.debug(
{ data: { image: action.meta.arg } }, { data: { image: action.meta.arg.originalArgs } },
'Unable to delete image' 'Unable to delete image'
); );
}, },

View File

@ -10,12 +10,9 @@ import {
imageSelected, imageSelected,
imagesAddedToBatch, imagesAddedToBatch,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
fieldValueChanged,
imageCollectionFieldValueChanged,
} from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '../'; import { startAppListening } from '../';
const moduleLog = log.child({ namespace: 'dnd' }); const moduleLog = log.child({ namespace: 'dnd' });
@ -137,23 +134,23 @@ export const addImageDroppedListener = () => {
return; return;
} }
// set multiple nodes images (multiple images handler) // // set multiple nodes images (multiple images handler)
if ( // if (
overData.actionType === 'SET_MULTI_NODES_IMAGE' && // overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_NAMES' // activeData.payloadType === 'IMAGE_NAMES'
) { // ) {
const { fieldName, nodeId } = overData.context; // const { fieldName, nodeId } = overData.context;
dispatch( // dispatch(
imageCollectionFieldValueChanged({ // imageCollectionFieldValueChanged({
nodeId, // nodeId,
fieldName, // fieldName,
value: activeData.payload.image_names.map((image_name) => ({ // value: activeData.payload.image_names.map((image_name) => ({
image_name, // image_name,
})), // })),
}) // })
); // );
return; // return;
} // }
// add image to board // add image to board
if ( if (
@ -162,97 +159,95 @@ export const addImageDroppedListener = () => {
activeData.payload.imageDTO && activeData.payload.imageDTO &&
overData.context.boardId overData.context.boardId
) { ) {
const { image_name } = activeData.payload.imageDTO; const { imageDTO } = activeData.payload;
const { boardId } = overData.context; const { boardId } = overData.context;
// if the board is "No Board", this is a remove action
if (boardId === 'no_board') {
dispatch( dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({ imagesApi.endpoints.removeImageFromBoard.initiate({
image_name, imageDTO,
})
);
return;
}
// Handle adding image to batch
if (boardId === 'batch') {
// TODO
}
// Otherwise, add the image to the board
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
imageDTO,
board_id: boardId, board_id: boardId,
}) })
); );
return; return;
} }
// remove image from board // // add gallery selection to board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' && // activeData.payloadType === 'IMAGE_NAMES' &&
activeData.payload.imageDTO && // overData.context.boardId
overData.context.boardId === null // ) {
) { // console.log('adding gallery selection to board');
const { image_name, board_id } = activeData.payload.imageDTO; // const board_id = overData.context.boardId;
if (board_id) { // dispatch(
dispatch( // boardImagesApi.endpoints.addManyBoardImages.initiate({
boardImagesApi.endpoints.removeImageFromBoard.initiate({ // board_id,
image_name, // image_names: activeData.payload.image_names,
board_id, // })
}) // );
); // return;
} // }
return;
}
// add gallery selection to board // // remove gallery selection from board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' && // activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId // overData.context.boardId === null
) { // ) {
console.log('adding gallery selection to board'); // console.log('removing gallery selection to board');
const board_id = overData.context.boardId; // dispatch(
dispatch( // boardImagesApi.endpoints.deleteManyBoardImages.initiate({
boardImagesApi.endpoints.addManyBoardImages.initiate({ // image_names: activeData.payload.image_names,
board_id, // })
image_names: activeData.payload.image_names, // );
}) // return;
); // }
return;
}
// remove gallery selection from board // // add batch selection to board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' && // activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId === null // overData.context.boardId
) { // ) {
console.log('removing gallery selection to board'); // const board_id = overData.context.boardId;
dispatch( // dispatch(
boardImagesApi.endpoints.deleteManyBoardImages.initiate({ // boardImagesApi.endpoints.addManyBoardImages.initiate({
image_names: activeData.payload.image_names, // board_id,
}) // image_names: activeData.payload.image_names,
); // })
return; // );
} // return;
// }
// add batch selection to board // // remove batch selection from board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' && // activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId // overData.context.boardId === null
) { // ) {
const board_id = overData.context.boardId; // dispatch(
dispatch( // boardImagesApi.endpoints.deleteManyBoardImages.initiate({
boardImagesApi.endpoints.addManyBoardImages.initiate({ // image_names: activeData.payload.image_names,
board_id, // })
image_names: activeData.payload.image_names, // );
}) // return;
); // }
return;
}
// remove batch selection from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId === null
) {
dispatch(
boardImagesApi.endpoints.deleteManyBoardImages.initiate({
image_names: activeData.payload.image_names,
})
);
return;
}
}, },
}); });
}; };

View File

@ -1,51 +0,0 @@
import { log } from 'app/logging/useLogger';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { imageDTOReceived, imageUpdated } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' });
export const addImageMetadataReceivedFulfilledListener = () => {
startAppListening({
actionCreator: imageDTOReceived.fulfilled,
effect: (action, { getState, dispatch }) => {
const image = action.payload;
const state = getState();
if (
image.session_id === state.canvas.layerState.stagingArea.sessionId &&
state.canvas.shouldAutoSave
) {
dispatch(
imageUpdated({
image_name: image.image_name,
is_intermediate: image.is_intermediate,
})
);
} else if (image.is_intermediate) {
// No further actions needed for intermediate images
moduleLog.trace(
{ data: { image } },
'Image metadata received (intermediate), skipping'
);
return;
}
moduleLog.debug({ data: { image } }, 'Image metadata received');
dispatch(imageUpserted(image));
},
});
};
export const addImageMetadataReceivedRejectedListener = () => {
startAppListening({
actionCreator: imageDTOReceived.rejected,
effect: (action, { getState, dispatch }) => {
moduleLog.debug(
{ data: { image: action.meta.arg } },
'Problem receiving image metadata'
);
},
});
};

View File

@ -1,12 +1,12 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' }); const moduleLog = log.child({ namespace: 'boards' });
export const addImageRemovedFromBoardFulfilledListener = () => { export const addImageRemovedFromBoardFulfilledListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchFulfilled, matcher: imagesApi.endpoints.removeImageFromBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, image_name } = action.meta.arg.originalArgs;
@ -20,7 +20,7 @@ export const addImageRemovedFromBoardFulfilledListener = () => {
export const addImageRemovedFromBoardRejectedListener = () => { export const addImageRemovedFromBoardRejectedListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchRejected, matcher: imagesApi.endpoints.removeImageFromBoard.matchRejected,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, image_name } = action.meta.arg.originalArgs;

View File

@ -1,15 +1,20 @@
import { startAppListening } from '..';
import { imageUpdated } from 'services/api/thunks/image';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
export const addImageUpdatedFulfilledListener = () => { export const addImageUpdatedFulfilledListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUpdated.fulfilled, matcher: imagesApi.endpoints.updateImage.matchFulfilled,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
moduleLog.debug( moduleLog.debug(
{ oldImage: action.meta.arg, updatedImage: action.payload }, {
data: {
oldImage: action.meta.arg.originalArgs,
updatedImage: action.payload,
},
},
'Image updated' 'Image updated'
); );
}, },
@ -18,9 +23,12 @@ export const addImageUpdatedFulfilledListener = () => {
export const addImageUpdatedRejectedListener = () => { export const addImageUpdatedRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUpdated.rejected, matcher: imagesApi.endpoints.updateImage.matchRejected,
effect: (action, { dispatch }) => { effect: (action, { dispatch }) => {
moduleLog.debug({ oldImage: action.meta.arg }, 'Image update failed'); moduleLog.debug(
{ data: action.meta.arg.originalArgs },
'Image update failed'
);
}, },
}); });
}; };

View File

@ -1,49 +1,87 @@
import { UseToastOptions } from '@chakra-ui/react';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice'; import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import { import { imagesAddedToBatch } from 'features/gallery/store/gallerySlice';
imageUpserted,
imagesAddedToBatch,
} from 'features/gallery/store/gallerySlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice'; import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imageUploaded } from 'services/api/thunks/image'; import { boardsApi } from 'services/api/endpoints/boards';
import { startAppListening } from '..'; import { startAppListening } from '..';
import {
SYSTEM_BOARDS,
imagesApi,
} from '../../../../../services/api/endpoints/images';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
const DEFAULT_UPLOADED_TOAST: UseToastOptions = {
title: 'Image Uploaded',
status: 'success',
};
export const addImageUploadedFulfilledListener = () => { export const addImageUploadedFulfilledListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUploaded.fulfilled, matcher: imagesApi.endpoints.uploadImage.matchFulfilled,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
const image = action.payload; const imageDTO = action.payload;
const state = getState();
const { selectedBoardId } = state.gallery;
moduleLog.debug({ arg: '<Blob>', image }, 'Image uploaded'); moduleLog.debug({ arg: '<Blob>', imageDTO }, 'Image uploaded');
if (action.payload.is_intermediate) { const { postUploadAction } = action.meta.arg.originalArgs;
// No further actions needed for intermediate images
if (
// No further actions needed for intermediate images,
action.payload.is_intermediate &&
// unless they have an explicit post-upload action
!postUploadAction
) {
return; return;
} }
dispatch(imageUpserted(image)); // default action - just upload and alert user
if (postUploadAction?.type === 'TOAST') {
const { postUploadAction } = action.meta.arg; const { toastOptions } = postUploadAction;
if (SYSTEM_BOARDS.includes(selectedBoardId)) {
if (postUploadAction?.type === 'TOAST_CANVAS_SAVED_TO_GALLERY') { dispatch(addToast({ ...DEFAULT_UPLOADED_TOAST, ...toastOptions }));
} else {
// Add this image to the board
dispatch( dispatch(
addToast({ title: 'Canvas Saved to Gallery', status: 'success' }) imagesApi.endpoints.addImageToBoard.initiate({
board_id: selectedBoardId,
imageDTO,
})
); );
return;
}
if (postUploadAction?.type === 'TOAST_CANVAS_MERGED') { // Attempt to get the board's name for the toast
dispatch(addToast({ title: 'Canvas Merged', status: 'success' })); const { data } = boardsApi.endpoints.listAllBoards.select()(state);
// Fall back to just the board id if we can't find the board for some reason
const board = data?.find((b) => b.board_id === selectedBoardId);
const description = board
? `Added to board ${board.board_name}`
: `Added to board ${selectedBoardId}`;
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description,
})
);
}
return; return;
} }
if (postUploadAction?.type === 'SET_CANVAS_INITIAL_IMAGE') { if (postUploadAction?.type === 'SET_CANVAS_INITIAL_IMAGE') {
dispatch(setInitialCanvasImage(image)); dispatch(setInitialCanvasImage(imageDTO));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Set as canvas initial image',
})
);
return; return;
} }
@ -52,30 +90,49 @@ export const addImageUploadedFulfilledListener = () => {
dispatch( dispatch(
controlNetImageChanged({ controlNetImageChanged({
controlNetId, controlNetId,
controlImage: image.image_name, controlImage: imageDTO.image_name,
})
);
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Set as control image',
}) })
); );
return; return;
} }
if (postUploadAction?.type === 'SET_INITIAL_IMAGE') { if (postUploadAction?.type === 'SET_INITIAL_IMAGE') {
dispatch(initialImageChanged(image)); dispatch(initialImageChanged(imageDTO));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Set as initial image',
})
);
return; return;
} }
if (postUploadAction?.type === 'SET_NODES_IMAGE') { if (postUploadAction?.type === 'SET_NODES_IMAGE') {
const { nodeId, fieldName } = postUploadAction; const { nodeId, fieldName } = postUploadAction;
dispatch(fieldValueChanged({ nodeId, fieldName, value: image })); dispatch(fieldValueChanged({ nodeId, fieldName, value: imageDTO }));
return; dispatch(
} addToast({
...DEFAULT_UPLOADED_TOAST,
if (postUploadAction?.type === 'TOAST_UPLOADED') { description: `Set as node field ${fieldName}`,
dispatch(addToast({ title: 'Image Uploaded', status: 'success' })); })
);
return; return;
} }
if (postUploadAction?.type === 'ADD_TO_BATCH') { if (postUploadAction?.type === 'ADD_TO_BATCH') {
dispatch(imagesAddedToBatch([image.image_name])); dispatch(imagesAddedToBatch([imageDTO.image_name]));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Added to batch',
})
);
return; return;
} }
}, },
@ -84,10 +141,10 @@ export const addImageUploadedFulfilledListener = () => {
export const addImageUploadedRejectedListener = () => { export const addImageUploadedRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUploaded.rejected, matcher: imagesApi.endpoints.uploadImage.matchRejected,
effect: (action, { dispatch }) => { effect: (action, { dispatch }) => {
const { formData, ...rest } = action.meta.arg; const { file, postUploadAction, ...rest } = action.meta.arg.originalArgs;
const sanitizedData = { arg: { ...rest, formData: { file: '<Blob>' } } }; const sanitizedData = { arg: { ...rest, file: '<Blob>' } };
moduleLog.error({ data: sanitizedData }, 'Image upload failed'); moduleLog.error({ data: sanitizedData }, 'Image upload failed');
dispatch( dispatch(
addToast({ addToast({

View File

@ -1,37 +0,0 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageUrlsReceived } from 'services/api/thunks/image';
import { imageUpdatedOne } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'image' });
export const addImageUrlsReceivedFulfilledListener = () => {
startAppListening({
actionCreator: imageUrlsReceived.fulfilled,
effect: (action, { getState, dispatch }) => {
const image = action.payload;
moduleLog.debug({ data: { image } }, 'Image URLs received');
const { image_name, image_url, thumbnail_url } = image;
dispatch(
imageUpdatedOne({
id: image_name,
changes: { image_url, thumbnail_url },
})
);
},
});
};
export const addImageUrlsReceivedRejectedListener = () => {
startAppListening({
actionCreator: imageUrlsReceived.rejected,
effect: (action, { getState, dispatch }) => {
moduleLog.debug(
{ data: { image: action.meta.arg } },
'Problem getting image URLs'
);
},
});
};

View File

@ -1,11 +1,9 @@
import { initialImageChanged } from 'features/parameters/store/generationSlice';
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'; import { makeToast } from 'app/components/Toaster';
import { selectImagesById } from 'features/gallery/store/gallerySlice'; import { initialImageSelected } from 'features/parameters/store/actions';
import { isImageDTO } from 'services/api/guards'; import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice';
import { t } from 'i18next';
import { startAppListening } from '..';
export const addInitialImageSelectedListener = () => { export const addInitialImageSelectedListener = () => {
startAppListening({ startAppListening({
@ -20,26 +18,8 @@ export const addInitialImageSelectedListener = () => {
return; return;
} }
if (isImageDTO(action.payload)) {
dispatch(initialImageChanged(action.payload)); dispatch(initialImageChanged(action.payload));
dispatch(addToast(makeToast(t('toast.sentToImageToImage')))); dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
return;
}
const imageName = action.payload;
const image = selectImagesById(getState(), imageName);
if (!image) {
dispatch(
addToast(
makeToast({ title: t('toast.imageNotLoadedDesc'), status: 'error' })
)
);
return;
}
dispatch(initialImageChanged(image));
dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
}, },
}); });
}; };

View File

@ -1,40 +0,0 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { serializeError } from 'serialize-error';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { imagesApi } from 'services/api/endpoints/images';
const moduleLog = log.child({ namespace: 'gallery' });
export const addReceivedPageOfImagesFulfilledListener = () => {
startAppListening({
actionCreator: receivedPageOfImages.fulfilled,
effect: (action, { getState, dispatch }) => {
const { items } = action.payload;
moduleLog.debug(
{ data: { payload: action.payload } },
`Received ${items.length} images`
);
items.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
},
});
};
export const addReceivedPageOfImagesRejectedListener = () => {
startAppListening({
actionCreator: receivedPageOfImages.rejected,
effect: (action, { getState, dispatch }) => {
if (action.payload) {
moduleLog.debug(
{ data: { error: serializeError(action.payload) } },
'Problem receiving images'
);
}
},
});
};

View File

@ -1,9 +1,17 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice'; import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import {
IMAGE_CATEGORIES,
boardIdSelected,
imageSelected,
} from 'features/gallery/store/gallerySlice';
import { progressImageSet } from 'features/system/store/systemSlice'; import { progressImageSet } from 'features/system/store/systemSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import {
SYSTEM_BOARDS,
imagesAdapter,
imagesApi,
} from 'services/api/endpoints/images';
import { isImageOutput } from 'services/api/guards'; import { isImageOutput } from 'services/api/guards';
import { imageDTOReceived } from 'services/api/thunks/image';
import { sessionCanceled } from 'services/api/thunks/session'; import { sessionCanceled } from 'services/api/thunks/session';
import { import {
appSocketInvocationComplete, appSocketInvocationComplete,
@ -22,7 +30,6 @@ export const addInvocationCompleteEventListener = () => {
{ data: action.payload }, { data: action.payload },
`Invocation complete (${action.payload.data.node.type})` `Invocation complete (${action.payload.data.node.type})`
); );
const session_id = action.payload.data.graph_execution_state_id; const session_id = action.payload.data.graph_execution_state_id;
const { cancelType, isCancelScheduled, boardIdToAddTo } = const { cancelType, isCancelScheduled, boardIdToAddTo } =
@ -39,35 +46,72 @@ export const addInvocationCompleteEventListener = () => {
// This complete event has an associated image output // This complete event has an associated image output
if (isImageOutput(result) && !nodeDenylist.includes(node.type)) { if (isImageOutput(result) && !nodeDenylist.includes(node.type)) {
const { image_name } = result.image; const { image_name } = result.image;
const { canvas, gallery } = getState();
// Get its metadata const imageDTO = await dispatch(
dispatch( imagesApi.endpoints.getImageDTO.initiate(image_name)
imageDTOReceived({ ).unwrap();
image_name,
})
);
const [{ payload: imageDTO }] = await take( // Add canvas images to the staging area
imageDTOReceived.fulfilled.match
);
// Handle canvas image
if ( if (
graph_execution_state_id === graph_execution_state_id === canvas.layerState.stagingArea.sessionId
getState().canvas.layerState.stagingArea.sessionId
) { ) {
dispatch(addImageToStagingArea(imageDTO)); dispatch(addImageToStagingArea(imageDTO));
} }
if (boardIdToAddTo && !imageDTO.is_intermediate) { if (!imageDTO.is_intermediate) {
// update the cache for 'All Images'
dispatch( dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({ imagesApi.util.updateQueryData(
'listImages',
{
categories: IMAGE_CATEGORIES,
},
(draft) => {
imagesAdapter.addOne(draft, imageDTO);
draft.total = draft.total + 1;
}
)
);
// update the cache for 'No Board'
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{
board_id: 'none',
},
(draft) => {
imagesAdapter.addOne(draft, imageDTO);
draft.total = draft.total + 1;
}
)
);
// add image to the board if we had one selected
if (boardIdToAddTo && !SYSTEM_BOARDS.includes(boardIdToAddTo)) {
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
board_id: boardIdToAddTo, board_id: boardIdToAddTo,
image_name, imageDTO,
}) })
); );
} }
const { selectedBoardId } = gallery;
if (boardIdToAddTo && boardIdToAddTo !== selectedBoardId) {
dispatch(boardIdSelected(boardIdToAddTo));
} else if (!boardIdToAddTo) {
dispatch(boardIdSelected('all'));
}
// If auto-switch is enabled, select the new image
if (getState().gallery.shouldAutoSwitch) {
dispatch(imageSelected(imageDTO.image_name));
}
}
dispatch(progressImageSet(null)); dispatch(progressImageSet(null));
} }
// pass along the socket event as an application action // pass along the socket event as an application action

View File

@ -1,9 +1,8 @@
import { stagingAreaImageSaved } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imageUpdated } from 'services/api/thunks/image'; import { stagingAreaImageSaved } from 'features/canvas/store/actions';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvas' }); const moduleLog = log.child({ namespace: 'canvas' });
@ -11,41 +10,27 @@ export const addStagingAreaImageSavedListener = () => {
startAppListening({ startAppListening({
actionCreator: stagingAreaImageSaved, actionCreator: stagingAreaImageSaved,
effect: async (action, { dispatch, getState, take }) => { effect: async (action, { dispatch, getState, take }) => {
const { imageName } = action.payload; const { imageDTO } = action.payload;
dispatch( dispatch(
imageUpdated({ imagesApi.endpoints.updateImage.initiate({
image_name: imageName, imageDTO,
is_intermediate: false, changes: { is_intermediate: false },
}) })
); )
.unwrap()
const [imageUpdatedAction] = await take( .then((image) => {
(action) => dispatch(addToast({ title: 'Image Saved', status: 'success' }));
(imageUpdated.fulfilled.match(action) || })
imageUpdated.rejected.match(action)) && .catch((error) => {
action.meta.arg.image_name === imageName
);
if (imageUpdated.rejected.match(imageUpdatedAction)) {
moduleLog.error(
{ data: { arg: imageUpdatedAction.meta.arg } },
'Image saving failed'
);
dispatch( dispatch(
addToast({ addToast({
title: 'Image Saving Failed', title: 'Image Saving Failed',
description: imageUpdatedAction.error.message, description: error.message,
status: 'error', status: 'error',
}) })
); );
return; });
}
if (imageUpdated.fulfilled.match(imageUpdatedAction)) {
dispatch(imageUpserted(imageUpdatedAction.payload));
dispatch(addToast({ title: 'Image Saved', status: 'success' }));
}
}, },
}); });
}; };

View File

@ -1,91 +0,0 @@
import { socketConnected } from 'services/events/actions';
import { startAppListening } from '..';
import { createSelector } from '@reduxjs/toolkit';
import { generationSelector } from 'features/parameters/store/generationSelectors';
import { canvasSelector } from 'features/canvas/store/canvasSelectors';
import { nodesSelector } from 'features/nodes/store/nodesSlice';
import { controlNetSelector } from 'features/controlNet/store/controlNetSlice';
import { forEach, uniqBy } from 'lodash-es';
import { imageUrlsReceived } from 'services/api/thunks/image';
import { log } from 'app/logging/useLogger';
import { selectImagesEntities } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'images' });
const selectAllUsedImages = createSelector(
[
generationSelector,
canvasSelector,
nodesSelector,
controlNetSelector,
selectImagesEntities,
],
(generation, canvas, nodes, controlNet, imageEntities) => {
const allUsedImages: string[] = [];
if (generation.initialImage) {
allUsedImages.push(generation.initialImage.imageName);
}
canvas.layerState.objects.forEach((obj) => {
if (obj.kind === 'image') {
allUsedImages.push(obj.imageName);
}
});
nodes.nodes.forEach((node) => {
forEach(node.data.inputs, (input) => {
if (input.type === 'image' && input.value) {
allUsedImages.push(input.value.image_name);
}
});
});
forEach(controlNet.controlNets, (c) => {
if (c.controlImage) {
allUsedImages.push(c.controlImage);
}
if (c.processedControlImage) {
allUsedImages.push(c.processedControlImage);
}
});
forEach(imageEntities, (image) => {
if (image) {
allUsedImages.push(image.image_name);
}
});
const uniqueImages = uniqBy(allUsedImages, 'image_name');
return uniqueImages;
}
);
export const addUpdateImageUrlsOnConnectListener = () => {
startAppListening({
actionCreator: socketConnected,
effect: async (action, { dispatch, getState, take }) => {
const state = getState();
if (!state.config.shouldUpdateImagesOnConnect) {
return;
}
const allUsedImages = selectAllUsedImages(state);
moduleLog.trace(
{ data: allUsedImages },
`Fetching new image URLs for ${allUsedImages.length} images`
);
allUsedImages.forEach((image_name) => {
dispatch(
imageUrlsReceived({
image_name,
})
);
});
},
});
};

View File

@ -1,20 +1,20 @@
import { startAppListening } from '..';
import { sessionCreated } from 'services/api/thunks/session';
import { buildCanvasGraph } from 'features/nodes/util/graphBuilders/buildCanvasGraph';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { canvasGraphBuilt } from 'features/nodes/store/actions'; import { userInvoked } from 'app/store/actions';
import { imageUpdated, imageUploaded } from 'services/api/thunks/image'; import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { ImageDTO } from 'services/api/types';
import { import {
canvasSessionIdChanged, canvasSessionIdChanged,
stagingAreaInitialized, stagingAreaInitialized,
} from 'features/canvas/store/canvasSlice'; } from 'features/canvas/store/canvasSlice';
import { userInvoked } from 'app/store/actions'; import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
import { getCanvasData } from 'features/canvas/util/getCanvasData'; import { getCanvasData } from 'features/canvas/util/getCanvasData';
import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode'; import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode';
import { blobToDataURL } from 'features/canvas/util/blobToDataURL'; import { canvasGraphBuilt } from 'features/nodes/store/actions';
import openBase64ImageInTab from 'common/util/openBase64ImageInTab'; import { buildCanvasGraph } from 'features/nodes/util/graphBuilders/buildCanvasGraph';
import { sessionReadyToInvoke } from 'features/system/store/actions'; import { sessionReadyToInvoke } from 'features/system/store/actions';
import { imagesApi } from 'services/api/endpoints/images';
import { sessionCreated } from 'services/api/thunks/session';
import { ImageDTO } from 'services/api/types';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'invoke' }); const moduleLog = log.child({ namespace: 'invoke' });
@ -74,7 +74,7 @@ export const addUserInvokedCanvasListener = () => {
if (['img2img', 'inpaint', 'outpaint'].includes(generationMode)) { if (['img2img', 'inpaint', 'outpaint'].includes(generationMode)) {
// upload the image, saving the request id // upload the image, saving the request id
const { requestId: initImageUploadedRequestId } = dispatch( const { requestId: initImageUploadedRequestId } = dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([baseBlob], 'canvasInitImage.png', { file: new File([baseBlob], 'canvasInitImage.png', {
type: 'image/png', type: 'image/png',
}), }),
@ -85,19 +85,20 @@ export const addUserInvokedCanvasListener = () => {
// Wait for the image to be uploaded, matching by request id // Wait for the image to be uploaded, matching by request id
const [{ payload }] = await take( const [{ payload }] = await take(
(action): action is ReturnType<typeof imageUploaded.fulfilled> => // TODO: figure out how to narrow this action's type
imageUploaded.fulfilled.match(action) && (action) =>
imagesApi.endpoints.uploadImage.matchFulfilled(action) &&
action.meta.requestId === initImageUploadedRequestId action.meta.requestId === initImageUploadedRequestId
); );
canvasInitImage = payload; canvasInitImage = payload as ImageDTO;
} }
// For inpaint/outpaint, we also need to upload the mask layer // For inpaint/outpaint, we also need to upload the mask layer
if (['inpaint', 'outpaint'].includes(generationMode)) { if (['inpaint', 'outpaint'].includes(generationMode)) {
// upload the image, saving the request id // upload the image, saving the request id
const { requestId: maskImageUploadedRequestId } = dispatch( const { requestId: maskImageUploadedRequestId } = dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([maskBlob], 'canvasMaskImage.png', { file: new File([maskBlob], 'canvasMaskImage.png', {
type: 'image/png', type: 'image/png',
}), }),
@ -108,12 +109,13 @@ export const addUserInvokedCanvasListener = () => {
// Wait for the image to be uploaded, matching by request id // Wait for the image to be uploaded, matching by request id
const [{ payload }] = await take( const [{ payload }] = await take(
(action): action is ReturnType<typeof imageUploaded.fulfilled> => // TODO: figure out how to narrow this action's type
imageUploaded.fulfilled.match(action) && (action) =>
imagesApi.endpoints.uploadImage.matchFulfilled(action) &&
action.meta.requestId === maskImageUploadedRequestId action.meta.requestId === maskImageUploadedRequestId
); );
canvasMaskImage = payload; canvasMaskImage = payload as ImageDTO;
} }
const graph = buildCanvasGraph( const graph = buildCanvasGraph(
@ -144,9 +146,9 @@ export const addUserInvokedCanvasListener = () => {
// Associate the init image with the session, now that we have the session ID // Associate the init image with the session, now that we have the session ID
if (['img2img', 'inpaint'].includes(generationMode) && canvasInitImage) { if (['img2img', 'inpaint'].includes(generationMode) && canvasInitImage) {
dispatch( dispatch(
imageUpdated({ imagesApi.endpoints.updateImage.initiate({
image_name: canvasInitImage.image_name, imageDTO: canvasInitImage,
session_id: sessionId, changes: { session_id: sessionId },
}) })
); );
} }
@ -154,9 +156,9 @@ export const addUserInvokedCanvasListener = () => {
// Associate the mask image with the session, now that we have the session ID // Associate the mask image with the session, now that we have the session ID
if (['inpaint'].includes(generationMode) && canvasMaskImage) { if (['inpaint'].includes(generationMode) && canvasMaskImage) {
dispatch( dispatch(
imageUpdated({ imagesApi.endpoints.updateImage.initiate({
image_name: canvasMaskImage.image_name, imageDTO: canvasMaskImage,
session_id: sessionId, changes: { session_id: sessionId },
}) })
); );
} }

View File

@ -11,13 +11,15 @@ import {
TypesafeDroppableData, TypesafeDroppableData,
} from 'app/components/ImageDnd/typesafeDnd'; } from 'app/components/ImageDnd/typesafeDnd';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import {
IAILoadingImageFallback,
IAINoContentFallback,
} from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react'; import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react';
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa'; import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
import { PostUploadAction } from 'services/api/thunks/image'; import { ImageDTO, PostUploadAction } from 'services/api/types';
import { ImageDTO } from 'services/api/types';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import IAIDraggable from './IAIDraggable'; import IAIDraggable from './IAIDraggable';
import IAIDroppable from './IAIDroppable'; import IAIDroppable from './IAIDroppable';
@ -46,6 +48,7 @@ type IAIDndImageProps = {
isSelected?: boolean; isSelected?: boolean;
thumbnail?: boolean; thumbnail?: boolean;
noContentFallback?: ReactElement; noContentFallback?: ReactElement;
useThumbailFallback?: boolean;
}; };
const IAIDndImage = (props: IAIDndImageProps) => { const IAIDndImage = (props: IAIDndImageProps) => {
@ -71,6 +74,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
resetTooltip = 'Reset', resetTooltip = 'Reset',
resetIcon = <FaUndo />, resetIcon = <FaUndo />,
noContentFallback = <IAINoContentFallback icon={FaImage} />, noContentFallback = <IAINoContentFallback icon={FaImage} />,
useThumbailFallback,
} = props; } = props;
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
@ -126,9 +130,14 @@ const IAIDndImage = (props: IAIDndImageProps) => {
<Image <Image
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url} src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
fallbackStrategy="beforeLoadOrError" fallbackStrategy="beforeLoadOrError"
// If we fall back to thumbnail, it feels much snappier than the skeleton... fallbackSrc={
fallbackSrc={imageDTO.thumbnail_url} useThumbailFallback ? imageDTO.thumbnail_url : undefined
// fallback={<IAILoadingImageFallback image={imageDTO} />} }
fallback={
useThumbailFallback ? undefined : (
<IAILoadingImageFallback image={imageDTO} />
)
}
width={imageDTO.width} width={imageDTO.width}
height={imageDTO.height} height={imageDTO.height}
onError={onError} onError={onError}

View File

@ -1,12 +1,12 @@
import { Flex, Text, useColorMode } from '@chakra-ui/react'; import { Flex, Text, useColorMode } from '@chakra-ui/react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { memo, useRef } from 'react'; import { ReactNode, memo, useRef } from 'react';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
type Props = { type Props = {
isOver: boolean; isOver: boolean;
label?: string; label?: ReactNode;
}; };
export const IAIDropOverlay = (props: Props) => { export const IAIDropOverlay = (props: Props) => {
@ -57,16 +57,16 @@ export const IAIDropOverlay = (props: Props) => {
<Flex <Flex
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: 0, top: 0.5,
insetInlineStart: 0, insetInlineStart: 0.5,
w: 'full', insetInlineEnd: 0.5,
h: 'full', bottom: 0.5,
opacity: 1, opacity: 1,
borderWidth: 3, borderWidth: 2,
borderColor: isOver borderColor: isOver
? mode('base.50', 'base.200')(colorMode) ? mode('base.50', 'base.50')(colorMode)
: mode('base.100', 'base.500')(colorMode), : mode('base.200', 'base.300')(colorMode),
borderRadius: 'base', borderRadius: 'lg',
borderStyle: 'dashed', borderStyle: 'dashed',
transitionProperty: 'common', transitionProperty: 'common',
transitionDuration: '0.1s', transitionDuration: '0.1s',
@ -78,10 +78,10 @@ export const IAIDropOverlay = (props: Props) => {
sx={{ sx={{
fontSize: '2xl', fontSize: '2xl',
fontWeight: 600, fontWeight: 600,
transform: isOver ? 'scale(1.02)' : 'scale(1)', transform: isOver ? 'scale(1.1)' : 'scale(1)',
color: isOver color: isOver
? mode('base.50', 'base.50')(colorMode) ? mode('base.50', 'base.50')(colorMode)
: mode('base.100', 'base.200')(colorMode), : mode('base.200', 'base.300')(colorMode),
transitionProperty: 'common', transitionProperty: 'common',
transitionDuration: '0.1s', transitionDuration: '0.1s',
}} }}

View File

@ -5,12 +5,12 @@ import {
useDroppable, useDroppable,
} from 'app/components/ImageDnd/typesafeDnd'; } from 'app/components/ImageDnd/typesafeDnd';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import { memo, useRef } from 'react'; import { ReactNode, memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay'; import IAIDropOverlay from './IAIDropOverlay';
type IAIDroppableProps = { type IAIDroppableProps = {
dropLabel?: string; dropLabel?: ReactNode;
disabled?: boolean; disabled?: boolean;
data?: TypesafeDroppableData; data?: TypesafeDroppableData;
}; };

View File

@ -68,6 +68,7 @@ export const IAINoContentFallback = (props: IAINoImageFallbackProps) => {
flexDir: 'column', flexDir: 'column',
gap: 2, gap: 2,
userSelect: 'none', userSelect: 'none',
opacity: 0.7,
color: 'base.700', color: 'base.700',
_dark: { _dark: {
color: 'base.500', color: 'base.500',

View File

@ -32,17 +32,46 @@ const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
> >
<Flex <Flex
sx={{ sx={{
opacity: 0.4, position: 'absolute',
width: '100%', top: 0,
height: '100%', insetInlineStart: 0,
flexDirection: 'column', w: 'full',
rowGap: 4, h: 'full',
bg: 'base.700',
_dark: { bg: 'base.900' },
opacity: 0.7,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
bg: 'base.900', transitionProperty: 'common',
boxShadow: `inset 0 0 20rem 1rem var(--invokeai-colors-${ transitionDuration: '0.1s',
isDragAccept ? 'accent' : 'error' }}
}-500)`, />
<Flex
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
width: 'full',
height: 'full',
alignItems: 'center',
justifyContent: 'center',
p: 4,
}}
>
<Flex
sx={{
width: 'full',
height: 'full',
alignItems: 'center',
justifyContent: 'center',
flexDir: 'column',
gap: 4,
borderWidth: 3,
borderRadius: 'xl',
borderStyle: 'dashed',
color: 'base.100',
borderColor: 'base.100',
_dark: { borderColor: 'base.200' },
}} }}
> >
{isDragAccept ? ( {isDragAccept ? (
@ -54,6 +83,7 @@ const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
</> </>
)} )}
</Flex> </Flex>
</Flex>
</Box> </Box>
); );
}; };

View File

@ -1,35 +1,43 @@
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { createSelector } from '@reduxjs/toolkit';
import useImageUploader from 'common/hooks/useImageUploader'; import { useAppToaster } from 'app/components/Toaster';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { selectIsBusy } from 'features/system/store/systemSelectors';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { import {
KeyboardEvent, KeyboardEvent,
memo,
ReactNode, ReactNode,
memo,
useCallback, useCallback,
useEffect, useEffect,
useState, useState,
} from 'react'; } from 'react';
import { FileRejection, useDropzone } from 'react-dropzone'; import { FileRejection, useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { imageUploaded } from 'services/api/thunks/image'; import { useUploadImageMutation } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/types';
import ImageUploadOverlay from './ImageUploadOverlay'; import ImageUploadOverlay from './ImageUploadOverlay';
import { useAppToaster } from 'app/components/Toaster'; import { AnimatePresence, motion } from 'framer-motion';
import { createSelector } from '@reduxjs/toolkit';
import { systemSelector } from 'features/system/store/systemSelectors';
const selector = createSelector( const selector = createSelector(
[systemSelector, activeTabNameSelector], [activeTabNameSelector],
(system, activeTabName) => { (activeTabName) => {
const { isConnected, isUploading } = system; let postUploadAction: PostUploadAction = { type: 'TOAST' };
const isUploaderDisabled = !isConnected || isUploading; if (activeTabName === 'unifiedCanvas') {
postUploadAction = { type: 'SET_CANVAS_INITIAL_IMAGE' };
}
if (activeTabName === 'img2img') {
postUploadAction = { type: 'SET_INITIAL_IMAGE' };
}
return { return {
isUploaderDisabled, postUploadAction,
activeTabName,
}; };
} },
defaultSelectorOptions
); );
type ImageUploaderProps = { type ImageUploaderProps = {
@ -38,12 +46,13 @@ type ImageUploaderProps = {
const ImageUploader = (props: ImageUploaderProps) => { const ImageUploader = (props: ImageUploaderProps) => {
const { children } = props; const { children } = props;
const dispatch = useAppDispatch(); const { postUploadAction } = useAppSelector(selector);
const { isUploaderDisabled, activeTabName } = useAppSelector(selector); const isBusy = useAppSelector(selectIsBusy);
const toaster = useAppToaster(); const toaster = useAppToaster();
const { t } = useTranslation(); const { t } = useTranslation();
const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false); const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false);
const { setOpenUploaderFunction } = useImageUploader();
const [uploadImage] = useUploadImageMutation();
const fileRejectionCallback = useCallback( const fileRejectionCallback = useCallback(
(rejection: FileRejection) => { (rejection: FileRejection) => {
@ -60,16 +69,14 @@ const ImageUploader = (props: ImageUploaderProps) => {
const fileAcceptedCallback = useCallback( const fileAcceptedCallback = useCallback(
async (file: File) => { async (file: File) => {
dispatch( uploadImage({
imageUploaded({
file, file,
image_category: 'user', image_category: 'user',
is_intermediate: false, is_intermediate: false,
postUploadAction: { type: 'TOAST_UPLOADED' }, postUploadAction,
}) });
);
}, },
[dispatch] [postUploadAction, uploadImage]
); );
const onDrop = useCallback( const onDrop = useCallback(
@ -101,13 +108,12 @@ const ImageUploader = (props: ImageUploaderProps) => {
isDragReject, isDragReject,
isDragActive, isDragActive,
inputRef, inputRef,
open,
} = useDropzone({ } = useDropzone({
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] }, accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
noClick: true, noClick: true,
onDrop, onDrop,
onDragOver: () => setIsHandlingUpload(true), onDragOver: () => setIsHandlingUpload(true),
disabled: isUploaderDisabled, disabled: isBusy,
multiple: false, multiple: false,
}); });
@ -126,19 +132,13 @@ const ImageUploader = (props: ImageUploaderProps) => {
} }
}; };
// Set the open function so we can open the uploader from anywhere
setOpenUploaderFunction(open);
// Add the paste event listener // Add the paste event listener
document.addEventListener('paste', handlePaste); document.addEventListener('paste', handlePaste);
return () => { return () => {
document.removeEventListener('paste', handlePaste); document.removeEventListener('paste', handlePaste);
setOpenUploaderFunction(() => {
return;
});
}; };
}, [inputRef, open, setOpenUploaderFunction]); }, [inputRef]);
return ( return (
<Box <Box
@ -150,13 +150,30 @@ const ImageUploader = (props: ImageUploaderProps) => {
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
{children} {children}
<AnimatePresence>
{isDragActive && isHandlingUpload && ( {isDragActive && isHandlingUpload && (
<motion.div
key="image-upload-overlay"
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
transition: { duration: 0.1 },
}}
exit={{
opacity: 0,
transition: { duration: 0.1 },
}}
>
<ImageUploadOverlay <ImageUploadOverlay
isDragAccept={isDragAccept} isDragAccept={isDragAccept}
isDragReject={isDragReject} isDragReject={isDragReject}
setIsHandlingUpload={setIsHandlingUpload} setIsHandlingUpload={setIsHandlingUpload}
/> />
</motion.div>
)} )}
</AnimatePresence>
</Box> </Box>
); );
}; };

View File

@ -1,49 +0,0 @@
import { Flex, Heading, Icon } from '@chakra-ui/react';
import useImageUploader from 'common/hooks/useImageUploader';
import { FaUpload } from 'react-icons/fa';
type ImageUploaderButtonProps = {
styleClass?: string;
};
const ImageUploaderButton = (props: ImageUploaderButtonProps) => {
const { styleClass } = props;
const { openUploader } = useImageUploader();
return (
<Flex
sx={{
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
className={styleClass}
>
<Flex
onClick={openUploader}
sx={{
display: 'flex',
flexDirection: 'column',
rowGap: 8,
p: 8,
borderRadius: 'base',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
cursor: 'pointer',
color: 'base.600',
bg: 'base.800',
_hover: {
bg: 'base.700',
},
}}
>
<Icon as={FaUpload} boxSize={24} />
<Heading size="md">Click or Drag and Drop</Heading>
</Flex>
</Flex>
);
};
export default ImageUploaderButton;

View File

@ -1,20 +0,0 @@
import { useTranslation } from 'react-i18next';
import { FaUpload } from 'react-icons/fa';
import IAIIconButton from './IAIIconButton';
import useImageUploader from 'common/hooks/useImageUploader';
const ImageUploaderIconButton = () => {
const { t } = useTranslation();
const { openUploader } = useImageUploader();
return (
<IAIIconButton
aria-label={t('accessibility.uploadImage')}
tooltip="Upload Image"
icon={<FaUpload />}
onClick={openUploader}
/>
);
};
export default ImageUploaderIconButton;

View File

@ -1,7 +1,7 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { PostUploadAction, imageUploaded } from 'services/api/thunks/image'; import { useUploadImageMutation } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/types';
type UseImageUploadButtonArgs = { type UseImageUploadButtonArgs = {
postUploadAction?: PostUploadAction; postUploadAction?: PostUploadAction;
@ -12,7 +12,7 @@ type UseImageUploadButtonArgs = {
* Provides image uploader functionality to any component. * Provides image uploader functionality to any component.
* *
* @example * @example
* const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({ * const { getUploadButtonProps, getUploadInputProps, openUploader } = useImageUploadButton({
* postUploadAction: { * postUploadAction: {
* type: 'SET_CONTROLNET_IMAGE', * type: 'SET_CONTROLNET_IMAGE',
* controlNetId: '12345', * controlNetId: '12345',
@ -20,6 +20,9 @@ type UseImageUploadButtonArgs = {
* isDisabled: getIsUploadDisabled(), * isDisabled: getIsUploadDisabled(),
* }); * });
* *
* // open the uploaded directly
* const handleSomething = () => { openUploader() }
*
* // in the render function * // in the render function
* <Button {...getUploadButtonProps()} /> // will open the file dialog on click * <Button {...getUploadButtonProps()} /> // will open the file dialog on click
* <input {...getUploadInputProps()} /> // hidden, handles native upload functionality * <input {...getUploadInputProps()} /> // hidden, handles native upload functionality
@ -28,24 +31,23 @@ export const useImageUploadButton = ({
postUploadAction, postUploadAction,
isDisabled, isDisabled,
}: UseImageUploadButtonArgs) => { }: UseImageUploadButtonArgs) => {
const dispatch = useAppDispatch(); const [uploadImage] = useUploadImageMutation();
const onDropAccepted = useCallback( const onDropAccepted = useCallback(
(files: File[]) => { (files: File[]) => {
const file = files[0]; const file = files[0];
if (!file) { if (!file) {
return; return;
} }
dispatch( uploadImage({
imageUploaded({
file, file,
image_category: 'user', image_category: 'user',
is_intermediate: false, is_intermediate: false,
postUploadAction, postUploadAction: postUploadAction ?? { type: 'TOAST' },
}) });
);
}, },
[dispatch, postUploadAction] [postUploadAction, uploadImage]
); );
const { const {

View File

@ -1,23 +0,0 @@
import { useCallback } from 'react';
let openUploader = () => {
return;
};
const useImageUploader = () => {
const setOpenUploaderFunction = useCallback(
(openUploaderFunction?: () => void) => {
if (openUploaderFunction) {
openUploader = openUploaderFunction;
}
},
[]
);
return {
setOpenUploaderFunction,
openUploader,
};
};
export default useImageUploader;

View File

@ -26,6 +26,8 @@ import {
FaSave, FaSave,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { stagingAreaImageSaved } from '../store/actions'; import { stagingAreaImageSaved } from '../store/actions';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query';
const selector = createSelector( const selector = createSelector(
[canvasSelector], [canvasSelector],
@ -123,6 +125,10 @@ const IAICanvasStagingAreaToolbar = () => {
[dispatch, sessionId] [dispatch, sessionId]
); );
const { data: imageDTO } = useGetImageDTOQuery(
currentStagingAreaImage?.imageName ?? skipToken
);
if (!currentStagingAreaImage) return null; if (!currentStagingAreaImage) return null;
return ( return (
@ -173,14 +179,19 @@ const IAICanvasStagingAreaToolbar = () => {
<IAIIconButton <IAIIconButton
tooltip={t('unifiedCanvas.saveToGallery')} tooltip={t('unifiedCanvas.saveToGallery')}
aria-label={t('unifiedCanvas.saveToGallery')} aria-label={t('unifiedCanvas.saveToGallery')}
isDisabled={!imageDTO || !imageDTO.is_intermediate}
icon={<FaSave />} icon={<FaSave />}
onClick={() => onClick={() => {
if (!imageDTO) {
return;
}
dispatch( dispatch(
stagingAreaImageSaved({ stagingAreaImageSaved({
imageName: currentStagingAreaImage.imageName, imageDTO,
}) })
) );
} }}
colorScheme="accent" colorScheme="accent"
/> />
<IAIIconButton <IAIIconButton

View File

@ -2,7 +2,6 @@ import { Box, ButtonGroup, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import useImageUploader from 'common/hooks/useImageUploader';
import { useSingleAndDoubleClick } from 'common/hooks/useSingleAndDoubleClick'; import { useSingleAndDoubleClick } from 'common/hooks/useSingleAndDoubleClick';
import { import {
canvasSelector, canvasSelector,
@ -25,6 +24,7 @@ import { systemSelector } from 'features/system/store/systemSelectors';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect'; import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { import {
canvasCopiedToClipboard, canvasCopiedToClipboard,
canvasDownloadedAsImage, canvasDownloadedAsImage,
@ -82,7 +82,9 @@ const IAICanvasToolbar = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isClipboardAPIAvailable } = useCopyImageToClipboard(); const { isClipboardAPIAvailable } = useCopyImageToClipboard();
const { openUploader } = useImageUploader(); const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction: { type: 'SET_CANVAS_INITIAL_IMAGE' },
});
useHotkeys( useHotkeys(
['v'], ['v'],
@ -288,9 +290,10 @@ const IAICanvasToolbar = () => {
aria-label={`${t('common.upload')}`} aria-label={`${t('common.upload')}`}
tooltip={`${t('common.upload')}`} tooltip={`${t('common.upload')}`}
icon={<FaUpload />} icon={<FaUpload />}
onClick={openUploader}
isDisabled={isStaging} isDisabled={isStaging}
{...getUploadButtonProps()}
/> />
<input {...getUploadInputProps()} />
<IAIIconButton <IAIIconButton
aria-label={`${t('unifiedCanvas.clearCanvas')}`} aria-label={`${t('unifiedCanvas.clearCanvas')}`}
tooltip={`${t('unifiedCanvas.clearCanvas')}`} tooltip={`${t('unifiedCanvas.clearCanvas')}`}

View File

@ -1,4 +1,5 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import { ImageDTO } from 'services/api/types';
export const canvasSavedToGallery = createAction('canvas/canvasSavedToGallery'); export const canvasSavedToGallery = createAction('canvas/canvasSavedToGallery');
@ -12,6 +13,6 @@ export const canvasDownloadedAsImage = createAction(
export const canvasMerged = createAction('canvas/canvasMerged'); export const canvasMerged = createAction('canvas/canvasMerged');
export const stagingAreaImageSaved = createAction<{ imageName: string }>( export const stagingAreaImageSaved = createAction<{ imageDTO: ImageDTO }>(
'canvas/stagingAreaImageSaved' 'canvas/stagingAreaImageSaved'
); );

View File

@ -11,8 +11,8 @@ import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/thunks/image';
import { controlNetImageChanged } from '../store/controlNetSlice'; import { controlNetImageChanged } from '../store/controlNetSlice';
import { PostUploadAction } from 'services/api/types';
type Props = { type Props = {
controlNetId: string; controlNetId: string;

View File

@ -2,7 +2,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store'; import { RootState } from 'app/store/store';
import { ControlNetModelParam } from 'features/parameters/types/parameterSchemas'; import { ControlNetModelParam } from 'features/parameters/types/parameterSchemas';
import { cloneDeep, forEach } from 'lodash-es'; import { cloneDeep, forEach } from 'lodash-es';
import { imageDeleted } from 'services/api/thunks/image'; import { imagesApi } from 'services/api/endpoints/images';
import { isAnySessionRejected } from 'services/api/thunks/session'; import { isAnySessionRejected } from 'services/api/thunks/session';
import { appSocketInvocationError } from 'services/events/actions'; import { appSocketInvocationError } from 'services/events/actions';
import { controlNetImageProcessed } from './actions'; import { controlNetImageProcessed } from './actions';
@ -300,10 +300,20 @@ export const controlNetSlice = createSlice({
} }
}); });
builder.addCase(imageDeleted.pending, (state, action) => { builder.addCase(appSocketInvocationError, (state, action) => {
state.pendingControlImages = [];
});
builder.addMatcher(isAnySessionRejected, (state, action) => {
state.pendingControlImages = [];
});
builder.addMatcher(
imagesApi.endpoints.deleteImage.matchFulfilled,
(state, action) => {
// Preemptively remove the image from all controlnets // Preemptively remove the image from all controlnets
// TODO: doesn't the imageusage stuff do this for us? // TODO: doesn't the imageusage stuff do this for us?
const { image_name } = action.meta.arg; const { image_name } = action.meta.arg.originalArgs;
forEach(state.controlNets, (c) => { forEach(state.controlNets, (c) => {
if (c.controlImage === image_name) { if (c.controlImage === image_name) {
c.controlImage = null; c.controlImage = null;
@ -313,15 +323,8 @@ export const controlNetSlice = createSlice({
c.processedControlImage = null; c.processedControlImage = null;
} }
}); });
}); }
);
builder.addCase(appSocketInvocationError, (state, action) => {
state.pendingControlImages = [];
});
builder.addMatcher(isAnySessionRejected, (state, action) => {
state.pendingControlImages = [];
});
}, },
}); });

View File

@ -0,0 +1,50 @@
import {
ASSETS_CATEGORIES,
INITIAL_IMAGE_LIMIT,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import { FaFileImage } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import {
ListImagesArgs,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GenericBoard from './GenericBoard';
const baseQueryArg: ListImagesArgs = {
categories: ASSETS_CATEGORIES,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const AllAssetsBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const handleClick = () => {
dispatch(boardIdSelected('assets'));
};
const { total } = useListImagesQuery(baseQueryArg, {
selectFromResult: ({ data }) => ({ total: data?.total ?? 0 }),
});
// TODO: Do we support making 'images' 'assets? if yes, we need to handle this
// const droppableData: MoveBoardDropData = {
// id: 'all-images-board',
// actionType: 'MOVE_BOARD',
// context: { boardId: 'assets' },
// };
return (
<GenericBoard
onClick={handleClick}
isSelected={isSelected}
icon={FaFileImage}
label="All Assets"
badgeCount={total}
/>
);
};
export default AllAssetsBoard;

View File

@ -1,29 +1,48 @@
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; import {
import { boardIdSelected } from 'features/gallery/store/gallerySlice'; IMAGE_CATEGORIES,
INITIAL_IMAGE_LIMIT,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import { FaImages } from 'react-icons/fa'; import { FaImages } from 'react-icons/fa';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import {
ListImagesArgs,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GenericBoard from './GenericBoard'; import GenericBoard from './GenericBoard';
const baseQueryArg: ListImagesArgs = {
categories: IMAGE_CATEGORIES,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => { const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const handleAllImagesBoardClick = () => { const handleClick = () => {
dispatch(boardIdSelected('all')); dispatch(boardIdSelected('images'));
}; };
const droppableData: MoveBoardDropData = { const { total } = useListImagesQuery(baseQueryArg, {
id: 'all-images-board', selectFromResult: ({ data }) => ({ total: data?.total ?? 0 }),
actionType: 'MOVE_BOARD', });
context: { boardId: null },
}; // TODO: Do we support making 'images' 'assets? if yes, we need to handle this
// const droppableData: MoveBoardDropData = {
// id: 'all-images-board',
// actionType: 'MOVE_BOARD',
// context: { boardId: 'images' },
// };
return ( return (
<GenericBoard <GenericBoard
droppableData={droppableData} onClick={handleClick}
onClick={handleAllImagesBoardClick}
isSelected={isSelected} isSelected={isSelected}
icon={FaImages} icon={FaImages}
label="All Images" label="All Images"
badgeCount={total}
/> />
); );
}; };

View File

@ -1,27 +1,27 @@
import { CloseIcon } from '@chakra-ui/icons';
import { import {
Collapse, Collapse,
Flex, Flex,
Grid, Grid,
GridItem, GridItem,
IconButton, useDisclosure,
Input,
InputGroup,
InputRightElement,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { setBoardSearchText } from 'features/gallery/store/boardSlice';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { memo, useState } from 'react'; import { memo, useState } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus';
import AddBoardButton from './AddBoardButton'; import AddBoardButton from './AddBoardButton';
import AllAssetsBoard from './AllAssetsBoard';
import AllImagesBoard from './AllImagesBoard'; import AllImagesBoard from './AllImagesBoard';
import BatchBoard from './BatchBoard'; import BatchBoard from './BatchBoard';
import BoardsSearch from './BoardsSearch';
import GalleryBoard from './GalleryBoard'; import GalleryBoard from './GalleryBoard';
import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus'; import NoBoardBoard from './NoBoardBoard';
import DeleteBoardModal from '../DeleteBoardModal';
import { BoardDTO } from 'services/api/types';
const selector = createSelector( const selector = createSelector(
[stateSelector], [stateSelector],
@ -39,31 +39,19 @@ type Props = {
const BoardsList = (props: Props) => { const BoardsList = (props: Props) => {
const { isOpen } = props; const { isOpen } = props;
const dispatch = useAppDispatch();
const { selectedBoardId, searchText } = useAppSelector(selector); const { selectedBoardId, searchText } = useAppSelector(selector);
const { data: boards } = useListAllBoardsQuery(); const { data: boards } = useListAllBoardsQuery();
const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled; const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled;
const filteredBoards = searchText const filteredBoards = searchText
? boards?.filter((board) => ? boards?.filter((board) =>
board.board_name.toLowerCase().includes(searchText.toLowerCase()) board.board_name.toLowerCase().includes(searchText.toLowerCase())
) )
: boards; : boards;
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
const [searchMode, setSearchMode] = useState(false); const [searchMode, setSearchMode] = useState(false);
const handleBoardSearch = (searchTerm: string) => {
setSearchMode(searchTerm.length > 0);
dispatch(setBoardSearchText(searchTerm));
};
const clearBoardSearch = () => {
setSearchMode(false);
dispatch(setBoardSearchText(''));
};
return ( return (
<>
<Collapse in={isOpen} animateOpacity> <Collapse in={isOpen} animateOpacity>
<Flex <Flex
layerStyle={'first'} layerStyle={'first'}
@ -76,26 +64,7 @@ const BoardsList = (props: Props) => {
}} }}
> >
<Flex sx={{ gap: 2, alignItems: 'center' }}> <Flex sx={{ gap: 2, alignItems: 'center' }}>
<InputGroup> <BoardsSearch setSearchMode={setSearchMode} />
<Input
placeholder="Search Boards..."
value={searchText}
onChange={(e) => {
handleBoardSearch(e.target.value);
}}
/>
{searchText && searchText.length && (
<InputRightElement>
<IconButton
onClick={clearBoardSearch}
size="xs"
variant="ghost"
aria-label="Clear Search"
icon={<CloseIcon boxSize={3} />}
/>
</InputRightElement>
)}
</InputGroup>
<AddBoardButton /> <AddBoardButton />
</Flex> </Flex>
<OverlayScrollbarsComponent <OverlayScrollbarsComponent
@ -121,7 +90,13 @@ const BoardsList = (props: Props) => {
{!searchMode && ( {!searchMode && (
<> <>
<GridItem sx={{ p: 1.5 }}> <GridItem sx={{ p: 1.5 }}>
<AllImagesBoard isSelected={selectedBoardId === 'all'} /> <AllImagesBoard isSelected={selectedBoardId === 'images'} />
</GridItem>
<GridItem sx={{ p: 1.5 }}>
<AllAssetsBoard isSelected={selectedBoardId === 'assets'} />
</GridItem>
<GridItem sx={{ p: 1.5 }}>
<NoBoardBoard isSelected={selectedBoardId === 'no_board'} />
</GridItem> </GridItem>
{isBatchEnabled && ( {isBatchEnabled && (
<GridItem sx={{ p: 1.5 }}> <GridItem sx={{ p: 1.5 }}>
@ -136,6 +111,7 @@ const BoardsList = (props: Props) => {
<GalleryBoard <GalleryBoard
board={board} board={board}
isSelected={selectedBoardId === board.board_id} isSelected={selectedBoardId === board.board_id}
setBoardToDelete={setBoardToDelete}
/> />
</GridItem> </GridItem>
))} ))}
@ -143,6 +119,11 @@ const BoardsList = (props: Props) => {
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
</Flex> </Flex>
</Collapse> </Collapse>
<DeleteBoardModal
boardToDelete={boardToDelete}
setBoardToDelete={setBoardToDelete}
/>
</>
); );
}; };

View File

@ -0,0 +1,66 @@
import { CloseIcon } from '@chakra-ui/icons';
import {
IconButton,
Input,
InputGroup,
InputRightElement,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { setBoardSearchText } from 'features/gallery/store/boardSlice';
import { memo } from 'react';
const selector = createSelector(
[stateSelector],
({ boards }) => {
const { searchText } = boards;
return { searchText };
},
defaultSelectorOptions
);
type Props = {
setSearchMode: (searchMode: boolean) => void;
};
const BoardsSearch = (props: Props) => {
const { setSearchMode } = props;
const dispatch = useAppDispatch();
const { searchText } = useAppSelector(selector);
const handleBoardSearch = (searchTerm: string) => {
setSearchMode(searchTerm.length > 0);
dispatch(setBoardSearchText(searchTerm));
};
const clearBoardSearch = () => {
setSearchMode(false);
dispatch(setBoardSearchText(''));
};
return (
<InputGroup>
<Input
placeholder="Search Boards..."
value={searchText}
onChange={(e) => {
handleBoardSearch(e.target.value);
}}
/>
{searchText && searchText.length && (
<InputRightElement>
<IconButton
onClick={clearBoardSearch}
size="xs"
variant="ghost"
aria-label="Clear Search"
icon={<CloseIcon boxSize={3} />}
/>
</InputRightElement>
)}
</InputGroup>
);
};
export default memo(BoardsSearch);

View File

@ -8,35 +8,32 @@ import {
Image, Image,
MenuItem, MenuItem,
MenuList, MenuList,
Text,
useColorMode, useColorMode,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { ContextMenu } from 'chakra-ui-contextmenu';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useContext, useMemo } from 'react';
import { FaFolder, FaImages, FaTrash } from 'react-icons/fa';
import {
useDeleteBoardMutation,
useUpdateBoardMutation,
} from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { BoardDTO } from 'services/api/types';
import { skipToken } from '@reduxjs/toolkit/dist/query'; import { skipToken } from '@reduxjs/toolkit/dist/query';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
// import { boardAddedToBatch } from 'app/store/middleware/listenerMiddleware/listeners/addBoardToBatch'; import { useAppDispatch } from 'app/store/storeHooks';
import { ContextMenu } from 'chakra-ui-contextmenu';
import IAIDroppable from 'common/components/IAIDroppable'; import IAIDroppable from 'common/components/IAIDroppable';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useMemo } from 'react';
import { FaTrash, FaUser } from 'react-icons/fa';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { BoardDTO } from 'services/api/types';
import { menuListMotionProps } from 'theme/components/menu';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import { DeleteBoardImagesContext } from '../../../../../app/contexts/DeleteBoardImagesContext';
interface GalleryBoardProps { interface GalleryBoardProps {
board: BoardDTO; board: BoardDTO;
isSelected: boolean; isSelected: boolean;
setBoardToDelete: (board?: BoardDTO) => void;
} }
const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => { const GalleryBoard = memo(
({ board, isSelected, setBoardToDelete }: GalleryBoardProps) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { currentData: coverImage } = useGetImageDTOQuery( const { currentData: coverImage } = useGetImageDTOQuery(
@ -44,11 +41,7 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
); );
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const { board_name, board_id } = board; const { board_name, board_id } = board;
const { onClickDeleteBoardImages } = useContext(DeleteBoardImagesContext);
const handleSelectBoard = useCallback(() => { const handleSelectBoard = useCallback(() => {
dispatch(boardIdSelected(board_id)); dispatch(boardIdSelected(board_id));
}, [board_id, dispatch]); }, [board_id, dispatch]);
@ -56,24 +49,13 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
const [updateBoard, { isLoading: isUpdateBoardLoading }] = const [updateBoard, { isLoading: isUpdateBoardLoading }] =
useUpdateBoardMutation(); useUpdateBoardMutation();
const [deleteBoard, { isLoading: isDeleteBoardLoading }] =
useDeleteBoardMutation();
const handleUpdateBoardName = (newBoardName: string) => { const handleUpdateBoardName = (newBoardName: string) => {
updateBoard({ board_id, changes: { board_name: newBoardName } }); updateBoard({ board_id, changes: { board_name: newBoardName } });
}; };
const handleDeleteBoard = useCallback(() => { const handleDeleteBoard = useCallback(() => {
deleteBoard(board_id); setBoardToDelete(board);
}, [board_id, deleteBoard]); }, [board, setBoardToDelete]);
const handleAddBoardToBatch = useCallback(() => {
// dispatch(boardAddedToBatch({ board_id }));
}, []);
const handleDeleteBoardAndImages = useCallback(() => {
onClickDeleteBoardImages(board);
}, [board, onClickDeleteBoardImages]);
const droppableData: MoveBoardDropData = useMemo( const droppableData: MoveBoardDropData = useMemo(
() => ({ () => ({
@ -88,24 +70,24 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
<Box sx={{ touchAction: 'none', height: 'full' }}> <Box sx={{ touchAction: 'none', height: 'full' }}>
<ContextMenu<HTMLDivElement> <ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }} menuProps={{ size: 'sm', isLazy: true }}
menuButtonProps={{
bg: 'transparent',
_hover: { bg: 'transparent' },
}}
renderMenu={() => ( renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}> <MenuList
sx={{ visibility: 'visible !important' }}
motionProps={menuListMotionProps}
>
{board.image_count > 0 && ( {board.image_count > 0 && (
<> <>
<MenuItem {/* <MenuItem
isDisabled={!board.image_count} isDisabled={!board.image_count}
icon={<FaImages />} icon={<FaImages />}
onClickCapture={handleAddBoardToBatch} onClickCapture={handleAddBoardToBatch}
> >
Add Board to Batch Add Board to Batch
</MenuItem> </MenuItem> */}
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoardAndImages}
>
Delete Board and Images
</MenuItem>
</> </>
)} )}
<MenuItem <MenuItem
@ -147,17 +129,19 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
flexShrink: 0, flexShrink: 0,
}} }}
> >
{board.cover_image_name && coverImage?.image_url && ( {board.cover_image_name && coverImage?.thumbnail_url && (
<Image src={coverImage?.image_url} draggable={false} /> <Image src={coverImage?.thumbnail_url} draggable={false} />
)} )}
{!(board.cover_image_name && coverImage?.image_url) && ( {!(board.cover_image_name && coverImage?.thumbnail_url) && (
<IAINoContentFallback <IAINoContentFallback
boxSize={8} boxSize={8}
icon={FaFolder} icon={FaUser}
sx={{ sx={{
border: '2px solid var(--invokeai-colors-base-200)', borderWidth: '2px',
borderStyle: 'solid',
borderColor: 'base.200',
_dark: { _dark: {
border: '2px solid var(--invokeai-colors-base-800)', borderColor: 'base.800',
}, },
}} }}
/> />
@ -172,7 +156,10 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
> >
<Badge variant="solid">{board.image_count}</Badge> <Badge variant="solid">{board.image_count}</Badge>
</Flex> </Flex>
<IAIDroppable data={droppableData} /> <IAIDroppable
data={droppableData}
dropLabel={<Text fontSize="md">Move</Text>}
/>
</Flex> </Flex>
<Flex <Flex
@ -189,6 +176,7 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
onSubmit={(nextValue) => { onSubmit={(nextValue) => {
handleUpdateBoardName(nextValue); handleUpdateBoardName(nextValue);
}} }}
sx={{ maxW: 'full' }}
> >
<EditablePreview <EditablePreview
sx={{ sx={{
@ -199,6 +187,8 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
fontSize: 'xs', fontSize: 'xs',
textAlign: 'center', textAlign: 'center',
p: 0, p: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
}} }}
noOfLines={1} noOfLines={1}
/> />
@ -218,7 +208,8 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
</ContextMenu> </ContextMenu>
</Box> </Box>
); );
}); }
);
GalleryBoard.displayName = 'HoverableBoard'; GalleryBoard.displayName = 'HoverableBoard';

View File

@ -2,18 +2,34 @@ import { As, Badge, Flex } from '@chakra-ui/react';
import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd'; import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd';
import IAIDroppable from 'common/components/IAIDroppable'; import IAIDroppable from 'common/components/IAIDroppable';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { ReactNode } from 'react';
type GenericBoardProps = { type GenericBoardProps = {
droppableData: TypesafeDroppableData; droppableData?: TypesafeDroppableData;
onClick: () => void; onClick: () => void;
isSelected: boolean; isSelected: boolean;
icon: As; icon: As;
label: string; label: string;
dropLabel?: ReactNode;
badgeCount?: number; badgeCount?: number;
}; };
const formatBadgeCount = (count: number) =>
Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(count);
const GenericBoard = (props: GenericBoardProps) => { const GenericBoard = (props: GenericBoardProps) => {
const { droppableData, onClick, isSelected, icon, label, badgeCount } = props; const {
droppableData,
onClick,
isSelected,
icon,
label,
badgeCount,
dropLabel,
} = props;
return ( return (
<Flex <Flex
@ -59,10 +75,10 @@ const GenericBoard = (props: GenericBoardProps) => {
}} }}
> >
{badgeCount !== undefined && ( {badgeCount !== undefined && (
<Badge variant="solid">{badgeCount}</Badge> <Badge variant="solid">{formatBadgeCount(badgeCount)}</Badge>
)} )}
</Flex> </Flex>
<IAIDroppable data={droppableData} /> <IAIDroppable data={droppableData} dropLabel={dropLabel} />
</Flex> </Flex>
<Flex <Flex
sx={{ sx={{

View File

@ -0,0 +1,53 @@
import { Text } from '@chakra-ui/react';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import {
INITIAL_IMAGE_LIMIT,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import { FaFolderOpen } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import {
ListImagesArgs,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GenericBoard from './GenericBoard';
const baseQueryArg: ListImagesArgs = {
board_id: 'none',
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const NoBoardBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const handleClick = () => {
dispatch(boardIdSelected('no_board'));
};
const { total } = useListImagesQuery(baseQueryArg, {
selectFromResult: ({ data }) => ({ total: data?.total ?? 0 }),
});
// TODO: Do we support making 'images' 'assets? if yes, we need to handle this
const droppableData: MoveBoardDropData = {
id: 'all-images-board',
actionType: 'MOVE_BOARD',
context: { boardId: 'no_board' },
};
return (
<GenericBoard
droppableData={droppableData}
dropLabel={<Text fontSize="md">Move</Text>}
onClick={handleClick}
isSelected={isSelected}
icon={FaFolderOpen}
label="No Board"
badgeCount={total}
/>
);
};
export default NoBoardBoard;

View File

@ -1,114 +0,0 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Divider,
Flex,
ListItem,
Text,
UnorderedList,
} from '@chakra-ui/react';
import IAIButton from 'common/components/IAIButton';
import { memo, useContext, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext';
import { some } from 'lodash-es';
import { ImageUsage } from '../../../../app/contexts/DeleteImageContext';
const BoardImageInUseMessage = (props: { imagesUsage?: ImageUsage }) => {
const { imagesUsage } = props;
if (!imagesUsage) {
return null;
}
if (!some(imagesUsage)) {
return null;
}
return (
<>
<Text>
An image from this board is currently in use in the following features:
</Text>
<UnorderedList sx={{ paddingInlineStart: 6 }}>
{imagesUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
{imagesUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
{imagesUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
{imagesUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
</UnorderedList>
<Text>
If you delete images from this board, those features will immediately be
reset.
</Text>
</>
);
};
const DeleteBoardImagesModal = () => {
const { t } = useTranslation();
const {
isOpen,
onClose,
board,
handleDeleteBoardImages,
handleDeleteBoardOnly,
imagesUsage,
} = useContext(DeleteBoardImagesContext);
const cancelRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
isCentered
>
<AlertDialogOverlay>
{board && (
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete Board
</AlertDialogHeader>
<AlertDialogBody>
<Flex direction="column" gap={3}>
<BoardImageInUseMessage imagesUsage={imagesUsage} />
<Divider />
<Text>{t('common.areYouSure')}</Text>
<Text fontWeight="bold">
This board has {board.image_count} image(s) that will be
deleted.
</Text>
</Flex>
</AlertDialogBody>
<AlertDialogFooter gap={3}>
<IAIButton ref={cancelRef} onClick={onClose}>
Cancel
</IAIButton>
<IAIButton
colorScheme="warning"
onClick={() => handleDeleteBoardOnly(board.board_id)}
>
Delete Board Only
</IAIButton>
<IAIButton
colorScheme="error"
onClick={() => handleDeleteBoardImages(board.board_id)}
>
Delete Board and Images
</IAIButton>
</AlertDialogFooter>
</AlertDialogContent>
)}
</AlertDialogOverlay>
</AlertDialog>
);
};
export default memo(DeleteBoardImagesModal);

View File

@ -0,0 +1,181 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Flex,
Skeleton,
Text,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import ImageUsageMessage from 'features/imageDeletion/components/ImageUsageMessage';
import {
ImageUsage,
getImageUsage,
} from 'features/imageDeletion/store/imageDeletionSlice';
import { some } from 'lodash-es';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
useDeleteBoardAndImagesMutation,
useDeleteBoardMutation,
useListAllImageNamesForBoardQuery,
} from 'services/api/endpoints/boards';
import { BoardDTO } from 'services/api/types';
type Props = {
boardToDelete?: BoardDTO;
setBoardToDelete: (board?: BoardDTO) => void;
};
const DeleteImageModal = (props: Props) => {
const { boardToDelete, setBoardToDelete } = props;
const { t } = useTranslation();
const canRestoreDeletedImagesFromBin = useAppSelector(
(state) => state.config.canRestoreDeletedImagesFromBin
);
const { currentData: boardImageNames, isFetching: isFetchingBoardNames } =
useListAllImageNamesForBoardQuery(boardToDelete?.board_id ?? skipToken);
const selectImageUsageSummary = useMemo(
() =>
createSelector([stateSelector], (state) => {
const allImageUsage = (boardImageNames ?? []).map((imageName) =>
getImageUsage(state, imageName)
);
const imageUsageSummary: ImageUsage = {
isInitialImage: some(allImageUsage, (usage) => usage.isInitialImage),
isCanvasImage: some(allImageUsage, (usage) => usage.isCanvasImage),
isNodesImage: some(allImageUsage, (usage) => usage.isNodesImage),
isControlNetImage: some(
allImageUsage,
(usage) => usage.isControlNetImage
),
};
return { imageUsageSummary };
}),
[boardImageNames]
);
const [deleteBoardOnly, { isLoading: isDeleteBoardOnlyLoading }] =
useDeleteBoardMutation();
const [deleteBoardAndImages, { isLoading: isDeleteBoardAndImagesLoading }] =
useDeleteBoardAndImagesMutation();
const { imageUsageSummary } = useAppSelector(selectImageUsageSummary);
const handleDeleteBoardOnly = useCallback(() => {
if (!boardToDelete) {
return;
}
deleteBoardOnly(boardToDelete.board_id);
setBoardToDelete(undefined);
}, [boardToDelete, deleteBoardOnly, setBoardToDelete]);
const handleDeleteBoardAndImages = useCallback(() => {
if (!boardToDelete) {
return;
}
deleteBoardAndImages(boardToDelete.board_id);
setBoardToDelete(undefined);
}, [boardToDelete, deleteBoardAndImages, setBoardToDelete]);
const handleClose = useCallback(() => {
setBoardToDelete(undefined);
}, [setBoardToDelete]);
const cancelRef = useRef<HTMLButtonElement>(null);
const isLoading = useMemo(
() =>
isDeleteBoardAndImagesLoading ||
isDeleteBoardOnlyLoading ||
isFetchingBoardNames,
[
isDeleteBoardAndImagesLoading,
isDeleteBoardOnlyLoading,
isFetchingBoardNames,
]
);
if (!boardToDelete) {
return null;
}
return (
<AlertDialog
isOpen={Boolean(boardToDelete)}
onClose={handleClose}
leastDestructiveRef={cancelRef}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete {boardToDelete.board_name}
</AlertDialogHeader>
<AlertDialogBody>
<Flex direction="column" gap={3}>
{isFetchingBoardNames ? (
<Skeleton>
<Flex
sx={{
w: 'full',
h: 32,
}}
/>
</Skeleton>
) : (
<ImageUsageMessage
imageUsage={imageUsageSummary}
topMessage="This board contains images used in the following features:"
bottomMessage="Deleting this board and its images will reset any features currently using them."
/>
)}
<Text>Deleted boards cannot be restored.</Text>
<Text>
{canRestoreDeletedImagesFromBin
? t('gallery.deleteImageBin')
: t('gallery.deleteImagePermanent')}
</Text>
</Flex>
</AlertDialogBody>
<AlertDialogFooter>
<Flex
sx={{ justifyContent: 'space-between', width: 'full', gap: 2 }}
>
<IAIButton ref={cancelRef} onClick={handleClose}>
Cancel
</IAIButton>
<IAIButton
colorScheme="warning"
isLoading={isLoading}
onClick={handleDeleteBoardOnly}
>
Delete Board Only
</IAIButton>
<IAIButton
colorScheme="error"
isLoading={isLoading}
onClick={handleDeleteBoardAndImages}
>
Delete Board and Images
</IAIButton>
</Flex>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};
export default memo(DeleteImageModal);

View File

@ -17,6 +17,8 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import ImageMetadataViewer from '../ImageMetadataViewer/ImageMetadataViewer'; import ImageMetadataViewer from '../ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from '../NextPrevImageButtons'; import NextPrevImageButtons from '../NextPrevImageButtons';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { FaImage } from 'react-icons/fa';
export const imagesSelector = createSelector( export const imagesSelector = createSelector(
[stateSelector, selectLastSelectedImage], [stateSelector, selectLastSelectedImage],
@ -168,7 +170,11 @@ const CurrentImagePreview = () => {
draggableData={draggableData} draggableData={draggableData}
isUploadDisabled={true} isUploadDisabled={true}
fitContainer fitContainer
useThumbailFallback
dropLabel="Set as Current Image" dropLabel="Set as Current Image"
noContentFallback={
<IAINoContentFallback icon={FaImage} label="No image selected" />
}
/> />
)} )}
{shouldShowImageDetails && imageDTO && ( {shouldShowImageDetails && imageDTO && (

View File

@ -0,0 +1,91 @@
import { ChevronUpIcon } from '@chakra-ui/icons';
import { Button, Flex, Text } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { memo } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
const selector = createSelector(
[stateSelector],
(state) => {
const { selectedBoardId } = state.gallery;
return {
selectedBoardId,
};
},
defaultSelectorOptions
);
type Props = {
isOpen: boolean;
onToggle: () => void;
};
const GalleryBoardName = (props: Props) => {
const { isOpen, onToggle } = props;
const { selectedBoardId } = useAppSelector(selector);
const { selectedBoardName } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => {
let selectedBoardName = '';
if (selectedBoardId === 'images') {
selectedBoardName = 'All Images';
} else if (selectedBoardId === 'assets') {
selectedBoardName = 'All Assets';
} else if (selectedBoardId === 'no_board') {
selectedBoardName = 'No Board';
} else if (selectedBoardId === 'batch') {
selectedBoardName = 'Batch';
} else {
const selectedBoard = data?.find((b) => b.board_id === selectedBoardId);
selectedBoardName = selectedBoard?.board_name || 'Unknown Board';
}
return { selectedBoardName };
},
});
return (
<Flex
as={Button}
onClick={onToggle}
size="sm"
variant="ghost"
sx={{
w: 'full',
justifyContent: 'center',
alignItems: 'center',
px: 2,
_hover: {
bg: 'base.100',
_dark: { bg: 'base.800' },
},
}}
>
<Text
noOfLines={1}
sx={{
w: 'full',
fontWeight: 600,
color: 'base.800',
_dark: {
color: 'base.200',
},
}}
>
{selectedBoardName}
</Text>
<ChevronUpIcon
sx={{
transform: isOpen ? 'rotate(0deg)' : 'rotate(180deg)',
transitionProperty: 'common',
transitionDuration: 'normal',
}}
/>
</Flex>
);
};
export default memo(GalleryBoardName);

View File

@ -0,0 +1,44 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIIconButton from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
const selector = createSelector(
[stateSelector],
(state) => {
const { shouldPinGallery } = state.ui;
return {
shouldPinGallery,
};
},
defaultSelectorOptions
);
const GalleryPinButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { shouldPinGallery } = useAppSelector(selector);
const handleSetShouldPinGallery = () => {
dispatch(togglePinGalleryPanel());
dispatch(requestCanvasRescale());
};
return (
<IAIIconButton
size="sm"
aria-label={t('gallery.pinGallery')}
tooltip={`${t('gallery.pinGallery')} (Shift+G)`}
onClick={handleSetShouldPinGallery}
icon={shouldPinGallery ? <BsPinAngleFill /> : <BsPinAngle />}
/>
);
};
export default GalleryPinButton;

View File

@ -0,0 +1,76 @@
import { Flex } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
import IAISlider from 'common/components/IAISlider';
import { setGalleryImageMinimumWidth } from 'features/gallery/store/gallerySlice';
import { ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { FaWrench } from 'react-icons/fa';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice';
const selector = createSelector(
[stateSelector],
(state) => {
const { galleryImageMinimumWidth, shouldAutoSwitch } = state.gallery;
return {
galleryImageMinimumWidth,
shouldAutoSwitch,
};
},
defaultSelectorOptions
);
const GallerySettingsPopover = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { galleryImageMinimumWidth, shouldAutoSwitch } =
useAppSelector(selector);
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
};
return (
<IAIPopover
triggerComponent={
<IAIIconButton
tooltip={t('gallery.gallerySettings')}
aria-label={t('gallery.gallerySettings')}
size="sm"
icon={<FaWrench />}
/>
}
>
<Flex direction="column" gap={2}>
<IAISlider
value={galleryImageMinimumWidth}
onChange={handleChangeGalleryImageMinimumWidth}
min={32}
max={256}
hideTooltip={true}
label={t('gallery.galleryImageSize')}
withReset
handleReset={() => dispatch(setGalleryImageMinimumWidth(64))}
/>
<IAISimpleCheckbox
label={t('gallery.autoSwitchNewImages')}
isChecked={shouldAutoSwitch}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(shouldAutoSwitchChanged(e.target.checked))
}
/>
</Flex>
</IAIPopover>
);
};
export default GallerySettingsPopover;

View File

@ -1,13 +1,8 @@
import { MenuList } from '@chakra-ui/react'; import { MenuList } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
import { MouseEvent, memo, useCallback, useMemo } from 'react'; import { MouseEvent, memo, useCallback } from 'react';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { menuListMotionProps } from 'theme/components/menu'; import { menuListMotionProps } from 'theme/components/menu';
import MultipleSelectionMenuItems from './MultipleSelectionMenuItems';
import SingleSelectionMenuItems from './SingleSelectionMenuItems'; import SingleSelectionMenuItems from './SingleSelectionMenuItems';
type Props = { type Props = {
@ -16,23 +11,23 @@ type Props = {
}; };
const ImageContextMenu = ({ imageDTO, children }: Props) => { const ImageContextMenu = ({ imageDTO, children }: Props) => {
const selector = useMemo( // const selector = useMemo(
() => // () =>
createSelector( // createSelector(
[stateSelector], // [stateSelector],
({ gallery }) => { // ({ gallery }) => {
const selectionCount = gallery.selection.length; // const selectionCount = gallery.selection.length;
return { selectionCount }; // return { selectionCount };
}, // },
defaultSelectorOptions // defaultSelectorOptions
), // ),
[] // []
); // );
const { selectionCount } = useAppSelector(selector); // const { selectionCount } = useAppSelector(selector);
const handleContextMenu = useCallback((e: MouseEvent<HTMLDivElement>) => { const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
}, []); }, []);
@ -48,13 +43,9 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => {
<MenuList <MenuList
sx={{ visibility: 'visible !important' }} sx={{ visibility: 'visible !important' }}
motionProps={menuListMotionProps} motionProps={menuListMotionProps}
onContextMenu={handleContextMenu} onContextMenu={skipEvent}
> >
{selectionCount === 1 ? (
<SingleSelectionMenuItems imageDTO={imageDTO} /> <SingleSelectionMenuItems imageDTO={imageDTO} />
) : (
<MultipleSelectionMenuItems />
)}
</MenuList> </MenuList>
) : null ) : null
} }

View File

@ -28,8 +28,10 @@ import {
FaShare, FaShare,
FaTrash, FaTrash,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages'; import {
import { useGetImageMetadataQuery } from 'services/api/endpoints/images'; useGetImageMetadataQuery,
useRemoveImageFromBoardMutation,
} from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext'; import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext';
import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions'; import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions';
@ -128,15 +130,8 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
if (!imageDTO.board_id) { if (!imageDTO.board_id) {
return; return;
} }
removeFromBoard({ removeFromBoard({ imageDTO });
board_id: imageDTO.board_id, }, [imageDTO, removeFromBoard]);
image_name: imageDTO.image_name,
});
}, [imageDTO.board_id, imageDTO.image_name, removeFromBoard]);
const handleOpenInNewTab = useCallback(() => {
window.open(imageDTO.image_url, '_blank');
}, [imageDTO.image_url]);
const handleAddToBatch = useCallback(() => { const handleAddToBatch = useCallback(() => {
dispatch(imagesAddedToBatch([imageDTO.image_name])); dispatch(imagesAddedToBatch([imageDTO.image_name]));
@ -149,10 +144,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
return ( return (
<> <>
<Link href={imageDTO.image_url} target="_blank"> <Link href={imageDTO.image_url} target="_blank">
<MenuItem <MenuItem icon={<FaExternalLinkAlt />}>
icon={<FaExternalLinkAlt />}
onClickCapture={handleOpenInNewTab}
>
{t('common.openInNewTab')} {t('common.openInNewTab')}
</MenuItem> </MenuItem>
</Link> </Link>
@ -161,6 +153,11 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
{t('parameters.copyImage')} {t('parameters.copyImage')}
</MenuItem> </MenuItem>
)} )}
<Link download={true} href={imageDTO.image_url} target="_blank">
<MenuItem icon={<FaDownload />} w="100%">
{t('parameters.downloadImage')}
</MenuItem>
</Link>
<MenuItem <MenuItem
icon={<FaQuoteRight />} icon={<FaQuoteRight />}
onClickCapture={handleRecallPrompt} onClickCapture={handleRecallPrompt}
@ -219,11 +216,6 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
Remove from Board Remove from Board
</MenuItem> </MenuItem>
)} )}
<Link download={true} href={imageDTO.image_url} target="_blank">
<MenuItem icon={<FaDownload />} w="100%">
{t('parameters.downloadImage')}
</MenuItem>
</Link>
<MenuItem <MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }} sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />} icon={<FaTrash />}

View File

@ -1,113 +1,34 @@
import { import { Box, Flex, VStack, useDisclosure } from '@chakra-ui/react';
Box,
Button,
ButtonGroup,
Flex,
Text,
VStack,
useColorMode,
useDisclosure,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
import IAISlider from 'common/components/IAISlider';
import {
setGalleryImageMinimumWidth,
setGalleryView,
} from 'features/gallery/store/gallerySlice';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { ChangeEvent, memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { FaImage, FaServer, FaWrench } from 'react-icons/fa';
import { ChevronUpIcon } from '@chakra-ui/icons';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { memo, useRef } from 'react';
import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { mode } from 'theme/util/mode';
import BoardsList from './Boards/BoardsList/BoardsList'; import BoardsList from './Boards/BoardsList/BoardsList';
import GalleryBoardName from './GalleryBoardName';
import GalleryPinButton from './GalleryPinButton';
import GallerySettingsPopover from './GallerySettingsPopover';
import BatchImageGrid from './ImageGrid/BatchImageGrid'; import BatchImageGrid from './ImageGrid/BatchImageGrid';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
const selector = createSelector( const selector = createSelector(
[stateSelector], [stateSelector],
(state) => { (state) => {
const { const { selectedBoardId } = state.gallery;
selectedBoardId,
galleryImageMinimumWidth,
galleryView,
shouldAutoSwitch,
} = state.gallery;
const { shouldPinGallery } = state.ui;
return { return {
selectedBoardId, selectedBoardId,
shouldPinGallery,
galleryImageMinimumWidth,
shouldAutoSwitch,
galleryView,
}; };
}, },
defaultSelectorOptions defaultSelectorOptions
); );
const ImageGalleryContent = () => { const ImageGalleryContent = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const resizeObserverRef = useRef<HTMLDivElement>(null); const resizeObserverRef = useRef<HTMLDivElement>(null);
const galleryGridRef = useRef<HTMLDivElement>(null); const galleryGridRef = useRef<HTMLDivElement>(null);
const { selectedBoardId } = useAppSelector(selector);
const { colorMode } = useColorMode(); const { isOpen: isBoardListOpen, onToggle: onToggleBoardList } =
useDisclosure();
const {
selectedBoardId,
shouldPinGallery,
galleryImageMinimumWidth,
shouldAutoSwitch,
galleryView,
} = useAppSelector(selector);
const { selectedBoard } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => ({
selectedBoard: data?.find((b) => b.board_id === selectedBoardId),
}),
});
const boardTitle = useMemo(() => {
if (selectedBoardId === 'batch') {
return 'Batch';
}
if (selectedBoard) {
return selectedBoard.board_name;
}
return 'All Images';
}, [selectedBoard, selectedBoardId]);
const { isOpen: isBoardListOpen, onToggle } = useDisclosure();
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
};
const handleSetShouldPinGallery = () => {
dispatch(togglePinGalleryPanel());
dispatch(requestCanvasRescale());
};
const handleClickImagesCategory = useCallback(() => {
dispatch(setGalleryView('images'));
}, [dispatch]);
const handleClickAssetsCategory = useCallback(() => {
dispatch(setGalleryView('assets'));
}, [dispatch]);
return ( return (
<VStack <VStack
@ -127,95 +48,12 @@ const ImageGalleryContent = () => {
gap: 2, gap: 2,
}} }}
> >
<ButtonGroup isAttached> <GallerySettingsPopover />
<IAIIconButton <GalleryBoardName
tooltip={t('gallery.images')} isOpen={isBoardListOpen}
aria-label={t('gallery.images')} onToggle={onToggleBoardList}
onClick={handleClickImagesCategory}
isChecked={galleryView === 'images'}
size="sm"
icon={<FaImage />}
/>
<IAIIconButton
tooltip={t('gallery.assets')}
aria-label={t('gallery.assets')}
onClick={handleClickAssetsCategory}
isChecked={galleryView === 'assets'}
size="sm"
icon={<FaServer />}
/>
</ButtonGroup>
<Flex
as={Button}
onClick={onToggle}
size="sm"
variant="ghost"
sx={{
w: 'full',
justifyContent: 'center',
alignItems: 'center',
px: 2,
_hover: {
bg: mode('base.100', 'base.800')(colorMode),
},
}}
>
<Text
noOfLines={1}
sx={{
w: 'full',
color: mode('base.800', 'base.200')(colorMode),
fontWeight: 600,
}}
>
{boardTitle}
</Text>
<ChevronUpIcon
sx={{
transform: isBoardListOpen ? 'rotate(0deg)' : 'rotate(180deg)',
transitionProperty: 'common',
transitionDuration: 'normal',
}}
/>
</Flex>
<IAIPopover
triggerComponent={
<IAIIconButton
tooltip={t('gallery.gallerySettings')}
aria-label={t('gallery.gallerySettings')}
size="sm"
icon={<FaWrench />}
/>
}
>
<Flex direction="column" gap={2}>
<IAISlider
value={galleryImageMinimumWidth}
onChange={handleChangeGalleryImageMinimumWidth}
min={32}
max={256}
hideTooltip={true}
label={t('gallery.galleryImageSize')}
withReset
handleReset={() => dispatch(setGalleryImageMinimumWidth(64))}
/>
<IAISimpleCheckbox
label={t('gallery.autoSwitchNewImages')}
isChecked={shouldAutoSwitch}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(shouldAutoSwitchChanged(e.target.checked))
}
/>
</Flex>
</IAIPopover>
<IAIIconButton
size="sm"
aria-label={t('gallery.pinGallery')}
tooltip={`${t('gallery.pinGallery')} (Shift+G)`}
onClick={handleSetShouldPinGallery}
icon={shouldPinGallery ? <BsPinAngleFill /> : <BsPinAngle />}
/> />
<GalleryPinButton />
</Flex> </Flex>
<Box> <Box>
<BoardsList isOpen={isBoardListOpen} /> <BoardsList isOpen={isBoardListOpen} />

View File

@ -1,16 +1,13 @@
import { Box, Spinner } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { import { imageSelected } from 'features/gallery/store/gallerySlice';
imageRangeEndSelected,
imageSelected,
imageSelectionToggled,
} from 'features/gallery/store/gallerySlice';
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import { MouseEvent, memo, useCallback, useMemo } from 'react'; import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
@ -84,7 +81,7 @@ const GalleryImage = (props: HoverableImageProps) => {
}, [imageDTO, selection, selectionCount]); }, [imageDTO, selection, selectionCount]);
if (!imageDTO) { if (!imageDTO) {
return <Spinner />; return <IAIFillSkeleton />;
} }
return ( return (

View File

@ -1,58 +1,26 @@
import { Box } from '@chakra-ui/react'; import { Box, Spinner } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton'; import IAIButton from 'common/components/IAIButton';
import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { IMAGE_LIMIT } from 'features/gallery//store/gallerySlice';
import {
UseOverlayScrollbarsParams,
useOverlayScrollbars,
} from 'overlayscrollbars-react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa'; import { FaExclamationCircle, FaImage } from 'react-icons/fa';
import GalleryImage from './GalleryImage';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
IMAGE_LIMIT,
} from 'features/gallery//store/gallerySlice';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import { VirtuosoGrid } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso';
import { receivedPageOfImages } from 'services/api/thunks/image'; import {
import { useListBoardImagesQuery } from '../../../../services/api/endpoints/boardImages'; useLazyListImagesQuery,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GalleryImage from './GalleryImage';
import ImageGridItemContainer from './ImageGridItemContainer'; import ImageGridItemContainer from './ImageGridItemContainer';
import ImageGridListContainer from './ImageGridListContainer'; import ImageGridListContainer from './ImageGridListContainer';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
const selector = createSelector( const overlayScrollbarsConfig: UseOverlayScrollbarsParams = {
[stateSelector, selectFilteredImages],
(state, filteredImages) => {
const {
galleryImageMinimumWidth,
selectedBoardId,
galleryView,
total,
isLoading,
} = state.gallery;
return {
imageNames: filteredImages.map((i) => i.image_name),
total,
selectedBoardId,
galleryView,
galleryImageMinimumWidth,
isLoading,
};
},
defaultSelectorOptions
);
const GalleryImageGrid = () => {
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null);
const emptyGalleryRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true, defer: true,
options: { options: {
scrollbars: { scrollbars: {
@ -63,62 +31,40 @@ const GalleryImageGrid = () => {
}, },
overflow: { x: 'hidden' }, overflow: { x: 'hidden' },
}, },
}); };
const [didInitialFetch, setDidInitialFetch] = useState(false); const GalleryImageGrid = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch(); const rootRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const { const [initialize, osInstance] = useOverlayScrollbars(
galleryImageMinimumWidth, overlayScrollbarsConfig
imageNames: imageNamesAll, //all images names loaded on main tab,
total: totalAll,
selectedBoardId,
galleryView,
isLoading: isLoadingAll,
} = useAppSelector(selector);
const { data: imagesForBoard, isLoading: isLoadingImagesForBoard } =
useListBoardImagesQuery(
{ board_id: selectedBoardId },
{ skip: selectedBoardId === 'all' }
); );
const imageNames = useMemo(() => { const queryArgs = useAppSelector(selectListImagesBaseQueryArgs);
if (selectedBoardId === 'all') {
return imageNamesAll; // already sorted by images/uploads in gallery selector const { currentData, isFetching, isSuccess, isError } =
} else { useListImagesQuery(queryArgs);
const categories =
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; const [listImages] = useLazyListImagesQuery();
const imageList = (imagesForBoard?.items || []).filter((img) =>
categories.includes(img.image_category)
);
return imageList.map((img) => img.image_name);
}
}, [selectedBoardId, galleryView, imagesForBoard, imageNamesAll]);
const areMoreAvailable = useMemo(() => { const areMoreAvailable = useMemo(() => {
return selectedBoardId === 'all' ? totalAll > imageNamesAll.length : false; if (!currentData) {
}, [selectedBoardId, imageNamesAll.length, totalAll]); return false;
}
const isLoading = useMemo(() => { return currentData.ids.length < currentData.total;
return selectedBoardId === 'all' ? isLoadingAll : isLoadingImagesForBoard; }, [currentData]);
}, [selectedBoardId, isLoadingAll, isLoadingImagesForBoard]);
const handleLoadMoreImages = useCallback(() => { const handleLoadMoreImages = useCallback(() => {
dispatch( listImages({
receivedPageOfImages({ ...queryArgs,
categories: offset: currentData?.ids.length ?? 0,
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
is_intermediate: false,
offset: imageNames.length,
limit: IMAGE_LIMIT, limit: IMAGE_LIMIT,
}) });
); }, [listImages, queryArgs, currentData?.ids.length]);
}, [dispatch, imageNames.length, galleryView]);
useEffect(() => { useEffect(() => {
// Set up gallery scroler // Initialize the gallery's custom scrollbar
const { current: root } = rootRef; const { current: root } = rootRef;
if (scroller && root) { if (scroller && root) {
initialize({ initialize({
@ -131,47 +77,17 @@ const GalleryImageGrid = () => {
return () => osInstance()?.destroy(); return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]); }, [scroller, initialize, osInstance]);
const handleEndReached = useMemo(() => { if (!currentData) {
if (areMoreAvailable) {
return handleLoadMoreImages;
}
return undefined;
}, [areMoreAvailable, handleLoadMoreImages]);
// useEffect(() => {
// if (!didInitialFetch) {
// return;
// }
// // rough, conservative calculation of how many images fit in the gallery
// // TODO: this gets an incorrect value on first load...
// const galleryHeight = rootRef.current?.clientHeight ?? 0;
// const galleryWidth = rootRef.current?.clientHeight ?? 0;
// const rows = galleryHeight / galleryImageMinimumWidth;
// const columns = galleryWidth / galleryImageMinimumWidth;
// const imagesToLoad = Math.ceil(rows * columns);
// setDidInitialFetch(true);
// // load up that many images
// dispatch(
// receivedPageOfImages({
// offset: 0,
// limit: 10,
// })
// );
// }, [
// didInitialFetch,
// dispatch,
// galleryImageMinimumWidth,
// galleryView,
// selectedBoardId,
// ]);
if (!isLoading && imageNames.length === 0) {
return ( return (
<Box ref={emptyGalleryRef} sx={{ w: 'full', h: 'full' }}> <Box sx={{ w: 'full', h: 'full' }}>
<Spinner size="2xl" opacity={0.5} />
</Box>
);
}
if (isSuccess && currentData?.ids.length === 0) {
return (
<Box sx={{ w: 'full', h: 'full' }}>
<IAINoContentFallback <IAINoContentFallback
label={t('gallery.noImagesInGallery')} label={t('gallery.noImagesInGallery')}
icon={FaImage} icon={FaImage}
@ -180,27 +96,28 @@ const GalleryImageGrid = () => {
); );
} }
if (status !== 'rejected') { if (isSuccess && currentData) {
return ( return (
<> <>
<Box ref={rootRef} data-overlayscrollbars="" h="100%"> <Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid <VirtuosoGrid
style={{ height: '100%' }} style={{ height: '100%' }}
data={imageNames} data={currentData.ids}
endReached={handleLoadMoreImages}
components={{ components={{
Item: ImageGridItemContainer, Item: ImageGridItemContainer,
List: ImageGridListContainer, List: ImageGridListContainer,
}} }}
scrollerRef={setScroller} scrollerRef={setScroller}
itemContent={(index, imageName) => ( itemContent={(index, imageName) => (
<GalleryImage key={imageName} imageName={imageName} /> <GalleryImage key={imageName} imageName={imageName as string} />
)} )}
/> />
</Box> </Box>
<IAIButton <IAIButton
onClick={handleLoadMoreImages} onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable} isDisabled={!areMoreAvailable}
isLoading={status === 'pending'} isLoading={isFetching}
loadingText="Loading" loadingText="Loading"
flexShrink={0} flexShrink={0}
> >
@ -211,6 +128,17 @@ const GalleryImageGrid = () => {
</> </>
); );
} }
if (isError) {
return (
<Box sx={{ w: 'full', h: 'full' }}>
<IAINoContentFallback
label="Unable to load Gallery"
icon={FaExclamationCircle}
/>
</Box>
);
}
}; };
export default memo(GalleryImageGrid); export default memo(GalleryImageGrid);

View File

@ -11,11 +11,9 @@ const ImageMetadataActions = (props: Props) => {
const { metadata } = props; const { metadata } = props;
const { const {
recallBothPrompts,
recallPositivePrompt, recallPositivePrompt,
recallNegativePrompt, recallNegativePrompt,
recallSeed, recallSeed,
recallInitialImage,
recallCfgScale, recallCfgScale,
recallModel, recallModel,
recallScheduler, recallScheduler,
@ -23,7 +21,6 @@ const ImageMetadataActions = (props: Props) => {
recallWidth, recallWidth,
recallHeight, recallHeight,
recallStrength, recallStrength,
recallAllParameters,
} = useRecallParameters(); } = useRecallParameters();
const handleRecallPositivePrompt = useCallback(() => { const handleRecallPositivePrompt = useCallback(() => {

View File

@ -2,61 +2,76 @@ import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { import {
IMAGE_LIMIT,
imageSelected, imageSelected,
selectImagesById, selectImagesById,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es'; import { clamp, isEqual } from 'lodash-es';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { receivedPageOfImages } from 'services/api/thunks/image'; import {
import { selectFilteredImages } from '../store/gallerySelectors'; ListImagesArgs,
imagesAdapter,
imagesApi,
useLazyListImagesQuery,
} from 'services/api/endpoints/images';
import { selectListImagesBaseQueryArgs } from '../store/gallerySelectors';
export const nextPrevImageButtonsSelector = createSelector( export const nextPrevImageButtonsSelector = createSelector(
[stateSelector, selectFilteredImages], [stateSelector, selectListImagesBaseQueryArgs],
(state, filteredImages) => { (state, baseQueryArgs) => {
const { total, isFetching } = state.gallery; const { data, status } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const lastSelectedImage = const lastSelectedImage =
state.gallery.selection[state.gallery.selection.length - 1]; state.gallery.selection[state.gallery.selection.length - 1];
if (!lastSelectedImage || filteredImages.length === 0) { const isFetching = status === 'pending';
if (!data || !lastSelectedImage || data.total === 0) {
return { return {
isFetching,
queryArgs: baseQueryArgs,
isOnFirstImage: true, isOnFirstImage: true,
isOnLastImage: true, isOnLastImage: true,
}; };
} }
const currentImageIndex = filteredImages.findIndex( const queryArgs: ListImagesArgs = {
...baseQueryArgs,
offset: data.ids.length,
limit: IMAGE_LIMIT,
};
const selectors = imagesAdapter.getSelectors();
const images = selectors.selectAll(data);
const currentImageIndex = images.findIndex(
(i) => i.image_name === lastSelectedImage (i) => i.image_name === lastSelectedImage
); );
const nextImageIndex = clamp( const nextImageIndex = clamp(currentImageIndex + 1, 0, images.length - 1);
currentImageIndex + 1,
0,
filteredImages.length - 1
);
const prevImageIndex = clamp( const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1);
currentImageIndex - 1,
0,
filteredImages.length - 1
);
const nextImageId = filteredImages[nextImageIndex].image_name; const nextImageId = images[nextImageIndex].image_name;
const prevImageId = filteredImages[prevImageIndex].image_name; const prevImageId = images[prevImageIndex].image_name;
const nextImage = selectImagesById(state, nextImageId); const nextImage = selectors.selectById(data, nextImageId);
const prevImage = selectImagesById(state, prevImageId); const prevImage = selectors.selectById(data, prevImageId);
const imagesLength = filteredImages.length; const imagesLength = images.length;
return { return {
isOnFirstImage: currentImageIndex === 0, isOnFirstImage: currentImageIndex === 0,
isOnLastImage: isOnLastImage:
!isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1, !isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1,
areMoreImagesAvailable: total > imagesLength, areMoreImagesAvailable: data?.total ?? 0 > imagesLength,
isFetching, isFetching: status === 'pending',
nextImage, nextImage,
prevImage, prevImage,
nextImageId, nextImageId,
prevImageId, prevImageId,
queryArgs,
}; };
}, },
{ {
@ -76,6 +91,7 @@ export const useNextPrevImage = () => {
prevImageId, prevImageId,
areMoreImagesAvailable, areMoreImagesAvailable,
isFetching, isFetching,
queryArgs,
} = useAppSelector(nextPrevImageButtonsSelector); } = useAppSelector(nextPrevImageButtonsSelector);
const handlePrevImage = useCallback(() => { const handlePrevImage = useCallback(() => {
@ -86,13 +102,11 @@ export const useNextPrevImage = () => {
nextImageId && dispatch(imageSelected(nextImageId)); nextImageId && dispatch(imageSelected(nextImageId));
}, [dispatch, nextImageId]); }, [dispatch, nextImageId]);
const [listImages] = useLazyListImagesQuery();
const handleLoadMoreImages = useCallback(() => { const handleLoadMoreImages = useCallback(() => {
dispatch( listImages(queryArgs);
receivedPageOfImages({ }, [listImages, queryArgs]);
is_intermediate: false,
})
);
}, [dispatch]);
return { return {
handlePrevImage, handlePrevImage,

View File

@ -1,136 +1,38 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store'; import { RootState } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { clamp, keyBy } from 'lodash-es'; import { ListImagesArgs } from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types'; import { INITIAL_IMAGE_LIMIT } from './gallerySlice';
import { import {
ASSETS_CATEGORIES, getBoardIdQueryParamForBoard,
BoardId, getCategoriesQueryParamForBoard,
IMAGE_CATEGORIES, } from './util';
imagesAdapter,
initialGalleryState,
} from './gallerySlice';
export const gallerySelector = (state: RootState) => state.gallery; export const gallerySelector = (state: RootState) => state.gallery;
const isInSelectedBoard = (
selectedBoardId: BoardId,
imageDTO: ImageDTO,
batchImageNames: string[]
) => {
if (selectedBoardId === 'all') {
// all images are in the "All Images" board
return true;
}
if (selectedBoardId === 'none' && !imageDTO.board_id) {
// Only images without a board are in the "No Board" board
return true;
}
if (
selectedBoardId === 'batch' &&
batchImageNames.includes(imageDTO.image_name)
) {
// Only images with is_batch are in the "Batch" board
return true;
}
return selectedBoardId === imageDTO.board_id;
};
export const selectFilteredImagesLocal = createSelector(
[(state: typeof initialGalleryState) => state],
(galleryState) => {
const allImages = imagesAdapter.getSelectors().selectAll(galleryState);
const { galleryView, selectedBoardId } = galleryState;
const categories =
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInBoard = isInSelectedBoard(
selectedBoardId,
i,
galleryState.batchImageNames
);
return isInCategory && isInBoard;
});
return filteredImages;
}
);
export const selectFilteredImages = createSelector(
(state: RootState) => state,
(state) => {
return selectFilteredImagesLocal(state.gallery);
},
defaultSelectorOptions
);
export const selectFilteredImagesAsObject = createSelector(
selectFilteredImages,
(filteredImages) => keyBy(filteredImages, 'image_name')
);
export const selectFilteredImagesIds = createSelector(
selectFilteredImages,
(filteredImages) => filteredImages.map((i) => i.image_name)
);
export const selectLastSelectedImage = createSelector( export const selectLastSelectedImage = createSelector(
(state: RootState) => state, (state: RootState) => state,
(state) => state.gallery.selection[state.gallery.selection.length - 1], (state) => state.gallery.selection[state.gallery.selection.length - 1],
defaultSelectorOptions defaultSelectorOptions
); );
export const selectSelectedImages = createSelector( export const selectListImagesBaseQueryArgs = createSelector(
(state: RootState) => state, [(state: RootState) => state],
(state) => (state) => {
imagesAdapter const { selectedBoardId } = state.gallery;
.getSelectors()
.selectAll(state.gallery)
.filter((i) => state.gallery.selection.includes(i.image_name)),
defaultSelectorOptions
);
export const selectNextImageToSelectLocal = createSelector( const categories = getCategoriesQueryParamForBoard(selectedBoardId);
[ const board_id = getBoardIdQueryParamForBoard(selectedBoardId);
(state: typeof initialGalleryState) => state,
(state: typeof initialGalleryState, image_name: string) => image_name,
],
(state, image_name) => {
const filteredImages = selectFilteredImagesLocal(state);
const ids = filteredImages.map((i) => i.image_name);
const deletedImageIndex = ids.findIndex( const listImagesBaseQueryArgs: ListImagesArgs = {
(result) => result.toString() === image_name categories,
); board_id,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const filteredIds = ids.filter((id) => id.toString() !== image_name); return listImagesBaseQueryArgs;
const newSelectedImageIndex = clamp(
deletedImageIndex,
0,
filteredIds.length - 1
);
const newSelectedImageId = filteredIds[newSelectedImageIndex];
return newSelectedImageId;
}
);
export const selectNextImageToSelect = createSelector(
[
(state: RootState) => state,
(state: RootState, image_name: string) => image_name,
],
(state, image_name) => {
return selectNextImageToSelectLocal(state.gallery, image_name);
}, },
defaultSelectorOptions defaultSelectorOptions
); );

View File

@ -1,20 +1,8 @@
import type { PayloadAction, Update } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { dateComparator } from 'common/util/dateComparator';
import { uniq } from 'lodash-es'; import { uniq } from 'lodash-es';
import { boardsApi } from 'services/api/endpoints/boards'; import { boardsApi } from 'services/api/endpoints/boards';
import { import { ImageCategory } from 'services/api/types';
imageUrlsReceived,
receivedPageOfImages,
} from 'services/api/thunks/image';
import { ImageCategory, ImageDTO } from 'services/api/types';
import { selectFilteredImagesLocal } from './gallerySelectors';
export const imagesAdapter = createEntityAdapter<ImageDTO>({
selectId: (image) => image.image_name,
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
});
export const IMAGE_CATEGORIES: ImageCategory[] = ['general']; export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
export const ASSETS_CATEGORIES: ImageCategory[] = [ export const ASSETS_CATEGORIES: ImageCategory[] = [
@ -26,113 +14,74 @@ export const ASSETS_CATEGORIES: ImageCategory[] = [
export const INITIAL_IMAGE_LIMIT = 100; export const INITIAL_IMAGE_LIMIT = 100;
export const IMAGE_LIMIT = 20; export const IMAGE_LIMIT = 20;
export type GalleryView = 'images' | 'assets'; // export type GalleryView = 'images' | 'assets';
export type BoardId = export type BoardId =
| 'all' | 'images'
| 'none' | 'assets'
| 'no_board'
| 'batch' | 'batch'
| (string & Record<never, never>); | (string & Record<never, never>);
type AdditionaGalleryState = { type GalleryState = {
offset: number;
limit: number;
total: number;
isLoading: boolean;
isFetching: boolean;
selection: string[]; selection: string[];
shouldAutoSwitch: boolean; shouldAutoSwitch: boolean;
galleryImageMinimumWidth: number; galleryImageMinimumWidth: number;
galleryView: GalleryView;
selectedBoardId: BoardId; selectedBoardId: BoardId;
isInitialized: boolean;
batchImageNames: string[]; batchImageNames: string[];
isBatchEnabled: boolean; isBatchEnabled: boolean;
}; };
export const initialGalleryState = export const initialGalleryState: GalleryState = {
imagesAdapter.getInitialState<AdditionaGalleryState>({
offset: 0,
limit: 0,
total: 0,
isLoading: true,
isFetching: true,
selection: [], selection: [],
shouldAutoSwitch: true, shouldAutoSwitch: true,
galleryImageMinimumWidth: 96, galleryImageMinimumWidth: 96,
galleryView: 'images', selectedBoardId: 'images',
selectedBoardId: 'all',
isInitialized: false,
batchImageNames: [], batchImageNames: [],
isBatchEnabled: false, isBatchEnabled: false,
}); };
export const gallerySlice = createSlice({ export const gallerySlice = createSlice({
name: 'gallery', name: 'gallery',
initialState: initialGalleryState, initialState: initialGalleryState,
reducers: { reducers: {
imageUpserted: (state, action: PayloadAction<ImageDTO>) => {
imagesAdapter.upsertOne(state, action.payload);
if (
state.shouldAutoSwitch &&
action.payload.image_category === 'general'
) {
state.selection = [action.payload.image_name];
state.galleryView = 'images';
state.selectedBoardId = 'all';
}
},
imageUpdatedOne: (state, action: PayloadAction<Update<ImageDTO>>) => {
imagesAdapter.updateOne(state, action.payload);
},
imageRemoved: (state, action: PayloadAction<string>) => {
imagesAdapter.removeOne(state, action.payload);
state.batchImageNames = state.batchImageNames.filter(
(name) => name !== action.payload
);
},
imagesRemoved: (state, action: PayloadAction<string[]>) => { imagesRemoved: (state, action: PayloadAction<string[]>) => {
imagesAdapter.removeMany(state, action.payload); // TODO: port all instances of this to use RTK Query cache
state.batchImageNames = state.batchImageNames.filter( // imagesAdapter.removeMany(state, action.payload);
(name) => !action.payload.includes(name) // state.batchImageNames = state.batchImageNames.filter(
); // (name) => !action.payload.includes(name)
// );
}, },
imageRangeEndSelected: (state, action: PayloadAction<string>) => { imageRangeEndSelected: (state, action: PayloadAction<string>) => {
const rangeEndImageName = action.payload; // const rangeEndImageName = action.payload;
const lastSelectedImage = state.selection[state.selection.length - 1]; // const lastSelectedImage = state.selection[state.selection.length - 1];
// const filteredImages = selectFilteredImagesLocal(state);
const filteredImages = selectFilteredImagesLocal(state); // const lastClickedIndex = filteredImages.findIndex(
// (n) => n.image_name === lastSelectedImage
const lastClickedIndex = filteredImages.findIndex( // );
(n) => n.image_name === lastSelectedImage // const currentClickedIndex = filteredImages.findIndex(
); // (n) => n.image_name === rangeEndImageName
// );
const currentClickedIndex = filteredImages.findIndex( // if (lastClickedIndex > -1 && currentClickedIndex > -1) {
(n) => n.image_name === rangeEndImageName // // We have a valid range!
); // const start = Math.min(lastClickedIndex, currentClickedIndex);
// const end = Math.max(lastClickedIndex, currentClickedIndex);
if (lastClickedIndex > -1 && currentClickedIndex > -1) { // const imagesToSelect = filteredImages
// We have a valid range! // .slice(start, end + 1)
const start = Math.min(lastClickedIndex, currentClickedIndex); // .map((i) => i.image_name);
const end = Math.max(lastClickedIndex, currentClickedIndex); // state.selection = uniq(state.selection.concat(imagesToSelect));
// }
const imagesToSelect = filteredImages
.slice(start, end + 1)
.map((i) => i.image_name);
state.selection = uniq(state.selection.concat(imagesToSelect));
}
}, },
imageSelectionToggled: (state, action: PayloadAction<string>) => { imageSelectionToggled: (state, action: PayloadAction<string>) => {
if ( // if (
state.selection.includes(action.payload) && // state.selection.includes(action.payload) &&
state.selection.length > 1 // state.selection.length > 1
) { // ) {
state.selection = state.selection.filter( // state.selection = state.selection.filter(
(imageName) => imageName !== action.payload // (imageName) => imageName !== action.payload
); // );
} else { // } else {
state.selection = uniq(state.selection.concat(action.payload)); // state.selection = uniq(state.selection.concat(action.payload));
} // }
}, },
imageSelected: (state, action: PayloadAction<string | null>) => { imageSelected: (state, action: PayloadAction<string | null>) => {
state.selection = action.payload ? [action.payload] : []; state.selection = action.payload ? [action.payload] : [];
@ -143,15 +92,9 @@ export const gallerySlice = createSlice({
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => { setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
state.galleryImageMinimumWidth = action.payload; state.galleryImageMinimumWidth = action.payload;
}, },
setGalleryView: (state, action: PayloadAction<GalleryView>) => {
state.galleryView = action.payload;
},
boardIdSelected: (state, action: PayloadAction<BoardId>) => { boardIdSelected: (state, action: PayloadAction<BoardId>) => {
state.selectedBoardId = action.payload; state.selectedBoardId = action.payload;
}, },
isLoadingChanged: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
isBatchEnabledChanged: (state, action: PayloadAction<boolean>) => { isBatchEnabledChanged: (state, action: PayloadAction<boolean>) => {
state.isBatchEnabled = action.payload; state.isBatchEnabled = action.payload;
}, },
@ -182,47 +125,11 @@ export const gallerySlice = createSlice({
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(receivedPageOfImages.pending, (state) => {
state.isFetching = true;
});
builder.addCase(receivedPageOfImages.rejected, (state) => {
state.isFetching = false;
});
builder.addCase(receivedPageOfImages.fulfilled, (state, action) => {
state.isFetching = false;
const { board_id, categories, image_origin, is_intermediate } =
action.meta.arg;
const { items, offset, limit, total } = action.payload;
imagesAdapter.upsertMany(state, items);
if (state.selection.length === 0 && items.length) {
state.selection = [items[0].image_name];
}
if (!categories?.includes('general') || board_id) {
// need to skip updating the total images count if the images recieved were for a specific board
// TODO: this doesn't work when on the Asset tab/category...
return;
}
state.offset = offset;
state.total = total;
});
builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
const { image_name, image_url, thumbnail_url } = action.payload;
imagesAdapter.updateOne(state, {
id: image_name,
changes: { image_url, thumbnail_url },
});
});
builder.addMatcher( builder.addMatcher(
boardsApi.endpoints.deleteBoard.matchFulfilled, boardsApi.endpoints.deleteBoard.matchFulfilled,
(state, action) => { (state, action) => {
if (action.meta.arg.originalArgs === state.selectedBoardId) { if (action.meta.arg.originalArgs === state.selectedBoardId) {
state.selectedBoardId = 'all'; state.selectedBoardId = 'images';
} }
} }
); );
@ -230,26 +137,13 @@ export const gallerySlice = createSlice({
}); });
export const { export const {
selectAll: selectImagesAll,
selectById: selectImagesById,
selectEntities: selectImagesEntities,
selectIds: selectImagesIds,
selectTotal: selectImagesTotal,
} = imagesAdapter.getSelectors<RootState>((state) => state.gallery);
export const {
imageUpserted,
imageUpdatedOne,
imageRemoved,
imagesRemoved, imagesRemoved,
imageRangeEndSelected, imageRangeEndSelected,
imageSelectionToggled, imageSelectionToggled,
imageSelected, imageSelected,
shouldAutoSwitchChanged, shouldAutoSwitchChanged,
setGalleryImageMinimumWidth, setGalleryImageMinimumWidth,
setGalleryView,
boardIdSelected, boardIdSelected,
isLoadingChanged,
isBatchEnabledChanged, isBatchEnabledChanged,
imagesAddedToBatch, imagesAddedToBatch,
imagesRemovedFromBatch, imagesRemovedFromBatch,

View File

@ -0,0 +1,54 @@
import { SYSTEM_BOARDS } from 'services/api/endpoints/images';
import { ASSETS_CATEGORIES, BoardId, IMAGE_CATEGORIES } from './gallerySlice';
import { ImageCategory } from 'services/api/types';
import { isEqual } from 'lodash-es';
export const getCategoriesQueryParamForBoard = (
board_id: BoardId
): ImageCategory[] | undefined => {
if (board_id === 'assets') {
return ASSETS_CATEGORIES;
}
if (board_id === 'images') {
return IMAGE_CATEGORIES;
}
// 'no_board' board, 'batch' board, user boards
return undefined;
};
export const getBoardIdQueryParamForBoard = (
board_id: BoardId
): string | undefined => {
if (board_id === 'no_board') {
return 'none';
}
// system boards besides 'no_board'
if (SYSTEM_BOARDS.includes(board_id)) {
return undefined;
}
// user boards
return board_id;
};
export const getBoardIdFromBoardAndCategoriesQueryParam = (
board_id: string | undefined,
categories: ImageCategory[] | undefined
): BoardId => {
if (board_id === undefined && isEqual(categories, IMAGE_CATEGORIES)) {
return 'images';
}
if (board_id === undefined && isEqual(categories, ASSETS_CATEGORIES)) {
return 'assets';
}
if (board_id === 'none') {
return 'no_board';
}
return board_id ?? 'UNKNOWN_BOARD';
};

View File

@ -2,9 +2,17 @@ import { some } from 'lodash-es';
import { memo } from 'react'; import { memo } from 'react';
import { ImageUsage } from '../store/imageDeletionSlice'; import { ImageUsage } from '../store/imageDeletionSlice';
import { ListItem, Text, UnorderedList } from '@chakra-ui/react'; import { ListItem, Text, UnorderedList } from '@chakra-ui/react';
type Props = {
const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => { imageUsage?: ImageUsage;
const { imageUsage } = props; topMessage?: string;
bottomMessage?: string;
};
const ImageUsageMessage = (props: Props) => {
const {
imageUsage,
topMessage = 'This image is currently in use in the following features:',
bottomMessage = 'If you delete this image, those features will immediately be reset.',
} = props;
if (!imageUsage) { if (!imageUsage) {
return null; return null;
@ -16,16 +24,14 @@ const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => {
return ( return (
<> <>
<Text>This image is currently in use in the following features:</Text> <Text>{topMessage}</Text>
<UnorderedList sx={{ paddingInlineStart: 6 }}> <UnorderedList sx={{ paddingInlineStart: 6 }}>
{imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>} {imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
{imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>} {imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
{imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>} {imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
{imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>} {imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
</UnorderedList> </UnorderedList>
<Text> <Text>{bottomMessage}</Text>
If you delete this image, those features will immediately be reset.
</Text>
</> </>
); );
}; };

Some files were not shown because too many files have changed in this diff Show More