Rebases against development

This commit is contained in:
psychedelicious 2022-11-11 06:54:06 +11:00 committed by blessedcoolant
parent 248068fe5d
commit 6c7191712f
233 changed files with 7487 additions and 4316 deletions

View File

@ -7,10 +7,13 @@ import traceback
import math
import io
import base64
import os
from flask import Flask, redirect, send_from_directory
from werkzeug.utils import secure_filename
from flask import Flask, redirect, send_from_directory, flash, request, url_for, jsonify
from flask_socketio import SocketIO
from PIL import Image
from PIL.Image import Image as ImageType
from uuid import uuid4
from threading import Event
@ -19,6 +22,9 @@ from ldm.invoke.pngwriter import PngWriter, retrieve_metadata
from ldm.invoke.prompt_parser import split_weighted_subprompts
from backend.modules.parameters import parameters_to_command
from backend.modules.get_outpainting_generation_mode import (
get_outpainting_generation_mode,
)
# Loading Arguments
opt = Args()
@ -91,6 +97,43 @@ class InvokeAIWebServer:
else:
return send_from_directory(self.app.static_folder, "index.html")
@self.app.route("/upload", methods=["POST"])
def upload_base64_file():
try:
data = request.get_json()
dataURL = data["dataURL"]
name = data["name"]
print(f'>> Image upload requested "{name}"')
if dataURL is not None:
bytes = dataURL_to_bytes(dataURL)
file_path = self.save_file_unique_uuid_name(
bytes=bytes, name=name, path=self.result_path
)
mtime = os.path.getmtime(file_path)
(width, height) = Image.open(file_path).size
response = {
"url": self.get_url_from_image_path(file_path),
"mtime": mtime,
"width": width,
"height": height,
"category": "result",
"destination": "outpainting_merge",
}
return response
else:
return "No dataURL provided"
except Exception as e:
self.socketio.emit("error", {"message": (str(e))})
print("\n")
traceback.print_exc()
print("\n")
self.load_socketio_listeners(self.socketio)
if args.gui:
@ -308,19 +351,24 @@ class InvokeAIWebServer:
generation_parameters, esrgan_parameters, facetool_parameters
):
try:
# truncate long init_mask base64 if needed
# truncate long init_mask/init_img base64 if needed
printable_parameters = {
**generation_parameters,
}
if "init_img" in generation_parameters:
printable_parameters["init_img"] = (
printable_parameters["init_img"][:64] + "..."
)
if "init_mask" in generation_parameters:
printable_parameters = {
**generation_parameters,
"init_mask": generation_parameters["init_mask"][:20] + "...",
}
print(
f">> Image generation requested: {printable_parameters}\nESRGAN parameters: {esrgan_parameters}\nFacetool parameters: {facetool_parameters}"
)
else:
print(
f">> Image generation requested: {generation_parameters}\nESRGAN parameters: {esrgan_parameters}\nFacetool parameters: {facetool_parameters}"
printable_parameters["init_mask"] = (
printable_parameters["init_mask"][:64] + "..."
)
print(
f">> Image generation requested: {printable_parameters}\nESRGAN parameters: {esrgan_parameters}\nFacetool parameters: {facetool_parameters}"
)
self.generate_images(
generation_parameters,
esrgan_parameters,
@ -456,7 +504,7 @@ class InvokeAIWebServer:
from send2trash import send2trash
path = self.get_image_path_from_url(url)
print(path)
send2trash(path)
socketio.emit(
"imageDeleted",
@ -479,7 +527,7 @@ class InvokeAIWebServer:
)
mtime = os.path.getmtime(file_path)
(width, height) = Image.open(file_path).size
print(file_path)
socketio.emit(
"imageUploaded",
{
@ -499,17 +547,18 @@ class InvokeAIWebServer:
print("\n")
# TODO: I think this needs a safety mechanism.
@socketio.on("uploadMaskImage")
def handle_upload_mask_image(bytes, name):
@socketio.on("uploadOutpaintingMergeImage")
def handle_upload_outpainting_merge_image(dataURL, name):
try:
print(f'>> Mask image upload requested "{name}"')
print(f'>> Outpainting merge image upload requested "{name}"')
file_path = self.save_file_unique_uuid_name(
bytes=bytes, name=name, path=self.mask_image_path
)
image = dataURL_to_image(dataURL)
file_name = self.make_unique_init_image_filename(name)
file_path = os.path.join(self.result_path, file_name)
image.save(file_path)
socketio.emit(
"maskImageUploaded",
"outpaintingMergeImageUploaded",
{
"url": self.get_url_from_image_path(file_path),
},
@ -546,59 +595,146 @@ class InvokeAIWebServer:
else []
)
actual_generation_mode = generation_parameters["generation_mode"]
original_bounding_box = None
"""
TODO:
If a result image is used as an init image, and then deleted, we will want to be
able to use it as an init image in the future. Need to handle this case.
"""
# We need to give absolute paths to the generator, stash the URLs for later
init_img_url = None
mask_img_url = None
"""
Prepare for generation based on generation_mode
"""
if generation_parameters["generation_mode"] == "outpainting":
"""
generation_parameters["init_img"] is a base64 image
generation_parameters["init_mask"] is a base64 image
if "init_img" in generation_parameters:
So we need to convert each into a PIL Image.
"""
truncated_outpaint_image_b64 = generation_parameters["init_img"][:64]
truncated_outpaint_mask_b64 = generation_parameters["init_mask"][:64]
outpaint_image = dataURL_to_image(
generation_parameters["init_img"]
).convert("RGBA")
# Convert mask dataURL to an image and convert to greyscale
outpaint_mask = dataURL_to_image(
generation_parameters["init_mask"]
).convert("L")
actual_generation_mode = get_outpainting_generation_mode(
outpaint_image, outpaint_mask
)
"""
The outpaint image and mask are pre-cropped by the UI, so the bounding box we pass
to the generator should be:
{
"x": 0,
"y": 0,
"width": original_bounding_box["width"],
"height": original_bounding_box["height"]
}
Save the original bounding box, we need to give it back to the UI when finished,
because the UI needs to know where to put the inpainted image on the canvas.
"""
original_bounding_box = generation_parameters["bounding_box"].copy()
generation_parameters["bounding_box"]["x"] = 0
generation_parameters["bounding_box"]["y"] = 0
"""
Apply the mask to the init image, creating a "mask" image with
transparency where inpainting should occur. This is the kind of
mask that prompt2image() needs.
"""
alpha_mask = outpaint_image.copy()
alpha_mask.putalpha(outpaint_mask)
generation_parameters["init_img"] = outpaint_image
generation_parameters["init_mask"] = alpha_mask
# Remove the unneeded parameters for whichever mode we are doing
if actual_generation_mode == "inpainting":
generation_parameters.pop("seam_size", None)
generation_parameters.pop("seam_blur", None)
generation_parameters.pop("seam_strength", None)
generation_parameters.pop("seam_steps", None)
generation_parameters.pop("tile_size", None)
generation_parameters.pop("force_outpaint", None)
elif actual_generation_mode == "img2img":
generation_parameters["height"] = original_bounding_box["height"]
generation_parameters["width"] = original_bounding_box["width"]
generation_parameters.pop("init_mask", None)
generation_parameters.pop("seam_size", None)
generation_parameters.pop("seam_blur", None)
generation_parameters.pop("seam_strength", None)
generation_parameters.pop("seam_steps", None)
generation_parameters.pop("tile_size", None)
generation_parameters.pop("force_outpaint", None)
elif actual_generation_mode == "txt2img":
generation_parameters["height"] = original_bounding_box["height"]
generation_parameters["width"] = original_bounding_box["width"]
generation_parameters.pop("strength", None)
generation_parameters.pop("fit", None)
generation_parameters.pop("init_img", None)
generation_parameters.pop("init_mask", None)
generation_parameters.pop("seam_size", None)
generation_parameters.pop("seam_blur", None)
generation_parameters.pop("seam_strength", None)
generation_parameters.pop("seam_steps", None)
generation_parameters.pop("tile_size", None)
generation_parameters.pop("force_outpaint", None)
elif generation_parameters["generation_mode"] == "inpainting":
"""
generation_parameters["init_img"] is a url
generation_parameters["init_mask"] is a base64 image
So we need to convert each into a PIL Image.
"""
init_img_url = generation_parameters["init_img"]
truncated_outpaint_mask_b64 = generation_parameters["init_mask"][:64]
init_img_url = generation_parameters["init_img"]
init_img_path = self.get_image_path_from_url(init_img_url)
generation_parameters["init_img"] = init_img_path
# if 'init_mask' in generation_parameters:
# mask_img_url = generation_parameters['init_mask']
# generation_parameters[
# 'init_mask'
# ] = self.get_image_path_from_url(
# generation_parameters['init_mask']
# )
if "init_mask" in generation_parameters:
# grab an Image of the init image
original_image = Image.open(init_img_path)
rgba_image = original_image.convert("RGBA")
# copy a region from it which we will inpaint
cropped_init_image = copy_image_from_bounding_box(
original_image, **generation_parameters["bounding_box"]
rgba_image, **generation_parameters["bounding_box"]
)
generation_parameters["init_img"] = cropped_init_image
if generation_parameters["is_mask_empty"]:
generation_parameters["init_mask"] = None
else:
# grab an Image of the mask
mask_image = Image.open(
io.BytesIO(
base64.decodebytes(
bytes(generation_parameters["init_mask"], "utf-8")
)
)
)
generation_parameters["init_mask"] = mask_image
# Convert mask dataURL to an image and convert to greyscale
mask_image = dataURL_to_image(
generation_parameters["init_mask"]
).convert("L")
totalSteps = self.calculate_real_steps(
steps=generation_parameters["steps"],
strength=generation_parameters["strength"]
if "strength" in generation_parameters
else None,
has_init_image="init_img" in generation_parameters,
)
"""
Apply the mask to the init image, creating a "mask" image with
transparency where inpainting should occur. This is the kind of
mask that prompt2image() needs.
"""
alpha_mask = cropped_init_image.copy()
alpha_mask.putalpha(mask_image)
generation_parameters["init_mask"] = alpha_mask
elif generation_parameters["generation_mode"] == "img2img":
init_img_url = generation_parameters["init_img"]
init_img_path = self.get_image_path_from_url(init_img_url)
generation_parameters["init_img"] = init_img_path
progress = Progress(generation_parameters=generation_parameters)
@ -613,13 +749,22 @@ class InvokeAIWebServer:
nonlocal generation_parameters
nonlocal progress
generation_messages = {
"txt2img": "Text to Image",
"img2img": "Image to Image",
"inpainting": "Inpainting",
"outpainting": "Outpainting",
}
progress.set_current_step(step + 1)
progress.set_current_status("Generating")
progress.set_current_status(
f"Generating ({generation_messages[actual_generation_mode]})"
)
progress.set_current_status_has_steps(True)
if (
generation_parameters["progress_images"]
and step % generation_parameters['save_intermediates'] == 0
and step % generation_parameters["save_intermediates"] == 0
and step < generation_parameters["steps"] - 1
):
image = self.generate.sample_to_image(sample)
@ -648,6 +793,8 @@ class InvokeAIWebServer:
"metadata": metadata,
"width": width,
"height": height,
"generationMode": generation_parameters["generation_mode"],
"boundingBox": original_bounding_box,
},
)
@ -670,6 +817,8 @@ class InvokeAIWebServer:
"metadata": {},
"width": width,
"height": height,
"generationMode": generation_parameters["generation_mode"],
"boundingBox": original_bounding_box,
},
)
@ -688,8 +837,11 @@ class InvokeAIWebServer:
step_index = 1
nonlocal prior_variations
"""
Tidy up after generation based on generation_mode
"""
# paste the inpainting image back onto the original
if "init_mask" in generation_parameters:
if generation_parameters["generation_mode"] == "inpainting":
image = paste_image_into_bounding_box(
Image.open(init_img_path),
image,
@ -786,11 +938,14 @@ class InvokeAIWebServer:
# restore the stashed URLS and discard the paths, we are about to send the result to client
if "init_img" in all_parameters:
all_parameters["init_img"] = init_img_url
all_parameters["init_img"] = ""
if "init_mask" in all_parameters:
all_parameters["init_mask"] = "" # TODO: store the mask in metadata
if generation_parameters["generation_mode"] == "outpainting":
all_parameters["bounding_box"] = original_bounding_box
metadata = self.parameters_to_generated_image_metadata(all_parameters)
command = parameters_to_command(all_parameters)
@ -826,6 +981,8 @@ class InvokeAIWebServer:
"metadata": metadata,
"width": width,
"height": height,
"boundingBox": original_bounding_box,
"generationMode": generation_parameters["generation_mode"],
},
)
eventlet.sleep(0)
@ -933,25 +1090,25 @@ class InvokeAIWebServer:
rfc_dict["variations"] = variations
if "init_img" in parameters:
rfc_dict["type"] = "img2img"
rfc_dict["strength"] = parameters["strength"]
rfc_dict["fit"] = parameters["fit"] # TODO: Noncompliant
rfc_dict["orig_hash"] = calculate_init_img_hash(
self.get_image_path_from_url(parameters["init_img"])
)
rfc_dict["init_image_path"] = parameters[
"init_img"
] # TODO: Noncompliant
# if 'init_mask' in parameters:
# rfc_dict['mask_hash'] = calculate_init_img_hash(
# self.get_image_path_from_url(parameters['init_mask'])
# ) # TODO: Noncompliant
# rfc_dict['mask_image_path'] = parameters[
# 'init_mask'
# ] # TODO: Noncompliant
else:
rfc_dict["type"] = "txt2img"
# if "init_img" in parameters:
# rfc_dict["type"] = "img2img"
# rfc_dict["strength"] = parameters["strength"]
# rfc_dict["fit"] = parameters["fit"] # TODO: Noncompliant
# rfc_dict["orig_hash"] = calculate_init_img_hash(
# self.get_image_path_from_url(parameters["init_img"])
# )
# rfc_dict["init_image_path"] = parameters[
# "init_img"
# ] # TODO: Noncompliant
# # if 'init_mask' in parameters:
# # rfc_dict['mask_hash'] = calculate_init_img_hash(
# # self.get_image_path_from_url(parameters['init_mask'])
# # ) # TODO: Noncompliant
# # rfc_dict['mask_image_path'] = parameters[
# # 'init_mask'
# # ] # TODO: Noncompliant
# else:
# rfc_dict["type"] = "txt2img"
metadata["image"] = rfc_dict
@ -1244,23 +1401,67 @@ class CanceledException(Exception):
"""
Crops an image to a bounding box.
Returns a copy an image, cropped to a bounding box.
"""
def copy_image_from_bounding_box(image, x, y, width, height):
def copy_image_from_bounding_box(
image: ImageType, x: int, y: int, width: int, height: int
) -> ImageType:
with image as im:
bounds = (x, y, x + width, y + height)
im_cropped = im.crop(bounds)
return im_cropped
"""
Converts a base64 image dataURL into an image.
The dataURL is split on the first commma.
"""
def dataURL_to_image(dataURL: str) -> ImageType:
image = Image.open(
io.BytesIO(
base64.decodebytes(
bytes(
dataURL.split(",", 1)[1],
"utf-8",
)
)
)
)
return image
"""
Converts a base64 image dataURL into bytes.
The dataURL is split on the first commma.
"""
def dataURL_to_bytes(dataURL: str) -> bytes:
return base64.decodebytes(
bytes(
dataURL.split(",", 1)[1],
"utf-8",
)
)
"""
Pastes an image onto another with a bounding box.
"""
def paste_image_into_bounding_box(recipient_image, donor_image, x, y, width, height):
def paste_image_into_bounding_box(
recipient_image: ImageType,
donor_image: ImageType,
x: int,
y: int,
width: int,
height: int,
) -> ImageType:
with recipient_image as im:
bounds = (x, y, x + width, y + height)
im.paste(donor_image, bounds)

View File

@ -0,0 +1,117 @@
from PIL import Image, ImageChops
from PIL.Image import Image as ImageType
from typing import Union, Literal
# https://stackoverflow.com/questions/43864101/python-pil-check-if-image-is-transparent
def check_for_any_transparency(img: Union[ImageType, str]) -> bool:
if type(img) is str:
img = Image.open(str)
if img.info.get("transparency", None) is not None:
return True
if img.mode == "P":
transparent = img.info.get("transparency", -1)
for _, index in img.getcolors():
if index == transparent:
return True
elif img.mode == "RGBA":
extrema = img.getextrema()
if extrema[3][0] < 255:
return True
return False
def get_outpainting_generation_mode(
init_img: Union[ImageType, str], init_mask: Union[ImageType, str]
) -> Literal["txt2img", "outpainting", "inpainting", "img2img",]:
if type(init_img) is str:
init_img = Image.open(init_img)
if type(init_mask) is str:
init_mask = Image.open(init_mask)
init_img = init_img.convert("RGBA")
# Get alpha from init_img
init_img_alpha = init_img.split()[-1]
init_img_alpha_mask = init_img_alpha.convert("L")
init_img_has_transparency = check_for_any_transparency(init_img)
if init_img_has_transparency:
init_img_is_fully_transparent = (
True if init_img_alpha_mask.getbbox() is None else False
)
"""
Mask images are white in areas where no change should be made, black where changes
should be made.
"""
# Fit the mask to init_img's size and convert it to greyscale
init_mask = init_mask.resize(init_img.size).convert("L")
"""
PIL.Image.getbbox() returns the bounding box of non-zero areas of the image, so we first
invert the mask image so that masked areas are white and other areas black == zero.
getbbox() now tells us if the are any masked areas.
"""
init_mask_bbox = ImageChops.invert(init_mask).getbbox()
init_mask_exists = False if init_mask_bbox is None else True
if init_img_has_transparency:
if init_img_is_fully_transparent:
return "txt2img"
else:
return "outpainting"
else:
if init_mask_exists:
return "inpainting"
else:
return "img2img"
def main():
# Testing
init_img_opaque = "test_images/init-img_opaque.png"
init_img_partial_transparency = "test_images/init-img_partial_transparency.png"
init_img_full_transparency = "test_images/init-img_full_transparency.png"
init_mask_no_mask = "test_images/init-mask_no_mask.png"
init_mask_has_mask = "test_images/init-mask_has_mask.png"
print(
"OPAQUE IMAGE, NO MASK, expect img2img, got ",
get_outpainting_generation_mode(init_img_opaque, init_mask_no_mask),
)
print(
"IMAGE WITH TRANSPARENCY, NO MASK, expect outpainting, got ",
get_outpainting_generation_mode(
init_img_partial_transparency, init_mask_no_mask
),
)
print(
"FULLY TRANSPARENT IMAGE NO MASK, expect txt2img, got ",
get_outpainting_generation_mode(init_img_full_transparency, init_mask_no_mask),
)
print(
"OPAQUE IMAGE, WITH MASK, expect inpainting, got ",
get_outpainting_generation_mode(init_img_opaque, init_mask_has_mask),
)
print(
"IMAGE WITH TRANSPARENCY, WITH MASK, expect outpainting, got ",
get_outpainting_generation_mode(
init_img_partial_transparency, init_mask_has_mask
),
)
print(
"FULLY TRANSPARENT IMAGE WITH MASK, expect txt2img, got ",
get_outpainting_generation_mode(init_img_full_transparency, init_mask_has_mask),
)
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -5,7 +5,7 @@
- `python scripts/dream.py --web` serves both frontend and backend at
http://localhost:9090
## Environment
## Evironment
Install [node](https://nodejs.org/en/download/) (includes npm) and optionally
[yarn](https://yarnpkg.com/getting-started/install).
@ -15,7 +15,7 @@ packages.
## Dev
1. From `frontend/`, run `npm run dev` / `yarn dev` to start the dev server.
1. From `frontend/`, run `npm dev` / `yarn dev` to start the dev server.
2. Run `python scripts/dream.py --web`.
3. Navigate to the dev server address e.g. `http://localhost:5173/`.

View File

@ -0,0 +1,23 @@
{
"eslintConfig": {
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "eslint-plugin-react-hooks"],
"root": true,
"settings": {
"import/resolver": {
"node": {
"paths": ["src"],
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
}
},
"rules": {
"react/jsx-filename-extension": [1, { "extensions": [".tsx", ".ts"] }]
}
}
}

View File

@ -12,6 +12,7 @@
"dependencies": {
"@chakra-ui/icons": "^2.0.10",
"@chakra-ui/react": "^2.3.1",
"@emotion/cache": "^11.10.5",
"@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4",
"@radix-ui/react-context-menu": "^2.0.1",
@ -29,14 +30,17 @@
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.2",
"react-hotkeys-hook": "^3.4.7",
"react-hotkeys-hook": "4",
"react-icons": "^4.4.0",
"react-image-pan-zoom-rotate": "^1.6.0",
"react-konva": "^18.2.3",
"react-redux": "^8.0.2",
"react-transition-group": "^4.4.5",
"redux-deep-persist": "^1.0.6",
"redux-persist": "^6.0.0",
"socket.io": "^4.5.2",
"socket.io-client": "^4.5.2",
"use-image": "^1.1.0",
"uuid": "^9.0.0",
"yarn": "^1.22.19"
},
@ -55,6 +59,7 @@
"tsc-watch": "^5.0.3",
"typescript": "^4.6.4",
"vite": "^3.0.7",
"vite-plugin-eslint": "^1.8.1"
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^3.5.2"
}
}

View File

