mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
Rebases against development
This commit is contained in:
parent
248068fe5d
commit
6c7191712f
@ -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
|
||||
if "init_mask" in generation_parameters:
|
||||
# truncate long init_mask/init_img base64 if needed
|
||||
printable_parameters = {
|
||||
**generation_parameters,
|
||||
"init_mask": generation_parameters["init_mask"][:20] + "...",
|
||||
}
|
||||
|
||||
if "init_img" in generation_parameters:
|
||||
printable_parameters["init_img"] = (
|
||||
printable_parameters["init_img"][:64] + "..."
|
||||
)
|
||||
|
||||
if "init_mask" in generation_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}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f">> Image generation requested: {generation_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'
|
||||
# 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
|
||||
else:
|
||||
rfc_dict["type"] = "txt2img"
|
||||
# # 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)
|
||||
|
117
backend/modules/get_outpainting_generation_mode.py
Normal file
117
backend/modules/get_outpainting_generation_mode.py
Normal 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()
|
BIN
backend/modules/test_images/init-img_full_transparency.png
Normal file
BIN
backend/modules/test_images/init-img_full_transparency.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.7 KiB |
BIN
backend/modules/test_images/init-img_opaque.png
Normal file
BIN
backend/modules/test_images/init-img_opaque.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 292 KiB |
BIN
backend/modules/test_images/init-img_partial_transparency.png
Normal file
BIN
backend/modules/test_images/init-img_partial_transparency.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 164 KiB |
BIN
backend/modules/test_images/init-mask_has_mask.png
Normal file
BIN
backend/modules/test_images/init-mask_has_mask.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.5 KiB |
BIN
backend/modules/test_images/init-mask_no_mask.png
Normal file
BIN
backend/modules/test_images/init-mask_no_mask.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
@ -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/`.
|
||||
|
||||
|
23
frontend/eslintconfig.json
Normal file
23
frontend/eslintconfig.json
Normal 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"] }]
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,9 @@
|
||||
@use '../styles/Mixins/' as *;
|
||||
|
||||
svg {
|
||||
fill: var(--svg-color);
|
||||
}
|
||||
|
||||
.App {
|
||||
display: grid;
|
||||
width: 100vw;
|
||||
|
@ -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>
|
||||
|
@ -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> = [
|
||||
|
21
frontend/src/app/invokeai.d.ts
vendored
21
frontend/src/app/invokeai.d.ts
vendored
@ -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:
|
||||
@ -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;
|
||||
};
|
||||
|
@ -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) {
|
||||
|
@ -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';
|
||||
|
||||
|
||||
/**
|
||||
|
@ -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('...');
|
||||
}
|
||||
|
||||
|
@ -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(''));
|
||||
|
@ -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) => {
|
||||
socketio.on('imageUploaded', (data: InvokeAI.ImageUploadResponse) => {
|
||||
onImageUploaded(data);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
socketio.on('maskImageUploaded', (data: InvokeAI.ImageUrlResponse) => {
|
||||
onMaskImageUploaded(data);
|
||||
|
@ -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: [
|
||||
const inpaintingCanvasBlacklist = genericCanvasBlacklist.map(
|
||||
(blacklistItem) => `canvas.inpainting.${blacklistItem}`
|
||||
);
|
||||
|
||||
const outpaintingCanvasBlacklist = genericCanvasBlacklist.map(
|
||||
(blacklistItem) => `canvas.outpainting.${blacklistItem}`
|
||||
);
|
||||
|
||||
const systemBlacklist = [
|
||||
'currentIteration',
|
||||
'currentStatus',
|
||||
'currentStep',
|
||||
'isCancelable',
|
||||
'isConnected',
|
||||
'isProcessing',
|
||||
'currentStep',
|
||||
'socketId',
|
||||
'isESRGANAvailable',
|
||||
'isGFPGANAvailable',
|
||||
'currentStep',
|
||||
'totalSteps',
|
||||
'currentIteration',
|
||||
'isProcessing',
|
||||
'socketId',
|
||||
'totalIterations',
|
||||
'currentStatus',
|
||||
],
|
||||
};
|
||||
'totalSteps',
|
||||
].map((blacklistItem) => `system.${blacklistItem}`);
|
||||
|
||||
const galleryPersistConfig = {
|
||||
key: 'gallery',
|
||||
storage,
|
||||
stateReconciler: autoMergeLevel2,
|
||||
whitelist: [
|
||||
'galleryWidth',
|
||||
'shouldPinGallery',
|
||||
'shouldShowGallery',
|
||||
'galleryScrollPosition',
|
||||
'galleryImageMinimumWidth',
|
||||
'galleryImageObjectFit',
|
||||
],
|
||||
};
|
||||
const galleryBlacklist = [
|
||||
'categories',
|
||||
'currentCategory',
|
||||
'currentImage',
|
||||
'currentImageUuid',
|
||||
'shouldAutoSwitchToNewImages',
|
||||
'shouldHoldGalleryOpen',
|
||||
'intermediateImage',
|
||||
].map((blacklistItem) => `gallery.${blacklistItem}`);
|
||||
|
||||
const inpaintingPersistConfig = {
|
||||
key: 'inpainting',
|
||||
storage,
|
||||
stateReconciler: autoMergeLevel2,
|
||||
blacklist: ['pastLines', 'futuresLines', 'cursorPosition'],
|
||||
};
|
||||
|
||||
const reducers = combineReducers({
|
||||
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;
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
||||
svg {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
stroke-width: 3px !important;
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
&[data-checked] {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
border-color: var(--accent-color-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 {
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -27,5 +27,6 @@
|
||||
|
||||
.invokeai__select-option {
|
||||
background-color: var(--background-color-secondary);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
@ -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,6 +32,7 @@ const IAISelect = (props: IAISelectProps) => {
|
||||
e.nativeEvent.cancelBubble = true;
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<FormLabel
|
||||
className="invokeai__select-label"
|
||||
fontSize={fontSize}
|
||||
@ -41,6 +42,8 @@ const IAISelect = (props: IAISelectProps) => {
|
||||
>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<Select
|
||||
className="invokeai__select-picker"
|
||||
fontSize={fontSize}
|
||||
|
@ -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-form-label {
|
||||
color: var(--text-color-secondary);
|
||||
.invokeai__slider-component-label {
|
||||
min-width: max-content;
|
||||
margin: 0;
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.1rem;
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.invokeai__slider-root {
|
||||
.invokeai__slider-filled-track {
|
||||
background-color: var(--accent-color-hover);
|
||||
.invokeai__slider_track {
|
||||
background-color: var(--tab-color);
|
||||
}
|
||||
|
||||
.invokeai__slider-track {
|
||||
background-color: var(--text-color-secondary);
|
||||
height: 5px;
|
||||
border-radius: 9999px;
|
||||
.invokeai__slider_track-filled {
|
||||
background-color: var(--slider-color);
|
||||
}
|
||||
|
||||
.invokeai__slider-thumb {
|
||||
width: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invokeai__slider-thumb-tooltip {
|
||||
.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);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.invokeai__slider-number-stepper {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&[data-markers='true'] {
|
||||
.invokeai__slider_container {
|
||||
margin-top: -1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
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}
|
||||
{withSliderMarks && (
|
||||
<>
|
||||
<SliderMark
|
||||
value={min}
|
||||
className="invokeai__slider-mark invokeai__slider-mark-start"
|
||||
ml={sliderMarkLeftOffset}
|
||||
{...sliderMarkProps}
|
||||
>
|
||||
<SliderFilledTrack
|
||||
className={`invokeai__slider-filled-track`}
|
||||
{...sliderInnerTrackProps}
|
||||
/>
|
||||
{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;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Heading } from '@chakra-ui/react';
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
type ImageUploadOverlayProps = {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 && (
|
||||
|
@ -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;
|
||||
|
@ -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 = () => {
|
||||
|
@ -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 (
|
||||
|
21
frontend/src/common/util/openBase64ImageInTab.ts
Normal file
21
frontend/src/common/util/openBase64ImageInTab.ts
Normal 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;
|
@ -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;
|
||||
|
||||
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,
|
||||
|
@ -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) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import * as InvokeAI from '../../app/invokeai';
|
||||
import * as InvokeAI from 'app/invokeai';
|
||||
|
||||
export const stringToSeedWeights = (
|
||||
string: string
|
||||
|
275
frontend/src/features/canvas/IAICanvas.tsx
Normal file
275
frontend/src/features/canvas/IAICanvas.tsx
Normal 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;
|
@ -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(
|
||||
setBoundingBoxCoordinate({
|
||||
x: Math.floor(e.target.x()),
|
||||
y: Math.floor(e.target.y()),
|
||||
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(
|
||||
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));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Rect
|
||||
x={boundingBoxCoordinate.x}
|
||||
y={boundingBoxCoordinate.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={() => {
|
||||
const handleMouseOut = () => {
|
||||
!isTransformingBoundingBox &&
|
||||
!isMovingBoundingBox &&
|
||||
dispatch(setIsMouseOverBoundingBox(false));
|
||||
}}
|
||||
onMouseDown={handleStartedMoving}
|
||||
onMouseUp={handleEndedModifying}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group {...rest}>
|
||||
<Rect
|
||||
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}
|
||||
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;
|
95
frontend/src/features/canvas/IAICanvasBrushButtonPopover.tsx
Normal file
95
frontend/src/features/canvas/IAICanvasBrushButtonPopover.tsx
Normal 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;
|
121
frontend/src/features/canvas/IAICanvasBrushPreview.tsx
Normal file
121
frontend/src/features/canvas/IAICanvasBrushPreview.tsx
Normal 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;
|
100
frontend/src/features/canvas/IAICanvasControls.tsx
Normal file
100
frontend/src/features/canvas/IAICanvasControls.tsx
Normal 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;
|
@ -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>
|
||||
);
|
@ -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 = () => {
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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'}
|
||||
/>
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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;
|
49
frontend/src/features/canvas/IAICanvasEraserLines.tsx
Normal file
49
frontend/src/features/canvas/IAICanvasEraserLines.tsx
Normal 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 {}
|
88
frontend/src/features/canvas/IAICanvasGrid.tsx
Normal file
88
frontend/src/features/canvas/IAICanvasGrid.tsx
Normal 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;
|
15
frontend/src/features/canvas/IAICanvasImage.tsx
Normal file
15
frontend/src/features/canvas/IAICanvasImage.tsx
Normal 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;
|
59
frontend/src/features/canvas/IAICanvasIntermediateImage.tsx
Normal file
59
frontend/src/features/canvas/IAICanvasIntermediateImage.tsx
Normal 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;
|
69
frontend/src/features/canvas/IAICanvasMaskButtonPopover.tsx
Normal file
69
frontend/src/features/canvas/IAICanvasMaskButtonPopover.tsx
Normal 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;
|
49
frontend/src/features/canvas/IAICanvasMaskCompositer.tsx
Normal file
49
frontend/src/features/canvas/IAICanvasMaskCompositer.tsx
Normal 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;
|
64
frontend/src/features/canvas/IAICanvasMaskLines.tsx
Normal file
64
frontend/src/features/canvas/IAICanvasMaskLines.tsx
Normal 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;
|
112
frontend/src/features/canvas/IAICanvasOutpaintingControls.tsx
Normal file
112
frontend/src/features/canvas/IAICanvasOutpaintingControls.tsx
Normal 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;
|
65
frontend/src/features/canvas/IAICanvasOutpaintingObjects.tsx
Normal file
65
frontend/src/features/canvas/IAICanvasOutpaintingObjects.tsx
Normal 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;
|
78
frontend/src/features/canvas/IAICanvasResizer.tsx
Normal file
78
frontend/src/features/canvas/IAICanvasResizer.tsx
Normal 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;
|
101
frontend/src/features/canvas/IAICanvasSettingsButtonPopover.tsx
Normal file
101
frontend/src/features/canvas/IAICanvasSettingsButtonPopover.tsx
Normal 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;
|
74
frontend/src/features/canvas/IAICanvasStatusText.tsx
Normal file
74
frontend/src/features/canvas/IAICanvasStatusText.tsx
Normal 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;
|
764
frontend/src/features/canvas/canvasSlice.ts
Normal file
764
frontend/src/features/canvas/canvasSlice.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
49
frontend/src/features/canvas/hooks/useCanvasDragMove.ts
Normal file
49
frontend/src/features/canvas/hooks/useCanvasDragMove.ts
Normal 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;
|
111
frontend/src/features/canvas/hooks/useCanvasHotkeys.ts
Normal file
111
frontend/src/features/canvas/hooks/useCanvasHotkeys.ts
Normal 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;
|
56
frontend/src/features/canvas/hooks/useCanvasMouseDown.ts
Normal file
56
frontend/src/features/canvas/hooks/useCanvasMouseDown.ts
Normal 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;
|
48
frontend/src/features/canvas/hooks/useCanvasMouseEnter.ts
Normal file
48
frontend/src/features/canvas/hooks/useCanvasMouseEnter.ts
Normal 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;
|
64
frontend/src/features/canvas/hooks/useCanvasMouseMove.ts
Normal file
64
frontend/src/features/canvas/hooks/useCanvasMouseMove.ts
Normal 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;
|
15
frontend/src/features/canvas/hooks/useCanvasMouseOut.ts
Normal file
15
frontend/src/features/canvas/hooks/useCanvasMouseOut.ts
Normal 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;
|
64
frontend/src/features/canvas/hooks/useCanvasMouseUp.ts
Normal file
64
frontend/src/features/canvas/hooks/useCanvasMouseUp.ts
Normal 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;
|
83
frontend/src/features/canvas/hooks/useCanvasZoom.ts
Normal file
83
frontend/src/features/canvas/hooks/useCanvasZoom.ts
Normal 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;
|
23
frontend/src/features/canvas/hooks/useUnscaleCanvasValue.ts
Normal file
23
frontend/src/features/canvas/hooks/useUnscaleCanvasValue.ts
Normal 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;
|
@ -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
|
64
frontend/src/features/canvas/util/generateMask.ts
Normal file
64
frontend/src/features/canvas/util/generateMask.ts
Normal 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;
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
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}
|
||||
|
@ -25,6 +25,7 @@
|
||||
max-height: 100%;
|
||||
height: auto;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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(
|
||||
[
|
||||
|
@ -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 && (
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user