@ -1,5 +1,9 @@
@use '../styles/Mixins/' as *;
svg {
fill: var(--svg-color);
}
.App {
display: grid;
width: 100vw;

View File

@ -1,21 +1,21 @@
import { useEffect } from 'react';
import ProgressBar from '../features/system/ProgressBar';
import SiteHeader from '../features/system/SiteHeader';
import Console from '../features/system/Console';
import ProgressBar from 'features/system/ProgressBar';
import SiteHeader from 'features/system/SiteHeader';
import Console from 'features/system/Console';
import { useAppDispatch } from './store';
import { requestSystemConfig } from './socketio/actions';
import { keepGUIAlive } from './utils';
import InvokeTabs from '../features/tabs/InvokeTabs';
import ImageUploader from '../common/components/ImageUploader';
import { RootState, useAppSelector } from '../app/store';
import InvokeTabs from 'features/tabs/InvokeTabs';
import ImageUploader from 'common/components/ImageUploader';
import { RootState, useAppSelector } from 'app/store';
import FloatingGalleryButton from '../features/tabs/FloatingGalleryButton';
import FloatingOptionsPanelButtons from '../features/tabs/FloatingOptionsPanelButtons';
import FloatingGalleryButton from 'features/tabs/FloatingGalleryButton';
import FloatingOptionsPanelButtons from 'features/tabs/FloatingOptionsPanelButtons';
import { createSelector } from '@reduxjs/toolkit';
import { GalleryState } from '../features/gallery/gallerySlice';
import { OptionsState } from '../features/options/optionsSlice';
import { activeTabNameSelector } from '../features/options/optionsSelectors';
import { SystemState } from '../features/system/systemSlice';
import { GalleryState } from 'features/gallery/gallerySlice';
import { OptionsState } from 'features/options/optionsSlice';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { SystemState } from 'features/system/systemSlice';
import _ from 'lodash';
import { Model } from './invokeai';
@ -51,16 +51,20 @@ const appSelector = createSelector(
''
);
const shouldShowGalleryButton = !(
shouldShowGallery ||
(shouldHoldGalleryOpen && !shouldPinGallery)
);
const shouldShowGalleryButton =
!(shouldShowGallery || (shouldHoldGalleryOpen && !shouldPinGallery)) &&
['txt2img', 'img2img', 'inpainting', 'outpainting'].includes(
activeTabName
);
const shouldShowOptionsPanelButton =
!(
shouldShowOptionsPanel ||
(shouldHoldOptionsPanelOpen && !shouldPinOptionsPanel)
) && ['txt2img', 'img2img', 'inpainting'].includes(activeTabName);
) &&
['txt2img', 'img2img', 'inpainting', 'outpainting'].includes(
activeTabName
);
return {
modelStatusText,
@ -81,10 +85,6 @@ const App = () => {
const { shouldShowGalleryButton, shouldShowOptionsPanelButton } =
useAppSelector(appSelector);
useEffect(() => {
dispatch(requestSystemConfig());
}, [dispatch]);
return (
<div className="App">
<ImageUploader>

View File

@ -1,6 +1,6 @@
// TODO: use Enums?
import { InProgressImageType } from '../features/system/systemSlice';
import { InProgressImageType } from 'features/system/systemSlice';
// Valid samplers
export const SAMPLERS: Array<string> = [

View File

@ -12,7 +12,9 @@
* 'gfpgan'.
*/
import { Category as GalleryCategory } from '../features/gallery/gallerySlice';
import { Category as GalleryCategory } from 'features/gallery/gallerySlice';
import { InvokeTabName } from 'features/tabs/InvokeTabs';
import { IRect } from 'konva/lib/types';
/**
* TODO:
@ -115,8 +117,8 @@ export declare type Image = {
metadata?: Metadata;
width: number;
height: number;
category: GalleryCategory;
isBase64: boolean;
category: GalleryCategory;
isBase64: boolean;
};
// GalleryImages is an array of Image.
@ -171,10 +173,13 @@ export declare type SystemStatusResponse = SystemStatus;
export declare type SystemConfigResponse = SystemConfig;
export declare type ImageResultResponse = Omit<Image, 'uuid'>;
export declare type ImageResultResponse = Omit<Image, 'uuid'> & {
boundingBox?: IRect;
generationMode: InvokeTabName;
};
export declare type ImageUploadResponse = Omit<Image, 'uuid' | 'metadata'> & {
destination: 'img2img' | 'inpainting';
destination: 'img2img' | 'inpainting' | 'outpainting' | 'outpainting_merge';
};
export declare type ErrorResponse = {
@ -198,9 +203,17 @@ export declare type ImageUrlResponse = {
url: string;
};
export declare type ImageUploadDestination = 'img2img' | 'inpainting';
export declare type ImageUploadDestination =
| 'img2img'
| 'inpainting'
| 'outpainting_merge';
export declare type UploadImagePayload = {
file: File;
destination?: ImageUploadDestination;
};
export declare type UploadOutpaintingMergeImagePayload = {
dataURL: string;
name: string;
};

View File

@ -1,39 +1,35 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { RootState } from '../store';
import { activeTabNameSelector } from '../../features/options/optionsSelectors';
import { OptionsState } from '../../features/options/optionsSlice';
import { SystemState } from '../../features/system/systemSlice';
import { InpaintingState } from '../../features/tabs/Inpainting/inpaintingSlice';
import { validateSeedWeights } from '../../common/util/seedWeightPairs';
import { RootState } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { OptionsState } from 'features/options/optionsSlice';
import { SystemState } from 'features/system/systemSlice';
import { baseCanvasImageSelector } from 'features/canvas/canvasSlice';
import { validateSeedWeights } from 'common/util/seedWeightPairs';
export const readinessSelector = createSelector(
[
(state: RootState) => state.options,
(state: RootState) => state.system,
(state: RootState) => state.inpainting,
baseCanvasImageSelector,
activeTabNameSelector,
],
(
options: OptionsState,
system: SystemState,
inpainting: InpaintingState,
baseCanvasImage,
activeTabName
) => {
const {
prompt,
shouldGenerateVariations,
seedWeights,
// maskPath,
initialImage,
seed,
} = options;
const { isProcessing, isConnected } = system;
const { imageToInpaint } = inpainting;
let isReady = true;
const reasonsWhyNotReady: string[] = [];
@ -48,20 +44,11 @@ export const readinessSelector = createSelector(
reasonsWhyNotReady.push('No initial image selected');
}
if (activeTabName === 'inpainting' && !imageToInpaint) {
if (activeTabName === 'inpainting' && !baseCanvasImage) {
isReady = false;
reasonsWhyNotReady.push('No inpainting image selected');
}
// // We don't use mask paths now.
// // Cannot generate with a mask without img2img
// if (maskPath && !initialImage) {
// isReady = false;
// reasonsWhyNotReady.push(
// 'On ImageToImage tab, but no mask is provided.'
// );
// }
// TODO: job queue
// Cannot generate if already processing an image
if (isProcessing) {

View File

@ -1,7 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import { GalleryCategory } from '../../features/gallery/gallerySlice';
import { InvokeTabName } from '../../features/tabs/InvokeTabs';
import * as InvokeAI from '../invokeai';
import { GalleryCategory } from 'features/gallery/gallerySlice';
import { InvokeTabName } from 'features/tabs/InvokeTabs';
import * as InvokeAI from 'app/invokeai';
/**

View File

@ -4,23 +4,24 @@ import { Socket } from 'socket.io-client';
import {
frontendToBackendParameters,
FrontendToBackendParametersConfig,
} from '../../common/util/parameterTranslation';
} from 'common/util/parameterTranslation';
import {
GalleryCategory,
GalleryState,
removeImage,
} from '../../features/gallery/gallerySlice';
import { OptionsState } from '../../features/options/optionsSlice';
} from 'features/gallery/gallerySlice';
import { OptionsState } from 'features/options/optionsSlice';
import {
addLogEntry,
errorOccurred,
modelChangeRequested,
setIsProcessing,
} from '../../features/system/systemSlice';
import { inpaintingImageElementRef } from '../../features/tabs/Inpainting/InpaintingCanvas';
import { InvokeTabName } from '../../features/tabs/InvokeTabs';
import * as InvokeAI from '../invokeai';
import { RootState } from '../store';
} from 'features/system/systemSlice';
import { inpaintingImageElementRef } from 'features/canvas/IAICanvas';
import { InvokeTabName } from 'features/tabs/InvokeTabs';
import * as InvokeAI from 'app/invokeai';
import { RootState } from 'app/store';
import { baseCanvasImageSelector } from 'features/canvas/canvasSlice';
/**
* Returns an object containing all functions which use `socketio.emit()`.
@ -42,7 +43,7 @@ const makeSocketIOEmitters = (
const {
options: optionsState,
system: systemState,
inpainting: inpaintingState,
canvas: canvasState,
gallery: galleryState,
} = state;
@ -50,15 +51,15 @@ const makeSocketIOEmitters = (
{
generationMode,
optionsState,
inpaintingState,
canvasState,
systemState,
};
if (generationMode === 'inpainting') {
if (
!inpaintingImageElementRef.current ||
!inpaintingState.imageToInpaint?.url
) {
if (['inpainting', 'outpainting'].includes(generationMode)) {
const baseCanvasImage = baseCanvasImageSelector(getState());
const imageUrl = baseCanvasImage?.url;
if (!inpaintingImageElementRef.current || !imageUrl) {
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
@ -70,11 +71,10 @@ const makeSocketIOEmitters = (
return;
}
frontendToBackendParametersConfig.imageToProcessUrl =
inpaintingState.imageToInpaint.url;
frontendToBackendParametersConfig.imageToProcessUrl = imageUrl;
frontendToBackendParametersConfig.maskImageElement =
inpaintingImageElementRef.current;
// frontendToBackendParametersConfig.maskImageElement =
// inpaintingImageElementRef.current;
} else if (!['txt2img', 'img2img'].includes(generationMode)) {
if (!galleryState.currentImage?.url) return;
@ -96,7 +96,12 @@ const makeSocketIOEmitters = (
// TODO: handle maintaining masks for reproducibility in future
if (generationParameters.init_mask) {
generationParameters.init_mask = generationParameters.init_mask
.substr(0, 20)
.substr(0, 64)
.concat('...');
}
if (generationParameters.init_img) {
generationParameters.init_img = generationParameters.init_img
.substr(0, 64)
.concat('...');
}

View File

@ -2,7 +2,7 @@ import { AnyAction, MiddlewareAPI, Dispatch } from '@reduxjs/toolkit';
import { v4 as uuidv4 } from 'uuid';
import dateFormat from 'dateformat';
import * as InvokeAI from '../invokeai';
import * as InvokeAI from 'app/invokeai';
import {
addLogEntry,
@ -15,7 +15,7 @@ import {
errorOccurred,
setModelList,
setIsCancelable,
} from '../../features/system/systemSlice';
} from 'features/system/systemSlice';
import {
addGalleryImages,
@ -25,19 +25,21 @@ import {
removeImage,
setCurrentImage,
setIntermediateImage,
} from '../../features/gallery/gallerySlice';
} from 'features/gallery/gallerySlice';
import {
clearInitialImage,
setInitialImage,
setMaskPath,
} from '../../features/options/optionsSlice';
import { requestImages, requestNewImages } from './actions';
} from 'features/options/optionsSlice';
import { requestImages, requestNewImages, requestSystemConfig } from './actions';
import {
addImageToOutpaintingSesion,
clearImageToInpaint,
setImageToInpaint,
} from '../../features/tabs/Inpainting/inpaintingSlice';
import { tabMap } from '../../features/tabs/InvokeTabs';
setImageToOutpaint,
} from 'features/canvas/canvasSlice';
import { tabMap } from 'features/tabs/InvokeTabs';
/**
* Returns an object containing listener callbacks for socketio events.
@ -56,6 +58,7 @@ const makeSocketIOListeners = (
try {
dispatch(setIsConnected(true));
dispatch(setCurrentStatus('Connected'));
dispatch(requestSystemConfig());
const gallery: GalleryState = getState().gallery;
if (gallery.categories.user.latest_mtime) {
@ -111,6 +114,16 @@ const makeSocketIOListeners = (
})
);
if (data.generationMode === 'outpainting' && data.boundingBox) {
const { boundingBox } = data;
dispatch(
addImageToOutpaintingSesion({
image: newImage,
boundingBox,
})
);
}
if (shouldLoopback) {
const activeTabName = tabMap[activeTab];
switch (activeTabName) {
@ -299,15 +312,15 @@ const makeSocketIOListeners = (
// remove references to image in options
const { initialImage, maskPath } = getState().options;
const { imageToInpaint } = getState().inpainting;
const { inpainting, outpainting } = getState().canvas;
if (initialImage?.url === url || initialImage === url) {
dispatch(clearInitialImage());
}
if (imageToInpaint?.url === url) {
dispatch(clearImageToInpaint());
}
// if (imageToInpaint?.url === url) {
// dispatch(clearImageToInpaint());
// }
if (maskPath === url) {
dispatch(setMaskPath(''));

View File

@ -4,7 +4,7 @@ import { io } from 'socket.io-client';
import makeSocketIOListeners from './listeners';
import makeSocketIOEmitters from './emitters';
import * as InvokeAI from '../invokeai';
import * as InvokeAI from 'app/invokeai';
/**
* Creates a socketio middleware to handle communication with server.
@ -104,12 +104,9 @@ export const socketioMiddleware = () => {
onImageDeleted(data);
});
socketio.on(
'imageUploaded',
(data: InvokeAI.ImageUploadResponse) => {
onImageUploaded(data);
}
);
socketio.on('imageUploaded', (data: InvokeAI.ImageUploadResponse) => {
onImageUploaded(data);
});
socketio.on('maskImageUploaded', (data: InvokeAI.ImageUrlResponse) => {
onMaskImageUploaded(data);

View File

@ -5,16 +5,14 @@ import type { TypedUseSelectorHook } from 'react-redux';
import { persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage'; // defaults to localStorage for web
import optionsReducer, { OptionsState } from '../features/options/optionsSlice';
import galleryReducer, { GalleryState } from '../features/gallery/gallerySlice';
import inpaintingReducer, {
InpaintingState,
} from '../features/tabs/Inpainting/inpaintingSlice';
import { getPersistConfig } from 'redux-deep-persist';
import optionsReducer from 'features/options/optionsSlice';
import galleryReducer from 'features/gallery/gallerySlice';
import systemReducer from 'features/system/systemSlice';
import canvasReducer from 'features/canvas/canvasSlice';
import systemReducer, { SystemState } from '../features/system/systemSlice';
import { socketioMiddleware } from './socketio/middleware';
import autoMergeLevel2 from 'redux-persist/es/stateReconciler/autoMergeLevel2';
import { PersistPartial } from 'redux-persist/es/persistReducer';
/**
* redux-persist provides an easy and reliable way to persist state across reloads.
@ -28,87 +26,82 @@ import { PersistPartial } from 'redux-persist/es/persistReducer';
* These can be blacklisted in redux-persist.
*
* The necesssary nested persistors with blacklists are configured below.
*
* TODO: Do we blacklist initialImagePath? If the image is deleted from disk we get an
* ugly 404. But if we blacklist it, then this is a valuable parameter that is lost
* on reload. Need to figure out a good way to handle this.
*/
const rootPersistConfig = {
key: 'root',
storage,
stateReconciler: autoMergeLevel2,
blacklist: ['gallery', 'system', 'inpainting'],
};
const genericCanvasBlacklist = [
'pastObjects',
'futureObjects',
'stageScale',
'stageDimensions',
'stageCoordinates',
'cursorPosition',
];
const systemPersistConfig = {
key: 'system',
storage,
stateReconciler: autoMergeLevel2,
blacklist: [
'isCancelable',
'isConnected',
'isProcessing',
'currentStep',
'socketId',
'isESRGANAvailable',
'isGFPGANAvailable',
'currentStep',
'totalSteps',
'currentIteration',
'totalIterations',
'currentStatus',
],
};
const inpaintingCanvasBlacklist = genericCanvasBlacklist.map(
(blacklistItem) => `canvas.inpainting.${blacklistItem}`
);
const galleryPersistConfig = {
key: 'gallery',
storage,
stateReconciler: autoMergeLevel2,
whitelist: [
'galleryWidth',
'shouldPinGallery',
'shouldShowGallery',
'galleryScrollPosition',
'galleryImageMinimumWidth',
'galleryImageObjectFit',
],
};
const outpaintingCanvasBlacklist = genericCanvasBlacklist.map(
(blacklistItem) => `canvas.outpainting.${blacklistItem}`
);
const inpaintingPersistConfig = {
key: 'inpainting',
storage,
stateReconciler: autoMergeLevel2,
blacklist: ['pastLines', 'futuresLines', 'cursorPosition'],
};
const systemBlacklist = [
'currentIteration',
'currentStatus',
'currentStep',
'isCancelable',
'isConnected',
'isESRGANAvailable',
'isGFPGANAvailable',
'isProcessing',
'socketId',
'totalIterations',
'totalSteps',
].map((blacklistItem) => `system.${blacklistItem}`);
const reducers = combineReducers({
const galleryBlacklist = [
'categories',
'currentCategory',
'currentImage',
'currentImageUuid',
'shouldAutoSwitchToNewImages',
'shouldHoldGalleryOpen',
'intermediateImage',
].map((blacklistItem) => `gallery.${blacklistItem}`);
const rootReducer = combineReducers({
options: optionsReducer,
gallery: persistReducer<GalleryState>(galleryPersistConfig, galleryReducer),
system: persistReducer<SystemState>(systemPersistConfig, systemReducer),
inpainting: persistReducer<InpaintingState>(
inpaintingPersistConfig,
inpaintingReducer
),
gallery: galleryReducer,
system: systemReducer,
canvas: canvasReducer,
});
const persistedReducer = persistReducer<{
options: OptionsState;
gallery: GalleryState & PersistPartial;
system: SystemState & PersistPartial;
inpainting: InpaintingState & PersistPartial;
}>(rootPersistConfig, reducers);
const rootPersistConfig = getPersistConfig({
key: 'root',
storage,
rootReducer,
blacklist: [
...inpaintingCanvasBlacklist,
...outpaintingCanvasBlacklist,
...systemBlacklist,
...galleryBlacklist,
],
throttle: 500,
});
const persistedReducer = persistReducer(rootPersistConfig, rootReducer);
// Continue with store setup
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
// redux-persist sometimes needs to temporarily put a function in redux state, need to disable this check
immutableCheck: false,
serializableCheck: false,
}).concat(socketioMiddleware()),
});
export type AppGetState = typeof store.getState;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@ -1,7 +1,7 @@
import { Box, forwardRef, Icon } from '@chakra-ui/react';
import { IconType } from 'react-icons';
import { MdHelp } from 'react-icons/md';
import { Feature } from '../../app/features';
import { Feature } from 'app/features';
import GuidePopover from './GuidePopover';
type GuideIconProps = {

View File

@ -1,11 +1,11 @@
.guide-popover-arrow {
background-color: var(--tab-panel-bg) !important;
box-shadow: none !important;
background-color: var(--tab-panel-bg);
box-shadow: none;
}
.guide-popover-content {
background-color: var(--background-color-secondary) !important;
border: none !important;
background-color: var(--background-color-secondary);
border: none;
}
.guide-popover-guide-content {

View File

@ -5,12 +5,12 @@ import {
PopoverTrigger,
Box,
} from '@chakra-ui/react';
import { SystemState } from '../../features/system/systemSlice';
import { useAppSelector } from '../../app/store';
import { RootState } from '../../app/store';
import { SystemState } from 'features/system/systemSlice';
import { useAppSelector } from 'app/store';
import { RootState } from 'app/store';
import { createSelector } from '@reduxjs/toolkit';
import { ReactElement } from 'react';
import { Feature, FEATURES } from '../../app/features';
import { Feature, FEATURES } from 'app/features';
type GuideProps = {
children: ReactElement;

View File

@ -1,3 +1,8 @@
.invokeai__button {
justify-content: space-between;
background-color: var(--btn-base-color);
place-content: center;
&:hover {
background-color: var(--btn-base-color-hover);
}
}

View File

@ -15,7 +15,7 @@
svg {
width: 0.6rem;
height: 0.6rem;
stroke-width: 3px !important;
stroke-width: 3px;
}
&[data-checked] {

View File

@ -1,11 +1,11 @@
@use '../../styles/Mixins/' as *;
.invokeai__icon-button {
background-color: var(--btn-grey);
background: var(--btn-base-color);
cursor: pointer;
&:hover {
background-color: var(--btn-grey-hover);
background-color: var(--btn-base-color-hover);
}
&[data-selected='true'] {
@ -20,16 +20,39 @@
}
&[data-variant='link'] {
background: none !important;
background: none;
&:hover {
background: none !important;
background: none;
}
}
&[data-selected='true'] {
border-color: var(--accent-color);
// Check Box Style
&[data-as-checkbox='true'] {
background-color: var(--btn-base-color);
border: 3px solid var(--btn-base-color);
svg {
fill: var(--text-color);
}
&:hover {
border-color: var(--accent-color-hover);
background-color: var(--btn-base-color);
border-color: var(--btn-checkbox-border-hover);
svg {
fill: var(--text-color);
}
}
&[data-selected='true'] {
border-color: var(--accent-color);
svg {
fill: var(--accent-color-hover);
}
&:hover {
svg {
fill: var(--accent-color-hover);
}
}
}
}
@ -38,28 +61,12 @@
animation-duration: 1s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
&:hover {
animation: none;
background-color: var(--accent-color-hover);
}
}
&[data-as-checkbox='true'] {
background-color: var(--btn-grey);
border: 3px solid var(--btn-grey);
svg {
fill: var(--text-color);
}
&:hover {
background-color: var(--btn-grey);
border-color: var(--btn-checkbox-border-hover);
svg {
fill: var(--text-color);
}
}
}
}
@keyframes pulseColor {

View File

@ -25,13 +25,23 @@ const IAIIconButton = forwardRef((props: IAIIconButtonProps, forwardedRef) => {
} = props;
return (
<Tooltip label={tooltip} hasArrow {...tooltipProps}>
<Tooltip
label={tooltip}
hasArrow
{...tooltipProps}
{...(tooltipProps?.placement
? { placement: tooltipProps.placement }
: { placement: 'top' })}
>
<IconButton
ref={forwardedRef}
className={`invokeai__icon-button ${styleClass}`}
className={
styleClass
? `invokeai__icon-button ${styleClass}`
: `invokeai__icon-button`
}
data-as-checkbox={asCheckbox}
data-selected={isChecked !== undefined ? isChecked : undefined}
style={props.onClick ? { cursor: 'pointer' } : {}}
{...rest}
/>
</Tooltip>

View File

@ -1,16 +1,14 @@
.invokeai__number-input-form-control {
display: grid;
grid-template-columns: max-content auto;
display: flex;
align-items: center;
column-gap: 1rem;
.invokeai__number-input-form-label {
color: var(--text-color-secondary);
margin-right: 0;
font-size: 1rem;
margin-bottom: 0;
flex-grow: 2;
white-space: nowrap;
padding-right: 1rem;
&[data-focus] + .invokeai__number-input-root {
outline: none;
@ -33,7 +31,7 @@
align-items: center;
background-color: var(--background-color-secondary);
border: 2px solid var(--border-color);
border-radius: 0.2rem;
border-radius: 0.3rem;
}
.invokeai__number-input-field {
@ -41,10 +39,8 @@
font-weight: bold;
width: 100%;
height: auto;
padding: 0;
font-size: 0.9rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
padding: 0 0.5rem;
&:focus {
outline: none;

View File

@ -21,6 +21,7 @@ const numberStringRegex = /^-?(0\.)?\.?$/;
interface Props extends Omit<NumberInputProps, 'onChange'> {
styleClass?: string;
label?: string;
labelFontSize?: string | number;
width?: string | number;
showStepper?: boolean;
value: number;
@ -43,6 +44,7 @@ interface Props extends Omit<NumberInputProps, 'onChange'> {
const IAINumberInput = (props: Props) => {
const {
label,
labelFontSize = '1rem',
styleClass,
isDisabled = false,
showStepper = true,
@ -127,6 +129,7 @@ const IAINumberInput = (props: Props) => {
<FormLabel
className="invokeai__number-input-form-label"
style={{ display: label ? 'block' : 'none' }}
fontSize={labelFontSize}
{...formLabelProps}
>
{label}

View File

@ -1,10 +1,10 @@
.invokeai__popover-content {
min-width: unset;
width: unset !important;
width: unset;
padding: 1rem;
border-radius: 0.5rem !important;
background-color: var(--background-color) !important;
border: 2px solid var(--border-color) !important;
border-radius: 0.5rem;
background-color: var(--background-color);
border: 2px solid var(--border-color);
.invokeai__popover-arrow {
background-color: var(--background-color) !important;

View File

@ -29,7 +29,7 @@ const IAIPopover = (props: IAIPopoverProps) => {
<Popover {...rest}>
<PopoverTrigger>{triggerComponent}</PopoverTrigger>
<PopoverContent className={`invokeai__popover-content ${styleClass}`}>
{hasArrow && <PopoverArrow className={'invokeai__popover-arrow'} />}
{hasArrow && <PopoverArrow className="invokeai__popover-arrow" />}
{children}
</PopoverContent>
</Popover>

View File

@ -27,5 +27,6 @@
.invokeai__select-option {
background-color: var(--background-color-secondary);
color: var(--text-color-secondary);
}
}

View File

@ -2,7 +2,7 @@ import { FormControl, FormLabel, Select, SelectProps } from '@chakra-ui/react';
import { MouseEvent } from 'react';
type IAISelectProps = SelectProps & {
label: string;
label?: string;
styleClass?: string;
validValues:
| Array<number | string>
@ -32,15 +32,18 @@ const IAISelect = (props: IAISelectProps) => {
e.nativeEvent.cancelBubble = true;
}}
>
<FormLabel
className="invokeai__select-label"
fontSize={fontSize}
marginBottom={1}
flexGrow={2}
whiteSpace="nowrap"
>
{label}
</FormLabel>
{label && (
<FormLabel
className="invokeai__select-label"
fontSize={fontSize}
marginBottom={1}
flexGrow={2}
whiteSpace="nowrap"
>
{label}
</FormLabel>
)}
<Select
className="invokeai__select-picker"
fontSize={fontSize}

View File

@ -1,40 +1,62 @@
@use '../../styles/Mixins/' as *;
.invokeai__slider-form-control {
.invokeai__slider-component {
display: flex;
column-gap: 1rem;
justify-content: space-between;
gap: 1rem;
align-items: center;
width: max-content;
padding-right: 0.25rem;
.invokeai__slider-inner-container {
display: flex;
column-gap: 0.5rem;
.invokeai__slider-component-label {
min-width: max-content;
margin: 0;
font-weight: bold;
font-size: 0.9rem;
color: var(--text-color-secondary);
}
.invokeai__slider-form-label {
color: var(--text-color-secondary);
margin: 0;
margin-right: 0.5rem;
margin-bottom: 0.1rem;
.invokeai__slider_track {
background-color: var(--tab-color);
}
.invokeai__slider_track-filled {
background-color: var(--slider-color);
}
.invokeai__slider-thumb {
width: 4px;
}
.invokeai__slider-mark {
font-size: 0.75rem;
font-weight: bold;
color: var(--slider-color);
margin-top: 0.3rem;
}
.invokeai__slider-number-input {
border: none;
font-size: 0.9rem;
font-weight: bold;
height: 2rem;
background-color: var(--background-color-secondary);
border: 2px solid var(--border-color);
&:focus {
outline: none;
box-shadow: none;
border: 2px solid var(--input-border-color);
box-shadow: 0 0 10px 0 var(--input-box-shadow-color);
}
.invokeai__slider-root {
.invokeai__slider-filled-track {
background-color: var(--accent-color-hover);
}
&:disabled {
opacity: 0.2;
}
}
.invokeai__slider-track {
background-color: var(--text-color-secondary);
height: 5px;
border-radius: 9999px;
}
.invokeai__slider-number-stepper {
border: none;
}
.invokeai__slider-thumb {
}
&[data-markers='true'] {
.invokeai__slider_container {
margin-top: -1rem;
}
}
}
.invokeai__slider-thumb-tooltip {
}

View File

@ -1,87 +1,241 @@
import {
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
FormControl,
FormLabel,
Tooltip,
SliderProps,
FormControlProps,
FormLabel,
FormLabelProps,
SliderTrackProps,
HStack,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputFieldProps,
NumberInputProps,
NumberInputStepper,
NumberInputStepperProps,
Slider,
SliderFilledTrack,
SliderMark,
SliderMarkProps,
SliderThumb,
SliderThumbProps,
SliderTrack,
SliderTrackProps,
Tooltip,
TooltipProps,
SliderInnerTrackProps,
} from '@chakra-ui/react';
import React, { FocusEvent, useEffect, useState } from 'react';
import { BiReset } from 'react-icons/bi';
import IAIIconButton, { IAIIconButtonProps } from './IAIIconButton';
import _ from 'lodash';
type IAISliderProps = SliderProps & {
label?: string;
export type IAIFullSliderProps = {
label: string;
value: number;
min?: number;
max?: number;
step?: number;
onChange: (v: number) => void;
withSliderMarks?: boolean;
sliderMarkLeftOffset?: number;
sliderMarkRightOffset?: number;
withInput?: boolean;
isInteger?: boolean;
inputWidth?: string | number;
inputReadOnly?: boolean;
withReset?: boolean;
handleReset?: () => void;
isResetDisabled?: boolean;
isSliderDisabled?: boolean;
isInputDisabled?: boolean;
tooltipSuffix?: string;
hideTooltip?: boolean;
styleClass?: string;
formControlProps?: FormControlProps;
formLabelProps?: FormLabelProps;
sliderFormControlProps?: FormControlProps;
sliderFormLabelProps?: FormLabelProps;
sliderMarkProps?: Omit<SliderMarkProps, 'value'>;
sliderTrackProps?: SliderTrackProps;
sliderInnerTrackProps?: SliderInnerTrackProps;
sliderThumbProps?: SliderThumbProps;
sliderThumbTooltipProps?: Omit<TooltipProps, 'children'>;
sliderNumberInputProps?: NumberInputProps;
sliderNumberInputFieldProps?: NumberInputFieldProps;
sliderNumberInputStepperProps?: NumberInputStepperProps;
sliderTooltipProps?: Omit<TooltipProps, 'children'>;
sliderIAIIconButtonProps?: IAIIconButtonProps;
};
const IAISlider = (props: IAISliderProps) => {
export default function IAISlider(props: IAIFullSliderProps) {
const [showTooltip, setShowTooltip] = useState(false);
const {
label,
value,
min = 1,
max = 100,
step = 1,
onChange,
tooltipSuffix = '',
withSliderMarks = false,
sliderMarkLeftOffset = 0,
sliderMarkRightOffset = -7,
withInput = false,
isInteger = false,
inputWidth = '5rem',
inputReadOnly = true,
withReset = false,
hideTooltip = false,
handleReset,
isResetDisabled,
isSliderDisabled,
isInputDisabled,
styleClass,
formControlProps,
formLabelProps,
sliderFormControlProps,
sliderFormLabelProps,
sliderMarkProps,
sliderTrackProps,
sliderInnerTrackProps,
sliderThumbProps,
sliderThumbTooltipProps,
sliderNumberInputProps,
sliderNumberInputFieldProps,
sliderNumberInputStepperProps,
sliderTooltipProps,
sliderIAIIconButtonProps,
...rest
} = props;
const [localInputValue, setLocalInputValue] = useState<string>(String(value));
useEffect(() => {
if (String(value) !== localInputValue && localInputValue !== '') {
setLocalInputValue(String(value));
}
}, [value, localInputValue, setLocalInputValue]);
const handleInputBlur = (e: FocusEvent<HTMLInputElement>) => {
const clamped = _.clamp(
isInteger ? Math.floor(Number(e.target.value)) : Number(e.target.value),
min,
max
);
setLocalInputValue(String(clamped));
onChange(clamped);
};
const handleInputChange = (v: any) => {
setLocalInputValue(v);
onChange(Number(v));
};
const handleResetDisable = () => {
if (!handleReset) return;
handleReset();
};
return (
<FormControl
className={`invokeai__slider-form-control ${styleClass}`}
{...formControlProps}
className={
styleClass
? `invokeai__slider-component ${styleClass}`
: `invokeai__slider-component`
}
data-markers={withSliderMarks}
{...sliderFormControlProps}
>
<div className="invokeai__slider-inner-container">
<FormLabel
className={`invokeai__slider-form-label`}
whiteSpace="nowrap"
{...formLabelProps}
>
{label}
</FormLabel>
<FormLabel
className="invokeai__slider-component-label"
{...sliderFormLabelProps}
>
{label}
</FormLabel>
<HStack w={'100%'} gap={2}>
<Slider
className={`invokeai__slider-root`}
aria-label={label}
value={value}
min={min}
max={max}
step={step}
onChange={handleInputChange}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
focusThumbOnChange={false}
isDisabled={isSliderDisabled}
{...rest}
>
<SliderTrack
className={`invokeai__slider-track`}
{...sliderTrackProps}
>
<SliderFilledTrack
className={`invokeai__slider-filled-track`}
{...sliderInnerTrackProps}
/>
{withSliderMarks && (
<>
<SliderMark
value={min}
className="invokeai__slider-mark invokeai__slider-mark-start"
ml={sliderMarkLeftOffset}
{...sliderMarkProps}
>
{min}
</SliderMark>
<SliderMark
value={max}
className="invokeai__slider-mark invokeai__slider-mark-end"
ml={sliderMarkRightOffset}
{...sliderMarkProps}
>
{max}
</SliderMark>
</>
)}
<SliderTrack className="invokeai__slider_track" {...sliderTrackProps}>
<SliderFilledTrack className="invokeai__slider_track-filled" />
</SliderTrack>
<Tooltip
className={`invokeai__slider-thumb-tooltip`}
placement="top"
hasArrow
{...sliderThumbTooltipProps}
className="invokeai__slider-component-tooltip"
placement="top"
isOpen={showTooltip}
label={`${value}${tooltipSuffix}`}
hidden={hideTooltip}
{...sliderTooltipProps}
>
<SliderThumb
className={`invokeai__slider-thumb`}
className="invokeai__slider-thumb"
{...sliderThumbProps}
/>
</Tooltip>
</Slider>
</div>
{withInput && (
<NumberInput
min={min}
max={max}
step={step}
value={localInputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
className="invokeai__slider-number-field"
isDisabled={isInputDisabled}
{...sliderNumberInputProps}
>
<NumberInputField
className="invokeai__slider-number-input"
width={inputWidth}
readOnly={inputReadOnly}
{...sliderNumberInputFieldProps}
/>
<NumberInputStepper {...sliderNumberInputStepperProps}>
<NumberIncrementStepper className="invokeai__slider-number-stepper" />
<NumberDecrementStepper className="invokeai__slider-number-stepper" />
</NumberInputStepper>
</NumberInput>
)}
{withReset && (
<IAIIconButton
size={'sm'}
aria-label={'Reset'}
tooltip={'Reset'}
icon={<BiReset />}
onClick={handleResetDisable}
isDisabled={isResetDisabled}
{...sliderIAIIconButtonProps}
/>
)}
</HStack>
</FormControl>
);
};
export default IAISlider;
}

View File

@ -1,4 +1,5 @@
import { Heading } from '@chakra-ui/react';
import { KeyboardEvent } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
type ImageUploadOverlayProps = {

View File

@ -33,7 +33,6 @@
}
.image-uploader-button-outer {
min-width: 20rem;
width: 100%;
height: 100%;
display: flex;
@ -42,10 +41,10 @@
cursor: pointer;
border-radius: 0.5rem;
color: var(--tab-list-text-inactive);
background-color: var(--btn-grey);
background-color: var(--background-color);
&:hover {
background-color: var(--btn-grey-hover);
background-color: var(--background-color-light);
}
}
@ -66,10 +65,10 @@
text-align: center;
svg {
width: 4rem !important;
height: 4rem !important;
width: 4rem;
height: 4rem;
}
h2 {
font-size: 1.2rem !important;
font-size: 1.2rem;
}
}

View File

@ -1,12 +1,18 @@
import { useCallback, ReactNode, useState, useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../app/store';
import {
useCallback,
ReactNode,
useState,
useEffect,
KeyboardEvent,
} from 'react';
import { useAppDispatch, useAppSelector } from 'app/store';
import { FileRejection, useDropzone } from 'react-dropzone';
import { useToast } from '@chakra-ui/react';
import { uploadImage } from '../../app/socketio/actions';
import { ImageUploadDestination, UploadImagePayload } from '../../app/invokeai';
import { ImageUploaderTriggerContext } from '../../app/contexts/ImageUploaderTriggerContext';
import { activeTabNameSelector } from '../../features/options/optionsSelectors';
import { tabDict } from '../../features/tabs/InvokeTabs';
import { uploadImage } from 'app/socketio/actions';
import { ImageUploadDestination, UploadImagePayload } from 'app/invokeai';
import { ImageUploaderTriggerContext } from 'app/contexts/ImageUploaderTriggerContext';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { tabDict } from 'features/tabs/InvokeTabs';
import ImageUploadOverlay from './ImageUploadOverlay';
type ImageUploaderProps = {
@ -41,7 +47,7 @@ const ImageUploader = (props: ImageUploaderProps) => {
(file: File) => {
setIsHandlingUpload(true);
const payload: UploadImagePayload = { file };
if (['img2img', 'inpainting'].includes(activeTabName)) {
if (['img2img', 'inpainting', 'outpainting'].includes(activeTabName)) {
payload.destination = activeTabName as ImageUploadDestination;
}
dispatch(uploadImage(payload));
@ -137,7 +143,13 @@ const ImageUploader = (props: ImageUploaderProps) => {
return (
<ImageUploaderTriggerContext.Provider value={open}>
<div {...getRootProps({ style: {} })}>
<div
{...getRootProps({ style: {} })}
onKeyDown={(e: KeyboardEvent) => {
// Bail out if user hits spacebar - do not open the uploader
if (e.key === ' ') return;
}}
>
<input {...getInputProps()} />
{children}
{isDragActive && isHandlingUpload && (

View File

@ -1,7 +1,7 @@
import { Heading } from '@chakra-ui/react';
import { useContext } from 'react';
import { FaUpload } from 'react-icons/fa';
import { ImageUploaderTriggerContext } from '../../app/contexts/ImageUploaderTriggerContext';
import { ImageUploaderTriggerContext } from 'app/contexts/ImageUploaderTriggerContext';
type ImageUploaderButtonProps = {
styleClass?: string;

View File

@ -1,6 +1,6 @@
import { useContext } from 'react';
import { FaUpload } from 'react-icons/fa';
import { ImageUploaderTriggerContext } from '../../app/contexts/ImageUploaderTriggerContext';
import { ImageUploaderTriggerContext } from 'app/contexts/ImageUploaderTriggerContext';
import IAIIconButton from './IAIIconButton';
const ImageUploaderIconButton = () => {

View File

@ -1,5 +1,5 @@
import React from 'react';
import Img2ImgPlaceHolder from '../../../assets/images/image2img.png';
import Img2ImgPlaceHolder from 'assets/images/image2img.png';
export const ImageToImageWIP = () => {
return (

View File

@ -0,0 +1,21 @@
type Base64AndCaption = {
base64: string;
caption: string;
};
const openBase64ImageInTab = (images: Base64AndCaption[]) => {
const w = window.open('');
if (!w) return;
images.forEach((i) => {
const image = new Image();
image.src = i.base64;
w.document.write(i.caption);
w.document.write('</br>');
w.document.write(image.outerHTML);
w.document.write('</br></br>');
});
};
export default openBase64ImageInTab;

View File

@ -1,20 +1,21 @@
import { NUMPY_RAND_MAX, NUMPY_RAND_MIN } from '../../app/constants';
import { OptionsState } from '../../features/options/optionsSlice';
import { SystemState } from '../../features/system/systemSlice';
import { NUMPY_RAND_MAX, NUMPY_RAND_MIN } from 'app/constants';
import { OptionsState } from 'features/options/optionsSlice';
import { SystemState } from 'features/system/systemSlice';
import { stringToSeedWeightsArray } from './seedWeightPairs';
import randomInt from './randomInt';
import { InvokeTabName } from '../../features/tabs/InvokeTabs';
import { InpaintingState } from '../../features/tabs/Inpainting/inpaintingSlice';
import generateMask from '../../features/tabs/Inpainting/util/generateMask';
import { InvokeTabName } from 'features/tabs/InvokeTabs';
import { CanvasState, isCanvasMaskLine } from 'features/canvas/canvasSlice';
import generateMask from 'features/canvas/util/generateMask';
import { canvasImageLayerRef } from 'features/canvas/IAICanvas';
import openBase64ImageInTab from './openBase64ImageInTab';
export type FrontendToBackendParametersConfig = {
generationMode: InvokeTabName;
optionsState: OptionsState;
inpaintingState: InpaintingState;
canvasState: CanvasState;
systemState: SystemState;
imageToProcessUrl?: string;
maskImageElement?: HTMLImageElement;
};
/**
@ -27,10 +28,9 @@ export const frontendToBackendParameters = (
const {
generationMode,
optionsState,
inpaintingState,
canvasState,
systemState,
imageToProcessUrl,
maskImageElement,
} = config;
const {
@ -62,8 +62,11 @@ export const frontendToBackendParameters = (
shouldRandomizeSeed,
} = optionsState;
const { shouldDisplayInProgressType, saveIntermediatesInterval } =
systemState;
const {
shouldDisplayInProgressType,
saveIntermediatesInterval,
enableImageDebugging,
} = systemState;
const generationParameters: { [k: string]: any } = {
prompt,
@ -80,6 +83,8 @@ export const frontendToBackendParameters = (
progress_images: shouldDisplayInProgressType === 'full-res',
progress_latents: shouldDisplayInProgressType === 'latents',
save_intermediates: saveIntermediatesInterval,
generation_mode: generationMode,
init_mask: '',
};
generationParameters.seed = shouldRandomizeSeed
@ -101,35 +106,36 @@ export const frontendToBackendParameters = (
}
// inpainting exclusive parameters
if (generationMode === 'inpainting' && maskImageElement) {
if (
['inpainting', 'outpainting'].includes(generationMode) &&
canvasImageLayerRef.current
) {
const {
lines,
boundingBoxCoordinate,
objects,
boundingBoxCoordinates,
boundingBoxDimensions,
inpaintReplace,
shouldUseInpaintReplace,
} = inpaintingState;
stageScale,
isMaskEnabled,
} = canvasState[canvasState.currentCanvas];
const boundingBox = {
...boundingBoxCoordinate,
...boundingBoxCoordinates,
...boundingBoxDimensions,
};
generationParameters.init_img = imageToProcessUrl;
generationParameters.strength = img2imgStrength;
generationParameters.fit = false;
const { maskDataURL, isMaskEmpty } = generateMask(
maskImageElement,
lines,
const maskDataURL = generateMask(
isMaskEnabled ? objects.filter(isCanvasMaskLine) : [],
boundingBox
);
generationParameters.is_mask_empty = isMaskEmpty;
generationParameters.init_mask = maskDataURL;
generationParameters.init_mask = maskDataURL.split(
'data:image/png;base64,'
)[1];
generationParameters.fit = false;
generationParameters.init_img = imageToProcessUrl;
generationParameters.strength = img2imgStrength;
if (shouldUseInpaintReplace) {
generationParameters.inpaint_replace = inpaintReplace;
@ -137,8 +143,44 @@ export const frontendToBackendParameters = (
generationParameters.bounding_box = boundingBox;
// TODO: The server metadata generation needs to be changed to fix this.
generationParameters.progress_images = false;
if (generationMode === 'outpainting') {
const tempScale = canvasImageLayerRef.current.scale();
canvasImageLayerRef.current.scale({
x: 1 / stageScale,
y: 1 / stageScale,
});
const absPos = canvasImageLayerRef.current.getAbsolutePosition();
const imageDataURL = canvasImageLayerRef.current.toDataURL({
x: boundingBox.x + absPos.x,
y: boundingBox.y + absPos.y,
width: boundingBox.width,
height: boundingBox.height,
});
if (enableImageDebugging) {
openBase64ImageInTab([
{ base64: maskDataURL, caption: 'mask sent as init_mask' },
{ base64: imageDataURL, caption: 'image sent as init_img' },
]);
}
canvasImageLayerRef.current.scale(tempScale);
generationParameters.init_img = imageDataURL;
// TODO: The server metadata generation needs to be changed to fix this.
generationParameters.progress_images = false;
generationParameters.seam_size = 96;
generationParameters.seam_blur = 16;
generationParameters.seam_strength = 0.7;
generationParameters.seam_steps = 10;
generationParameters.tile_size = 32;
generationParameters.force_outpaint = false;
}
}
if (shouldGenerateVariations) {
@ -171,6 +213,10 @@ export const frontendToBackendParameters = (
}
}
if (enableImageDebugging) {
generationParameters.enable_image_debugging = enableImageDebugging;
}
return {
generationParameters,
esrganParameters,

View File

@ -1,4 +1,4 @@
import * as InvokeAI from '../../app/invokeai';
import * as InvokeAI from 'app/invokeai';
const promptToString = (prompt: InvokeAI.Prompt): string => {
if (prompt.length === 1) {

View File

@ -1,4 +1,4 @@
import * as InvokeAI from '../../app/invokeai';
import * as InvokeAI from 'app/invokeai';
export const stringToSeedWeights = (
string: string

View File

@ -0,0 +1,275 @@
// lib
import { MutableRefObject, useEffect, useRef, useState } from 'react';
import Konva from 'konva';
import { Layer, Stage } from 'react-konva';
import { Image as KonvaImage } from 'react-konva';
import { Stage as StageType } from 'konva/lib/Stage';
// app
import { useAppDispatch, useAppSelector } from 'app/store';
import {
baseCanvasImageSelector,
clearImageToInpaint,
currentCanvasSelector,
outpaintingCanvasSelector,
} from 'features/canvas/canvasSlice';
// component
import IAICanvasMaskLines from './IAICanvasMaskLines';
import IAICanvasBrushPreview from './IAICanvasBrushPreview';
import { Vector2d } from 'konva/lib/types';
import IAICanvasBoundingBoxPreview from './IAICanvasBoundingBoxPreview';
import { useToast } from '@chakra-ui/react';
import useCanvasHotkeys from './hooks/useCanvasHotkeys';
import _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import IAICanvasMaskCompositer from './IAICanvasMaskCompositer';
import useCanvasWheel from './hooks/useCanvasZoom';
import useCanvasMouseDown from './hooks/useCanvasMouseDown';
import useCanvasMouseUp from './hooks/useCanvasMouseUp';
import useCanvasMouseMove from './hooks/useCanvasMouseMove';
import useCanvasMouseEnter from './hooks/useCanvasMouseEnter';
import useCanvasMouseOut from './hooks/useCanvasMouseOut';
import useCanvasDragMove from './hooks/useCanvasDragMove';
import IAICanvasOutpaintingObjects from './IAICanvasOutpaintingObjects';
import IAICanvasGrid from './IAICanvasGrid';
import IAICanvasIntermediateImage from './IAICanvasIntermediateImage';
import IAICanvasStatusText from './IAICanvasStatusText';
const canvasSelector = createSelector(
[
currentCanvasSelector,
outpaintingCanvasSelector,
baseCanvasImageSelector,
activeTabNameSelector,
],
(currentCanvas, outpaintingCanvas, baseCanvasImage, activeTabName) => {
const {
shouldInvertMask,
isMaskEnabled,
shouldShowCheckboardTransparency,
stageScale,
shouldShowBoundingBox,
shouldLockBoundingBox,
isTransformingBoundingBox,
isMouseOverBoundingBox,
isMovingBoundingBox,
stageDimensions,
stageCoordinates,
isMoveStageKeyHeld,
tool,
isMovingStage,
} = currentCanvas;
const { shouldShowGrid } = outpaintingCanvas;
let stageCursor: string | undefined = '';
if (tool === 'move') {
if (isTransformingBoundingBox) {
stageCursor = undefined;
} else if (isMouseOverBoundingBox) {
stageCursor = 'move';
} else {
if (isMovingStage) {
stageCursor = 'grabbing';
} else {
stageCursor = 'grab';
}
}
} else {
stageCursor = 'none';
}
return {
shouldInvertMask,
isMaskEnabled,
shouldShowCheckboardTransparency,
stageScale,
shouldShowBoundingBox,
shouldLockBoundingBox,
shouldShowGrid,
isTransformingBoundingBox,
isModifyingBoundingBox: isTransformingBoundingBox || isMovingBoundingBox,
stageCursor,
isMouseOverBoundingBox,
stageDimensions,
stageCoordinates,
isMoveStageKeyHeld,
activeTabName,
baseCanvasImage,
tool,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
// Use a closure allow other components to use these things... not ideal...
export let stageRef: MutableRefObject<StageType | null>;
export let canvasImageLayerRef: MutableRefObject<Konva.Layer | null>;
export let inpaintingImageElementRef: MutableRefObject<HTMLImageElement | null>;
const IAICanvas = () => {
const dispatch = useAppDispatch();
const {
shouldInvertMask,
isMaskEnabled,
shouldShowCheckboardTransparency,
stageScale,
shouldShowBoundingBox,
isModifyingBoundingBox,
stageCursor,
stageDimensions,
stageCoordinates,
shouldShowGrid,
activeTabName,
baseCanvasImage,
tool,
} = useAppSelector(canvasSelector);
useCanvasHotkeys();
const toast = useToast();
// set the closure'd refs
stageRef = useRef<StageType>(null);
canvasImageLayerRef = useRef<Konva.Layer>(null);
inpaintingImageElementRef = useRef<HTMLImageElement>(null);
const lastCursorPositionRef = useRef<Vector2d>({ x: 0, y: 0 });
// Use refs for values that do not affect rendering, other values in redux
const didMouseMoveRef = useRef<boolean>(false);
// Load the image into this
const [canvasBgImage, setCanvasBgImage] = useState<HTMLImageElement | null>(
null
);
const handleWheel = useCanvasWheel(stageRef);
const handleMouseDown = useCanvasMouseDown(stageRef);
const handleMouseUp = useCanvasMouseUp(stageRef, didMouseMoveRef);
const handleMouseMove = useCanvasMouseMove(
stageRef,
didMouseMoveRef,
lastCursorPositionRef
);
const handleMouseEnter = useCanvasMouseEnter(stageRef);
const handleMouseOut = useCanvasMouseOut();
const { handleDragStart, handleDragMove, handleDragEnd } =
useCanvasDragMove();
// Load the image and set the options panel width & height
useEffect(() => {
if (baseCanvasImage) {
const image = new Image();
image.onload = () => {
inpaintingImageElementRef.current = image;
setCanvasBgImage(image);
};
image.onerror = () => {
toast({
title: 'Unable to Load Image',
description: `Image ${baseCanvasImage.url} failed to load`,
status: 'error',
isClosable: true,
});
dispatch(clearImageToInpaint());
};
image.src = baseCanvasImage.url;
} else {
setCanvasBgImage(null);
}
}, [baseCanvasImage, dispatch, stageScale, toast]);
return (
<div className="inpainting-canvas-container">
<div className="inpainting-canvas-wrapper">
<Stage
ref={stageRef}
style={{ ...(stageCursor ? { cursor: stageCursor } : {}) }}
className="inpainting-canvas-stage checkerboard"
x={stageCoordinates.x}
y={stageCoordinates.y}
width={stageDimensions.width}
height={stageDimensions.height}
scale={{ x: stageScale, y: stageScale }}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseOut}
onMouseMove={handleMouseMove}
onMouseOut={handleMouseOut}
onMouseUp={handleMouseUp}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
onWheel={handleWheel}
listening={
tool === 'move' &&
!isModifyingBoundingBox &&
activeTabName === 'outpainting'
}
draggable={
tool === 'move' &&
!isModifyingBoundingBox &&
activeTabName === 'outpainting'
}
>
<Layer visible={shouldShowGrid}>
<IAICanvasGrid />
</Layer>
<Layer
id={'image-layer'}
ref={canvasImageLayerRef}
listening={false}
imageSmoothingEnabled={false}
>
<IAICanvasOutpaintingObjects />
<IAICanvasIntermediateImage />
</Layer>
<Layer id={'mask-layer'} visible={isMaskEnabled} listening={false}>
<IAICanvasMaskLines visible={true} listening={false} />
<IAICanvasMaskCompositer listening={false} />
{canvasBgImage && (
<>
<KonvaImage
image={canvasBgImage}
listening={false}
globalCompositeOperation="source-in"
visible={shouldInvertMask}
/>
<KonvaImage
image={canvasBgImage}
listening={false}
globalCompositeOperation="source-out"
visible={
!shouldInvertMask && shouldShowCheckboardTransparency
}
/>
</>
)}
</Layer>
<Layer id={'preview-layer'}>
<IAICanvasBoundingBoxPreview visible={shouldShowBoundingBox} />
<IAICanvasBrushPreview
visible={tool !== 'move'}
listening={false}
/>
</Layer>
</Stage>
<IAICanvasStatusText />
</div>
</div>
);
};
export default IAICanvas;

View File

@ -7,58 +7,60 @@ import { Vector2d } from 'konva/lib/types';
import _ from 'lodash';
import { useCallback, useEffect, useRef } from 'react';
import { Group, Rect, Transformer } from 'react-konva';
import { useAppDispatch, useAppSelector } from 'app/store';
import { roundToMultiple } from 'common/util/roundDownToMultiple';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import { roundToMultiple } from '../../../../common/util/roundDownToMultiple';
import {
InpaintingState,
setBoundingBoxCoordinate,
baseCanvasImageSelector,
currentCanvasSelector,
outpaintingCanvasSelector,
setBoundingBoxCoordinates,
setBoundingBoxDimensions,
setIsMouseOverBoundingBox,
setIsMovingBoundingBox,
setIsTransformingBoundingBox,
} from '../inpaintingSlice';
import { rgbaColorToString } from '../util/colorToString';
import {
DASH_WIDTH,
// MARCHING_ANTS_SPEED,
} from '../util/constants';
} from 'features/canvas/canvasSlice';
import { GroupConfig } from 'konva/lib/Group';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
const boundingBoxPreviewSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
currentCanvasSelector,
outpaintingCanvasSelector,
baseCanvasImageSelector,
activeTabNameSelector,
(currentCanvas, outpaintingCanvas, baseCanvasImage, activeTabName) => {
const {
boundingBoxCoordinate,
boundingBoxCoordinates,
boundingBoxDimensions,
boundingBoxPreviewFill,
canvasDimensions,
stageDimensions,
stageScale,
imageToInpaint,
shouldLockBoundingBox,
isDrawing,
isTransformingBoundingBox,
isMovingBoundingBox,
isMouseOverBoundingBox,
isSpacebarHeld,
} = inpainting;
shouldDarkenOutsideBoundingBox,
tool,
stageCoordinates,
} = currentCanvas;
const { shouldSnapToGrid } = outpaintingCanvas;
return {
boundingBoxCoordinate,
boundingBoxCoordinates,
boundingBoxDimensions,
boundingBoxPreviewFillString: rgbaColorToString(boundingBoxPreviewFill),
canvasDimensions,
stageScale,
imageToInpaint,
dash: DASH_WIDTH / stageScale, // scale dash lengths
strokeWidth: 1 / stageScale, // scale stroke thickness
shouldLockBoundingBox,
isDrawing,
isTransformingBoundingBox,
isMouseOverBoundingBox,
shouldDarkenOutsideBoundingBox,
isMovingBoundingBox,
isSpacebarHeld,
isTransformingBoundingBox,
shouldLockBoundingBox,
stageDimensions,
stageScale,
baseCanvasImage,
activeTabName,
shouldSnapToGrid,
tool,
stageCoordinates,
boundingBoxStrokeWidth: (isMouseOverBoundingBox ? 8 : 1) / stageScale,
};
},
{
@ -68,52 +70,31 @@ const boundingBoxPreviewSelector = createSelector(
}
);
/**
* Shades the area around the mask.
*/
export const InpaintingBoundingBoxPreviewOverlay = () => {
const {
boundingBoxCoordinate,
boundingBoxDimensions,
boundingBoxPreviewFillString,
canvasDimensions,
} = useAppSelector(boundingBoxPreviewSelector);
type IAICanvasBoundingBoxPreviewProps = GroupConfig;
return (
<Group>
<Rect
x={0}
y={0}
height={canvasDimensions.height}
width={canvasDimensions.width}
fill={boundingBoxPreviewFillString}
/>
<Rect
x={boundingBoxCoordinate.x}
y={boundingBoxCoordinate.y}
width={boundingBoxDimensions.width}
height={boundingBoxDimensions.height}
fill={'rgb(255,255,255)'}
listening={false}
globalCompositeOperation={'destination-out'}
/>
</Group>
);
};
const IAICanvasBoundingBoxPreview = (
props: IAICanvasBoundingBoxPreviewProps
) => {
const { ...rest } = props;
const InpaintingBoundingBoxPreview = () => {
const dispatch = useAppDispatch();
const {
boundingBoxCoordinate,
boundingBoxCoordinates,
boundingBoxDimensions,
stageScale,
imageToInpaint,
shouldLockBoundingBox,
isDrawing,
isTransformingBoundingBox,
isMovingBoundingBox,
isMouseOverBoundingBox,
isSpacebarHeld,
shouldDarkenOutsideBoundingBox,
isMovingBoundingBox,
isTransformingBoundingBox,
shouldLockBoundingBox,
stageCoordinates,
stageDimensions,
stageScale,
baseCanvasImage,
activeTabName,
shouldSnapToGrid,
tool,
boundingBoxStrokeWidth,
} = useAppSelector(boundingBoxPreviewSelector);
const transformerRef = useRef<Konva.Transformer>(null);
@ -129,31 +110,60 @@ const InpaintingBoundingBoxPreview = () => {
const handleOnDragMove = useCallback(
(e: KonvaEventObject<DragEvent>) => {
if (activeTabName === 'inpainting' || !shouldSnapToGrid) {
dispatch(
setBoundingBoxCoordinates({
x: e.target.x(),
y: e.target.y(),
})
);
return;
}
const dragX = e.target.x();
const dragY = e.target.y();
const newX = roundToMultiple(dragX, 64);
const newY = roundToMultiple(dragY, 64);
e.target.x(newX);
e.target.y(newY);
dispatch(
setBoundingBoxCoordinate({
x: Math.floor(e.target.x()),
y: Math.floor(e.target.y()),
setBoundingBoxCoordinates({
x: newX,
y: newY,
})
);
},
[dispatch]
[activeTabName, dispatch, shouldSnapToGrid]
);
const dragBoundFunc = useCallback(
(position: Vector2d) => {
if (!imageToInpaint) return boundingBoxCoordinate;
if (!baseCanvasImage) return boundingBoxCoordinates;
const { x, y } = position;
const maxX = imageToInpaint.width - boundingBoxDimensions.width;
const maxY = imageToInpaint.height - boundingBoxDimensions.height;
const maxX =
stageDimensions.width - boundingBoxDimensions.width * stageScale;
const maxY =
stageDimensions.height - boundingBoxDimensions.height * stageScale;
const clampedX = Math.floor(_.clamp(x, 0, maxX * stageScale));
const clampedY = Math.floor(_.clamp(y, 0, maxY * stageScale));
const clampedX = Math.floor(_.clamp(x, 0, maxX));
const clampedY = Math.floor(_.clamp(y, 0, maxY));
return { x: clampedX, y: clampedY };
},
[boundingBoxCoordinate, boundingBoxDimensions, imageToInpaint, stageScale]
[
baseCanvasImage,
boundingBoxCoordinates,
stageDimensions.width,
stageDimensions.height,
boundingBoxDimensions.width,
boundingBoxDimensions.height,
stageScale,
]
);
const handleOnTransform = useCallback(() => {
@ -184,7 +194,7 @@ const InpaintingBoundingBoxPreview = () => {
);
dispatch(
setBoundingBoxCoordinate({
setBoundingBoxCoordinates({
x,
y,
})
@ -195,6 +205,7 @@ const InpaintingBoundingBoxPreview = () => {
rect.scaleY(1);
}, [dispatch]);
// OK
const anchorDragBoundFunc = useCallback(
(
oldPos: Vector2d, // old absolute position of anchor point
@ -253,6 +264,7 @@ const InpaintingBoundingBoxPreview = () => {
[scaledStep]
);
// OK
const boundBoxFunc = useCallback(
(oldBoundBox: Box, newBoundBox: Box) => {
/**
@ -260,12 +272,10 @@ const InpaintingBoundingBoxPreview = () => {
* Unlike anchorDragBoundFunc, it does get a width and height, so
* the logic to constrain the size of the bounding box is very simple.
*/
if (!imageToInpaint) return oldBoundBox;
if (!baseCanvasImage) return oldBoundBox;
if (
newBoundBox.width + newBoundBox.x > imageToInpaint.width * stageScale ||
newBoundBox.height + newBoundBox.y >
imageToInpaint.height * stageScale ||
newBoundBox.width + newBoundBox.x > stageDimensions.width ||
newBoundBox.height + newBoundBox.y > stageDimensions.height ||
newBoundBox.x < 0 ||
newBoundBox.y < 0
) {
@ -274,101 +284,107 @@ const InpaintingBoundingBoxPreview = () => {
return newBoundBox;
},
[imageToInpaint, stageScale]
[baseCanvasImage, stageDimensions]
);
const handleStartedTransforming = (e: KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
e.evt.stopImmediatePropagation();
console.log("Started transform")
const handleStartedTransforming = () => {
dispatch(setIsTransformingBoundingBox(true));
};
const handleEndedTransforming = (e: KonvaEventObject<MouseEvent>) => {
const handleEndedTransforming = () => {
dispatch(setIsTransformingBoundingBox(false));
dispatch(setIsMouseOverBoundingBox(false));
};
const handleStartedMoving = (e: KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
e.evt.stopImmediatePropagation();
const handleStartedMoving = () => {
dispatch(setIsMovingBoundingBox(true));
};
const handleEndedModifying = (e: KonvaEventObject<MouseEvent>) => {
const handleEndedModifying = () => {
dispatch(setIsTransformingBoundingBox(false));
dispatch(setIsMovingBoundingBox(false));
dispatch(setIsMouseOverBoundingBox(false));
};
const spacebarHeldHitFunc = (context: Context, shape: Konva.Shape) => {
context.rect(0, 0, imageToInpaint?.width, imageToInpaint?.height);
context.fillShape(shape);
const handleMouseOver = () => {
dispatch(setIsMouseOverBoundingBox(true));
};
const handleMouseOut = () => {
!isTransformingBoundingBox &&
!isMovingBoundingBox &&
dispatch(setIsMouseOverBoundingBox(false));
};
return (
<>
<Group {...rest}>
<Rect
x={boundingBoxCoordinate.x}
y={boundingBoxCoordinate.y}
offsetX={stageCoordinates.x / stageScale}
offsetY={stageCoordinates.y / stageScale}
height={stageDimensions.height / stageScale}
width={stageDimensions.width / stageScale}
fill={'rgba(0,0,0,0.4)'}
listening={false}
visible={shouldDarkenOutsideBoundingBox}
/>
<Rect
x={boundingBoxCoordinates.x}
y={boundingBoxCoordinates.y}
width={boundingBoxDimensions.width}
height={boundingBoxDimensions.height}
ref={shapeRef}
stroke={isMouseOverBoundingBox ? 'rgba(255,255,255,0.3)' : 'white'}
strokeWidth={Math.floor((isMouseOverBoundingBox ? 8 : 1) / stageScale)}
fillEnabled={isSpacebarHeld}
hitFunc={isSpacebarHeld ? spacebarHeldHitFunc : undefined}
hitStrokeWidth={Math.floor(13 / stageScale)}
listening={!isDrawing && !shouldLockBoundingBox}
onMouseOver={() => {
dispatch(setIsMouseOverBoundingBox(true));
}}
onMouseOut={() => {
!isTransformingBoundingBox &&
!isMovingBoundingBox &&
dispatch(setIsMouseOverBoundingBox(false));
}}
onMouseDown={handleStartedMoving}
onMouseUp={handleEndedModifying}
fill={'rgb(255,255,255)'}
listening={false}
visible={shouldDarkenOutsideBoundingBox}
globalCompositeOperation={'destination-out'}
/>
<Rect
dragBoundFunc={
activeTabName === 'inpainting' ? dragBoundFunc : undefined
}
draggable={true}
onDragMove={handleOnDragMove}
dragBoundFunc={dragBoundFunc}
onTransform={handleOnTransform}
fillEnabled={tool === 'move'}
height={boundingBoxDimensions.height}
listening={!isDrawing && tool === 'move'}
onDragEnd={handleEndedModifying}
onDragMove={handleOnDragMove}
onMouseDown={handleStartedMoving}
onMouseOut={handleMouseOut}
onMouseOver={handleMouseOver}
onMouseUp={handleEndedModifying}
onTransform={handleOnTransform}
onTransformEnd={handleEndedTransforming}
ref={shapeRef}
stroke={isMouseOverBoundingBox ? 'rgba(255,255,255,0.7)' : 'white'}
strokeWidth={boundingBoxStrokeWidth}
width={boundingBoxDimensions.width}
x={boundingBoxCoordinates.x}
y={boundingBoxCoordinates.y}
/>
<Transformer
ref={transformerRef}
anchorCornerRadius={3}
anchorDragBoundFunc={anchorDragBoundFunc}
anchorFill={'rgba(212,216,234,1)'}
anchorSize={15}
anchorStroke={'rgb(42,42,42)'}
borderDash={[4, 4]}
borderStroke={'black'}
rotateEnabled={false}
borderEnabled={true}
borderStroke={'black'}
boundBoxFunc={boundBoxFunc}
draggable={false}
enabledAnchors={tool === 'move' ? undefined : []}
flipEnabled={false}
ignoreStroke={true}
keepRatio={false}
listening={!isDrawing && !shouldLockBoundingBox}
listening={!isDrawing && tool === 'move'}
onDragEnd={handleEndedModifying}
onMouseDown={handleStartedTransforming}
onMouseUp={handleEndedTransforming}
enabledAnchors={shouldLockBoundingBox ? [] : undefined}
boundBoxFunc={boundBoxFunc}
anchorDragBoundFunc={anchorDragBoundFunc}
onDragEnd={handleEndedModifying}
onTransformEnd={handleEndedTransforming}
onMouseOver={() => {
dispatch(setIsMouseOverBoundingBox(true));
}}
onMouseOut={() => {
!isTransformingBoundingBox &&
!isMovingBoundingBox &&
dispatch(setIsMouseOverBoundingBox(false));
}}
ref={transformerRef}
rotateEnabled={false}
/>
</>
</Group>
);
};
export default InpaintingBoundingBoxPreview;
export default IAICanvasBoundingBoxPreview;

View File

@ -0,0 +1,95 @@
import { createSelector } from '@reduxjs/toolkit';
import {
currentCanvasSelector,
outpaintingCanvasSelector,
setBrushColor,
setBrushSize,
setTool,
} from './canvasSlice';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import _ from 'lodash';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaPaintBrush } from 'react-icons/fa';
import IAIPopover from 'common/components/IAIPopover';
import IAIColorPicker from 'common/components/IAIColorPicker';
import IAISlider from 'common/components/IAISlider';
import { Flex } from '@chakra-ui/react';
import IAINumberInput from 'common/components/IAINumberInput';
export const selector = createSelector(
[currentCanvasSelector, outpaintingCanvasSelector, activeTabNameSelector],
(currentCanvas, outpaintingCanvas, activeTabName) => {
const {
layer,
maskColor,
brushColor,
brushSize,
eraserSize,
tool,
shouldDarkenOutsideBoundingBox,
shouldShowIntermediates,
} = currentCanvas;
const { shouldShowGrid, shouldSnapToGrid, shouldAutoSave } =
outpaintingCanvas;
return {
layer,
tool,
maskColor,
brushColor,
brushSize,
eraserSize,
activeTabName,
shouldShowGrid,
shouldSnapToGrid,
shouldAutoSave,
shouldDarkenOutsideBoundingBox,
shouldShowIntermediates,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasBrushButtonPopover = () => {
const dispatch = useAppDispatch();
const { tool, brushColor, brushSize } = useAppSelector(selector);
return (
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton
aria-label="Brush (B)"
tooltip="Brush (B)"
icon={<FaPaintBrush />}
data-selected={tool === 'brush'}
onClick={() => dispatch(setTool('brush'))}
/>
}
>
<Flex minWidth={'15rem'} direction={'column'} gap={'1rem'} width={'100%'}>
<Flex gap={'1rem'} justifyContent="space-between">
<IAISlider
label="Size"
value={brushSize}
withInput
onChange={(newSize) => dispatch(setBrushSize(newSize))}
/>
</Flex>
<IAIColorPicker
style={{ width: '100%' }}
color={brushColor}
onChange={(newColor) => dispatch(setBrushColor(newColor))}
/>
</Flex>
</IAIPopover>
);
};
export default IAICanvasBrushButtonPopover;

View File

@ -0,0 +1,121 @@
import { createSelector } from '@reduxjs/toolkit';
import { GroupConfig } from 'konva/lib/Group';
import _ from 'lodash';
import { Circle, Group } from 'react-konva';
import { useAppSelector } from 'app/store';
import { currentCanvasSelector } from 'features/canvas/canvasSlice';
import { rgbaColorToString } from './util/colorToString';
const canvasBrushPreviewSelector = createSelector(
currentCanvasSelector,
(currentCanvas) => {
const {
cursorPosition,
stageDimensions: { width, height },
brushSize,
eraserSize,
maskColor,
brushColor,
tool,
layer,
shouldShowBrush,
isMovingBoundingBox,
isTransformingBoundingBox,
stageScale,
} = currentCanvas;
return {
cursorPosition,
width,
height,
radius: tool === 'brush' ? brushSize / 2 : eraserSize / 2,
brushColorString: rgbaColorToString(
layer === 'mask' ? maskColor : brushColor
),
tool,
shouldShowBrush,
shouldDrawBrushPreview:
!(
isMovingBoundingBox ||
isTransformingBoundingBox ||
!cursorPosition
) && shouldShowBrush,
strokeWidth: 1.5 / stageScale,
dotRadius: 1.5 / stageScale,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
/**
* Draws a black circle around the canvas brush preview.
*/
const IAICanvasBrushPreview = (props: GroupConfig) => {
const { ...rest } = props;
const {
cursorPosition,
width,
height,
radius,
brushColorString,
tool,
shouldDrawBrushPreview,
dotRadius,
strokeWidth,
} = useAppSelector(canvasBrushPreviewSelector);
if (!shouldDrawBrushPreview) return null;
return (
<Group listening={false} {...rest}>
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={radius}
fill={brushColorString}
listening={false}
globalCompositeOperation={
tool === 'eraser' ? 'destination-out' : 'source-over'
}
/>
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={radius}
stroke={'rgba(255,255,255,0.4)'}
strokeWidth={strokeWidth * 2}
strokeEnabled={true}
listening={false}
/>
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={radius}
stroke={'rgba(0,0,0,1)'}
strokeWidth={strokeWidth}
strokeEnabled={true}
listening={false}
/>
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={dotRadius * 2}
fill={'rgba(255,255,255,0.4)'}
listening={false}
/>
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={dotRadius}
fill={'rgba(0,0,0,1)'}
listening={false}
/>
</Group>
);
};
export default IAICanvasBrushPreview;

View File

@ -0,0 +1,100 @@
import IAICanvasBrushControl from './IAICanvasControls/IAICanvasBrushControl';
import IAICanvasEraserControl from './IAICanvasControls/IAICanvasEraserControl';
import IAICanvasUndoControl from './IAICanvasControls/IAICanvasUndoButton';
import IAICanvasRedoControl from './IAICanvasControls/IAICanvasRedoButton';
import { Button, ButtonGroup } from '@chakra-ui/react';
import IAICanvasMaskClear from './IAICanvasControls/IAICanvasMaskControls/IAICanvasMaskClear';
import IAICanvasMaskVisibilityControl from './IAICanvasControls/IAICanvasMaskControls/IAICanvasMaskVisibilityControl';
import IAICanvasMaskInvertControl from './IAICanvasControls/IAICanvasMaskControls/IAICanvasMaskInvertControl';
import IAICanvasLockBoundingBoxControl from './IAICanvasControls/IAICanvasLockBoundingBoxControl';
import IAICanvasShowHideBoundingBoxControl from './IAICanvasControls/IAICanvasShowHideBoundingBoxControl';
import ImageUploaderIconButton from 'common/components/ImageUploaderIconButton';
import { createSelector } from '@reduxjs/toolkit';
import {
currentCanvasSelector,
GenericCanvasState,
outpaintingCanvasSelector,
OutpaintingCanvasState,
uploadOutpaintingMergedImage,
} from './canvasSlice';
import { RootState, useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { OptionsState } from 'features/options/optionsSlice';
import _ from 'lodash';
import IAICanvasImageEraserControl from './IAICanvasControls/IAICanvasImageEraserControl';
import { canvasImageLayerRef } from './IAICanvas';
import { uploadImage } from 'app/socketio/actions';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaSave } from 'react-icons/fa';
export const canvasControlsSelector = createSelector(
[
outpaintingCanvasSelector,
(state: RootState) => state.options,
activeTabNameSelector,
],
(
outpaintingCanvas: OutpaintingCanvasState,
options: OptionsState,
activeTabName
) => {
const { stageScale, boundingBoxCoordinates, boundingBoxDimensions } =
outpaintingCanvas;
return {
activeTabName,
stageScale,
boundingBoxCoordinates,
boundingBoxDimensions,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasControls = () => {
const dispatch = useAppDispatch();
const {
activeTabName,
boundingBoxCoordinates,
boundingBoxDimensions,
stageScale,
} = useAppSelector(canvasControlsSelector);
return (
<div className="inpainting-settings">
<ButtonGroup isAttached={true}>
<IAICanvasBrushControl />
<IAICanvasEraserControl />
{activeTabName === 'outpainting' && <IAICanvasImageEraserControl />}
</ButtonGroup>
<ButtonGroup isAttached={true}>
<IAICanvasMaskVisibilityControl />
<IAICanvasMaskInvertControl />
<IAICanvasLockBoundingBoxControl />
<IAICanvasShowHideBoundingBoxControl />
<IAICanvasMaskClear />
</ButtonGroup>
<IAIIconButton
aria-label="Save"
tooltip="Save"
icon={<FaSave />}
onClick={() => {
dispatch(uploadOutpaintingMergedImage(canvasImageLayerRef));
}}
fontSize={20}
/>
<ButtonGroup isAttached={true}>
<IAICanvasUndoControl />
<IAICanvasRedoControl />
</ButtonGroup>
<ButtonGroup isAttached={true}>
<ImageUploaderIconButton />
</ButtonGroup>
</div>
);
};
export default IAICanvasControls;

View File

@ -1,37 +1,31 @@
import { createSelector } from '@reduxjs/toolkit';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaPaintBrush } from 'react-icons/fa';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import IAINumberInput from '../../../../common/components/IAINumberInput';
import IAIPopover from '../../../../common/components/IAIPopover';
import IAISlider from '../../../../common/components/IAISlider';
import { activeTabNameSelector } from '../../../options/optionsSelectors';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import IAINumberInput from 'common/components/IAINumberInput';
import IAIPopover from 'common/components/IAIPopover';
import IAISlider from 'common/components/IAISlider';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import {
InpaintingState,
currentCanvasSelector,
setBrushSize,
setShouldShowBrushPreview,
setTool,
} from '../inpaintingSlice';
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
import InpaintingMaskColorPicker from './InpaintingMaskControls/InpaintingMaskColorPicker';
import IAICanvasMaskColorPicker from './IAICanvasMaskControls/IAICanvasMaskColorPicker';
const inpaintingBrushSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { tool, brushSize, shouldShowMask } = inpainting;
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas, activeTabName) => {
const { tool, brushSize } = currentCanvas;
return {
tool,
brushSize,
shouldShowMask,
activeTabName,
};
},
@ -42,9 +36,9 @@ const inpaintingBrushSelector = createSelector(
}
);
export default function InpaintingBrushControl() {
export default function IAICanvasBrushControl() {
const dispatch = useAppDispatch();
const { tool, brushSize, shouldShowMask, activeTabName } = useAppSelector(
const { tool, brushSize, activeTabName } = useAppSelector(
inpaintingBrushSelector
);
@ -63,9 +57,6 @@ export default function InpaintingBrushControl() {
dispatch(setBrushSize(v));
};
// Hotkeys
// Decrease brush size
useHotkeys(
'[',
(e: KeyboardEvent) => {
@ -77,9 +68,9 @@ export default function InpaintingBrushControl() {
}
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldShowMask, brushSize]
[activeTabName, brushSize]
);
// Increase brush size
@ -90,9 +81,9 @@ export default function InpaintingBrushControl() {
handleChangeBrushSize(brushSize + 5);
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldShowMask, brushSize]
[activeTabName, brushSize]
);
// Set tool to brush
@ -103,9 +94,9 @@ export default function InpaintingBrushControl() {
handleSelectBrushTool();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldShowMask]
[activeTabName]
);
return (
@ -120,7 +111,6 @@ export default function InpaintingBrushControl() {
icon={<FaPaintBrush />}
onClick={handleSelectBrushTool}
data-selected={tool === 'brush'}
isDisabled={!shouldShowMask}
/>
}
>
@ -131,9 +121,6 @@ export default function InpaintingBrushControl() {
onChange={handleChangeBrushSize}
min={1}
max={200}
width="100px"
focusThumbOnChange={false}
isDisabled={!shouldShowMask}
/>
<IAINumberInput
value={brushSize}
@ -141,9 +128,8 @@ export default function InpaintingBrushControl() {
width={'80px'}
min={1}
max={999}
isDisabled={!shouldShowMask}
/>
<InpaintingMaskColorPicker />
<IAICanvasMaskColorPicker />
</div>
</IAIPopover>
);

View File

@ -1,10 +1,9 @@
import React from 'react';
import { FaTrash } from 'react-icons/fa';
import { useAppDispatch } from '../../../../app/store';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import { clearImageToInpaint } from '../inpaintingSlice';
import { useAppDispatch } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { clearImageToInpaint } from 'features/canvas/canvasSlice';
export default function InpaintingClearImageControl() {
export default function IAICanvasClearImageControl() {
const dispatch = useAppDispatch();
const handleClearImage = () => {

View File

@ -0,0 +1,61 @@
import { createSelector } from '@reduxjs/toolkit';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaEraser } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { currentCanvasSelector, setTool } from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
const eraserSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas, activeTabName) => {
const { tool, isMaskEnabled } = currentCanvas;
return {
tool,
isMaskEnabled,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasEraserControl() {
const { tool, isMaskEnabled, activeTabName } = useAppSelector(eraserSelector);
const dispatch = useAppDispatch();
const handleSelectEraserTool = () => dispatch(setTool('eraser'));
// Hotkeys
// Set tool to maskEraser
useHotkeys(
'e',
(e: KeyboardEvent) => {
e.preventDefault();
handleSelectEraserTool();
},
{
enabled: true,
},
[activeTabName]
);
return (
<IAIIconButton
aria-label={
activeTabName === 'inpainting' ? 'Eraser (E)' : 'Erase Mask (E)'
}
tooltip={activeTabName === 'inpainting' ? 'Eraser (E)' : 'Erase Mask (E)'}
icon={<FaEraser />}
onClick={handleSelectEraserTool}
data-selected={tool === 'eraser'}
isDisabled={!isMaskEnabled}
/>
);
}

View File

@ -0,0 +1,60 @@
import { createSelector } from '@reduxjs/toolkit';
import { useHotkeys } from 'react-hotkeys-hook';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { currentCanvasSelector, setTool } from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { BsEraser } from 'react-icons/bs';
const imageEraserSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas, activeTabName) => {
const { tool, isMaskEnabled } = currentCanvas;
return {
tool,
isMaskEnabled,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasImageEraserControl() {
const { tool, isMaskEnabled, activeTabName } =
useAppSelector(imageEraserSelector);
const dispatch = useAppDispatch();
const handleSelectEraserTool = () => dispatch(setTool('eraser'));
// Hotkeys
useHotkeys(
'shift+e',
(e: KeyboardEvent) => {
e.preventDefault();
handleSelectEraserTool();
},
{
enabled: true,
},
[activeTabName, isMaskEnabled]
);
return (
<IAIIconButton
aria-label="Erase Canvas (Shift+E)"
tooltip="Erase Canvas (Shift+E)"
icon={<BsEraser />}
fontSize={18}
onClick={handleSelectEraserTool}
data-selected={tool === 'eraser'}
isDisabled={!isMaskEnabled}
/>
);
}

View File

@ -0,0 +1,47 @@
import { FaLock, FaUnlock } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import {
currentCanvasSelector,
GenericCanvasState,
setShouldLockBoundingBox,
} from 'features/canvas/canvasSlice';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
const canvasLockBoundingBoxSelector = createSelector(
currentCanvasSelector,
(currentCanvas: GenericCanvasState) => {
const { shouldLockBoundingBox } = currentCanvas;
return {
shouldLockBoundingBox,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasLockBoundingBoxControl = () => {
const dispatch = useAppDispatch();
const { shouldLockBoundingBox } = useAppSelector(
canvasLockBoundingBoxSelector
);
return (
<IAIIconButton
aria-label="Lock Inpainting Box"
tooltip="Lock Inpainting Box"
icon={shouldLockBoundingBox ? <FaLock /> : <FaUnlock />}
data-selected={shouldLockBoundingBox}
onClick={() => {
dispatch(setShouldLockBoundingBox(!shouldLockBoundingBox));
}}
/>
);
};
export default IAICanvasLockBoundingBoxControl;

View File

@ -0,0 +1,38 @@
import { useState } from 'react';
import { FaMask } from 'react-icons/fa';
import IAIPopover from 'common/components/IAIPopover';
import IAIIconButton from 'common/components/IAIIconButton';
import IAICanvasMaskInvertControl from './IAICanvasMaskControls/IAICanvasMaskInvertControl';
import IAICanvasMaskVisibilityControl from './IAICanvasMaskControls/IAICanvasMaskVisibilityControl';
import IAICanvasMaskColorPicker from './IAICanvasMaskControls/IAICanvasMaskColorPicker';
export default function IAICanvasMaskControl() {
const [maskOptionsOpen, setMaskOptionsOpen] = useState<boolean>(false);
return (
<>
<IAIPopover
trigger="hover"
onOpen={() => setMaskOptionsOpen(true)}
onClose={() => setMaskOptionsOpen(false)}
triggerComponent={
<IAIIconButton
aria-label="Mask Options"
tooltip="Mask Options"
icon={<FaMask />}
cursor={'pointer'}
data-selected={maskOptionsOpen}
/>
}
>
<div className="inpainting-button-dropdown">
<IAICanvasMaskVisibilityControl />
<IAICanvasMaskInvertControl />
<IAICanvasMaskColorPicker />
</div>
</IAIPopover>
</>
);
}

View File

@ -0,0 +1,75 @@
import { RgbaColor } from 'react-colorful';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIColorPicker from 'common/components/IAIColorPicker';
import {
currentCanvasSelector,
GenericCanvasState,
setMaskColor,
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { useHotkeys } from 'react-hotkeys-hook';
const selector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas: GenericCanvasState, activeTabName) => {
const { brushColor } = currentCanvas;
return {
brushColor,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasBrushColorPicker() {
const dispatch = useAppDispatch();
const { brushColor, activeTabName } = useAppSelector(selector);
const handleChangeBrushColor = (newColor: RgbaColor) => {
dispatch(setMaskColor(newColor));
};
// Decrease brush opacity
useHotkeys(
'shift+[',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeBrushColor({
...brushColor,
a: Math.max(brushColor.a - 0.05, 0),
});
},
{
enabled: true,
},
[activeTabName, brushColor.a]
);
// Increase brush opacity
useHotkeys(
'shift+]',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeBrushColor({
...brushColor,
a: Math.min(brushColor.a + 0.05, 1),
});
},
{
enabled: true,
},
[activeTabName, brushColor.a]
);
return (
<IAIColorPicker color={brushColor} onChange={handleChangeBrushColor} />
);
}

View File

@ -0,0 +1,77 @@
import { createSelector } from '@reduxjs/toolkit';
import { FaPlus } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import {
clearMask,
currentCanvasSelector,
InpaintingCanvasState,
isCanvasMaskLine,
OutpaintingCanvasState,
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { useHotkeys } from 'react-hotkeys-hook';
import { useToast } from '@chakra-ui/react';
const canvasMaskClearSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas, activeTabName) => {
const { isMaskEnabled, objects } = currentCanvas as
| InpaintingCanvasState
| OutpaintingCanvasState;
return {
isMaskEnabled,
activeTabName,
isMaskEmpty: objects.filter(isCanvasMaskLine).length === 0,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasMaskClear() {
const { isMaskEnabled, activeTabName, isMaskEmpty } = useAppSelector(
canvasMaskClearSelector
);
const dispatch = useAppDispatch();
const toast = useToast();
const handleClearMask = () => {
dispatch(clearMask());
};
// Clear mask
useHotkeys(
'shift+c',
(e: KeyboardEvent) => {
e.preventDefault();
handleClearMask();
toast({
title: 'Mask Cleared',
status: 'success',
duration: 2500,
isClosable: true,
});
},
{
enabled: !isMaskEmpty,
},
[activeTabName, isMaskEmpty, isMaskEnabled]
);
return (
<IAIIconButton
aria-label="Clear Mask (Shift+C)"
tooltip="Clear Mask (Shift+C)"
icon={<FaPlus size={20} style={{ transform: 'rotate(45deg)' }} />}
onClick={handleClearMask}
isDisabled={isMaskEmpty || !isMaskEnabled}
/>
);
}

View File

@ -1,27 +1,31 @@
import React from 'react';
import { RgbaColor } from 'react-colorful';
import { FaPalette } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIColorPicker from 'common/components/IAIColorPicker';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../../app/store';
import IAIColorPicker from '../../../../../common/components/IAIColorPicker';
import IAIIconButton from '../../../../../common/components/IAIIconButton';
import IAIPopover from '../../../../../common/components/IAIPopover';
import { InpaintingState, setMaskColor } from '../../inpaintingSlice';
currentCanvasSelector,
GenericCanvasState,
setMaskColor,
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { activeTabNameSelector } from '../../../../options/optionsSelectors';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { useHotkeys } from 'react-hotkeys-hook';
const inpaintingMaskColorPickerSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { shouldShowMask, maskColor } = inpainting;
const maskColorPickerSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas: GenericCanvasState, activeTabName) => {
const { isMaskEnabled, maskColor } = currentCanvas;
return { shouldShowMask, maskColor, activeTabName };
return {
isMaskEnabled,
maskColor,
activeTabName,
};
},
{
memoizeOptions: {
@ -30,9 +34,9 @@ const inpaintingMaskColorPickerSelector = createSelector(
}
);
export default function InpaintingMaskColorPicker() {
const { shouldShowMask, maskColor, activeTabName } = useAppSelector(
inpaintingMaskColorPickerSelector
export default function IAICanvasMaskColorPicker() {
const { isMaskEnabled, maskColor, activeTabName } = useAppSelector(
maskColorPickerSelector
);
const dispatch = useAppDispatch();
const handleChangeMaskColor = (newColor: RgbaColor) => {
@ -51,9 +55,9 @@ export default function InpaintingMaskColorPicker() {
});
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldShowMask, maskColor.a]
[activeTabName, isMaskEnabled, maskColor.a]
);
// Increase mask opacity
@ -63,13 +67,13 @@ export default function InpaintingMaskColorPicker() {
e.preventDefault();
handleChangeMaskColor({
...maskColor,
a: Math.min(maskColor.a + 0.05, 100),
a: Math.min(maskColor.a + 0.05, 1),
});
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldShowMask, maskColor.a]
[activeTabName, isMaskEnabled, maskColor.a]
);
return (
@ -80,7 +84,7 @@ export default function InpaintingMaskColorPicker() {
<IAIIconButton
aria-label="Mask Color"
icon={<FaPalette />}
isDisabled={!shouldShowMask}
isDisabled={!isMaskEnabled}
cursor={'pointer'}
/>
}

View File

@ -1,24 +1,27 @@
import { createSelector } from '@reduxjs/toolkit';
import React from 'react';
import { MdInvertColors, MdInvertColorsOff } from 'react-icons/md';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../../app/store';
import IAIIconButton from '../../../../../common/components/IAIIconButton';
import { InpaintingState, setShouldInvertMask } from '../../inpaintingSlice';
currentCanvasSelector,
GenericCanvasState,
setShouldInvertMask,
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { activeTabNameSelector } from '../../../../options/optionsSelectors';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { useHotkeys } from 'react-hotkeys-hook';
const inpaintingMaskInvertSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { shouldShowMask, shouldInvertMask } = inpainting;
const canvasMaskInvertSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas: GenericCanvasState, activeTabName) => {
const { isMaskEnabled, shouldInvertMask } = currentCanvas;
return { shouldInvertMask, shouldShowMask, activeTabName };
return {
shouldInvertMask,
isMaskEnabled,
activeTabName,
};
},
{
memoizeOptions: {
@ -27,9 +30,9 @@ const inpaintingMaskInvertSelector = createSelector(
}
);
export default function InpaintingMaskInvertControl() {
const { shouldInvertMask, shouldShowMask, activeTabName } = useAppSelector(
inpaintingMaskInvertSelector
export default function IAICanvasMaskInvertControl() {
const { shouldInvertMask, isMaskEnabled, activeTabName } = useAppSelector(
canvasMaskInvertSelector
);
const dispatch = useAppDispatch();
@ -44,9 +47,9 @@ export default function InpaintingMaskInvertControl() {
handleToggleShouldInvertMask();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldInvertMask, shouldShowMask]
[activeTabName, shouldInvertMask, isMaskEnabled]
);
return (
@ -62,7 +65,7 @@ export default function InpaintingMaskInvertControl() {
)
}
onClick={handleToggleShouldInvertMask}
isDisabled={!shouldShowMask}
isDisabled={!isMaskEnabled}
/>
);
}

View File

@ -0,0 +1,60 @@
import { useHotkeys } from 'react-hotkeys-hook';
import { BiHide, BiShow } from 'react-icons/bi';
import { createSelector } from 'reselect';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import {
currentCanvasSelector,
GenericCanvasState,
setIsMaskEnabled,
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
const canvasMaskVisibilitySelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas: GenericCanvasState, activeTabName) => {
const { isMaskEnabled } = currentCanvas;
return { isMaskEnabled, activeTabName };
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasMaskVisibilityControl() {
const dispatch = useAppDispatch();
const { isMaskEnabled, activeTabName } = useAppSelector(
canvasMaskVisibilitySelector
);
const handleToggleShouldShowMask = () =>
dispatch(setIsMaskEnabled(!isMaskEnabled));
// Hotkeys
// Show/hide mask
useHotkeys(
'h',
(e: KeyboardEvent) => {
e.preventDefault();
handleToggleShouldShowMask();
},
{
enabled: activeTabName === 'inpainting' || activeTabName == 'outpainting',
},
[activeTabName, isMaskEnabled]
);
return (
<IAIIconButton
aria-label="Hide Mask (H)"
tooltip="Hide Mask (H)"
data-alert={!isMaskEnabled}
icon={isMaskEnabled ? <BiShow size={22} /> : <BiHide size={22} />}
onClick={handleToggleShouldShowMask}
/>
);
}

View File

@ -0,0 +1,57 @@
import { createSelector } from '@reduxjs/toolkit';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaRedo } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { currentCanvasSelector, redo } from 'features/canvas/canvasSlice';
import _ from 'lodash';
const canvasRedoSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas, activeTabName) => {
const { futureObjects } = currentCanvas;
return {
canRedo: futureObjects.length > 0,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasRedoButton() {
const dispatch = useAppDispatch();
const { canRedo, activeTabName } = useAppSelector(canvasRedoSelector);
const handleRedo = () => {
dispatch(redo());
};
useHotkeys(
['meta+shift+z', 'control+shift+z', 'control+y', 'meta+y'],
(e: KeyboardEvent) => {
e.preventDefault();
handleRedo();
},
{
enabled: canRedo,
},
[activeTabName, canRedo]
);
return (
<IAIIconButton
aria-label="Redo"
tooltip="Redo"
icon={<FaRedo />}
onClick={handleRedo}
isDisabled={!canRedo}
/>
);
}

View File

@ -0,0 +1,46 @@
import { FaVectorSquare } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import {
currentCanvasSelector,
GenericCanvasState,
setShouldShowBoundingBox,
} from 'features/canvas/canvasSlice';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
const canvasShowHideBoundingBoxControlSelector = createSelector(
currentCanvasSelector,
(currentCanvas: GenericCanvasState) => {
const { shouldShowBoundingBox } = currentCanvas;
return {
shouldShowBoundingBox,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasShowHideBoundingBoxControl = () => {
const dispatch = useAppDispatch();
const { shouldShowBoundingBox } = useAppSelector(
canvasShowHideBoundingBoxControlSelector
);
return (
<IAIIconButton
aria-label="Hide Inpainting Box (Shift+H)"
tooltip="Hide Inpainting Box (Shift+H)"
icon={<FaVectorSquare />}
data-alert={!shouldShowBoundingBox}
onClick={() => {
dispatch(setShouldShowBoundingBox(!shouldShowBoundingBox));
}}
/>
);
};
export default IAICanvasShowHideBoundingBoxControl;

View File

@ -1,16 +1,11 @@
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { VscSplitHorizontal } from 'react-icons/vsc';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import { setShowDualDisplay } from '../../../options/optionsSlice';
import { setNeedsCache } from '../inpaintingSlice';
import { RootState, useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { setShowDualDisplay } from 'features/options/optionsSlice';
import { setDoesCanvasNeedScaling } from 'features/canvas/canvasSlice';
export default function InpaintingSplitLayoutControl() {
export default function IAICanvasSplitLayoutControl() {
const dispatch = useAppDispatch();
const showDualDisplay = useAppSelector(
(state: RootState) => state.options.showDualDisplay
@ -18,7 +13,7 @@ export default function InpaintingSplitLayoutControl() {
const handleDualDisplay = () => {
dispatch(setShowDualDisplay(!showDualDisplay));
dispatch(setNeedsCache(true));
dispatch(setDoesCanvasNeedScaling(true));
};
// Hotkeys

View File

@ -0,0 +1,58 @@
import { createSelector } from '@reduxjs/toolkit';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaUndo } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { currentCanvasSelector, undo } from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
const canvasUndoSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(canvas, activeTabName) => {
const { pastObjects } = canvas;
return {
canUndo: pastObjects.length > 0,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasUndoButton() {
const dispatch = useAppDispatch();
const { canUndo, activeTabName } = useAppSelector(canvasUndoSelector);
const handleUndo = () => {
dispatch(undo());
};
useHotkeys(
['meta+z', 'control+z'],
(e: KeyboardEvent) => {
e.preventDefault();
handleUndo();
},
{
enabled: canUndo,
},
[activeTabName, canUndo]
);
return (
<IAIIconButton
aria-label="Undo"
tooltip="Undo"
icon={<FaUndo />}
onClick={handleUndo}
isDisabled={!canUndo}
/>
);
}

View File

@ -0,0 +1,71 @@
import { createSelector } from '@reduxjs/toolkit';
import { currentCanvasSelector, setEraserSize, setTool } from './canvasSlice';
import { useAppDispatch, useAppSelector } from 'app/store';
import _ from 'lodash';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaEraser } from 'react-icons/fa';
import IAIPopover from 'common/components/IAIPopover';
import IAISlider from 'common/components/IAISlider';
import { Flex } from '@chakra-ui/react';
import { useHotkeys } from 'react-hotkeys-hook';
export const selector = createSelector(
[currentCanvasSelector],
(currentCanvas) => {
const { eraserSize, tool } = currentCanvas;
return {
tool,
eraserSize,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasEraserButtonPopover = () => {
const dispatch = useAppDispatch();
const { tool, eraserSize } = useAppSelector(selector);
const handleSelectEraserTool = () => dispatch(setTool('eraser'));
useHotkeys(
'e',
(e: KeyboardEvent) => {
e.preventDefault();
handleSelectEraserTool();
},
{
enabled: true,
},
[tool]
);
return (
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton
aria-label="Eraser (E)"
tooltip="Eraser (E)"
icon={<FaEraser />}
data-selected={tool === 'eraser'}
onClick={() => dispatch(setTool('eraser'))}
/>
}
>
<Flex minWidth={'15rem'} direction={'column'} gap={'1rem'}>
<IAISlider
label="Size"
value={eraserSize}
withInput
onChange={(newSize) => dispatch(setEraserSize(newSize))}
/>
</Flex>
</IAIPopover>
);
};
export default IAICanvasEraserButtonPopover;

View File

@ -0,0 +1,49 @@
// import { GroupConfig } from 'konva/lib/Group';
// import { Group, Line } from 'react-konva';
// import { RootState, useAppSelector } from 'app/store';
// import { createSelector } from '@reduxjs/toolkit';
// import { OutpaintingCanvasState } from './canvasSlice';
// export const canvasEraserLinesSelector = createSelector(
// (state: RootState) => state.canvas.outpainting,
// (outpainting: OutpaintingCanvasState) => {
// const { eraserLines } = outpainting;
// return {
// eraserLines,
// };
// }
// );
// type IAICanvasEraserLinesProps = GroupConfig;
// /**
// * Draws the lines which comprise the mask.
// *
// * Uses globalCompositeOperation to handle the brush and eraser tools.
// */
// const IAICanvasEraserLines = (props: IAICanvasEraserLinesProps) => {
// const { ...rest } = props;
// const { eraserLines } = useAppSelector(canvasEraserLinesSelector);
// return (
// <Group {...rest} globalCompositeOperation={'destination-out'}>
// {eraserLines.map((line, i) => (
// <Line
// key={i}
// points={line.points}
// stroke={'rgb(0,0,0)'} // The lines can be any color, just need alpha > 0
// strokeWidth={line.strokeWidth * 2}
// tension={0}
// lineCap="round"
// lineJoin="round"
// shadowForStrokeEnabled={false}
// listening={false}
// globalCompositeOperation={'source-over'}
// />
// ))}
// </Group>
// );
// };
// export default IAICanvasEraserLines;
export default {}

View File

@ -0,0 +1,88 @@
// Grid drawing adapted from https://longviewcoder.com/2021/12/08/konva-a-better-grid/
import { useColorMode } from '@chakra-ui/react';
import _ from 'lodash';
import { Group, Line as KonvaLine } from 'react-konva';
import useUnscaleCanvasValue from './hooks/useUnscaleCanvasValue';
import { stageRef } from './IAICanvas';
const IAICanvasGrid = () => {
const { colorMode } = useColorMode();
const unscale = useUnscaleCanvasValue();
if (!stageRef.current) return null;
const gridLineColor =
colorMode === 'light' ? 'rgba(0,0,0,0.3)' : 'rgba(255,255,255,0.3)';
const stage = stageRef.current;
const width = stage.width();
const height = stage.height();
const x = stage.x();
const y = stage.y();
const stageRect = {
x1: 0,
y1: 0,
x2: width,
y2: height,
offset: {
x: unscale(x),
y: unscale(y),
},
};
const gridOffset = {
x: Math.ceil(unscale(x) / 64) * 64,
y: Math.ceil(unscale(y) / 64) * 64,
};
const gridRect = {
x1: -gridOffset.x,
y1: -gridOffset.y,
x2: unscale(width) - gridOffset.x + 64,
y2: unscale(height) - gridOffset.y + 64,
};
const gridFullRect = {
x1: Math.min(stageRect.x1, gridRect.x1),
y1: Math.min(stageRect.y1, gridRect.y1),
x2: Math.max(stageRect.x2, gridRect.x2),
y2: Math.max(stageRect.y2, gridRect.y2),
};
const fullRect = gridFullRect;
const // find the x & y size of the grid
xSize = fullRect.x2 - fullRect.x1,
ySize = fullRect.y2 - fullRect.y1,
// compute the number of steps required on each axis.
xSteps = Math.round(xSize / 64) + 1,
ySteps = Math.round(ySize / 64) + 1;
return (
<Group>
{_.range(0, xSteps).map((i) => (
<KonvaLine
key={`x_${i}`}
x={fullRect.x1 + i * 64}
y={fullRect.y1}
points={[0, 0, 0, ySize]}
stroke={gridLineColor}
strokeWidth={1}
/>
))}
{_.range(0, ySteps).map((i) => (
<KonvaLine
key={`y_${i}`}
x={fullRect.x1}
y={fullRect.y1 + i * 64}
points={[0, 0, xSize, 0]}
stroke={gridLineColor}
strokeWidth={1}
/>
))}
</Group>
);
};
export default IAICanvasGrid;

View File

@ -0,0 +1,15 @@
import { Image } from 'react-konva';
import useImage from 'use-image';
type IAICanvasImageProps = {
url: string;
x: number;
y: number;
};
const IAICanvasImage = (props: IAICanvasImageProps) => {
const { url, x, y } = props;
const [image] = useImage(url);
return <Image x={x} y={y} image={image} listening={false} />;
};
export default IAICanvasImage;

View File

@ -0,0 +1,59 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState, useAppSelector } from 'app/store';
import { GalleryState } from 'features/gallery/gallerySlice';
import { ImageConfig } from 'konva/lib/shapes/Image';
import _ from 'lodash';
import { useEffect, useState } from 'react';
import { Image as KonvaImage } from 'react-konva';
const selector = createSelector(
[(state: RootState) => state.gallery],
(gallery: GalleryState) => {
return gallery.intermediateImage ? gallery.intermediateImage : null;
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
type Props = Omit<ImageConfig, 'image'>;
const IAICanvasIntermediateImage = (props: Props) => {
const { ...rest } = props;
const intermediateImage = useAppSelector(selector);
const [loadedImageElement, setLoadedImageElement] =
useState<HTMLImageElement | null>(null);
useEffect(() => {
if (!intermediateImage) return;
const tempImage = new Image();
tempImage.onload = () => {
setLoadedImageElement(tempImage);
};
tempImage.src = intermediateImage.url;
}, [intermediateImage]);
if (!intermediateImage?.boundingBox) return null;
const {
boundingBox: { x, y, width, height },
} = intermediateImage;
return loadedImageElement ? (
<KonvaImage
x={x}
y={y}
width={width}
height={height}
image={loadedImageElement}
listening={false}
{...rest}
/>
) : null;
};
export default IAICanvasIntermediateImage;

View File

@ -0,0 +1,69 @@
import { Button, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import {
clearMask,
currentCanvasSelector,
setIsMaskEnabled,
setLayer,
setMaskColor,
} from './canvasSlice';
import { useAppDispatch, useAppSelector } from 'app/store';
import _ from 'lodash';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaMask } from 'react-icons/fa';
import IAIPopover from 'common/components/IAIPopover';
import IAICheckbox from 'common/components/IAICheckbox';
import IAIColorPicker from 'common/components/IAIColorPicker';
export const selector = createSelector(
[currentCanvasSelector],
(currentCanvas) => {
const { maskColor, layer, isMaskEnabled } = currentCanvas;
return {
layer,
maskColor,
isMaskEnabled,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasMaskButtonPopover = () => {
const dispatch = useAppDispatch();
const { layer, maskColor, isMaskEnabled } = useAppSelector(selector);
return (
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton
aria-label="Select Mask Layer"
tooltip="Select Mask Layer"
data-alert={layer === 'mask'}
onClick={() => dispatch(setLayer(layer === 'mask' ? 'base' : 'mask'))}
icon={<FaMask />}
/>
}
>
<Flex direction={'column'} gap={'0.5rem'}>
<Button onClick={() => dispatch(clearMask())}>Clear Mask</Button>
<IAICheckbox
label="Enable Mask"
isChecked={isMaskEnabled}
onChange={(e) => dispatch(setIsMaskEnabled(e.target.checked))}
/>
<IAICheckbox label="Invert Mask" />
<IAIColorPicker
color={maskColor}
onChange={(newColor) => dispatch(setMaskColor(newColor))}
/>
</Flex>
</IAIPopover>
);
};
export default IAICanvasMaskButtonPopover;

View File

@ -0,0 +1,49 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store';
import { RectConfig } from 'konva/lib/shapes/Rect';
import { Rect } from 'react-konva';
import {
currentCanvasSelector,
InpaintingCanvasState,
OutpaintingCanvasState,
} from './canvasSlice';
import { rgbaColorToString } from './util/colorToString';
export const canvasMaskCompositerSelector = createSelector(
currentCanvasSelector,
(currentCanvas) => {
const { maskColor, stageCoordinates, stageDimensions, stageScale } =
currentCanvas as InpaintingCanvasState | OutpaintingCanvasState;
return {
stageCoordinates,
stageDimensions,
stageScale,
maskColorString: rgbaColorToString(maskColor),
};
}
);
type IAICanvasMaskCompositerProps = RectConfig;
const IAICanvasMaskCompositer = (props: IAICanvasMaskCompositerProps) => {
const { ...rest } = props;
const { maskColorString, stageCoordinates, stageDimensions, stageScale } =
useAppSelector(canvasMaskCompositerSelector);
return (
<Rect
offsetX={stageCoordinates.x / stageScale}
offsetY={stageCoordinates.y / stageScale}
height={stageDimensions.height / stageScale}
width={stageDimensions.width / stageScale}
fill={maskColorString}
globalCompositeOperation={'source-in'}
listening={false}
{...rest}
/>
);
};
export default IAICanvasMaskCompositer;

View File

@ -0,0 +1,64 @@
import { GroupConfig } from 'konva/lib/Group';
import { Group, Line } from 'react-konva';
import { useAppSelector } from 'app/store';
import { createSelector } from '@reduxjs/toolkit';
import {
currentCanvasSelector,
GenericCanvasState,
InpaintingCanvasState,
isCanvasMaskLine,
OutpaintingCanvasState,
} from './canvasSlice';
import _ from 'lodash';
export const canvasLinesSelector = createSelector(
currentCanvasSelector,
(currentCanvas: GenericCanvasState) => {
const { objects } = currentCanvas as
| InpaintingCanvasState
| OutpaintingCanvasState;
return {
objects,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
type InpaintingCanvasLinesProps = GroupConfig;
/**
* Draws the lines which comprise the mask.
*
* Uses globalCompositeOperation to handle the brush and eraser tools.
*/
const IAICanvasLines = (props: InpaintingCanvasLinesProps) => {
const { ...rest } = props;
const { objects } = useAppSelector(canvasLinesSelector);
return (
<Group listening={false} {...rest}>
{objects.filter(isCanvasMaskLine).map((line, i) => (
<Line
key={i}
points={line.points}
stroke={'rgb(0,0,0)'} // The lines can be any color, just need alpha > 0
strokeWidth={line.strokeWidth * 2}
tension={0}
lineCap="round"
lineJoin="round"
shadowForStrokeEnabled={false}
listening={false}
globalCompositeOperation={
line.tool === 'brush' ? 'source-over' : 'destination-out'
}
/>
))}
</Group>
);
};
export default IAICanvasLines;

View File

@ -0,0 +1,112 @@
import { ButtonGroup } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import {
currentCanvasSelector,
resetCanvas,
setTool,
uploadOutpaintingMergedImage,
} from './canvasSlice';
import { useAppDispatch, useAppSelector } from 'app/store';
import _ from 'lodash';
import { canvasImageLayerRef } from './IAICanvas';
import IAIIconButton from 'common/components/IAIIconButton';
import {
FaArrowsAlt,
FaCopy,
FaDownload,
FaLayerGroup,
FaSave,
FaTrash,
FaUpload,
} from 'react-icons/fa';
import IAICanvasUndoButton from './IAICanvasControls/IAICanvasUndoButton';
import IAICanvasRedoButton from './IAICanvasControls/IAICanvasRedoButton';
import IAICanvasSettingsButtonPopover from './IAICanvasSettingsButtonPopover';
import IAICanvasEraserButtonPopover from './IAICanvasEraserButtonPopover';
import IAICanvasBrushButtonPopover from './IAICanvasBrushButtonPopover';
import IAICanvasMaskButtonPopover from './IAICanvasMaskButtonPopover';
export const canvasControlsSelector = createSelector(
[currentCanvasSelector],
(currentCanvas) => {
const { tool } = currentCanvas;
return {
tool,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasOutpaintingControls = () => {
const dispatch = useAppDispatch();
const { tool } = useAppSelector(canvasControlsSelector);
return (
<div className="inpainting-settings">
<IAICanvasMaskButtonPopover />
<ButtonGroup isAttached>
<IAICanvasBrushButtonPopover />
<IAICanvasEraserButtonPopover />
<IAIIconButton
aria-label="Move (M)"
tooltip="Move (M)"
icon={<FaArrowsAlt />}
data-selected={tool === 'move'}
onClick={() => dispatch(setTool('move'))}
/>
</ButtonGroup>
<ButtonGroup isAttached>
<IAIIconButton
aria-label="Merge Visible"
tooltip="Merge Visible"
icon={<FaLayerGroup />}
onClick={() => {
dispatch(uploadOutpaintingMergedImage(canvasImageLayerRef));
}}
/>
<IAIIconButton
aria-label="Save Selection to Gallery"
tooltip="Save Selection to Gallery"
icon={<FaSave />}
/>
<IAIIconButton
aria-label="Copy Selection"
tooltip="Copy Selection"
icon={<FaCopy />}
/>
<IAIIconButton
aria-label="Download Selection"
tooltip="Download Selection"
icon={<FaDownload />}
/>
</ButtonGroup>
<ButtonGroup isAttached>
<IAICanvasUndoButton />
<IAICanvasRedoButton />
</ButtonGroup>
<ButtonGroup isAttached>
<IAICanvasSettingsButtonPopover />
</ButtonGroup>
<ButtonGroup isAttached>
<IAIIconButton
aria-label="Upload"
tooltip="Upload"
icon={<FaUpload />}
/>
<IAIIconButton
aria-label="Reset Canvas"
tooltip="Reset Canvas"
icon={<FaTrash />}
onClick={() => dispatch(resetCanvas())}
/>
</ButtonGroup>
</div>
);
};
export default IAICanvasOutpaintingControls;

View File

@ -0,0 +1,65 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState, useAppSelector } from 'app/store';
import _ from 'lodash';
import { Group, Line } from 'react-konva';
import {
CanvasState,
isCanvasBaseImage,
isCanvasBaseLine,
} from './canvasSlice';
import IAICanvasImage from './IAICanvasImage';
import { rgbaColorToString } from './util/colorToString';
const selector = createSelector(
[(state: RootState) => state.canvas],
(canvas: CanvasState) => {
return {
objects:
canvas.currentCanvas === 'outpainting'
? canvas.outpainting.objects
: undefined,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasOutpaintingObjects = () => {
const { objects } = useAppSelector(selector);
if (!objects) return null;
return (
<Group name="outpainting-objects" listening={false}>
{objects.map((obj, i) => {
if (isCanvasBaseImage(obj)) {
return (
<IAICanvasImage key={i} x={obj.x} y={obj.y} url={obj.image.url} />
);
} else if (isCanvasBaseLine(obj)) {
return (
<Line
key={i}
points={obj.points}
stroke={obj.color ? rgbaColorToString(obj.color) : 'rgb(0,0,0)'} // The lines can be any color, just need alpha > 0
strokeWidth={obj.strokeWidth * 2}
tension={0}
lineCap="round"
lineJoin="round"
shadowForStrokeEnabled={false}
listening={false}
globalCompositeOperation={
obj.tool === 'brush' ? 'source-over' : 'destination-out'
}
/>
);
}
})}
</Group>
);
};
export default IAICanvasOutpaintingObjects;

View File

@ -0,0 +1,78 @@
import { Spinner } from '@chakra-ui/react';
import { useLayoutEffect, useRef } from 'react';
import { RootState, useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import {
baseCanvasImageSelector,
CanvasState,
currentCanvasSelector,
GenericCanvasState,
setStageDimensions,
setStageScale,
} from 'features/canvas/canvasSlice';
import { createSelector } from '@reduxjs/toolkit';
import * as InvokeAI from 'app/invokeai';
import { first } from 'lodash';
const canvasResizerSelector = createSelector(
(state: RootState) => state.canvas,
baseCanvasImageSelector,
activeTabNameSelector,
(canvas: CanvasState, baseCanvasImage, activeTabName) => {
const { doesCanvasNeedScaling } = canvas;
return {
doesCanvasNeedScaling,
activeTabName,
baseCanvasImage,
};
}
);
const IAICanvasResizer = () => {
const dispatch = useAppDispatch();
const { doesCanvasNeedScaling, activeTabName, baseCanvasImage } =
useAppSelector(canvasResizerSelector);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
window.setTimeout(() => {
if (!ref.current || !baseCanvasImage) return;
const width = ref.current.clientWidth;
const height = ref.current.clientHeight;
const scale = Math.min(
1,
Math.min(width / baseCanvasImage.width, height / baseCanvasImage.height)
);
dispatch(setStageScale(scale));
if (activeTabName === 'inpainting') {
dispatch(
setStageDimensions({
width: Math.floor(baseCanvasImage.width * scale),
height: Math.floor(baseCanvasImage.height * scale),
})
);
} else if (activeTabName === 'outpainting') {
dispatch(
setStageDimensions({
width: Math.floor(width),
height: Math.floor(height),
})
);
}
}, 0);
}, [dispatch, baseCanvasImage, doesCanvasNeedScaling, activeTabName]);
return (
<div ref={ref} className="inpainting-canvas-area">
<Spinner thickness="2px" speed="1s" size="xl" />
</div>
);
};
export default IAICanvasResizer;

View File

@ -0,0 +1,101 @@
import { Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import {
currentCanvasSelector,
outpaintingCanvasSelector,
setShouldAutoSave,
setShouldDarkenOutsideBoundingBox,
setShouldShowGrid,
setShouldShowIntermediates,
setShouldSnapToGrid,
} from './canvasSlice';
import { useAppDispatch, useAppSelector } from 'app/store';
import _ from 'lodash';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaWrench } from 'react-icons/fa';
import IAIPopover from 'common/components/IAIPopover';
import IAICheckbox from 'common/components/IAICheckbox';
export const canvasControlsSelector = createSelector(
[currentCanvasSelector, outpaintingCanvasSelector],
(currentCanvas, outpaintingCanvas) => {
const { shouldDarkenOutsideBoundingBox, shouldShowIntermediates } =
currentCanvas;
const { shouldShowGrid, shouldSnapToGrid, shouldAutoSave } =
outpaintingCanvas;
return {
shouldShowGrid,
shouldSnapToGrid,
shouldAutoSave,
shouldDarkenOutsideBoundingBox,
shouldShowIntermediates,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasSettingsButtonPopover = () => {
const dispatch = useAppDispatch();
const {
shouldShowIntermediates,
shouldShowGrid,
shouldSnapToGrid,
shouldAutoSave,
shouldDarkenOutsideBoundingBox,
} = useAppSelector(canvasControlsSelector);
return (
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton
variant="link"
data-variant="link"
tooltip="Canvas Settings"
aria-label="Canvas Settings"
icon={<FaWrench />}
/>
}
>
<Flex direction={'column'} gap={'0.5rem'}>
<IAICheckbox
label="Show Intermediates"
isChecked={shouldShowIntermediates}
onChange={(e) =>
dispatch(setShouldShowIntermediates(e.target.checked))
}
/>
<IAICheckbox
label="Show Grid"
isChecked={shouldShowGrid}
onChange={(e) => dispatch(setShouldShowGrid(e.target.checked))}
/>
<IAICheckbox
label="Snap to Grid"
isChecked={shouldSnapToGrid}
onChange={(e) => dispatch(setShouldSnapToGrid(e.target.checked))}
/>
<IAICheckbox
label="Darken Outside Selection"
isChecked={shouldDarkenOutsideBoundingBox}
onChange={(e) =>
dispatch(setShouldDarkenOutsideBoundingBox(e.target.checked))
}
/>
<IAICheckbox
label="Auto Save to Gallery"
isChecked={shouldAutoSave}
onChange={(e) => dispatch(setShouldAutoSave(e.target.checked))}
/>
</Flex>
</IAIPopover>
);
};
export default IAICanvasSettingsButtonPopover;

View File

@ -0,0 +1,74 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store';
import _ from 'lodash';
import { currentCanvasSelector } from './canvasSlice';
const roundToHundreth = (val: number): number => {
return Math.round(val * 100) / 100;
};
const selector = createSelector(
[currentCanvasSelector],
(currentCanvas) => {
const {
stageDimensions: { width: stageWidth, height: stageHeight },
stageCoordinates: { x: stageX, y: stageY },
boundingBoxDimensions: { width: boxWidth, height: boxHeight },
boundingBoxCoordinates: { x: boxX, y: boxY },
cursorPosition,
stageScale,
} = currentCanvas;
const position = cursorPosition
? { cursorX: cursorPosition.x, cursorY: cursorPosition.y }
: { cursorX: -1, cursorY: -1 };
return {
stageWidth,
stageHeight,
stageX,
stageY,
boxWidth,
boxHeight,
boxX,
boxY,
stageScale,
...position,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasStatusText = () => {
const {
stageWidth,
stageHeight,
stageX,
stageY,
boxWidth,
boxHeight,
boxX,
boxY,
cursorX,
cursorY,
stageScale,
} = useAppSelector(selector);
return (
<div className="canvas-status-text">
<div>{`Stage: ${stageWidth} x ${stageHeight}`}</div>
<div>{`Stage: ${roundToHundreth(stageX)}, ${roundToHundreth(
stageY
)}`}</div>
<div>{`Scale: ${roundToHundreth(stageScale)}`}</div>
<div>{`Box: ${boxWidth} x ${boxHeight}`}</div>
<div>{`Box: ${roundToHundreth(boxX)}, ${roundToHundreth(boxY)}`}</div>
<div>{`Cursor: ${cursorX}, ${cursorY}`}</div>
</div>
);
};
export default IAICanvasStatusText;

View File

@ -0,0 +1,764 @@
import {
createAsyncThunk,
createSelector,
createSlice,
} from '@reduxjs/toolkit';
import { v4 as uuidv4 } from 'uuid';
import type { PayloadAction } from '@reduxjs/toolkit';
import { IRect, Vector2d } from 'konva/lib/types';
import { RgbaColor } from 'react-colorful';
import * as InvokeAI from 'app/invokeai';
import _ from 'lodash';
import { roundDownToMultiple } from 'common/util/roundDownToMultiple';
import { RootState } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { MutableRefObject } from 'react';
import Konva from 'konva';
export interface GenericCanvasState {
tool: CanvasTool;
brushSize: number;
brushColor: RgbaColor;
eraserSize: number;
maskColor: RgbaColor;
cursorPosition: Vector2d | null;
stageDimensions: Dimensions;
stageCoordinates: Vector2d;
boundingBoxDimensions: Dimensions;
boundingBoxCoordinates: Vector2d;
boundingBoxPreviewFill: RgbaColor;
shouldShowBoundingBox: boolean;
shouldDarkenOutsideBoundingBox: boolean;
isMaskEnabled: boolean;
shouldInvertMask: boolean;
shouldShowCheckboardTransparency: boolean;
shouldShowBrush: boolean;
shouldShowBrushPreview: boolean;
stageScale: number;
isDrawing: boolean;
isTransformingBoundingBox: boolean;
isMouseOverBoundingBox: boolean;
isMovingBoundingBox: boolean;
isMovingStage: boolean;
shouldUseInpaintReplace: boolean;
inpaintReplace: number;
shouldLockBoundingBox: boolean;
isMoveBoundingBoxKeyHeld: boolean;
isMoveStageKeyHeld: boolean;
intermediateImage?: InvokeAI.Image;
shouldShowIntermediates: boolean;
}
export type CanvasLayer = 'base' | 'mask';
export type CanvasDrawingTool = 'brush' | 'eraser';
export type CanvasTool = CanvasDrawingTool | 'move';
export type Dimensions = {
width: number;
height: number;
};
export type CanvasAnyLine = {
kind: 'line';
tool: CanvasDrawingTool;
strokeWidth: number;
points: number[];
};
export type CanvasImage = {
kind: 'image';
layer: 'base';
x: number;
y: number;
image: InvokeAI.Image;
};
export type CanvasMaskLine = CanvasAnyLine & {
layer: 'mask';
};
export type CanvasLine = CanvasAnyLine & {
layer: 'base';
color?: RgbaColor;
};
type CanvasObject = CanvasImage | CanvasLine | CanvasMaskLine;
// type guards
export const isCanvasMaskLine = (obj: CanvasObject): obj is CanvasMaskLine =>
obj.kind === 'line' && obj.layer === 'mask';
export const isCanvasBaseLine = (obj: CanvasObject): obj is CanvasLine =>
obj.kind === 'line' && obj.layer === 'base';
export const isCanvasBaseImage = (obj: CanvasObject): obj is CanvasImage =>
obj.kind === 'image' && obj.layer === 'base';
export const isCanvasAnyLine = (
obj: CanvasObject
): obj is CanvasMaskLine | CanvasLine => obj.kind === 'line';
export type OutpaintingCanvasState = GenericCanvasState & {
layer: CanvasLayer;
objects: CanvasObject[];
pastObjects: CanvasObject[][];
futureObjects: CanvasObject[][];
shouldShowGrid: boolean;
shouldSnapToGrid: boolean;
shouldAutoSave: boolean;
stagingArea: {
images: CanvasImage[];
selectedImageIndex: number;
};
};
export type InpaintingCanvasState = GenericCanvasState & {
layer: 'mask';
objects: CanvasObject[];
pastObjects: CanvasObject[][];
futureObjects: CanvasObject[][];
imageToInpaint?: InvokeAI.Image;
};
export type BaseCanvasState = InpaintingCanvasState | OutpaintingCanvasState;
export type ValidCanvasName = 'inpainting' | 'outpainting';
export interface CanvasState {
doesCanvasNeedScaling: boolean;
currentCanvas: ValidCanvasName;
inpainting: InpaintingCanvasState;
outpainting: OutpaintingCanvasState;
}
const initialGenericCanvasState: GenericCanvasState = {
tool: 'brush',
brushColor: { r: 90, g: 90, b: 255, a: 1 },
brushSize: 50,
maskColor: { r: 255, g: 90, b: 90, a: 0.5 },
eraserSize: 50,
stageDimensions: { width: 0, height: 0 },
stageCoordinates: { x: 0, y: 0 },
boundingBoxDimensions: { width: 512, height: 512 },
boundingBoxCoordinates: { x: 0, y: 0 },
boundingBoxPreviewFill: { r: 0, g: 0, b: 0, a: 0.5 },
shouldShowBoundingBox: true,
shouldDarkenOutsideBoundingBox: false,
cursorPosition: null,
isMaskEnabled: true,
shouldInvertMask: false,
shouldShowCheckboardTransparency: false,
shouldShowBrush: true,
shouldShowBrushPreview: false,
isDrawing: false,
isTransformingBoundingBox: false,
isMouseOverBoundingBox: false,
isMovingBoundingBox: false,
stageScale: 1,
shouldUseInpaintReplace: false,
inpaintReplace: 0.1,
shouldLockBoundingBox: false,
isMoveBoundingBoxKeyHeld: false,
isMoveStageKeyHeld: false,
shouldShowIntermediates: true,
isMovingStage: false,
};
const initialCanvasState: CanvasState = {
currentCanvas: 'inpainting',
doesCanvasNeedScaling: false,
inpainting: {
layer: 'mask',
objects: [],
pastObjects: [],
futureObjects: [],
...initialGenericCanvasState,
},
outpainting: {
layer: 'base',
objects: [],
pastObjects: [],
futureObjects: [],
stagingArea: {
images: [],
selectedImageIndex: 0,
},
shouldShowGrid: true,
shouldSnapToGrid: true,
shouldAutoSave: false,
...initialGenericCanvasState,
},
};
export const canvasSlice = createSlice({
name: 'canvas',
initialState: initialCanvasState,
reducers: {
setTool: (state, action: PayloadAction<CanvasTool>) => {
const tool = action.payload;
state[state.currentCanvas].tool = action.payload;
if (tool !== 'move') {
state[state.currentCanvas].isTransformingBoundingBox = false;
state[state.currentCanvas].isMouseOverBoundingBox = false;
state[state.currentCanvas].isMovingBoundingBox = false;
state[state.currentCanvas].isMovingStage = false;
}
},
setLayer: (state, action: PayloadAction<CanvasLayer>) => {
state[state.currentCanvas].layer = action.payload;
},
toggleTool: (state) => {
const currentTool = state[state.currentCanvas].tool;
if (currentTool !== 'move') {
state[state.currentCanvas].tool =
currentTool === 'brush' ? 'eraser' : 'brush';
}
},
setMaskColor: (state, action: PayloadAction<RgbaColor>) => {
state[state.currentCanvas].maskColor = action.payload;
},
setBrushColor: (state, action: PayloadAction<RgbaColor>) => {
state[state.currentCanvas].brushColor = action.payload;
},
setBrushSize: (state, action: PayloadAction<number>) => {
state[state.currentCanvas].brushSize = action.payload;
},
setEraserSize: (state, action: PayloadAction<number>) => {
state[state.currentCanvas].eraserSize = action.payload;
},
clearMask: (state) => {
state[state.currentCanvas].pastObjects.push(
state[state.currentCanvas].objects
);
state[state.currentCanvas].objects = state[
state.currentCanvas
].objects.filter((obj) => !isCanvasMaskLine(obj));
state[state.currentCanvas].futureObjects = [];
state[state.currentCanvas].shouldInvertMask = false;
},
toggleShouldInvertMask: (state) => {
state[state.currentCanvas].shouldInvertMask =
!state[state.currentCanvas].shouldInvertMask;
},
toggleShouldShowMask: (state) => {
state[state.currentCanvas].isMaskEnabled =
!state[state.currentCanvas].isMaskEnabled;
},
setShouldInvertMask: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldInvertMask = action.payload;
},
setIsMaskEnabled: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMaskEnabled = action.payload;
state[state.currentCanvas].layer = action.payload ? 'mask' : 'base';
},
setShouldShowCheckboardTransparency: (
state,
action: PayloadAction<boolean>
) => {
state[state.currentCanvas].shouldShowCheckboardTransparency =
action.payload;
},
setShouldShowBrushPreview: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldShowBrushPreview = action.payload;
},
setShouldShowBrush: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldShowBrush = action.payload;
},
setCursorPosition: (state, action: PayloadAction<Vector2d | null>) => {
state[state.currentCanvas].cursorPosition = action.payload;
},
clearImageToInpaint: (state) => {
state.inpainting.imageToInpaint = undefined;
},
setImageToOutpaint: (state, action: PayloadAction<InvokeAI.Image>) => {
const { width: canvasWidth, height: canvasHeight } =
state.outpainting.stageDimensions;
const { width, height } = state.outpainting.boundingBoxDimensions;
const { x, y } = state.outpainting.boundingBoxCoordinates;
const maxWidth = Math.min(action.payload.width, canvasWidth);
const maxHeight = Math.min(action.payload.height, canvasHeight);
const newCoordinates: Vector2d = { x, y };
const newDimensions: Dimensions = { width, height };
if (width + x > maxWidth) {
// Bounding box at least needs to be translated
if (width > maxWidth) {
// Bounding box also needs to be resized
newDimensions.width = roundDownToMultiple(maxWidth, 64);
}
newCoordinates.x = maxWidth - newDimensions.width;
}
if (height + y > maxHeight) {
// Bounding box at least needs to be translated
if (height > maxHeight) {
// Bounding box also needs to be resized
newDimensions.height = roundDownToMultiple(maxHeight, 64);
}
newCoordinates.y = maxHeight - newDimensions.height;
}
state.outpainting.boundingBoxDimensions = newDimensions;
state.outpainting.boundingBoxCoordinates = newCoordinates;
// state.outpainting.imageToInpaint = action.payload;
state.outpainting.objects = [
{
kind: 'image',
layer: 'base',
x: 0,
y: 0,
image: action.payload,
},
];
state.doesCanvasNeedScaling = true;
},
setImageToInpaint: (state, action: PayloadAction<InvokeAI.Image>) => {
const { width: canvasWidth, height: canvasHeight } =
state.inpainting.stageDimensions;
const { width, height } = state.inpainting.boundingBoxDimensions;
const { x, y } = state.inpainting.boundingBoxCoordinates;
const maxWidth = Math.min(action.payload.width, canvasWidth);
const maxHeight = Math.min(action.payload.height, canvasHeight);
const newCoordinates: Vector2d = { x, y };
const newDimensions: Dimensions = { width, height };
if (width + x > maxWidth) {
// Bounding box at least needs to be translated
if (width > maxWidth) {
// Bounding box also needs to be resized
newDimensions.width = roundDownToMultiple(maxWidth, 64);
}
newCoordinates.x = maxWidth - newDimensions.width;
}
if (height + y > maxHeight) {
// Bounding box at least needs to be translated
if (height > maxHeight) {
// Bounding box also needs to be resized
newDimensions.height = roundDownToMultiple(maxHeight, 64);
}
newCoordinates.y = maxHeight - newDimensions.height;
}
state.inpainting.boundingBoxDimensions = newDimensions;
state.inpainting.boundingBoxCoordinates = newCoordinates;
state.inpainting.imageToInpaint = action.payload;
state.doesCanvasNeedScaling = true;
},
setStageDimensions: (state, action: PayloadAction<Dimensions>) => {
state[state.currentCanvas].stageDimensions = action.payload;
const { width: canvasWidth, height: canvasHeight } = action.payload;
const { width: boundingBoxWidth, height: boundingBoxHeight } =
state[state.currentCanvas].boundingBoxDimensions;
const newBoundingBoxWidth = roundDownToMultiple(
_.clamp(
boundingBoxWidth,
64,
canvasWidth / state[state.currentCanvas].stageScale
),
64
);
const newBoundingBoxHeight = roundDownToMultiple(
_.clamp(
boundingBoxHeight,
64,
canvasHeight / state[state.currentCanvas].stageScale
),
64
);
state[state.currentCanvas].boundingBoxDimensions = {
width: newBoundingBoxWidth,
height: newBoundingBoxHeight,
};
},
setBoundingBoxDimensions: (state, action: PayloadAction<Dimensions>) => {
state[state.currentCanvas].boundingBoxDimensions = action.payload;
const { width: boundingBoxWidth, height: boundingBoxHeight } =
action.payload;
const { x: boundingBoxX, y: boundingBoxY } =
state[state.currentCanvas].boundingBoxCoordinates;
const { width: canvasWidth, height: canvasHeight } =
state[state.currentCanvas].stageDimensions;
const scaledCanvasWidth =
canvasWidth / state[state.currentCanvas].stageScale;
const scaledCanvasHeight =
canvasHeight / state[state.currentCanvas].stageScale;
const roundedCanvasWidth = roundDownToMultiple(scaledCanvasWidth, 64);
const roundedCanvasHeight = roundDownToMultiple(scaledCanvasHeight, 64);
const roundedBoundingBoxWidth = roundDownToMultiple(boundingBoxWidth, 64);
const roundedBoundingBoxHeight = roundDownToMultiple(
boundingBoxHeight,
64
);
const overflowX = boundingBoxX + boundingBoxWidth - scaledCanvasWidth;
const overflowY = boundingBoxY + boundingBoxHeight - scaledCanvasHeight;
const newBoundingBoxWidth = _.clamp(
roundedBoundingBoxWidth,
64,
roundedCanvasWidth
);
const newBoundingBoxHeight = _.clamp(
roundedBoundingBoxHeight,
64,
roundedCanvasHeight
);
const overflowCorrectedX =
overflowX > 0 ? boundingBoxX - overflowX : boundingBoxX;
const overflowCorrectedY =
overflowY > 0 ? boundingBoxY - overflowY : boundingBoxY;
const clampedX = _.clamp(
overflowCorrectedX,
state[state.currentCanvas].stageCoordinates.x,
roundedCanvasWidth - newBoundingBoxWidth
);
const clampedY = _.clamp(
overflowCorrectedY,
state[state.currentCanvas].stageCoordinates.y,
roundedCanvasHeight - newBoundingBoxHeight
);
state[state.currentCanvas].boundingBoxDimensions = {
width: newBoundingBoxWidth,
height: newBoundingBoxHeight,
};
state[state.currentCanvas].boundingBoxCoordinates = {
x: clampedX,
y: clampedY,
};
},
setBoundingBoxCoordinates: (state, action: PayloadAction<Vector2d>) => {
state[state.currentCanvas].boundingBoxCoordinates = action.payload;
},
setStageCoordinates: (state, action: PayloadAction<Vector2d>) => {
state[state.currentCanvas].stageCoordinates = action.payload;
},
setBoundingBoxPreviewFill: (state, action: PayloadAction<RgbaColor>) => {
state[state.currentCanvas].boundingBoxPreviewFill = action.payload;
},
setDoesCanvasNeedScaling: (state, action: PayloadAction<boolean>) => {
state.doesCanvasNeedScaling = action.payload;
},
setStageScale: (state, action: PayloadAction<number>) => {
state[state.currentCanvas].stageScale = action.payload;
state.doesCanvasNeedScaling = false;
},
setShouldDarkenOutsideBoundingBox: (
state,
action: PayloadAction<boolean>
) => {
state[state.currentCanvas].shouldDarkenOutsideBoundingBox =
action.payload;
},
setIsDrawing: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isDrawing = action.payload;
},
setClearBrushHistory: (state) => {
state[state.currentCanvas].pastObjects = [];
state[state.currentCanvas].futureObjects = [];
},
setShouldUseInpaintReplace: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldUseInpaintReplace = action.payload;
},
setInpaintReplace: (state, action: PayloadAction<number>) => {
state[state.currentCanvas].inpaintReplace = action.payload;
},
setShouldLockBoundingBox: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldLockBoundingBox = action.payload;
},
toggleShouldLockBoundingBox: (state) => {
state[state.currentCanvas].shouldLockBoundingBox =
!state[state.currentCanvas].shouldLockBoundingBox;
},
setShouldShowBoundingBox: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldShowBoundingBox = action.payload;
},
setIsTransformingBoundingBox: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isTransformingBoundingBox = action.payload;
},
setIsMovingBoundingBox: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMovingBoundingBox = action.payload;
},
setIsMouseOverBoundingBox: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMouseOverBoundingBox = action.payload;
},
setIsMoveBoundingBoxKeyHeld: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMoveBoundingBoxKeyHeld = action.payload;
},
setIsMoveStageKeyHeld: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMoveStageKeyHeld = action.payload;
},
setCurrentCanvas: (state, action: PayloadAction<ValidCanvasName>) => {
state.currentCanvas = action.payload;
},
addImageToOutpaintingSesion: (
state,
action: PayloadAction<{
boundingBox: IRect;
image: InvokeAI.Image;
}>
) => {
const { boundingBox, image } = action.payload;
if (!boundingBox || !image) return;
const { x, y } = boundingBox;
state.outpainting.pastObjects.push([...state.outpainting.objects]);
state.outpainting.futureObjects = [];
state.outpainting.objects.push({
kind: 'image',
layer: 'base',
x,
y,
image,
});
},
addLine: (state, action: PayloadAction<number[]>) => {
const { tool, layer, brushColor, brushSize, eraserSize } =
state[state.currentCanvas];
if (tool === 'move') return;
state[state.currentCanvas].pastObjects.push(
state[state.currentCanvas].objects
);
state[state.currentCanvas].objects.push({
kind: 'line',
layer,
tool,
strokeWidth: tool === 'brush' ? brushSize / 2 : eraserSize / 2,
points: action.payload,
...(layer === 'base' && tool === 'brush' && { color: brushColor }),
});
state[state.currentCanvas].futureObjects = [];
},
addPointToCurrentLine: (state, action: PayloadAction<number[]>) => {
const lastLine =
state[state.currentCanvas].objects.findLast(isCanvasAnyLine);
if (!lastLine) return;
lastLine.points.push(...action.payload);
},
undo: (state) => {
if (state.outpainting.objects.length === 0) return;
const newObjects = state.outpainting.pastObjects.pop();
if (!newObjects) return;
state.outpainting.futureObjects.unshift(state.outpainting.objects);
state.outpainting.objects = newObjects;
},
redo: (state) => {
if (state.outpainting.futureObjects.length === 0) return;
const newObjects = state.outpainting.futureObjects.shift();
if (!newObjects) return;
state.outpainting.pastObjects.push(state.outpainting.objects);
state.outpainting.objects = newObjects;
},
setShouldShowGrid: (state, action: PayloadAction<boolean>) => {
state.outpainting.shouldShowGrid = action.payload;
},
setIsMovingStage: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMovingStage = action.payload;
},
setShouldSnapToGrid: (state, action: PayloadAction<boolean>) => {
state.outpainting.shouldSnapToGrid = action.payload;
},
setShouldAutoSave: (state, action: PayloadAction<boolean>) => {
state.outpainting.shouldAutoSave = action.payload;
},
setShouldShowIntermediates: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldShowIntermediates = action.payload;
},
resetCanvas: (state) => {
state[state.currentCanvas].pastObjects.push(
state[state.currentCanvas].objects
);
state[state.currentCanvas].objects = [];
state[state.currentCanvas].futureObjects = [];
},
},
extraReducers: (builder) => {
builder.addCase(uploadOutpaintingMergedImage.fulfilled, (state, action) => {
if (!action.payload) return;
state.outpainting.pastObjects.push([...state.outpainting.objects]);
state.outpainting.futureObjects = [];
state.outpainting.objects = [
{
kind: 'image',
layer: 'base',
...action.payload,
},
];
});
},
});
export const {
setTool,
setLayer,
setBrushColor,
setBrushSize,
setEraserSize,
addLine,
addPointToCurrentLine,
setShouldInvertMask,
setIsMaskEnabled,
setShouldShowCheckboardTransparency,
setShouldShowBrushPreview,
setMaskColor,
clearMask,
clearImageToInpaint,
undo,
redo,
setCursorPosition,
setStageDimensions,
setImageToInpaint,
setImageToOutpaint,
setBoundingBoxDimensions,
setBoundingBoxCoordinates,
setBoundingBoxPreviewFill,
setDoesCanvasNeedScaling,
setStageScale,
toggleTool,
setShouldShowBoundingBox,
setShouldDarkenOutsideBoundingBox,
setIsDrawing,
setShouldShowBrush,
setClearBrushHistory,
setShouldUseInpaintReplace,
setInpaintReplace,
setShouldLockBoundingBox,
toggleShouldLockBoundingBox,
setIsMovingBoundingBox,
setIsTransformingBoundingBox,
setIsMouseOverBoundingBox,
setIsMoveBoundingBoxKeyHeld,
setIsMoveStageKeyHeld,
setStageCoordinates,
setCurrentCanvas,
addImageToOutpaintingSesion,
resetCanvas,
setShouldShowGrid,
setShouldSnapToGrid,
setShouldAutoSave,
setShouldShowIntermediates,
setIsMovingStage,
} = canvasSlice.actions;
export default canvasSlice.reducer;
export const uploadOutpaintingMergedImage = createAsyncThunk(
'canvas/uploadOutpaintingMergedImage',
async (
canvasImageLayerRef: MutableRefObject<Konva.Layer | null>,
thunkAPI
) => {
const { getState } = thunkAPI;
const state = getState() as RootState;
const stageScale = state.canvas.outpainting.stageScale;
if (!canvasImageLayerRef.current) return;
const tempScale = canvasImageLayerRef.current.scale();
const { x: relativeX, y: relativeY } =
canvasImageLayerRef.current.getClientRect({
relativeTo: canvasImageLayerRef.current.getParent(),
});
canvasImageLayerRef.current.scale({
x: 1 / stageScale,
y: 1 / stageScale,
});
const clientRect = canvasImageLayerRef.current.getClientRect();
const imageDataURL = canvasImageLayerRef.current.toDataURL(clientRect);
canvasImageLayerRef.current.scale(tempScale);
if (!imageDataURL) return;
const response = await fetch(window.location.origin + '/upload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
dataURL: imageDataURL,
name: 'outpaintingmerge.png',
}),
});
const data = (await response.json()) as InvokeAI.ImageUploadResponse;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { destination, ...rest } = data;
const image = {
uuid: uuidv4(),
...rest,
};
return {
image,
x: relativeX,
y: relativeY,
};
}
);
export const currentCanvasSelector = (state: RootState): BaseCanvasState =>
state.canvas[state.canvas.currentCanvas];
export const outpaintingCanvasSelector = (
state: RootState
): OutpaintingCanvasState => state.canvas.outpainting;
export const inpaintingCanvasSelector = (
state: RootState
): InpaintingCanvasState => state.canvas.inpainting;
export const baseCanvasImageSelector = createSelector(
[(state: RootState) => state.canvas, activeTabNameSelector],
(canvas: CanvasState, activeTabName) => {
if (activeTabName === 'inpainting') {
return canvas.inpainting.imageToInpaint;
} else if (activeTabName === 'outpainting') {
const firstImageObject = canvas.outpainting.objects.find(
(obj) => obj.kind === 'image'
);
if (firstImageObject && firstImageObject.kind === 'image') {
return firstImageObject.image;
}
}
}
);

View File

@ -0,0 +1,49 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { KonvaEventObject } from 'konva/lib/Node';
import _ from 'lodash';
import { useCallback } from 'react';
import {
currentCanvasSelector,
setIsMovingStage,
setStageCoordinates,
} from '../canvasSlice';
const selector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(canvas, activeTabName) => {
const { tool } = canvas;
return {
tool,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasDrag = () => {
const dispatch = useAppDispatch();
const { tool, activeTabName } = useAppSelector(selector);
return {
handleDragStart: useCallback(() => {
if (tool !== 'move' || activeTabName !== 'outpainting') return;
dispatch(setIsMovingStage(true));
}, [activeTabName, dispatch, tool]),
handleDragMove: useCallback(
(e: KonvaEventObject<MouseEvent>) => {
if (tool !== 'move' || activeTabName !== 'outpainting') return;
dispatch(setStageCoordinates(e.target.getPosition()));
},
[activeTabName, dispatch, tool]
),
handleDragEnd: useCallback(() => {
if (tool !== 'move' || activeTabName !== 'outpainting') return;
dispatch(setIsMovingStage(false));
}, [activeTabName, dispatch, tool]),
};
};
export default useCanvasDrag;

View File

@ -0,0 +1,111 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { useHotkeys } from 'react-hotkeys-hook';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { OptionsState } from 'features/options/optionsSlice';
import {
CanvasTool,
setShouldShowBoundingBox,
setTool,
toggleShouldLockBoundingBox,
} from 'features/canvas/canvasSlice';
import { RootState, useAppDispatch, useAppSelector } from 'app/store';
import { currentCanvasSelector, GenericCanvasState } from '../canvasSlice';
import { useRef } from 'react';
const inpaintingCanvasHotkeysSelector = createSelector(
[
(state: RootState) => state.options,
currentCanvasSelector,
activeTabNameSelector,
],
(options: OptionsState, currentCanvas: GenericCanvasState, activeTabName) => {
const {
isMaskEnabled,
cursorPosition,
shouldLockBoundingBox,
shouldShowBoundingBox,
tool,
} = currentCanvas;
return {
activeTabName,
isMaskEnabled,
isCursorOnCanvas: Boolean(cursorPosition),
shouldLockBoundingBox,
shouldShowBoundingBox,
tool,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const useInpaintingCanvasHotkeys = () => {
const dispatch = useAppDispatch();
const { isMaskEnabled, activeTabName, shouldShowBoundingBox, tool } =
useAppSelector(inpaintingCanvasHotkeysSelector);
const previousToolRef = useRef<CanvasTool | null>(null);
// Toggle lock bounding box
useHotkeys(
'shift+w',
(e: KeyboardEvent) => {
e.preventDefault();
dispatch(toggleShouldLockBoundingBox());
},
{
enabled: true,
},
[activeTabName]
);
useHotkeys(
'shift+h',
(e: KeyboardEvent) => {
e.preventDefault();
dispatch(setShouldShowBoundingBox(!shouldShowBoundingBox));
},
{
enabled: true,
},
[activeTabName, shouldShowBoundingBox]
);
useHotkeys(
['space'],
(e: KeyboardEvent) => {
if (e.repeat) return;
if (tool !== 'move') {
previousToolRef.current = tool;
dispatch(setTool('move'));
}
},
{ keyup: false, keydown: true },
[tool, previousToolRef]
);
useHotkeys(
['space'],
(e: KeyboardEvent) => {
if (e.repeat) return;
if (
tool === 'move' &&
previousToolRef.current &&
previousToolRef.current !== 'move'
) {
dispatch(setTool(previousToolRef.current));
previousToolRef.current = 'move';
}
},
{ keyup: true, keydown: false },
[tool, previousToolRef]
);
};
export default useInpaintingCanvasHotkeys;

View File

@ -0,0 +1,56 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import Konva from 'konva';
import { KonvaEventObject } from 'konva/lib/Node';
import _ from 'lodash';
import { MutableRefObject, useCallback } from 'react';
import {
addLine,
currentCanvasSelector,
setIsDrawing,
setIsMovingStage,
} from '../canvasSlice';
import getScaledCursorPosition from '../util/getScaledCursorPosition';
const selector = createSelector(
[activeTabNameSelector, currentCanvasSelector],
(activeTabName, currentCanvas) => {
const { tool } = currentCanvas;
return {
tool,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasMouseDown = (stageRef: MutableRefObject<Konva.Stage | null>) => {
const dispatch = useAppDispatch();
const { tool } = useAppSelector(selector);
return useCallback(
(e: KonvaEventObject<MouseEvent>) => {
if (!stageRef.current) return;
if (tool === 'move') {
dispatch(setIsMovingStage(true));
return;
}
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition) return;
e.evt.preventDefault();
dispatch(setIsDrawing(true));
// Add a new line starting from the current cursor position.
dispatch(addLine([scaledCursorPosition.x, scaledCursorPosition.y]));
},
[stageRef, dispatch, tool]
);
};
export default useCanvasMouseDown;

View File

@ -0,0 +1,48 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import Konva from 'konva';
import { KonvaEventObject } from 'konva/lib/Node';
import _ from 'lodash';
import { MutableRefObject, useCallback } from 'react';
import { addLine, currentCanvasSelector, setIsDrawing } from '../canvasSlice';
import getScaledCursorPosition from '../util/getScaledCursorPosition';
const selector = createSelector(
[activeTabNameSelector, currentCanvasSelector],
(activeTabName, currentCanvas) => {
const { tool } = currentCanvas;
return {
tool,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasMouseEnter = (
stageRef: MutableRefObject<Konva.Stage | null>
) => {
const dispatch = useAppDispatch();
const { tool } = useAppSelector(selector);
return useCallback(
(e: KonvaEventObject<MouseEvent>) => {
if (e.evt.buttons !== 1) return;
if (!stageRef.current) return;
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition || tool === 'move') return;
dispatch(setIsDrawing(true));
// Add a new line starting from the current cursor position.
dispatch(addLine([scaledCursorPosition.x, scaledCursorPosition.y]));
},
[stageRef, tool, dispatch]
);
};
export default useCanvasMouseEnter;

View File

@ -0,0 +1,64 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import Konva from 'konva';
import { Vector2d } from 'konva/lib/types';
import _ from 'lodash';
import { MutableRefObject, useCallback } from 'react';
import {
addPointToCurrentLine,
currentCanvasSelector,
GenericCanvasState,
setCursorPosition,
} from '../canvasSlice';
import getScaledCursorPosition from '../util/getScaledCursorPosition';
const selector = createSelector(
[activeTabNameSelector, currentCanvasSelector],
(activeTabName, canvas: GenericCanvasState) => {
const { tool, isDrawing } = canvas;
return {
tool,
isDrawing,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasMouseMove = (
stageRef: MutableRefObject<Konva.Stage | null>,
didMouseMoveRef: MutableRefObject<boolean>,
lastCursorPositionRef: MutableRefObject<Vector2d>
) => {
const dispatch = useAppDispatch();
const { isDrawing, tool } = useAppSelector(selector);
return useCallback(() => {
if (!stageRef.current) return;
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition) return;
dispatch(setCursorPosition(scaledCursorPosition));
lastCursorPositionRef.current = scaledCursorPosition;
if (!isDrawing || tool === 'move') return;
didMouseMoveRef.current = true;
dispatch(
addPointToCurrentLine([scaledCursorPosition.x, scaledCursorPosition.y])
);
}, [
didMouseMoveRef,
dispatch,
isDrawing,
lastCursorPositionRef,
stageRef,
tool,
]);
};
export default useCanvasMouseMove;

View File

@ -0,0 +1,15 @@
import { useAppDispatch } from 'app/store';
import _ from 'lodash';
import { useCallback } from 'react';
import { setCursorPosition, setIsDrawing } from '../canvasSlice';
const useCanvasMouseOut = () => {
const dispatch = useAppDispatch();
return useCallback(() => {
dispatch(setCursorPosition(null));
dispatch(setIsDrawing(false));
}, [dispatch]);
};
export default useCanvasMouseOut;

View File

@ -0,0 +1,64 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import Konva from 'konva';
import _ from 'lodash';
import { MutableRefObject, useCallback } from 'react';
import {
// addPointToCurrentEraserLine,
addPointToCurrentLine,
currentCanvasSelector,
GenericCanvasState,
setIsDrawing,
setIsMovingStage,
} from '../canvasSlice';
import getScaledCursorPosition from '../util/getScaledCursorPosition';
const selector = createSelector(
[activeTabNameSelector, currentCanvasSelector],
(activeTabName, canvas: GenericCanvasState) => {
const { tool, isDrawing } = canvas;
return {
tool,
isDrawing,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasMouseUp = (
stageRef: MutableRefObject<Konva.Stage | null>,
didMouseMoveRef: MutableRefObject<boolean>
) => {
const dispatch = useAppDispatch();
const { tool, isDrawing } = useAppSelector(selector);
return useCallback(() => {
if (tool === 'move') {
dispatch(setIsMovingStage(false));
return;
}
if (!didMouseMoveRef.current && isDrawing && stageRef.current) {
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition) return;
/**
* Extend the current line.
* In this case, the mouse didn't move, so we append the same point to
* the line's existing points. This allows the line to render as a circle
* centered on that point.
*/
dispatch(
addPointToCurrentLine([scaledCursorPosition.x, scaledCursorPosition.y])
);
} else {
didMouseMoveRef.current = false;
}
dispatch(setIsDrawing(false));
}, [didMouseMoveRef, dispatch, isDrawing, stageRef, tool]);
};
export default useCanvasMouseUp;

View File

@ -0,0 +1,83 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import Konva from 'konva';
import { KonvaEventObject } from 'konva/lib/Node';
import _ from 'lodash';
import { MutableRefObject, useCallback } from 'react';
import {
currentCanvasSelector,
GenericCanvasState,
setStageCoordinates,
setStageScale,
} from '../canvasSlice';
import {
CANVAS_SCALE_BY,
MAX_CANVAS_SCALE,
MIN_CANVAS_SCALE,
} from '../util/constants';
const selector = createSelector(
[activeTabNameSelector, currentCanvasSelector],
(activeTabName, canvas: GenericCanvasState) => {
const { isMoveStageKeyHeld, stageScale } = canvas;
return {
isMoveStageKeyHeld,
stageScale,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasWheel = (stageRef: MutableRefObject<Konva.Stage | null>) => {
const dispatch = useAppDispatch();
const { isMoveStageKeyHeld, stageScale, activeTabName } =
useAppSelector(selector);
return useCallback(
(e: KonvaEventObject<WheelEvent>) => {
// stop default scrolling
if (activeTabName !== 'outpainting') return;
e.evt.preventDefault();
// const oldScale = stageRef.current.scaleX();
if (!stageRef.current || isMoveStageKeyHeld) return;
const cursorPos = stageRef.current.getPointerPosition();
if (!cursorPos) return;
const mousePointTo = {
x: (cursorPos.x - stageRef.current.x()) / stageScale,
y: (cursorPos.y - stageRef.current.y()) / stageScale,
};
let delta = e.evt.deltaY;
// when we zoom on trackpad, e.evt.ctrlKey is true
// in that case lets revert direction
if (e.evt.ctrlKey) {
delta = -delta;
}
const newScale = _.clamp(
stageScale * CANVAS_SCALE_BY ** delta,
MIN_CANVAS_SCALE,
MAX_CANVAS_SCALE
);
const newPos = {
x: cursorPos.x - mousePointTo.x * newScale,
y: cursorPos.y - mousePointTo.y * newScale,
};
dispatch(setStageScale(newScale));
dispatch(setStageCoordinates(newPos));
},
[activeTabName, dispatch, isMoveStageKeyHeld, stageRef, stageScale]
);
};
export default useCanvasWheel;

View File

@ -0,0 +1,23 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store';
import _ from 'lodash';
import { currentCanvasSelector, GenericCanvasState } from '../canvasSlice';
const selector = createSelector(
[currentCanvasSelector],
(currentCanvas: GenericCanvasState) => {
return currentCanvas.stageScale;
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const useUnscaleCanvasValue = () => {
const stageScale = useAppSelector(selector);
return (value: number) => value / stageScale;
};
export default useUnscaleCanvasValue;

View File

@ -7,4 +7,8 @@ export const MARCHING_ANTS_SPEED = 30;
// bounding box anchor size
export const TRANSFORMER_ANCHOR_SIZE = 15;
export const CANVAS_SCALE_BY = 0.999;
export const MIN_CANVAS_SCALE = 0.1
export const MAX_CANVAS_SCALE = 20

View File

@ -0,0 +1,64 @@
import Konva from 'konva';
import { IRect } from 'konva/lib/types';
import { CanvasMaskLine } from 'features/canvas/canvasSlice';
/**
* Generating a mask image from InpaintingCanvas.tsx is not as simple
* as calling toDataURL() on the canvas, because the mask may be represented
* by colored lines or transparency, or the user may have inverted the mask
* display.
*
* So we need to regenerate the mask image by creating an offscreen canvas,
* drawing the mask and compositing everything correctly to output a valid
* mask image.
*/
const generateMask = (lines: CanvasMaskLine[], boundingBox: IRect): string => {
// create an offscreen canvas and add the mask to it
const { width, height } = boundingBox;
const offscreenContainer = document.createElement('div');
const stage = new Konva.Stage({
container: offscreenContainer,
width: width,
height: height,
});
const baseLayer = new Konva.Layer();
const maskLayer = new Konva.Layer();
// composite the image onto the mask layer
baseLayer.add(
new Konva.Rect({
...boundingBox,
fill: 'white',
})
);
lines.forEach((line) =>
maskLayer.add(
new Konva.Line({
points: line.points,
stroke: 'black',
strokeWidth: line.strokeWidth * 2,
tension: 0,
lineCap: 'round',
lineJoin: 'round',
shadowForStrokeEnabled: false,
globalCompositeOperation:
line.tool === 'brush' ? 'source-over' : 'destination-out',
})
)
);
stage.add(baseLayer);
stage.add(maskLayer);
const dataURL = stage.toDataURL({ ...boundingBox });
offscreenContainer.remove();
return dataURL;
};
export default generateMask;

View File

@ -13,11 +13,18 @@
max-width: 25rem;
}
.current-image-send-to-popover {
.invokeai__button {
place-content: start;
}
}
.chakra-popover__popper {
z-index: 11;
}
.delete-image-btn {
background-color: var(--btn-base-color);
svg {
fill: var(--btn-delete-image);
}

View File

@ -1,24 +1,25 @@
import { createSelector } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { RootState } from '../../app/store';
import { useAppDispatch, useAppSelector } from 'app/store';
import { RootState } from 'app/store';
import {
OptionsState,
setActiveTab,
setAllParameters,
setInitialImage,
setIsLightBoxOpen,
setPrompt,
setSeed,
setShouldShowImageDetails,
} from '../options/optionsSlice';
} from 'features/options/optionsSlice';
import DeleteImageModal from './DeleteImageModal';
import { SystemState } from '../system/systemSlice';
import IAIButton from '../../common/components/IAIButton';
import { runESRGAN, runFacetool } from '../../app/socketio/actions';
import IAIIconButton from '../../common/components/IAIIconButton';
import UpscaleOptions from '../options/AdvancedOptions/Upscale/UpscaleOptions';
import FaceRestoreOptions from '../options/AdvancedOptions/FaceRestore/FaceRestoreOptions';
import { SystemState } from 'features/system/systemSlice';
import IAIButton from 'common/components/IAIButton';
import { runESRGAN, runFacetool } from 'app/socketio/actions';
import IAIIconButton from 'common/components/IAIIconButton';
import UpscaleOptions from 'features/options/AdvancedOptions/Upscale/UpscaleOptions';
import FaceRestoreOptions from 'features/options/AdvancedOptions/FaceRestore/FaceRestoreOptions';
import { useHotkeys } from 'react-hotkeys-hook';
import { ButtonGroup, Link, useClipboard, useToast } from '@chakra-ui/react';
import {
@ -36,11 +37,12 @@ import {
} from 'react-icons/fa';
import {
setImageToInpaint,
setNeedsCache,
} from '../tabs/Inpainting/inpaintingSlice';
setDoesCanvasNeedScaling,
setImageToOutpaint,
} from 'features/canvas/canvasSlice';
import { GalleryState } from './gallerySlice';
import { activeTabNameSelector } from '../options/optionsSelectors';
import IAIPopover from '../../common/components/IAIPopover';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import IAIPopover from 'common/components/IAIPopover';
const systemSelector = createSelector(
[
@ -58,8 +60,12 @@ const systemSelector = createSelector(
const { isProcessing, isConnected, isGFPGANAvailable, isESRGANAvailable } =
system;
const { upscalingLevel, facetoolStrength, shouldShowImageDetails } =
options;
const {
upscalingLevel,
facetoolStrength,
shouldShowImageDetails,
isLightBoxOpen,
} = options;
const { intermediateImage, currentImage } = gallery;
@ -74,6 +80,7 @@ const systemSelector = createSelector(
currentImage,
shouldShowImageDetails,
activeTabName,
isLightBoxOpen,
};
},
{
@ -99,28 +106,31 @@ const CurrentImageButtons = () => {
shouldDisableToolbarButtons,
shouldShowImageDetails,
currentImage,
isLightBoxOpen,
} = useAppSelector(systemSelector);
const { onCopy } = useClipboard(
currentImage ? window.location.toString() + currentImage.url : ''
);
const toast = useToast();
const handleClickUseAsInitialImage = () => {
if (!currentImage) return;
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setInitialImage(currentImage));
dispatch(setActiveTab('img2img'));
};
const handleCopyImageLink = () => {
onCopy();
toast({
title: 'Image Link Copied',
status: 'success',
duration: 2500,
isClosable: true,
});
navigator.clipboard
.writeText(
currentImage ? window.location.toString() + currentImage.url : ''
)
.then(() => {
toast({
title: 'Image Link Copied',
status: 'success',
duration: 2500,
isClosable: true,
});
});
};
useHotkeys(
@ -308,11 +318,27 @@ const CurrentImageButtons = () => {
const handleSendToInpainting = () => {
if (!currentImage) return;
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setImageToInpaint(currentImage));
dispatch(setActiveTab('inpainting'));
dispatch(setNeedsCache(true));
dispatch(setDoesCanvasNeedScaling(true));
toast({
title: 'Sent to Inpainting',
status: 'success',
duration: 2500,
isClosable: true,
});
};
const handleSendToOutpainting = () => {
if (!currentImage) return;
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setImageToOutpaint(currentImage));
dispatch(setActiveTab('outpainting'));
dispatch(setDoesCanvasNeedScaling(true));
toast({
title: 'Sent to Inpainting',
@ -363,6 +389,13 @@ const CurrentImageButtons = () => {
>
Send to Inpainting
</IAIButton>
<IAIButton
size={'sm'}
onClick={handleSendToOutpainting}
leftIcon={<FaShare />}
>
Send to Outpainting
</IAIButton>
<IAIButton
size={'sm'}
onClick={handleCopyImageLink}

View File

@ -25,6 +25,7 @@
max-height: 100%;
height: auto;
position: absolute;
cursor: pointer;
}
}

View File

@ -1,12 +1,12 @@
import { RootState, useAppSelector } from '../../app/store';
import { RootState, useAppSelector } from 'app/store';
import CurrentImageButtons from './CurrentImageButtons';
import { MdPhoto } from 'react-icons/md';
import CurrentImagePreview from './CurrentImagePreview';
import { GalleryState } from './gallerySlice';
import { OptionsState } from '../options/optionsSlice';
import { OptionsState } from 'features/options/optionsSlice';
import _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { activeTabNameSelector } from '../options/optionsSelectors';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
export const currentImageDisplaySelector = createSelector(
[

View File

@ -1,7 +1,7 @@
import { IconButton, Image, Spinner } from '@chakra-ui/react';
import { useState } from 'react';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { RootState, useAppDispatch, useAppSelector } from '../../app/store';
import { RootState, useAppDispatch, useAppSelector } from 'app/store';
import {
GalleryCategory,
GalleryState,
@ -10,7 +10,7 @@ import {
} from './gallerySlice';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { OptionsState } from '../options/optionsSlice';
import { OptionsState, setIsLightBoxOpen } from 'features/options/optionsSlice';
import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer';
export const imagesSelector = createSelector(
@ -76,6 +76,10 @@ export default function CurrentImagePreview() {
dispatch(selectNextImage());
};
const handleLightBox = () => {
dispatch(setIsLightBoxOpen(true));
};
return (
<div className={'current-image-preview'}>
{imageToDisplay && (
@ -83,6 +87,7 @@ export default function CurrentImagePreview() {
src={imageToDisplay.url}
width={imageToDisplay.width}
height={imageToDisplay.height}
onClick={handleLightBox}
/>
)}
{!shouldShowImageDetails && (

View File

@ -22,11 +22,11 @@ import {
SyntheticEvent,
useRef,
} from 'react';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { deleteImage } from '../../app/socketio/actions';
import { RootState } from '../../app/store';
import { setShouldConfirmOnDelete, SystemState } from '../system/systemSlice';
import * as InvokeAI from '../../app/invokeai';
import { useAppDispatch, useAppSelector } from 'app/store';
import { deleteImage } from 'app/socketio/actions';
import { RootState } from 'app/store';
import { setShouldConfirmOnDelete, SystemState } from 'features/system/systemSlice';
import * as InvokeAI from 'app/invokeai';
import { useHotkeys } from 'react-hotkeys-hook';
import _ from 'lodash';

View File

@ -6,7 +6,7 @@ import {
Tooltip,
useToast,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { useAppDispatch, useAppSelector } from 'app/store';
import { setCurrentImage } from './gallerySlice';
import { FaCheck, FaTrashAlt } from 'react-icons/fa';
import DeleteImageModal from './DeleteImageModal';
@ -16,12 +16,16 @@ import {
setAllImageToImageParameters,
setAllTextToImageParameters,
setInitialImage,
setIsLightBoxOpen,
setPrompt,
setSeed,
} from '../options/optionsSlice';
import * as InvokeAI from '../../app/invokeai';
} from 'features/options/optionsSlice';
import * as InvokeAI from 'app/invokeai';
import * as ContextMenu from '@radix-ui/react-context-menu';
import { setImageToInpaint } from '../tabs/Inpainting/inpaintingSlice';
import {
setImageToInpaint,
setImageToOutpaint,
} from 'features/canvas/canvasSlice';
import { hoverableImageSelector } from './gallerySliceSelectors';
interface HoverableImageProps {
@ -44,6 +48,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
galleryImageObjectFit,
galleryImageMinimumWidth,
mayDeleteImage,
isLightBoxOpen,
} = useAppSelector(hoverableImageSelector);
const { image, isSelected } = props;
const { url, uuid, metadata } = image;
@ -77,6 +82,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
};
const handleSendToImageToImage = () => {
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setInitialImage(image));
if (activeTabName !== 'img2img') {
dispatch(setActiveTab('img2img'));
@ -90,6 +96,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
};
const handleSendToInpainting = () => {
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setImageToInpaint(image));
if (activeTabName !== 'inpainting') {
dispatch(setActiveTab('inpainting'));
@ -102,6 +109,20 @@ const HoverableImage = memo((props: HoverableImageProps) => {
});
};
const handleSendToOutpainting = () => {
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setImageToOutpaint(image));
if (activeTabName !== 'outpainting') {
dispatch(setActiveTab('outpainting'));
}
toast({
title: 'Sent to Outpainting',
status: 'success',
duration: 2500,
isClosable: true,
});
};
const handleUseAllParameters = () => {
metadata && dispatch(setAllTextToImageParameters(metadata));
toast({
@ -228,6 +249,9 @@ const HoverableImage = memo((props: HoverableImageProps) => {
<ContextMenu.Item onClickCapture={handleSendToInpainting}>
Send to Inpainting
</ContextMenu.Item>
<ContextMenu.Item onClickCapture={handleSendToOutpainting}>
Send to Outpainting
</ContextMenu.Item>
<DeleteImageModal image={image}>
<ContextMenu.Item data-warning>Delete Image</ContextMenu.Item>
</DeleteImageModal>

View File

@ -35,7 +35,7 @@
}
.image-gallery-popup {
background-color: var(--tab-color);
background-color: var(--background-color-secondary);
padding: 1rem;
display: flex;
flex-direction: column;
@ -55,16 +55,16 @@
column-gap: 0.5rem;
justify-content: space-between;
div {
.image-gallery-header-right-icons {
display: flex;
column-gap: 0.5rem;
flex-direction: row;
column-gap: 0.5rem;
}
.image-gallery-icon-btn {
background-color: var(--btn-load-more) !important;
background-color: var(--btn-load-more);
&:hover {
background-color: var(--btn-load-more-hover) !important;
background-color: var(--btn-load-more-hover);
}
}
@ -96,7 +96,8 @@
.image-gallery-container-placeholder {
display: flex;
flex-direction: column;
background-color: var(--background-color-secondary);
row-gap: 0.5rem;
background-color: var(--background-color);
border-radius: 0.5rem;
place-items: center;
padding: 2rem;
@ -108,26 +109,26 @@
}
svg {
width: 5rem;
height: 5rem;
width: 4rem;
height: 4rem;
color: var(--svg-color);
}
}
.image-gallery-load-more-btn {
background-color: var(--btn-load-more) !important;
font-size: 0.85rem !important;
background-color: var(--btn-load-more);
font-size: 0.85rem;
padding: 0.5rem;
margin-top: 1rem;
&:disabled {
&:hover {
background-color: var(--btn-load-more) !important;
background-color: var(--btn-load-more);
}
}
&:hover {
background-color: var(--btn-load-more-hover) !important;
background-color: var(--btn-load-more-hover);
}
}
}
@ -135,11 +136,15 @@
}
.image-gallery-category-btn-group {
width: 100% !important;
column-gap: 0 !important;
justify-content: stretch !important;
width: max-content;
column-gap: 0;
justify-content: stretch;
button {
background-color: var(--btn-base-color);
&:hover {
background-color: var(--btn-base-color-hover);
}
flex-grow: 1;
&[data-selected='true'] {
background-color: var(--accent-color);

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