Merge branch 'inpainting-rebase' of https://github.com/psychedelicious/stable-diffusion into psychedelicious-inpainting-rebase

This commit is contained in:
Lincoln Stein 2022-10-29 13:42:00 -04:00
commit 0f4413da7d
151 changed files with 7435 additions and 3520 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,822 +0,0 @@
import mimetypes
import transformers
import json
import os
import traceback
import eventlet
import glob
import shlex
import math
import shutil
import sys
sys.path.append(".")
from argparse import ArgumentTypeError
from modules.create_cmd_parser import create_cmd_parser
parser = create_cmd_parser()
opt = parser.parse_args()
from flask_socketio import SocketIO
from flask import Flask, send_from_directory, url_for, jsonify
from pathlib import Path
from PIL import Image
from pytorch_lightning import logging
from threading import Event
from uuid import uuid4
from send2trash import send2trash
from ldm.generate import Generate
from ldm.invoke.restoration import Restoration
from ldm.invoke.pngwriter import PngWriter, retrieve_metadata
from ldm.invoke.args import APP_ID, APP_VERSION, calculate_init_img_hash
from ldm.invoke.prompt_parser import split_weighted_subprompts
from modules.parameters import parameters_to_command
"""
USER CONFIG
"""
if opt.cors and "*" in opt.cors:
raise ArgumentTypeError('"*" is not an allowed CORS origin')
output_dir = "outputs/" # Base output directory for images
host = opt.host # Web & socket.io host
port = opt.port # Web & socket.io port
verbose = opt.verbose # enables copious socket.io logging
precision = opt.precision
free_gpu_mem = opt.free_gpu_mem
embedding_path = opt.embedding_path
additional_allowed_origins = (
opt.cors if opt.cors else []
) # additional CORS allowed origins
model = "stable-diffusion-1.4"
"""
END USER CONFIG
"""
print("* Initializing, be patient...\n")
"""
SERVER SETUP
"""
# fix missing mimetypes on windows due to registry wonkiness
mimetypes.add_type("application/javascript", ".js")
mimetypes.add_type("text/css", ".css")
app = Flask(__name__, static_url_path="", static_folder="../frontend/dist/")
app.config["OUTPUTS_FOLDER"] = "../outputs"
@app.route("/outputs/<path:filename>")
def outputs(filename):
return send_from_directory(app.config["OUTPUTS_FOLDER"], filename)
@app.route("/", defaults={"path": ""})
def serve(path):
return send_from_directory(app.static_folder, "index.html")
logger = True if verbose else False
engineio_logger = True if verbose else False
# default 1,000,000, needs to be higher for socketio to accept larger images
max_http_buffer_size = 10000000
cors_allowed_origins = [f"http://{host}:{port}"] + additional_allowed_origins
socketio = SocketIO(
app,
logger=logger,
engineio_logger=engineio_logger,
max_http_buffer_size=max_http_buffer_size,
cors_allowed_origins=cors_allowed_origins,
ping_interval=(50, 50),
ping_timeout=60,
)
"""
END SERVER SETUP
"""
"""
APP SETUP
"""
class CanceledException(Exception):
pass
try:
gfpgan, codeformer, esrgan = None, None, None
from ldm.invoke.restoration.base import Restoration
restoration = Restoration()
gfpgan, codeformer = restoration.load_face_restore_models()
esrgan = restoration.load_esrgan()
# coreformer.process(self, image, strength, device, seed=None, fidelity=0.75)
except (ModuleNotFoundError, ImportError):
print(traceback.format_exc(), file=sys.stderr)
print(">> You may need to install the ESRGAN and/or GFPGAN modules")
canceled = Event()
# reduce logging outputs to error
transformers.logging.set_verbosity_error()
logging.getLogger("pytorch_lightning").setLevel(logging.ERROR)
# Initialize and load model
generate = Generate(
model,
precision=precision,
embedding_path=embedding_path,
)
generate.free_gpu_mem = free_gpu_mem
generate.load_model()
# location for "finished" images
result_path = os.path.join(output_dir, "img-samples/")
# temporary path for intermediates
intermediate_path = os.path.join(result_path, "intermediates/")
# path for user-uploaded init images and masks
init_image_path = os.path.join(result_path, "init-images/")
mask_image_path = os.path.join(result_path, "mask-images/")
# txt log
log_path = os.path.join(result_path, "invoke_log.txt")
# make all output paths
[
os.makedirs(path, exist_ok=True)
for path in [result_path, intermediate_path, init_image_path, mask_image_path]
]
"""
END APP SETUP
"""
"""
SOCKET.IO LISTENERS
"""
@socketio.on("requestSystemConfig")
def handle_request_capabilities():
print(f">> System config requested")
config = get_system_config()
socketio.emit("systemConfig", config)
@socketio.on("requestImages")
def handle_request_images(page=1, offset=0, last_mtime=None):
chunk_size = 50
if last_mtime:
print(f">> Latest images requested")
else:
print(
f">> Page {page} of images requested (page size {chunk_size} offset {offset})"
)
paths = glob.glob(os.path.join(result_path, "*.png"))
sorted_paths = sorted(paths, key=lambda x: os.path.getmtime(x), reverse=True)
if last_mtime:
image_paths = filter(lambda x: os.path.getmtime(x) > last_mtime, sorted_paths)
else:
image_paths = sorted_paths[
slice(chunk_size * (page - 1) + offset, chunk_size * page + offset)
]
page = page + 1
image_array = []
for path in image_paths:
metadata = retrieve_metadata(path)
image_array.append(
{
"url": path,
"mtime": os.path.getmtime(path),
"metadata": metadata["sd-metadata"],
}
)
socketio.emit(
"galleryImages",
{
"images": image_array,
"nextPage": page,
"offset": offset,
"onlyNewImages": True if last_mtime else False,
},
)
@socketio.on("generateImage")
def handle_generate_image_event(
generation_parameters, esrgan_parameters, gfpgan_parameters
):
print(
f">> Image generation requested: {generation_parameters}\nESRGAN parameters: {esrgan_parameters}\nGFPGAN parameters: {gfpgan_parameters}"
)
generate_images(generation_parameters, esrgan_parameters, gfpgan_parameters)
@socketio.on("runESRGAN")
def handle_run_esrgan_event(original_image, esrgan_parameters):
print(
f'>> ESRGAN upscale requested for "{original_image["url"]}": {esrgan_parameters}'
)
progress = {
"currentStep": 1,
"totalSteps": 1,
"currentIteration": 1,
"totalIterations": 1,
"currentStatus": "Preparing",
"isProcessing": True,
"currentStatusHasSteps": False,
}
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
image = Image.open(original_image["url"])
seed = (
original_image["metadata"]["seed"]
if "seed" in original_image["metadata"]
else "unknown_seed"
)
progress["currentStatus"] = "Upscaling"
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
image = esrgan.process(
image=image,
upsampler_scale=esrgan_parameters["upscale"][0],
strength=esrgan_parameters["upscale"][1],
seed=seed,
)
progress["currentStatus"] = "Saving image"
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
esrgan_parameters["seed"] = seed
metadata = parameters_to_post_processed_image_metadata(
parameters=esrgan_parameters,
original_image_path=original_image["url"],
type="esrgan",
)
command = parameters_to_command(esrgan_parameters)
path = save_image(image, command, metadata, result_path, postprocessing="esrgan")
write_log_message(f'[Upscaled] "{original_image["url"]}" > "{path}": {command}')
progress["currentStatus"] = "Finished"
progress["currentStep"] = 0
progress["totalSteps"] = 0
progress["currentIteration"] = 0
progress["totalIterations"] = 0
progress["isProcessing"] = False
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
socketio.emit(
"esrganResult",
{
"url": os.path.relpath(path),
"mtime": os.path.getmtime(path),
"metadata": metadata,
},
)
@socketio.on("runGFPGAN")
def handle_run_gfpgan_event(original_image, gfpgan_parameters):
print(
f'>> GFPGAN face fix requested for "{original_image["url"]}": {gfpgan_parameters}'
)
progress = {
"currentStep": 1,
"totalSteps": 1,
"currentIteration": 1,
"totalIterations": 1,
"currentStatus": "Preparing",
"isProcessing": True,
"currentStatusHasSteps": False,
}
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
image = Image.open(original_image["url"])
seed = (
original_image["metadata"]["seed"]
if "seed" in original_image["metadata"]
else "unknown_seed"
)
progress["currentStatus"] = "Fixing faces"
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
image = gfpgan.process(
image=image, strength=gfpgan_parameters["facetool_strength"], seed=seed
)
progress["currentStatus"] = "Saving image"
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
gfpgan_parameters["seed"] = seed
metadata = parameters_to_post_processed_image_metadata(
parameters=gfpgan_parameters,
original_image_path=original_image["url"],
type="gfpgan",
)
command = parameters_to_command(gfpgan_parameters)
path = save_image(image, command, metadata, result_path, postprocessing="gfpgan")
write_log_message(f'[Fixed faces] "{original_image["url"]}" > "{path}": {command}')
progress["currentStatus"] = "Finished"
progress["currentStep"] = 0
progress["totalSteps"] = 0
progress["currentIteration"] = 0
progress["totalIterations"] = 0
progress["isProcessing"] = False
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
socketio.emit(
"gfpganResult",
{
"url": os.path.relpath(path),
"mtime": os.path.mtime(path),
"metadata": metadata,
},
)
@socketio.on("cancel")
def handle_cancel():
print(f">> Cancel processing requested")
canceled.set()
socketio.emit("processingCanceled")
# TODO: I think this needs a safety mechanism.
@socketio.on("deleteImage")
def handle_delete_image(path, uuid):
print(f'>> Delete requested "{path}"')
send2trash(path)
socketio.emit("imageDeleted", {"url": path, "uuid": uuid})
# TODO: I think this needs a safety mechanism.
@socketio.on("uploadInitialImage")
def handle_upload_initial_image(bytes, name):
print(f'>> Init image upload requested "{name}"')
uuid = uuid4().hex
split = os.path.splitext(name)
name = f"{split[0]}.{uuid}{split[1]}"
file_path = os.path.join(init_image_path, name)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
newFile = open(file_path, "wb")
newFile.write(bytes)
socketio.emit("initialImageUploaded", {"url": file_path, "uuid": ""})
# TODO: I think this needs a safety mechanism.
@socketio.on("uploadMaskImage")
def handle_upload_mask_image(bytes, name):
print(f'>> Mask image upload requested "{name}"')
uuid = uuid4().hex
split = os.path.splitext(name)
name = f"{split[0]}.{uuid}{split[1]}"
file_path = os.path.join(mask_image_path, name)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
newFile = open(file_path, "wb")
newFile.write(bytes)
socketio.emit("maskImageUploaded", {"url": file_path, "uuid": ""})
"""
END SOCKET.IO LISTENERS
"""
"""
ADDITIONAL FUNCTIONS
"""
def get_system_config():
return {
"model": "stable diffusion",
"model_id": model,
"model_hash": generate.model_hash,
"app_id": APP_ID,
"app_version": APP_VERSION,
}
def parameters_to_post_processed_image_metadata(parameters, original_image_path, type):
# top-level metadata minus `image` or `images`
metadata = get_system_config()
orig_hash = calculate_init_img_hash(original_image_path)
image = {"orig_path": original_image_path, "orig_hash": orig_hash}
if type == "esrgan":
image["type"] = "esrgan"
image["scale"] = parameters["upscale"][0]
image["strength"] = parameters["upscale"][1]
elif type == "gfpgan":
image["type"] = "gfpgan"
image["strength"] = parameters["facetool_strength"]
else:
raise TypeError(f"Invalid type: {type}")
metadata["image"] = image
return metadata
def parameters_to_generated_image_metadata(parameters):
# top-level metadata minus `image` or `images`
metadata = get_system_config()
# remove any image keys not mentioned in RFC #266
rfc266_img_fields = [
"type",
"postprocessing",
"sampler",
"prompt",
"seed",
"variations",
"steps",
"cfg_scale",
"threshold",
"perlin",
"step_number",
"width",
"height",
"extra",
"seamless",
"hires_fix",
]
rfc_dict = {}
for item in parameters.items():
key, value = item
if key in rfc266_img_fields:
rfc_dict[key] = value
postprocessing = []
# 'postprocessing' is either null or an
if "facetool_strength" in parameters:
postprocessing.append(
{"type": "gfpgan", "strength": float(parameters["facetool_strength"])}
)
if "upscale" in parameters:
postprocessing.append(
{
"type": "esrgan",
"scale": int(parameters["upscale"][0]),
"strength": float(parameters["upscale"][1]),
}
)
rfc_dict["postprocessing"] = postprocessing if len(postprocessing) > 0 else None
# semantic drift
rfc_dict["sampler"] = parameters["sampler_name"]
# display weighted subprompts (liable to change)
subprompts = split_weighted_subprompts(parameters["prompt"])
subprompts = [{"prompt": x[0], "weight": x[1]} for x in subprompts]
rfc_dict["prompt"] = subprompts
# 'variations' should always exist and be an array, empty or consisting of {'seed': seed, 'weight': weight} pairs
variations = []
if "with_variations" in parameters:
variations = [
{"seed": x[0], "weight": x[1]} for x in parameters["with_variations"]
]
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(parameters["init_img"])
rfc_dict["init_image_path"] = parameters["init_img"] # TODO: Noncompliant
rfc_dict["sampler"] = "ddim" # TODO: FIX ME WHEN IMG2IMG SUPPORTS ALL SAMPLERS
if "init_mask" in parameters:
rfc_dict["mask_hash"] = calculate_init_img_hash(
parameters["init_mask"]
) # TODO: Noncompliant
rfc_dict["mask_image_path"] = parameters["init_mask"] # TODO: Noncompliant
else:
rfc_dict["type"] = "txt2img"
metadata["image"] = rfc_dict
return metadata
def make_unique_init_image_filename(name):
uuid = uuid4().hex
split = os.path.splitext(name)
name = f"{split[0]}.{uuid}{split[1]}"
return name
def write_log_message(message, log_path=log_path):
"""Logs the filename and parameters used to generate or process that image to log file"""
message = f"{message}\n"
with open(log_path, "a", encoding="utf-8") as file:
file.writelines(message)
def save_image(
image, command, metadata, output_dir, step_index=None, postprocessing=False
):
pngwriter = PngWriter(output_dir)
prefix = pngwriter.unique_prefix()
seed = "unknown_seed"
if "image" in metadata:
if "seed" in metadata["image"]:
seed = metadata["image"]["seed"]
filename = f"{prefix}.{seed}"
if step_index:
filename += f".{step_index}"
if postprocessing:
filename += f".postprocessed"
filename += ".png"
path = pngwriter.save_image_and_prompt_to_png(
image=image, dream_prompt=command, metadata=metadata, name=filename
)
return path
def calculate_real_steps(steps, strength, has_init_image):
return math.floor(strength * steps) if has_init_image else steps
def generate_images(generation_parameters, esrgan_parameters, gfpgan_parameters):
canceled.clear()
step_index = 1
prior_variations = (
generation_parameters["with_variations"]
if "with_variations" in generation_parameters
else []
)
"""
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 copy it.
If the init/mask image doesn't exist in the init_image_path/mask_image_path,
make a unique filename for it and copy it there.
"""
if "init_img" in generation_parameters:
filename = os.path.basename(generation_parameters["init_img"])
if not os.path.exists(os.path.join(init_image_path, filename)):
unique_filename = make_unique_init_image_filename(filename)
new_path = os.path.join(init_image_path, unique_filename)
shutil.copy(generation_parameters["init_img"], new_path)
generation_parameters["init_img"] = new_path
if "init_mask" in generation_parameters:
filename = os.path.basename(generation_parameters["init_mask"])
if not os.path.exists(os.path.join(mask_image_path, filename)):
unique_filename = make_unique_init_image_filename(filename)
new_path = os.path.join(init_image_path, unique_filename)
shutil.copy(generation_parameters["init_img"], new_path)
generation_parameters["init_mask"] = new_path
totalSteps = 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,
)
progress = {
"currentStep": 1,
"totalSteps": totalSteps,
"currentIteration": 1,
"totalIterations": generation_parameters["iterations"],
"currentStatus": "Preparing",
"isProcessing": True,
"currentStatusHasSteps": False,
}
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
def image_progress(sample, step):
if canceled.is_set():
raise CanceledException
nonlocal step_index
nonlocal generation_parameters
nonlocal progress
progress["currentStep"] = step + 1
progress["currentStatus"] = "Generating"
progress["currentStatusHasSteps"] = True
if (
generation_parameters["progress_images"]
and step % 5 == 0
and step < generation_parameters["steps"] - 1
):
image = generate.sample_to_image(sample)
metadata = parameters_to_generated_image_metadata(generation_parameters)
command = parameters_to_command(generation_parameters)
path = save_image(image, command, metadata, intermediate_path, step_index=step_index, postprocessing=False)
step_index += 1
socketio.emit(
"intermediateResult",
{
"url": os.path.relpath(path),
"mtime": os.path.getmtime(path),
"metadata": metadata,
},
)
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
def image_done(image, seed, first_seed):
nonlocal generation_parameters
nonlocal esrgan_parameters
nonlocal gfpgan_parameters
nonlocal progress
step_index = 1
nonlocal prior_variations
progress["currentStatus"] = "Generation complete"
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
all_parameters = generation_parameters
postprocessing = False
if (
"variation_amount" in all_parameters
and all_parameters["variation_amount"] > 0
):
first_seed = first_seed or seed
this_variation = [[seed, all_parameters["variation_amount"]]]
all_parameters["with_variations"] = prior_variations + this_variation
all_parameters["seed"] = first_seed
elif ("with_variations" in all_parameters):
all_parameters["seed"] = first_seed
else:
all_parameters["seed"] = seed
if esrgan_parameters:
progress["currentStatus"] = "Upscaling"
progress["currentStatusHasSteps"] = False
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
image = esrgan.process(
image=image,
upsampler_scale=esrgan_parameters["level"],
strength=esrgan_parameters["strength"],
seed=seed,
)
postprocessing = True
all_parameters["upscale"] = [
esrgan_parameters["level"],
esrgan_parameters["strength"],
]
if gfpgan_parameters:
progress["currentStatus"] = "Fixing faces"
progress["currentStatusHasSteps"] = False
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
image = gfpgan.process(
image=image, strength=gfpgan_parameters["strength"], seed=seed
)
postprocessing = True
all_parameters["facetool_strength"] = gfpgan_parameters["strength"]
progress["currentStatus"] = "Saving image"
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
metadata = parameters_to_generated_image_metadata(all_parameters)
command = parameters_to_command(all_parameters)
path = save_image(
image, command, metadata, result_path, postprocessing=postprocessing
)
print(f'>> Image generated: "{path}"')
write_log_message(f'[Generated] "{path}": {command}')
if progress["totalIterations"] > progress["currentIteration"]:
progress["currentStep"] = 1
progress["currentIteration"] += 1
progress["currentStatus"] = "Iteration finished"
progress["currentStatusHasSteps"] = False
else:
progress["currentStep"] = 0
progress["totalSteps"] = 0
progress["currentIteration"] = 0
progress["totalIterations"] = 0
progress["currentStatus"] = "Finished"
progress["isProcessing"] = False
socketio.emit("progressUpdate", progress)
eventlet.sleep(0)
socketio.emit(
"generationResult",
{
"url": os.path.relpath(path),
"mtime": os.path.getmtime(path),
"metadata": metadata,
},
)
eventlet.sleep(0)
try:
generate.prompt2image(
**generation_parameters,
step_callback=image_progress,
image_callback=image_done,
)
except KeyboardInterrupt:
raise
except CanceledException:
pass
except Exception as e:
socketio.emit("error", {"message": (str(e))})
print("\n")
traceback.print_exc()
print("\n")
"""
END ADDITIONAL FUNCTIONS
"""
if __name__ == "__main__":
print(f">> Starting server at http://{host}:{port}")
socketio.run(app, host=host, port=port)

View File

@ -34,16 +34,6 @@ original unedited image and the masked (partially transparent) image:
invoke> "man with cat on shoulder" -I./images/man.png -M./images/man-transparent.png
```
If you are using Photoshop to make your transparent masks, here is a
protocol contributed by III_Communication36 (Discord name):
Create your alpha channel for mask in photoshop, then run
image/adjust/threshold on that channel. Export as Save a copy using
superpng (3rd party free download plugin) making sure alpha channel
is selected. Then masking works as it should for the img2img
process 100%. Can feed just one image this way without needing to
feed the -M mask behind it
## **Masking using Text**
You can also create a mask using a text prompt to select the part of

View File

@ -0,0 +1,58 @@
# **WebUI Hotkey List**
## General
| Setting | Hotkey |
| ------------ | ---------------------- |
| a | Set All Parameters |
| s | Set Seed |
| u | Upscale |
| r | Restoration |
| i | Show Metadata |
| Ddl | Delete Image |
| alt + a | Focus prompt input |
| shift + i | Send To Image to Image |
| ctrl + enter | Start processing |
| shift + x | cancel Processing |
| shift + d | Toggle Dark Mode |
| ` | Toggle console |
## Tabs
| Setting | Hotkey |
| ------- | ------------------------- |
| 1 | Go to Text To Image Tab |
| 2 | Go to Image to Image Tab |
| 3 | Go to Inpainting Tab |
| 4 | Go to Outpainting Tab |
| 5 | Go to Nodes Tab |
| 6 | Go to Post Processing Tab |
## Gallery
| Setting | Hotkey |
| ------------ | ------------------------------- |
| g | Toggle Gallery |
| left arrow | Go to previous image in gallery |
| right arrow | Go to next image in gallery |
| shift + p | Pin gallery |
| shift + up | Increase gallery image size |
| shift + down | Decrease gallery image size |
| shift + r | Reset image gallery size |
## Inpainting
| Setting | Hotkey |
| -------------------------- | --------------------- |
| [ | Decrease brush size |
| ] | Increase brush size |
| alt + [ | Decrease mask opacity |
| alt + ] | Increase mask opacity |
| b | Select brush |
| e | Select eraser |
| ctrl + z | Undo brush stroke |
| ctrl + shift + z, ctrl + y | Redo brush stroke |
| h | Hide mask |
| shift + m | Invert mask |
| shift + c | Clear mask |
| shift + j | Expand canvas |

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

517
frontend/dist/assets/index.64b87783.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InvokeAI - A Stable Diffusion Toolkit</title>
<link rel="shortcut icon" type="icon" href="./assets/favicon.0d253ced.ico" />
<script type="module" crossorigin src="./assets/index.0a6593a2.js"></script>
<link rel="stylesheet" href="./assets/index.193aec6f.css">
<script type="module" crossorigin src="./assets/index.64b87783.js"></script>
<link rel="stylesheet" href="./assets/index.352e4760.css">
</head>
<body>

View File

@ -15,27 +15,36 @@
"@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4",
"@radix-ui/react-context-menu": "^2.0.1",
"@radix-ui/react-slider": "^1.1.0",
"@radix-ui/react-tooltip": "^1.0.2",
"@reduxjs/toolkit": "^1.8.5",
"@types/uuid": "^8.3.4",
"add": "^2.0.6",
"dateformat": "^5.0.3",
"framer-motion": "^7.2.1",
"konva": "^8.3.13",
"lodash": "^4.17.21",
"re-resizable": "^6.9.9",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.2",
"react-hotkeys-hook": "^3.4.7",
"react-icons": "^4.4.0",
"react-konva": "^18.2.3",
"react-redux": "^8.0.2",
"react-transition-group": "^4.4.5",
"redux-persist": "^6.0.0",
"socket.io": "^4.5.2",
"socket.io-client": "^4.5.2",
"uuid": "^9.0.0"
"uuid": "^9.0.0",
"yarn": "^1.22.19"
},
"devDependencies": {
"@types/dateformat": "^5.0.0",
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@types/react-transition-group": "^4.4.5",
"@typescript-eslint/eslint-plugin": "^5.36.2",
"@typescript-eslint/parser": "^5.36.2",
"@vitejs/plugin-react": "^2.0.1",

View File

@ -2,22 +2,20 @@
.App {
display: grid;
width: max-content;
width: 100vw;
height: 100vh;
background-color: var(--background-color);
}
.app-content {
display: grid;
row-gap: 1rem;
margin: 0.6rem;
padding: 1rem;
border-radius: 0.5rem;
background-color: var(--background-color);
row-gap: 0.5rem;
padding: $app-padding;
grid-auto-rows: max-content;
width: $app-width;
height: $app-height;
min-width: min-content;
}
.app-console {
z-index: 9999;
z-index: 20;
}

View File

@ -7,11 +7,13 @@ 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';
keepGUIAlive();
const App = () => {
const dispatch = useAppDispatch();
const [isReady, setIsReady] = useState<boolean>(false);
useEffect(() => {
@ -21,14 +23,16 @@ const App = () => {
return isReady ? (
<div className="App">
<ProgressBar />
<div className="app-content">
<SiteHeader />
<InvokeTabs />
</div>
<div className="app-console">
<Console />
</div>
<ImageUploader>
<ProgressBar />
<div className="app-content">
<SiteHeader />
<InvokeTabs />
</div>
<div className="app-console">
<Console />
</div>
</ImageUploader>
</div>
) : (
<Loading />

View File

@ -0,0 +1,8 @@
import { createContext } from 'react';
type VoidFunc = () => void;
type ImageUploaderTriggerContextType = VoidFunc | null;
export const ImageUploaderTriggerContext =
createContext<ImageUploaderTriggerContextType>(null);

View File

@ -12,6 +12,8 @@
* 'gfpgan'.
*/
import { Category as GalleryCategory } from '../features/gallery/gallerySlice';
/**
* TODO:
* Once an image has been generated, if it is postprocessed again,
@ -105,12 +107,15 @@ export declare type Metadata = SystemConfig & {
image: GeneratedImageMetadata | PostProcessedImageMetadata;
};
// An Image has a UUID, url (path?) and Metadata.
// An Image has a UUID, url, modified timestamp, width, height and maybe metadata
export declare type Image = {
uuid: string;
url: string;
mtime: number;
metadata: Metadata;
metadata?: Metadata;
width: number;
height: number;
category: GalleryCategory;
};
// GalleryImages is an array of Image.
@ -140,20 +145,35 @@ export declare type SystemConfig = {
model_hash: string;
app_id: string;
app_version: string;
model_list: ModelList;
};
export declare type ModelStatus = 'active' | 'cached' | 'not loaded';
export declare type Model = {
status: ModelStatus;
description: string;
};
export declare type ModelList = Record<string, Model>;
/**
* These types type data received from the server via socketio.
*/
export declare type ModelChangeResponse = {
model_name: string;
model_list: ModelList;
};
export declare type SystemStatusResponse = SystemStatus;
export declare type SystemConfigResponse = SystemConfig;
export declare type ImageResultResponse = {
url: string;
mtime: number;
metadata: Metadata;
export declare type ImageResultResponse = Omit<Image, 'uuid'>;
export declare type ImageUploadResponse = Omit<Image, 'uuid' | 'metadata'> & {
destination: 'img2img' | 'inpainting';
};
export declare type ErrorResponse = {
@ -164,13 +184,22 @@ export declare type ErrorResponse = {
export declare type GalleryImagesResponse = {
images: Array<Omit<Image, 'uuid'>>;
areMoreImagesAvailable: boolean;
category: GalleryCategory;
};
export declare type ImageUrlAndUuidResponse = {
export declare type ImageDeletedResponse = {
uuid: string;
url: string;
category: GalleryCategory;
};
export declare type ImageUrlResponse = {
url: string;
};
export declare type ImageUploadDestination = 'img2img' | 'inpainting';
export declare type UploadImagePayload = {
file: File;
destination?: ImageUploadDestination;
};

View File

@ -1,6 +1,9 @@
import { createAction } from '@reduxjs/toolkit';
import { GalleryCategory } from '../../features/gallery/gallerySlice';
import { InvokeTabName } from '../../features/tabs/InvokeTabs';
import * as InvokeAI from '../invokeai';
/**
* We can't use redux-toolkit's createSlice() to make these actions,
* because they have no associated reducer. They only exist to dispatch
@ -8,24 +11,28 @@ import * as InvokeAI from '../invokeai';
* by the middleware.
*/
export const generateImage = createAction<undefined>('socketio/generateImage');
export const generateImage = createAction<InvokeTabName>(
'socketio/generateImage'
);
export const runESRGAN = createAction<InvokeAI.Image>('socketio/runESRGAN');
export const runFacetool = createAction<InvokeAI.Image>('socketio/runFacetool');
export const deleteImage = createAction<InvokeAI.Image>('socketio/deleteImage');
export const requestImages = createAction<undefined>(
export const requestImages = createAction<GalleryCategory>(
'socketio/requestImages'
);
export const requestNewImages = createAction<undefined>(
export const requestNewImages = createAction<GalleryCategory>(
'socketio/requestNewImages'
);
export const cancelProcessing = createAction<undefined>(
'socketio/cancelProcessing'
);
export const uploadInitialImage = createAction<File>(
'socketio/uploadInitialImage'
);
export const uploadImage = createAction<InvokeAI.UploadImagePayload>('socketio/uploadImage');
export const uploadMaskImage = createAction<File>('socketio/uploadMaskImage');
export const requestSystemConfig = createAction<undefined>(
'socketio/requestSystemConfig'
);
export const requestModelChange = createAction<string>(
'socketio/requestModelChange'
);

View File

@ -1,13 +1,26 @@
import { AnyAction, Dispatch, MiddlewareAPI } from '@reduxjs/toolkit';
import dateFormat from 'dateformat';
import { Socket } from 'socket.io-client';
import { frontendToBackendParameters } from '../../common/util/parameterTranslation';
import {
frontendToBackendParameters,
FrontendToBackendParametersConfig,
} from '../../common/util/parameterTranslation';
import {
GalleryCategory,
GalleryState,
} from '../../features/gallery/gallerySlice';
import { OptionsState } from '../../features/options/optionsSlice';
import {
addLogEntry,
errorOccurred,
setCurrentStatus,
setIsCancelable,
setIsProcessing,
} from '../../features/system/systemSlice';
import { tabMap, tab_dict } from '../../features/tabs/InvokeTabs';
import { inpaintingImageElementRef } from '../../features/tabs/Inpainting/InpaintingCanvas';
import { InvokeTabName } from '../../features/tabs/InvokeTabs';
import * as InvokeAI from '../invokeai';
import { RootState } from '../store';
/**
* Returns an object containing all functions which use `socketio.emit()`.
@ -21,17 +34,56 @@ const makeSocketIOEmitters = (
const { dispatch, getState } = store;
return {
emitGenerateImage: () => {
emitGenerateImage: (generationMode: InvokeTabName) => {
dispatch(setIsProcessing(true));
const options = { ...getState().options };
const state: RootState = getState();
if (tabMap[options.activeTab] !== 'img2img') {
options.shouldUseInitImage = false;
const {
options: optionsState,
system: systemState,
inpainting: inpaintingState,
gallery: galleryState,
} = state;
const frontendToBackendParametersConfig: FrontendToBackendParametersConfig =
{
generationMode,
optionsState,
inpaintingState,
systemState,
};
if (generationMode === 'inpainting') {
if (
!inpaintingImageElementRef.current ||
!inpaintingState.imageToInpaint?.url
) {
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: 'Inpainting image not loaded, cannot generate image.',
level: 'error',
})
);
dispatch(errorOccurred());
return;
}
frontendToBackendParametersConfig.imageToProcessUrl =
inpaintingState.imageToInpaint.url;
frontendToBackendParametersConfig.maskImageElement =
inpaintingImageElementRef.current;
} else if (!['txt2img', 'img2img'].includes(generationMode)) {
if (!galleryState.currentImage?.url) return;
frontendToBackendParametersConfig.imageToProcessUrl =
galleryState.currentImage.url;
}
const { generationParameters, esrganParameters, facetoolParameters } =
frontendToBackendParameters(options, getState().system);
frontendToBackendParameters(frontendToBackendParametersConfig);
socketio.emit(
'generateImage',
@ -40,6 +92,14 @@ const makeSocketIOEmitters = (
facetoolParameters
);
// we need to truncate the init_mask base64 else it takes up the whole log
// TODO: handle maintaining masks for reproducibility in future
if (generationParameters.init_mask) {
generationParameters.init_mask = generationParameters.init_mask
.substr(0, 20)
.concat('...');
}
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
@ -53,7 +113,8 @@ const makeSocketIOEmitters = (
},
emitRunESRGAN: (imageToProcess: InvokeAI.Image) => {
dispatch(setIsProcessing(true));
const { upscalingLevel, upscalingStrength } = getState().options;
const options: OptionsState = getState().options;
const { upscalingLevel, upscalingStrength } = options;
const esrganParameters = {
upscale: [upscalingLevel, upscalingStrength],
};
@ -73,8 +134,8 @@ const makeSocketIOEmitters = (
},
emitRunFacetool: (imageToProcess: InvokeAI.Image) => {
dispatch(setIsProcessing(true));
const { facetoolType, facetoolStrength, codeformerFidelity } =
getState().options;
const options: OptionsState = getState().options;
const { facetoolType, facetoolStrength, codeformerFidelity } = options;
const facetoolParameters: Record<string, any> = {
facetool_strength: facetoolStrength,
@ -101,22 +162,25 @@ const makeSocketIOEmitters = (
);
},
emitDeleteImage: (imageToDelete: InvokeAI.Image) => {
const { url, uuid } = imageToDelete;
socketio.emit('deleteImage', url, uuid);
const { url, uuid, category } = imageToDelete;
socketio.emit('deleteImage', url, uuid, category);
},
emitRequestImages: () => {
const { earliest_mtime } = getState().gallery;
socketio.emit('requestImages', earliest_mtime);
emitRequestImages: (category: GalleryCategory) => {
const gallery: GalleryState = getState().gallery;
const { earliest_mtime } = gallery.categories[category];
socketio.emit('requestImages', category, earliest_mtime);
},
emitRequestNewImages: () => {
const { latest_mtime } = getState().gallery;
socketio.emit('requestLatestImages', latest_mtime);
emitRequestNewImages: (category: GalleryCategory) => {
const gallery: GalleryState = getState().gallery;
const { latest_mtime } = gallery.categories[category];
socketio.emit('requestLatestImages', category, latest_mtime);
},
emitCancelProcessing: () => {
socketio.emit('cancel');
},
emitUploadInitialImage: (file: File) => {
socketio.emit('uploadInitialImage', file, file.name);
emitUploadImage: (payload: InvokeAI.UploadImagePayload) => {
const { file, destination } = payload;
socketio.emit('uploadImage', file, file.name, destination);
},
emitUploadMaskImage: (file: File) => {
socketio.emit('uploadMaskImage', file, file.name);
@ -124,6 +188,12 @@ const makeSocketIOEmitters = (
emitRequestSystemConfig: () => {
socketio.emit('requestSystemConfig');
},
emitRequestModelChange: (modelName: string) => {
dispatch(setCurrentStatus('Changing Model'));
dispatch(setIsProcessing(true));
dispatch(setIsCancelable(false));
socketio.emit('requestModelChange', modelName);
},
};
};

View File

@ -13,21 +13,27 @@ import {
setSystemConfig,
processingCanceled,
errorOccurred,
setModelList,
setIsCancelable,
} from '../../features/system/systemSlice';
import {
addGalleryImages,
addImage,
clearIntermediateImage,
GalleryState,
removeImage,
setCurrentImage,
setIntermediateImage,
} from '../../features/gallery/gallerySlice';
import {
setInitialImagePath,
clearInitialImage,
setInitialImage,
setMaskPath,
} from '../../features/options/optionsSlice';
import { requestImages, requestNewImages } from './actions';
import { clearImageToInpaint, setImageToInpaint } from '../../features/tabs/Inpainting/inpaintingSlice';
/**
* Returns an object containing listener callbacks for socketio events.
@ -46,10 +52,18 @@ const makeSocketIOListeners = (
try {
dispatch(setIsConnected(true));
dispatch(setCurrentStatus('Connected'));
if (getState().gallery.latest_mtime) {
dispatch(requestNewImages());
const gallery: GalleryState = getState().gallery;
if (gallery.categories.user.latest_mtime) {
dispatch(requestNewImages('user'));
} else {
dispatch(requestImages());
dispatch(requestImages('user'));
}
if (gallery.categories.result.latest_mtime) {
dispatch(requestNewImages('result'));
} else {
dispatch(requestImages('result'));
}
} catch (e) {
console.error(e);
@ -79,21 +93,19 @@ const makeSocketIOListeners = (
*/
onGenerationResult: (data: InvokeAI.ImageResultResponse) => {
try {
const { url, mtime, metadata } = data;
const newUuid = uuidv4();
dispatch(
addImage({
uuid: newUuid,
url,
mtime,
metadata: metadata,
category: 'result',
image: {
uuid: uuidv4(),
...data,
},
})
);
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Image generated: ${url}`,
message: `Image generated: ${data.url}`,
})
);
} catch (e) {
@ -105,20 +117,16 @@ const makeSocketIOListeners = (
*/
onIntermediateResult: (data: InvokeAI.ImageResultResponse) => {
try {
const uuid = uuidv4();
const { url, metadata, mtime } = data;
dispatch(
setIntermediateImage({
uuid,
url,
mtime,
metadata,
uuid: uuidv4(),
...data,
})
);
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Intermediate image generated: ${url}`,
message: `Intermediate image generated: ${data.url}`,
})
);
} catch (e) {
@ -130,21 +138,20 @@ const makeSocketIOListeners = (
*/
onPostprocessingResult: (data: InvokeAI.ImageResultResponse) => {
try {
const { url, metadata, mtime } = data;
dispatch(
addImage({
uuid: uuidv4(),
url,
mtime,
metadata,
category: 'result',
image: {
uuid: uuidv4(),
...data,
},
})
);
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Postprocessed: ${url}`,
message: `Postprocessed: ${data.url}`,
})
);
} catch (e) {
@ -191,7 +198,7 @@ const makeSocketIOListeners = (
* Callback to run when we receive a 'galleryImages' event.
*/
onGalleryImages: (data: InvokeAI.GalleryImagesResponse) => {
const { images, areMoreImagesAvailable } = data;
const { images, areMoreImagesAvailable, category } = data;
/**
* the logic here ideally would be in the reducer but we have a side effect:
@ -200,17 +207,18 @@ const makeSocketIOListeners = (
// Generate a UUID for each image
const preparedImages = images.map((image): InvokeAI.Image => {
const { url, metadata, mtime } = image;
return {
uuid: uuidv4(),
url,
mtime,
metadata,
...image,
};
});
dispatch(
addGalleryImages({ images: preparedImages, areMoreImagesAvailable })
addGalleryImages({
images: preparedImages,
areMoreImagesAvailable,
category,
})
);
dispatch(
@ -229,7 +237,12 @@ const makeSocketIOListeners = (
const { intermediateImage } = getState().gallery;
if (intermediateImage) {
dispatch(addImage(intermediateImage));
dispatch(
addImage({
category: 'result',
image: intermediateImage,
})
);
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
@ -250,14 +263,22 @@ const makeSocketIOListeners = (
/**
* Callback to run when we receive a 'imageDeleted' event.
*/
onImageDeleted: (data: InvokeAI.ImageUrlAndUuidResponse) => {
const { url, uuid } = data;
dispatch(removeImage(uuid));
onImageDeleted: (data: InvokeAI.ImageDeletedResponse) => {
const { url, uuid, category } = data;
const { initialImagePath, maskPath } = getState().options;
// remove image from gallery
dispatch(removeImage(data));
if (initialImagePath === url) {
dispatch(setInitialImagePath(''));
// remove references to image in options
const { initialImage, maskPath } = getState().options;
const { imageToInpaint } = getState().inpainting;
if (initialImage?.url === url || initialImage === url) {
dispatch(clearInitialImage());
}
if (imageToInpaint?.url === url) {
dispatch(clearImageToInpaint());
}
if (maskPath === url) {
@ -271,18 +292,40 @@ const makeSocketIOListeners = (
})
);
},
/**
* Callback to run when we receive a 'initialImageUploaded' event.
*/
onInitialImageUploaded: (data: InvokeAI.ImageUrlResponse) => {
const { url } = data;
dispatch(setInitialImagePath(url));
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Initial image uploaded: ${url}`,
})
);
onImageUploaded: (data: InvokeAI.ImageUploadResponse) => {
const { destination, ...rest } = data;
const image = {
uuid: uuidv4(),
...rest,
};
try {
dispatch(addImage({ image, category: 'user' }));
switch (destination) {
case 'img2img': {
dispatch(setInitialImage(image));
break;
}
case 'inpainting': {
dispatch(setImageToInpaint(image));
break;
}
default: {
dispatch(setCurrentImage(image));
break;
}
}
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Image uploaded: ${data.url}`,
})
);
} catch (e) {
console.error(e);
}
},
/**
* Callback to run when we receive a 'maskImageUploaded' event.
@ -300,6 +343,34 @@ const makeSocketIOListeners = (
onSystemConfig: (data: InvokeAI.SystemConfig) => {
dispatch(setSystemConfig(data));
},
onModelChanged: (data: InvokeAI.ModelChangeResponse) => {
const { model_name, model_list } = data;
dispatch(setModelList(model_list));
dispatch(setCurrentStatus('Model Changed'));
dispatch(setIsProcessing(false));
dispatch(setIsCancelable(false));
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Model changed: ${model_name}`,
level: 'info',
})
);
},
onModelChangeFailed: (data: InvokeAI.ModelChangeResponse) => {
const { model_name, model_list } = data;
dispatch(setModelList(model_list));
dispatch(setIsProcessing(false));
dispatch(setIsCancelable(false));
dispatch(errorOccurred());
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Model change failed: ${model_name}`,
level: 'error',
})
);
},
};
};

View File

@ -43,9 +43,11 @@ export const socketioMiddleware = () => {
onGalleryImages,
onProcessingCanceled,
onImageDeleted,
onInitialImageUploaded,
onImageUploaded,
onMaskImageUploaded,
onSystemConfig,
onModelChanged,
onModelChangeFailed,
} = makeSocketIOListeners(store);
const {
@ -56,9 +58,10 @@ export const socketioMiddleware = () => {
emitRequestImages,
emitRequestNewImages,
emitCancelProcessing,
emitUploadInitialImage,
emitUploadImage,
emitUploadMaskImage,
emitRequestSystemConfig,
emitRequestModelChange,
} = makeSocketIOEmitters(store, socketio);
/**
@ -97,13 +100,16 @@ export const socketioMiddleware = () => {
onProcessingCanceled();
});
socketio.on('imageDeleted', (data: InvokeAI.ImageUrlAndUuidResponse) => {
socketio.on('imageDeleted', (data: InvokeAI.ImageDeletedResponse) => {
onImageDeleted(data);
});
socketio.on('initialImageUploaded', (data: InvokeAI.ImageUrlResponse) => {
onInitialImageUploaded(data);
});
socketio.on(
'imageUploaded',
(data: InvokeAI.ImageUploadResponse) => {
onImageUploaded(data);
}
);
socketio.on('maskImageUploaded', (data: InvokeAI.ImageUrlResponse) => {
onMaskImageUploaded(data);
@ -113,6 +119,14 @@ export const socketioMiddleware = () => {
onSystemConfig(data);
});
socketio.on('modelChanged', (data: InvokeAI.ModelChangeResponse) => {
onModelChanged(data);
});
socketio.on('modelChangeFailed', (data: InvokeAI.ModelChangeResponse) => {
onModelChangeFailed(data);
});
areListenersSet = true;
}
@ -121,7 +135,7 @@ export const socketioMiddleware = () => {
*/
switch (action.type) {
case 'socketio/generateImage': {
emitGenerateImage();
emitGenerateImage(action.payload);
break;
}
@ -141,12 +155,12 @@ export const socketioMiddleware = () => {
}
case 'socketio/requestImages': {
emitRequestImages();
emitRequestImages(action.payload);
break;
}
case 'socketio/requestNewImages': {
emitRequestNewImages();
emitRequestNewImages(action.payload);
break;
}
@ -155,8 +169,8 @@ export const socketioMiddleware = () => {
break;
}
case 'socketio/uploadInitialImage': {
emitUploadInitialImage(action.payload);
case 'socketio/uploadImage': {
emitUploadImage(action.payload);
break;
}
@ -169,6 +183,11 @@ export const socketioMiddleware = () => {
emitRequestSystemConfig();
break;
}
case 'socketio/requestModelChange': {
emitRequestModelChange(action.payload);
break;
}
}
next(action);

View File

@ -7,6 +7,7 @@ import storage from 'redux-persist/lib/storage'; // defaults to localStorage for
import optionsReducer from '../features/options/optionsSlice';
import galleryReducer from '../features/gallery/gallerySlice';
import inpaintingReducer from '../features/tabs/Inpainting/inpaintingSlice';
import systemReducer from '../features/system/systemSlice';
import { socketioMiddleware } from './socketio/middleware';
@ -32,13 +33,14 @@ import { socketioMiddleware } from './socketio/middleware';
const rootPersistConfig = {
key: 'root',
storage,
blacklist: ['gallery', 'system'],
blacklist: ['gallery', 'system', 'inpainting'],
};
const systemPersistConfig = {
key: 'system',
storage,
blacklist: [
'isCancelable',
'isConnected',
'isProcessing',
'currentStep',
@ -53,10 +55,30 @@ const systemPersistConfig = {
],
};
const galleryPersistConfig = {
key: 'gallery',
storage,
whitelist: [
'galleryWidth',
'shouldPinGallery',
'shouldShowGallery',
'galleryScrollPosition',
'galleryImageMinimumWidth',
'galleryImageObjectFit',
],
};
const inpaintingPersistConfig = {
key: 'inpainting',
storage,
blacklist: ['pastLines', 'futuresLines', 'cursorPosition'],
};
const reducers = combineReducers({
options: optionsReducer,
gallery: galleryReducer,
gallery: persistReducer(galleryPersistConfig, galleryReducer),
system: persistReducer(systemPersistConfig, systemReducer),
inpainting: persistReducer(inpaintingPersistConfig, inpaintingReducer),
});
const persistedReducer = persistReducer(rootPersistConfig, reducers);

View File

@ -25,7 +25,10 @@ const systemSelector = createSelector(
const GuidePopover = ({ children, feature }: GuideProps) => {
const shouldDisplayGuides = useAppSelector(systemSelector);
const { text } = FEATURES[feature];
return shouldDisplayGuides ? (
if (!shouldDisplayGuides) return null;
return (
<Popover trigger={'hover'}>
<PopoverTrigger>
<Box>{children}</Box>
@ -40,8 +43,6 @@ const GuidePopover = ({ children, feature }: GuideProps) => {
<div className="guide-popover-guide-content">{text}</div>
</PopoverContent>
</Popover>
) : (
<></>
);
};

View File

@ -0,0 +1,26 @@
.invokeai__checkbox {
.chakra-checkbox__label {
margin-top: 1px;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.chakra-checkbox__control {
width: 1rem;
height: 1rem;
border: none;
border-radius: 0.2rem;
background-color: var(--input-checkbox-bg);
svg {
width: 0.6rem;
height: 0.6rem;
stroke-width: 3px !important;
}
&[data-checked] {
color: var(--text-color);
background-color: var(--input-checkbox-checked-bg);
}
}
}

View File

@ -0,0 +1,17 @@
import { Checkbox, CheckboxProps } from '@chakra-ui/react';
type IAICheckboxProps = CheckboxProps & {
label: string;
styleClass?: string;
};
const IAICheckbox = (props: IAICheckboxProps) => {
const { label, styleClass, ...rest } = props;
return (
<Checkbox className={`invokeai__checkbox ${styleClass}`} {...rest}>
{label}
</Checkbox>
);
};
export default IAICheckbox;

View File

@ -0,0 +1,8 @@
.invokeai__color-picker {
.react-colorful__hue-pointer,
.react-colorful__saturation-pointer {
width: 1.5rem;
height: 1.5rem;
border-color: var(--white);
}
}

View File

@ -0,0 +1,19 @@
import { RgbaColorPicker } from 'react-colorful';
import { ColorPickerBaseProps, RgbaColor } from 'react-colorful/dist/types';
type IAIColorPickerProps = ColorPickerBaseProps<RgbaColor> & {
styleClass?: string;
};
const IAIColorPicker = (props: IAIColorPickerProps) => {
const { styleClass, ...rest } = props;
return (
<RgbaColorPicker
className={`invokeai__color-picker ${styleClass}`}
{...rest}
/>
);
};
export default IAIColorPicker;

View File

@ -0,0 +1,20 @@
@use '../../styles/Mixins/' as *;
.icon-button {
background-color: var(--btn-grey);
cursor: pointer;
&:hover {
background-color: var(--btn-grey-hover);
}
&[data-selected=true] {
background-color: var(--accent-color);
&:hover {
background-color: var(--accent-color-hover);
}
}
&[disabled] {
cursor: not-allowed;
}
}

View File

@ -8,20 +8,28 @@ import {
interface Props extends IconButtonProps {
tooltip?: string;
tooltipPlacement?: PlacementWithLogical | undefined;
styleClass?: string;
}
/**
* Reusable customized button component. Originally was more customized - now probably unecessary.
*
* TODO: Get rid of this.
*/
const IAIIconButton = (props: Props) => {
const { tooltip = '', tooltipPlacement = 'bottom', onClick, ...rest } = props;
const {
tooltip = '',
tooltipPlacement = 'bottom',
styleClass,
onClick,
cursor,
...rest
} = props;
return (
<Tooltip label={tooltip} hasArrow placement={tooltipPlacement}>
<IconButton
className={`icon-button ${styleClass}`}
{...rest}
cursor={onClick ? 'pointer' : 'unset'}
cursor={cursor ? cursor : onClick ? 'pointer' : 'unset'}
onClick={onClick}
/>
</Tooltip>

View File

@ -17,8 +17,8 @@
&:focus {
outline: none;
border: 2px solid var(--prompt-border-color);
box-shadow: 0 0 10px 0 var(--prompt-box-shadow-color);
border: 2px solid var(--input-border-color);
box-shadow: 0 0 10px 0 var(--input-box-shadow-color);
}
&:disabled {

View File

@ -1,15 +1,32 @@
.number-input {
.invokeai__number-input-form-control {
display: grid;
grid-template-columns: max-content auto;
column-gap: 1rem;
align-items: center;
.number-input-label {
.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;
&[data-focus] + .invokeai__number-input-root {
outline: none;
border: 2px solid var(--input-border-color);
box-shadow: 0 0 10px 0 var(--input-box-shadow-color);
}
&[aria-invalid='true'] + .invokeai__number-input-root {
outline: none;
border: 2px solid var(--border-color-invalid);
box-shadow: 0 0 10px 0 var(--box-shadow-color-invalid);
}
}
.number-input-field {
.invokeai__number-input-root {
height: 2rem;
display: grid;
grid-template-columns: auto max-content;
column-gap: 0.5rem;
@ -19,34 +36,39 @@
border-radius: 0.2rem;
}
.number-input-entry {
.invokeai__number-input-field {
border: none;
font-weight: bold;
width: 100%;
padding-inline-end: 0;
height: auto;
padding: 0;
font-size: 0.9rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
&:focus {
outline: none;
border: 2px solid var(--prompt-border-color);
box-shadow: 0 0 10px 0 var(--prompt-box-shadow-color);
box-shadow: none;
}
&:disabled {
opacity: 0.2;
}
}
.number-input-stepper {
.invokeai__number-input-stepper {
display: grid;
padding-right: 0.7rem;
padding-right: 0.5rem;
svg {
width: 12px;
height: 12px;
}
.number-input-stepper-button {
.invokeai__number-input-stepper-button {
border: none;
// expand arrow hitbox
padding: 0 0.5rem;
margin: 0 -0.5rem;
svg {
width: 10px;
height: 10px;
}
}
}
}

View File

@ -6,6 +6,12 @@ import {
NumberDecrementStepper,
NumberInputProps,
FormLabel,
NumberInputFieldProps,
NumberInputStepperProps,
FormControlProps,
FormLabelProps,
TooltipProps,
Tooltip,
} from '@chakra-ui/react';
import _ from 'lodash';
import { FocusEvent, useEffect, useState } from 'react';
@ -23,6 +29,12 @@ interface Props extends Omit<NumberInputProps, 'onChange'> {
max: number;
clamp?: boolean;
isInteger?: boolean;
formControlProps?: FormControlProps;
formLabelProps?: FormLabelProps;
numberInputProps?: NumberInputProps;
numberInputFieldProps?: NumberInputFieldProps;
numberInputStepperProps?: NumberInputStepperProps;
tooltipProps?: Omit<TooltipProps, 'children'>;
}
/**
@ -34,8 +46,6 @@ const IAINumberInput = (props: Props) => {
styleClass,
isDisabled = false,
showStepper = true,
fontSize = '1rem',
size = 'sm',
width,
textAlign,
isInvalid,
@ -44,6 +54,11 @@ const IAINumberInput = (props: Props) => {
min,
max,
isInteger = true,
formControlProps,
formLabelProps,
numberInputFieldProps,
numberInputStepperProps,
tooltipProps,
...rest
} = props;
@ -65,7 +80,10 @@ const IAINumberInput = (props: Props) => {
* from the current value.
*/
useEffect(() => {
if (!valueAsString.match(numberStringRegex) && value !== Number(valueAsString)) {
if (
!valueAsString.match(numberStringRegex) &&
value !== Number(valueAsString)
) {
setValueAsString(String(value));
}
}, [value, valueAsString]);
@ -94,47 +112,55 @@ const IAINumberInput = (props: Props) => {
};
return (
<FormControl
isDisabled={isDisabled}
isInvalid={isInvalid}
className={`number-input ${styleClass}`}
>
{label && (
<Tooltip {...tooltipProps}>
<FormControl
isDisabled={isDisabled}
isInvalid={isInvalid}
className={
styleClass
? `invokeai__number-input-form-control ${styleClass}`
: `invokeai__number-input-form-control`
}
{...formControlProps}
>
<FormLabel
fontSize={fontSize}
marginBottom={1}
flexGrow={2}
whiteSpace="nowrap"
className="number-input-label"
className="invokeai__number-input-form-label"
style={{ display: label ? 'block' : 'none' }}
{...formLabelProps}
>
{label}
</FormLabel>
)}
<NumberInput
size={size}
{...rest}
className="number-input-field"
value={valueAsString}
keepWithinRange={true}
clampValueOnBlur={false}
onChange={handleOnChange}
onBlur={handleBlur}
>
<NumberInputField
fontSize={fontSize}
className="number-input-entry"
<NumberInput
className="invokeai__number-input-root"
value={valueAsString}
keepWithinRange={true}
clampValueOnBlur={false}
onChange={handleOnChange}
onBlur={handleBlur}
width={width}
textAlign={textAlign}
/>
<div
className="number-input-stepper"
style={showStepper ? { display: 'block' } : { display: 'none' }}
{...rest}
>
<NumberIncrementStepper className="number-input-stepper-button" />
<NumberDecrementStepper className="number-input-stepper-button" />
</div>
</NumberInput>
</FormControl>
<NumberInputField
className="invokeai__number-input-field"
textAlign={textAlign}
{...numberInputFieldProps}
/>
<div
className="invokeai__number-input-stepper"
style={showStepper ? { display: 'block' } : { display: 'none' }}
>
<NumberIncrementStepper
{...numberInputStepperProps}
className="invokeai__number-input-stepper-button"
/>
<NumberDecrementStepper
{...numberInputStepperProps}
className="invokeai__number-input-stepper-button"
/>
</div>
</NumberInput>
</FormControl>
</Tooltip>
);
};

View File

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

View File

@ -0,0 +1,39 @@
import {
Popover,
PopoverArrow,
PopoverContent,
PopoverTrigger,
Box,
} from '@chakra-ui/react';
import { PopoverProps } from '@chakra-ui/react';
import { ReactNode } from 'react';
type IAIPopoverProps = PopoverProps & {
triggerComponent: ReactNode;
children: ReactNode;
styleClass?: string;
hasArrow?: boolean;
};
const IAIPopover = (props: IAIPopoverProps) => {
const {
triggerComponent,
children,
styleClass,
hasArrow = true,
...rest
} = props;
return (
<Popover {...rest}>
<PopoverTrigger>
<Box>{triggerComponent}</Box>
</PopoverTrigger>
<PopoverContent className={`invokeai__popover-content ${styleClass}`}>
{hasArrow && <PopoverArrow className={'invokeai__popover-arrow'} />}
{children}
</PopoverContent>
</Popover>
);
};
export default IAIPopover;

View File

@ -1,28 +1,32 @@
.iai-select {
@use '../../styles/Mixins/' as *;
.invokeai__select {
display: grid;
grid-template-columns: repeat(2, max-content);
column-gap: 1rem;
align-items: center;
width: max-content;
.iai-select-label {
.invokeai__select-label {
color: var(--text-color-secondary);
margin-right: 0;
}
.iai-select-picker {
.invokeai__select-picker {
border: 2px solid var(--border-color);
background-color: var(--background-color-secondary);
font-weight: bold;
height: 2rem;
border-radius: 0.2rem;
&:focus {
outline: none;
border: 2px solid var(--prompt-border-color);
box-shadow: 0 0 10px 0 var(--prompt-box-shadow-color);
border: 2px solid var(--input-border-color);
box-shadow: 0 0 10px 0 var(--input-box-shadow-color);
}
}
.iai-select-option {
.invokeai__select-option {
background-color: var(--background-color-secondary);
}
}

View File

@ -21,13 +21,13 @@ const IAISelect = (props: Props) => {
...rest
} = props;
return (
<FormControl isDisabled={isDisabled} className={`iai-select ${styleClass}`}>
<FormControl isDisabled={isDisabled} className={`invokeai__select ${styleClass}`}>
<FormLabel
fontSize={fontSize}
marginBottom={1}
flexGrow={2}
whiteSpace="nowrap"
className="iai-select-label"
className="invokeai__select-label"
>
{label}
</FormLabel>
@ -35,11 +35,11 @@ const IAISelect = (props: Props) => {
fontSize={fontSize}
size={size}
{...rest}
className="iai-select-picker"
className="invokeai__select-picker"
>
{validValues.map((opt) => {
return typeof opt === 'string' || typeof opt === 'number' ? (
<option key={opt} value={opt} className="iai-select-option">
<option key={opt} value={opt} className="invokeai__select-option">
{opt}
</option>
) : (

View File

@ -0,0 +1,40 @@
@use '../../styles/Mixins/' as *;
.invokeai__slider-form-control {
display: flex;
column-gap: 1rem;
justify-content: space-between;
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);
margin: 0;
margin-right: 0.5rem;
margin-bottom: 0.1rem;
}
.invokeai__slider-root {
.invokeai__slider-filled-track {
background-color: var(--accent-color-hover);
}
.invokeai__slider-track {
background-color: var(--text-color-secondary);
height: 5px;
border-radius: 9999px;
}
.invokeai__slider-thumb {
}
}
}
}
.invokeai__slider-thumb-tooltip {
}

View File

@ -0,0 +1,87 @@
import {
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
FormControl,
FormLabel,
Tooltip,
SliderProps,
FormControlProps,
FormLabelProps,
SliderTrackProps,
SliderThumbProps,
TooltipProps,
SliderInnerTrackProps,
} from '@chakra-ui/react';
type IAISliderProps = SliderProps & {
label?: string;
styleClass?: string;
formControlProps?: FormControlProps;
formLabelProps?: FormLabelProps;
sliderTrackProps?: SliderTrackProps;
sliderInnerTrackProps?: SliderInnerTrackProps;
sliderThumbProps?: SliderThumbProps;
sliderThumbTooltipProps?: Omit<TooltipProps, 'children'>;
};
const IAISlider = (props: IAISliderProps) => {
const {
label,
styleClass,
formControlProps,
formLabelProps,
sliderTrackProps,
sliderInnerTrackProps,
sliderThumbProps,
sliderThumbTooltipProps,
...rest
} = props;
return (
<FormControl
className={`invokeai__slider-form-control ${styleClass}`}
{...formControlProps}
>
<div className="invokeai__slider-inner-container">
<FormLabel
className={`invokeai__slider-form-label`}
whiteSpace="nowrap"
{...formLabelProps}
>
{label}
</FormLabel>
<Slider
className={`invokeai__slider-root`}
aria-label={label}
{...rest}
>
<SliderTrack
className={`invokeai__slider-track`}
{...sliderTrackProps}
>
<SliderFilledTrack
className={`invokeai__slider-filled-track`}
{...sliderInnerTrackProps}
/>
</SliderTrack>
<Tooltip
className={`invokeai__slider-thumb-tooltip`}
placement="top"
hasArrow
{...sliderThumbTooltipProps}
>
<SliderThumb
className={`invokeai__slider-thumb`}
{...sliderThumbProps}
/>
</Tooltip>
</Slider>
</div>
</FormControl>
);
};
export default IAISlider;

View File

@ -1,18 +1,33 @@
.chakra-switch,
.switch-button {
span {
background-color: var(--switch-bg-color);
.invokeai__switch-form-control {
.invokeai__switch-form-label {
display: flex;
column-gap: 1rem;
justify-content: space-between;
align-items: center;
color: var(--text-color-secondary);
font-size: 1rem;
margin-right: 0;
margin-bottom: 0.1rem;
white-space: nowrap;
width: auto;
span {
background-color: var(--white);
}
}
.invokeai__switch-root {
span {
background-color: var(--switch-bg-color);
span {
background-color: var(--white);
}
}
span[data-checked] {
background: var(--switch-bg-active-color);
&[data-checked] {
span {
background: var(--switch-bg-active-color);
span {
background-color: var(--white);
span {
background-color: var(--white);
}
}
}
}
}
}

View File

@ -1,7 +1,8 @@
import {
Flex,
FormControl,
FormControlProps,
FormLabel,
FormLabelProps,
Switch,
SwitchProps,
} from '@chakra-ui/react';
@ -9,6 +10,9 @@ import {
interface Props extends SwitchProps {
label?: string;
width?: string | number;
styleClass?: string;
formControlProps?: FormControlProps;
formLabelProps?: FormLabelProps;
}
/**
@ -18,26 +22,35 @@ const IAISwitch = (props: Props) => {
const {
label,
isDisabled = false,
fontSize = 'md',
size = 'md',
// fontSize = 'md',
// size = 'md',
width = 'auto',
formControlProps,
formLabelProps,
styleClass,
...rest
} = props;
return (
<FormControl isDisabled={isDisabled} width={width}>
<Flex justifyContent={'space-between'} alignItems={'center'}>
{label && (
<FormLabel
fontSize={fontSize}
marginBottom={1}
flexGrow={2}
whiteSpace="nowrap"
>
{label}
</FormLabel>
)}
<Switch size={size} className="switch-button" {...rest} />
</Flex>
<FormControl
isDisabled={isDisabled}
width={width}
className={`invokeai__switch-form-control ${styleClass}`}
{...formControlProps}
>
<FormLabel
className="invokeai__switch-form-label"
// fontSize={fontSize}
whiteSpace="nowrap"
{...formLabelProps}
>
{label}
<Switch
className="invokeai__switch-root"
// size={size}
// className="switch-button"
{...rest}
/>
</FormLabel>
</FormControl>
);
};

View File

@ -0,0 +1,72 @@
.dropzone-container {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
backdrop-filter: blur(20px);
.dropzone-overlay {
opacity: 0.5;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
row-gap: 1rem;
align-items: center;
justify-content: center;
&.is-drag-accept {
box-shadow: inset 0 0 20rem 1rem var(--status-good-color);
}
&.is-drag-reject {
box-shadow: inset 0 0 20rem 1rem var(--status-bad-color);
}
&.is-handling-upload {
box-shadow: inset 0 0 20rem 1rem var(--status-working-color);
}
}
}
.image-uploader-button-outer {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 0.5rem;
color: var(--subtext-color-bright);
&:hover {
background-color: var(--inpaint-bg-color);
}
}
.image-upload-button-inner {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.image-upload-button {
display: flex;
flex-direction: column;
row-gap: 2rem;
align-items: center;
justify-content: center;
text-align: center;
svg {
width: 4rem !important;
height: 4rem !important;
}
h2 {
font-size: 1.2rem !important;
}
}

View File

@ -0,0 +1,173 @@
import { useCallback, ReactNode, useState, useEffect } from 'react';
import { RootState, useAppDispatch, useAppSelector } from '../../app/store';
import { tabMap } from '../../features/tabs/InvokeTabs';
import { FileRejection, useDropzone } from 'react-dropzone';
import { Heading, Spinner, useToast } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { OptionsState } from '../../features/options/optionsSlice';
import { uploadImage } from '../../app/socketio/actions';
import { ImageUploadDestination, UploadImagePayload } from '../../app/invokeai';
import { ImageUploaderTriggerContext } from '../../app/contexts/ImageUploaderTriggerContext';
const appSelector = createSelector(
(state: RootState) => state.options,
(options: OptionsState) => {
const { activeTab } = options;
return {
activeTabName: tabMap[activeTab],
};
}
);
type ImageUploaderProps = {
children: ReactNode;
};
const ImageUploader = (props: ImageUploaderProps) => {
const { children } = props;
const dispatch = useAppDispatch();
const { activeTabName } = useAppSelector(appSelector);
const toast = useToast({});
const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false);
const fileRejectionCallback = useCallback(
(rejection: FileRejection) => {
setIsHandlingUpload(true);
const msg = rejection.errors.reduce(
(acc: string, cur: { message: string }) => acc + '\n' + cur.message,
''
);
toast({
title: 'Upload failed',
description: msg,
status: 'error',
isClosable: true,
});
},
[toast]
);
const fileAcceptedCallback = useCallback(
(file: File) => {
setIsHandlingUpload(true);
const payload: UploadImagePayload = { file };
if (['img2img', 'inpainting'].includes(activeTabName)) {
payload.destination = activeTabName as ImageUploadDestination;
}
dispatch(uploadImage(payload));
},
[dispatch, activeTabName]
);
const onDrop = useCallback(
(acceptedFiles: Array<File>, fileRejections: Array<FileRejection>) => {
fileRejections.forEach((rejection: FileRejection) => {
fileRejectionCallback(rejection);
});
acceptedFiles.forEach((file: File) => {
fileAcceptedCallback(file);
});
},
[fileAcceptedCallback, fileRejectionCallback]
);
const {
getRootProps,
getInputProps,
isDragAccept,
isDragReject,
isDragActive,
open,
} = useDropzone({
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
noClick: true,
onDrop,
maxFiles: 1,
});
useEffect(() => {
const pasteImageListener = (e: ClipboardEvent) => {
const dataTransferItemList = e.clipboardData?.items;
if (!dataTransferItemList) return;
const imageItems: Array<DataTransferItem> = [];
for (const item of dataTransferItemList) {
if (
item.kind === 'file' &&
['image/png', 'image/jpg'].includes(item.type)
) {
imageItems.push(item);
}
}
if (!imageItems.length) return;
e.stopImmediatePropagation();
if (imageItems.length > 1) {
toast({
description:
'Multiple images pasted, may only upload one image at a time',
status: 'error',
isClosable: true,
});
return;
}
const file = imageItems[0].getAsFile();
if (!file) {
toast({
description: 'Unable to load file',
status: 'error',
isClosable: true,
});
return;
}
const payload: UploadImagePayload = { file };
if (['img2img', 'inpainting'].includes(activeTabName)) {
payload.destination = activeTabName as ImageUploadDestination;
}
dispatch(uploadImage(payload));
};
document.addEventListener('paste', pasteImageListener);
return () => {
document.removeEventListener('paste', pasteImageListener);
};
}, [dispatch, toast, activeTabName]);
return (
<ImageUploaderTriggerContext.Provider value={open}>
<div {...getRootProps({ style: {} })}>
<input {...getInputProps()} />
{children}
{isDragActive && (
<div className="dropzone-container">
{isDragAccept && (
<div className="dropzone-overlay is-drag-accept">
<Heading size={'lg'}>Drop Images</Heading>
</div>
)}
{isDragReject && (
<div className="dropzone-overlay is-drag-reject">
<Heading size={'lg'}>Invalid Upload</Heading>
<Heading size={'md'}>Must be single JPEG or PNG image</Heading>
</div>
)}
{isHandlingUpload && (
<div className="dropzone-overlay is-handling-upload">
<Spinner />
</div>
)}
</div>
)}
</div>
</ImageUploaderTriggerContext.Provider>
);
};
export default ImageUploader;

View File

@ -0,0 +1,31 @@
import { Heading } from '@chakra-ui/react';
import { useContext } from 'react';
import { FaUpload } from 'react-icons/fa';
import { ImageUploaderTriggerContext } from '../../app/contexts/ImageUploaderTriggerContext';
type ImageUploaderButtonProps = {
styleClass?: string;
};
const ImageUploaderButton = (props: ImageUploaderButtonProps) => {
const { styleClass } = props;
const open = useContext(ImageUploaderTriggerContext);
const handleClickUpload = () => {
open && open();
};
return (
<div
className={`image-uploader-button-outer ${styleClass}`}
onClick={handleClickUpload}
>
<div className="image-upload-button">
<FaUpload />
<Heading size={'lg'}>Click or Drag and Drop</Heading>
</div>
</div>
);
};
export default ImageUploaderButton;

View File

@ -1,65 +0,0 @@
import { Button, useToast } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import { FileRejection } from 'react-dropzone';
import { useAppDispatch } from '../../app/store';
import ImageUploader from '../../features/options/ImageUploader';
interface InvokeImageUploaderProps {
label?: string;
icon?: any;
onMouseOver?: any;
OnMouseout?: any;
dispatcher: any;
styleClass?: string;
}
export default function InvokeImageUploader(props: InvokeImageUploaderProps) {
const { label, icon, dispatcher, styleClass, onMouseOver, OnMouseout } =
props;
const toast = useToast();
const dispatch = useAppDispatch();
// Callbacks to for handling file upload attempts
const fileAcceptedCallback = useCallback(
(file: File) => dispatch(dispatcher(file)),
[dispatch, dispatcher]
);
const fileRejectionCallback = useCallback(
(rejection: FileRejection) => {
const msg = rejection.errors.reduce(
(acc: string, cur: { message: string }) => acc + '\n' + cur.message,
''
);
toast({
title: 'Upload failed',
description: msg,
status: 'error',
isClosable: true,
});
},
[toast]
);
return (
<ImageUploader
fileAcceptedCallback={fileAcceptedCallback}
fileRejectionCallback={fileRejectionCallback}
styleClass={styleClass}
>
<Button
size={'sm'}
fontSize={'md'}
fontWeight={'normal'}
onMouseOver={onMouseOver}
onMouseOut={OnMouseout}
leftIcon={icon}
width={'100%'}
>
{label ? label : null}
</Button>
</ImageUploader>
);
}

View File

@ -0,0 +1,62 @@
.invokeai__slider-root {
position: relative;
display: flex;
align-items: center;
user-select: none;
touch-action: none;
width: 200px;
&[data-orientation='horizontal'] {
height: 20px;
}
&[data-orientation='vertical'] {
width: 20px;
height: 200px;
}
.invokeai__slider-track {
background-color: black;
position: relative;
flex-grow: 1;
border-radius: 9999px;
&[data-orientation='horizontal'] {
height: 0.25rem;
}
&[data-orientation='vertical'] {
width: 0.25rem;
}
.invokeai__slider-range {
position: absolute;
background-color: white;
border-radius: 9999px;
height: 100%;
}
}
.invokeai__slider-thumb {
display: flex;
align-items: center;
.invokeai__slider-thumb-div {
all: unset;
display: block;
width: 1rem;
height: 1rem;
background-color: white;
box-shadow: 0 2px 10px rgba(0, 2, 10, 0.3);
border-radius: 100%;
&:hover {
background-color: violet;
}
&:focus {
box-shadow: 0 0 0 5px rgba(0, 2, 10, 0.3);
}
}
}
}

View File

@ -0,0 +1,46 @@
import { Tooltip } from '@chakra-ui/react';
import * as Slider from '@radix-ui/react-slider';
import React from 'react';
import IAITooltip from './IAITooltip';
type IAISliderProps = Slider.SliderProps & {
value: number[];
tooltipLabel?: string;
orientation?: 'horizontal' | 'vertial';
trackProps?: Slider.SliderTrackProps;
rangeProps?: Slider.SliderRangeProps;
thumbProps?: Slider.SliderThumbProps;
};
const _IAISlider = (props: IAISliderProps) => {
const {
value,
tooltipLabel,
orientation,
trackProps,
rangeProps,
thumbProps,
...rest
} = props;
return (
<Slider.Root
className="invokeai__slider-root"
{...rest}
data-orientation={orientation || 'horizontal'}
>
<Slider.Track {...trackProps} className="invokeai__slider-track">
<Slider.Range {...rangeProps} className="invokeai__slider-range" />
</Slider.Track>
<Tooltip label={tooltipLabel ?? value[0]} placement="top">
<Slider.Thumb {...thumbProps} className="invokeai__slider-thumb">
<div className="invokeai__slider-thumb-div" />
{/*<IAITooltip trigger={<div className="invokeai__slider-thumb-div" />}>
{value && value[0]}
</IAITooltip>*/}
</Slider.Thumb>
</Tooltip>
</Slider.Root>
);
};
export default _IAISlider;

View File

@ -0,0 +1,8 @@
.invokeai__tooltip-content {
padding: 0.5rem;
background-color: grey;
border-radius: 0.25rem;
.invokeai__tooltip-arrow {
background-color: grey;
}
}

View File

@ -0,0 +1,35 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { ReactNode } from 'react';
type IAITooltipProps = Tooltip.TooltipProps & {
trigger: ReactNode;
children: ReactNode;
triggerProps?: Tooltip.TooltipTriggerProps;
contentProps?: Tooltip.TooltipContentProps;
arrowProps?: Tooltip.TooltipArrowProps;
};
const IAITooltip = (props: IAITooltipProps) => {
const { trigger, children, triggerProps, contentProps, arrowProps, ...rest } =
props;
return (
<Tooltip.Provider>
<Tooltip.Root {...rest} delayDuration={0}>
<Tooltip.Trigger {...triggerProps}>{trigger}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
{...contentProps}
onPointerDownOutside={(e: any) => {e.preventDefault()}}
className="invokeai__tooltip-content"
>
<Tooltip.Arrow {...arrowProps} className="invokeai__tooltip-arrow" />
{children}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
};
export default IAITooltip;

View File

@ -1,65 +1,61 @@
import { createSelector } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import _ from 'lodash';
import { useMemo } from 'react';
import { useAppSelector } from '../../app/store';
import { RootState } from '../../app/store';
import { OptionsState } from '../../features/options/optionsSlice';
import { SystemState } from '../../features/system/systemSlice';
import { InpaintingState } from '../../features/tabs/Inpainting/inpaintingSlice';
import { tabMap } from '../../features/tabs/InvokeTabs';
import { validateSeedWeights } from '../util/seedWeightPairs';
export const optionsSelector = createSelector(
(state: RootState) => state.options,
(options: OptionsState) => {
export const useCheckParametersSelector = createSelector(
[
(state: RootState) => state.options,
(state: RootState) => state.system,
(state: RootState) => state.inpainting,
],
(options: OptionsState, system: SystemState, inpainting: InpaintingState) => {
return {
// options
prompt: options.prompt,
shouldGenerateVariations: options.shouldGenerateVariations,
seedWeights: options.seedWeights,
maskPath: options.maskPath,
initialImagePath: options.initialImagePath,
initialImage: options.initialImage,
seed: options.seed,
activeTab: options.activeTab,
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
export const systemSelector = createSelector(
(state: RootState) => state.system,
(system: SystemState) => {
return {
activeTabName: tabMap[options.activeTab],
// system
isProcessing: system.isProcessing,
isConnected: system.isConnected,
// inpainting
hasInpaintingImage: Boolean(inpainting.imageToInpaint),
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
resultEqualityCheck: _.isEqual,
},
}
);
/**
* Checks relevant pieces of state to confirm generation will not deterministically fail.
* This is used to prevent the 'Generate' button from being clicked.
*/
const useCheckParameters = (): boolean => {
const { prompt } = useAppSelector(optionsSelector);
const {
prompt,
shouldGenerateVariations,
seedWeights,
maskPath,
initialImagePath,
initialImage,
seed,
activeTab,
} = useAppSelector(optionsSelector);
const { isProcessing, isConnected } = useAppSelector(systemSelector);
activeTabName,
isProcessing,
isConnected,
hasInpaintingImage,
} = useAppSelector(useCheckParametersSelector);
return useMemo(() => {
// Cannot generate without a prompt
@ -67,12 +63,16 @@ const useCheckParameters = (): boolean => {
return false;
}
if (prompt && !initialImagePath && activeTab === 1) {
if (activeTabName === 'img2img' && !initialImage) {
return false;
}
if (activeTabName === 'inpainting' && !hasInpaintingImage) {
return false;
}
// Cannot generate with a mask without img2img
if (maskPath && !initialImagePath) {
if (maskPath && !initialImage) {
return false;
}
@ -100,13 +100,14 @@ const useCheckParameters = (): boolean => {
}, [
prompt,
maskPath,
initialImagePath,
isProcessing,
initialImage,
isConnected,
shouldGenerateVariations,
seedWeights,
seed,
activeTab,
activeTabName,
hasInpaintingImage,
]);
};

View File

@ -1,22 +1,38 @@
/*
These functions translate frontend state into parameters
suitable for consumption by the backend, and vice-versa.
*/
import { NUMPY_RAND_MAX, NUMPY_RAND_MIN } from '../../app/constants';
import { OptionsState } from '../../features/options/optionsSlice';
import { SystemState } from '../../features/system/systemSlice';
import {
seedWeightsToString,
stringToSeedWeightsArray,
} from './seedWeightPairs';
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';
export type FrontendToBackendParametersConfig = {
generationMode: InvokeTabName;
optionsState: OptionsState;
inpaintingState: InpaintingState;
systemState: SystemState;
imageToProcessUrl?: string;
maskImageElement?: HTMLImageElement;
};
/**
* Translates/formats frontend state into parameters suitable
* for consumption by the API.
*/
export const frontendToBackendParameters = (
optionsState: OptionsState,
systemState: SystemState
config: FrontendToBackendParametersConfig
): { [key: string]: any } => {
const {
generationMode,
optionsState,
inpaintingState,
systemState,
imageToProcessUrl,
maskImageElement,
} = config;
const {
prompt,
iterations,
@ -30,10 +46,8 @@ export const frontendToBackendParameters = (
seed,
seamless,
hiresFix,
shouldUseInitImage,
img2imgStrength,
initialImagePath,
maskPath,
initialImage,
shouldFitToWidthHeight,
shouldGenerateVariations,
variationAmount,
@ -61,8 +75,6 @@ export const frontendToBackendParameters = (
width,
sampler_name: sampler,
seed,
seamless,
hires_fix: hiresFix,
progress_images: shouldDisplayInProgress,
};
@ -70,13 +82,65 @@ export const frontendToBackendParameters = (
? randomInt(NUMPY_RAND_MIN, NUMPY_RAND_MAX)
: seed;
if (shouldUseInitImage) {
generationParameters.init_img = initialImagePath;
// parameters common to txt2img and img2img
if (['txt2img', 'img2img'].includes(generationMode)) {
generationParameters.seamless = seamless;
generationParameters.hires_fix = hiresFix;
}
// img2img exclusive parameters
if (generationMode === 'img2img' && initialImage) {
generationParameters.init_img =
typeof initialImage === 'string' ? initialImage : initialImage.url;
generationParameters.strength = img2imgStrength;
generationParameters.fit = shouldFitToWidthHeight;
if (maskPath) {
generationParameters.init_mask = maskPath;
}
// inpainting exclusive parameters
if (generationMode === 'inpainting' && maskImageElement) {
const {
lines,
boundingBoxCoordinate: { x, y },
boundingBoxDimensions: { width, height },
shouldShowBoundingBox,
inpaintReplace,
shouldUseInpaintReplace,
} = inpaintingState;
let bx = x,
by = y,
bwidth = width,
bheight = height;
if (!shouldShowBoundingBox) {
bx = 0;
by = 0;
bwidth = maskImageElement.width;
bheight = maskImageElement.height;
}
const boundingBox = {
x: bx,
y: by,
width: bwidth,
height: bheight,
};
if (shouldUseInpaintReplace) {
generationParameters.inpaint_replace = inpaintReplace;
}
generationParameters.init_img = imageToProcessUrl;
generationParameters.strength = img2imgStrength;
generationParameters.fit = false;
const maskDataURL = generateMask(maskImageElement, lines, boundingBox);
generationParameters.init_mask = maskDataURL.split(
'data:image/png;base64,'
)[1];
generationParameters.bounding_box = boundingBox;
}
if (shouldGenerateVariations) {
@ -105,7 +169,7 @@ export const frontendToBackendParameters = (
strength: facetoolStrength,
};
if (facetoolType === 'codeformer') {
facetoolParameters.codeformer_fidelity = codeformerFidelity
facetoolParameters.codeformer_fidelity = codeformerFidelity;
}
}

View File

@ -0,0 +1,7 @@
export const roundDownToMultiple = (num: number, multiple: number): number => {
return Math.floor(num / multiple) * multiple;
};
export const roundToMultiple = (num: number, multiple: number): number => {
return Math.round(num / multiple) * multiple;
};

View File

@ -8,7 +8,7 @@ import { RootState } from '../../app/store';
import {
setActiveTab,
setAllParameters,
setInitialImagePath,
setInitialImage,
setSeed,
setShouldShowImageDetails,
} from '../options/optionsSlice';
@ -17,12 +17,21 @@ 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 { MdDelete, MdFace, MdHd, MdImage, MdInfo } from 'react-icons/md';
import {
MdDelete,
MdFace,
MdHd,
MdImage,
MdInfo,
} from 'react-icons/md';
import InvokePopover from './InvokePopover';
import UpscaleOptions from '../options/AdvancedOptions/Upscale/UpscaleOptions';
import FaceRestoreOptions from '../options/AdvancedOptions/FaceRestore/FaceRestoreOptions';
import { useHotkeys } from 'react-hotkeys-hook';
import { useToast } from '@chakra-ui/react';
import { FaCopy, FaPaintBrush, FaSeedling } from 'react-icons/fa';
import { setImageToInpaint } from '../tabs/Inpainting/inpaintingSlice';
import { hoverableImageSelector } from './gallerySliceSelectors';
const systemSelector = createSelector(
(state: RootState) => state.system,
@ -51,6 +60,7 @@ type CurrentImageButtonsProps = {
*/
const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
const dispatch = useAppDispatch();
const { activeTabName } = useAppSelector(hoverableImageSelector);
const shouldShowImageDetails = useAppSelector(
(state: RootState) => state.options.shouldShowImageDetails
@ -74,7 +84,7 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
useAppSelector(systemSelector);
const handleClickUseAsInitialImage = () => {
dispatch(setInitialImagePath(image.url));
dispatch(setInitialImage(image));
dispatch(setActiveTab(1));
};
@ -103,7 +113,8 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
);
const handleClickUseAllParameters = () =>
dispatch(setAllParameters(image.metadata));
image.metadata && dispatch(setAllParameters(image.metadata));
useHotkeys(
'a',
() => {
@ -128,9 +139,8 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
[image]
);
// Non-null assertion: this button is disabled if there is no seed.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const handleClickUseSeed = () => dispatch(setSeed(image.metadata.image.seed));
const handleClickUseSeed = () =>
image.metadata && dispatch(setSeed(image.metadata.image.seed));
useHotkeys(
's',
() => {
@ -221,6 +231,19 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
const handleClickShowImageDetails = () =>
dispatch(setShouldShowImageDetails(!shouldShowImageDetails));
const handleSendToInpainting = () => {
dispatch(setImageToInpaint(image));
if (activeTabName !== 'inpainting') {
dispatch(setActiveTab('inpainting'));
}
toast({
title: 'Sent to Inpainting',
status: 'success',
duration: 2500,
isClosable: true,
});
};
useHotkeys(
'i',
() => {
@ -247,7 +270,32 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
onClick={handleClickUseAsInitialImage}
/>
<IAIButton
<IAIIconButton
icon={<FaPaintBrush />}
tooltip="Send To Inpainting"
aria-label="Send To Inpainting"
onClick={handleSendToInpainting}
/>
<IAIIconButton
icon={<FaCopy />}
tooltip="Use All"
aria-label="Use All"
isDisabled={
!['txt2img', 'img2img'].includes(image?.metadata?.image?.type)
}
onClick={handleClickUseAllParameters}
/>
<IAIIconButton
icon={<FaSeedling />}
tooltip="Use Seed"
aria-label="Use Seed"
isDisabled={!image?.metadata?.image?.seed}
onClick={handleClickUseSeed}
/>
{/* <IAIButton
label="Use All"
isDisabled={
!['txt2img', 'img2img'].includes(image?.metadata?.image?.type)
@ -259,7 +307,7 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
label="Use Seed"
isDisabled={!image?.metadata?.image?.seed}
onClick={handleClickUseSeed}
/>
/> */}
<InvokePopover
title="Restore Faces"

View File

@ -1,57 +1,42 @@
@use '../../styles/Mixins/' as *;
.current-image-display {
display: grid;
grid-template-areas:
'current-image-tools'
'current-image-preview';
grid-template-rows: auto 1fr;
justify-items: center;
.current-image-area {
display: flex;
flex-direction: column;
height: 100%;
row-gap: 1rem;
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
}
.current-image-tools {
width: 100%;
height: 100%;
display: grid;
justify-content: center;
}
.current-image-options {
display: grid;
grid-auto-flow: column;
padding: 1rem;
height: fit-content;
gap: 0.5rem;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
column-gap: 0.5rem;
button {
@include Button(
$btn-width: 3rem,
$icon-size: 22px,
$btn-color: var(--btn-grey),
$btn-color-hover: var(--btn-grey-hover)
);
.chakra-popover__popper {
z-index: 11;
}
}
.current-image-preview {
grid-area: current-image-preview;
position: relative;
justify-content: center;
align-items: center;
display: grid;
display: flex;
width: 100%;
grid-template-areas: 'current-image-content';
height: 100%;
img {
grid-area: current-image-content;
background-color: var(--img2img-img-bg-color);
border-radius: 0.5rem;
object-fit: contain;
width: auto;
height: $app-gallery-height;
max-height: $app-gallery-height;
max-width: 100%;
max-height: 100%;
height: auto;
position: absolute;
}
}

View File

@ -2,39 +2,57 @@ import { RootState, useAppSelector } from '../../app/store';
import CurrentImageButtons from './CurrentImageButtons';
import { MdPhoto } from 'react-icons/md';
import CurrentImagePreview from './CurrentImagePreview';
import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer';
import { tabMap } from '../tabs/InvokeTabs';
import { GalleryState } from './gallerySlice';
import { OptionsState } from '../options/optionsSlice';
import _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
export const currentImageDisplaySelector = createSelector(
[(state: RootState) => state.gallery, (state: RootState) => state.options],
(gallery: GalleryState, options: OptionsState) => {
const { currentImage, intermediateImage } = gallery;
const { activeTab, shouldShowImageDetails } = options;
return {
currentImage,
intermediateImage,
activeTabName: tabMap[activeTab],
shouldShowImageDetails,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
/**
* Displays the current image if there is one, plus associated actions.
*/
const CurrentImageDisplay = () => {
const { currentImage, intermediateImage } = useAppSelector(
(state: RootState) => state.gallery
);
const shouldShowImageDetails = useAppSelector(
(state: RootState) => state.options.shouldShowImageDetails
);
const {
currentImage,
intermediateImage,
activeTabName,
} = useAppSelector(currentImageDisplaySelector);
const imageToDisplay = intermediateImage || currentImage;
return imageToDisplay ? (
<div className="current-image-display">
<div className="current-image-tools">
<CurrentImageButtons image={imageToDisplay} />
</div>
<CurrentImagePreview imageToDisplay={imageToDisplay} />
{shouldShowImageDetails && (
<ImageMetadataViewer
image={imageToDisplay}
styleClass="current-image-metadata"
/>
return (
<div className="current-image-area" data-tab-name={activeTabName}>
{imageToDisplay ? (
<>
<CurrentImageButtons image={imageToDisplay} />
<CurrentImagePreview imageToDisplay={imageToDisplay} />
</>
) : (
<div className="current-image-display-placeholder">
<MdPhoto />
</div>
)}
</div>
) : (
<div className="current-image-display-placeholder">
<MdPhoto />
</div>
);
};

View File

@ -1,23 +1,31 @@
import { IconButton, Image } from '@chakra-ui/react';
import React, { useState } from 'react';
import { useState } from 'react';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { RootState, useAppDispatch, useAppSelector } from '../../app/store';
import { GalleryState, selectNextImage, selectPrevImage } from './gallerySlice';
import * as InvokeAI from '../../app/invokeai';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { OptionsState } from '../options/optionsSlice';
import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer';
const imagesSelector = createSelector(
(state: RootState) => state.gallery,
(gallery: GalleryState) => {
const currentImageIndex = gallery.images.findIndex(
export const imagesSelector = createSelector(
[(state: RootState) => state.gallery, (state: RootState) => state.options],
(gallery: GalleryState, options: OptionsState) => {
const { currentCategory } = gallery;
const { shouldShowImageDetails } = options;
const tempImages = gallery.categories[currentCategory].images;
const currentImageIndex = tempImages.findIndex(
(i) => i.uuid === gallery?.currentImage?.uuid
);
const imagesLength = gallery.images.length;
const imagesLength = tempImages.length;
return {
currentCategory,
isOnFirstImage: currentImageIndex === 0,
isOnLastImage:
!isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1,
shouldShowImageDetails,
};
},
{
@ -35,11 +43,12 @@ export default function CurrentImagePreview(props: CurrentImagePreviewProps) {
const { imageToDisplay } = props;
const dispatch = useAppDispatch();
const { isOnFirstImage, isOnLastImage } = useAppSelector(imagesSelector);
const shouldShowImageDetails = useAppSelector(
(state: RootState) => state.options.shouldShowImageDetails
);
const {
isOnFirstImage,
isOnLastImage,
currentCategory,
shouldShowImageDetails,
} = useAppSelector(imagesSelector);
const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] =
useState<boolean>(false);
@ -53,20 +62,19 @@ export default function CurrentImagePreview(props: CurrentImagePreviewProps) {
};
const handleClickPrevButton = () => {
dispatch(selectPrevImage());
dispatch(selectPrevImage(currentCategory));
};
const handleClickNextButton = () => {
dispatch(selectNextImage());
dispatch(selectNextImage(currentCategory));
};
return (
<div className="current-image-preview">
<div className={'current-image-preview'}>
<Image
src={imageToDisplay.url}
fit="contain"
maxWidth={'100%'}
maxHeight={'100%'}
width={imageToDisplay.width}
height={imageToDisplay.height}
/>
{!shouldShowImageDetails && (
<div className="current-image-next-prev-buttons">
@ -100,6 +108,12 @@ export default function CurrentImagePreview(props: CurrentImagePreviewProps) {
</div>
</div>
)}
{shouldShowImageDetails && (
<ImageMetadataViewer
image={imageToDisplay}
styleClass="current-image-metadata"
/>
)}
</div>
);
}

View File

@ -12,7 +12,6 @@
.hoverable-image-image {
width: 100%;
height: 100%;
object-fit: cover;
max-width: 100%;
max-height: 100%;
}
@ -65,7 +64,7 @@
}
.hoverable-image-context-menu {
z-index: 999;
z-index: 15;
padding: 0.4rem;
border-radius: 0.25rem;
background-color: var(--context-menu-bg-color);

View File

@ -6,8 +6,12 @@ import {
Tooltip,
useToast,
} from '@chakra-ui/react';
import { RootState, useAppDispatch, useAppSelector } from '../../app/store';
import { setCurrentImage } from './gallerySlice';
import { useAppDispatch, useAppSelector } from '../../app/store';
import {
setCurrentImage,
setShouldHoldGalleryOpen,
setShouldShowGallery,
} from './gallerySlice';
import { FaCheck, FaTrashAlt } from 'react-icons/fa';
import DeleteImageModal from './DeleteImageModal';
import { memo, useState } from 'react';
@ -15,13 +19,15 @@ import {
setActiveTab,
setAllImageToImageParameters,
setAllTextToImageParameters,
setInitialImagePath,
setInitialImage,
setPrompt,
setSeed,
} from '../options/optionsSlice';
import * as InvokeAI from '../../app/invokeai';
import * as ContextMenu from '@radix-ui/react-context-menu';
import { tabMap } from '../tabs/InvokeTabs';
import { setImageToInpaint } from '../tabs/Inpainting/inpaintingSlice';
import { hoverableImageSelector } from './gallerySliceSelectors';
interface HoverableImageProps {
image: InvokeAI.Image;
@ -38,23 +44,21 @@ const memoEqualityCheck = (
*/
const HoverableImage = memo((props: HoverableImageProps) => {
const dispatch = useAppDispatch();
const activeTab = useAppSelector(
(state: RootState) => state.options.activeTab
);
const { activeTabName, galleryImageObjectFit, galleryImageMinimumWidth } =
useAppSelector(hoverableImageSelector);
const { image, isSelected } = props;
const { url, uuid, metadata } = image;
const [isHovered, setIsHovered] = useState<boolean>(false);
const toast = useToast();
const { image, isSelected } = props;
const { url, uuid, metadata } = image;
const handleMouseOver = () => setIsHovered(true);
const handleMouseOut = () => setIsHovered(false);
const handleUsePrompt = () => {
dispatch(setPrompt(image.metadata.image.prompt));
image.metadata && dispatch(setPrompt(image.metadata.image.prompt));
toast({
title: 'Prompt Set',
status: 'success',
@ -64,7 +68,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
};
const handleUseSeed = () => {
dispatch(setSeed(image.metadata.image.seed));
image.metadata && dispatch(setSeed(image.metadata.image.seed));
toast({
title: 'Seed Set',
status: 'success',
@ -74,9 +78,9 @@ const HoverableImage = memo((props: HoverableImageProps) => {
};
const handleSendToImageToImage = () => {
dispatch(setInitialImagePath(image.url));
if (activeTab !== 1) {
dispatch(setActiveTab(1));
dispatch(setInitialImage(image));
if (activeTabName !== 'img2img') {
dispatch(setActiveTab('img2img'));
}
toast({
title: 'Sent to Image To Image',
@ -86,8 +90,21 @@ const HoverableImage = memo((props: HoverableImageProps) => {
});
};
const handleSendToInpainting = () => {
dispatch(setImageToInpaint(image));
if (activeTabName !== 'inpainting') {
dispatch(setActiveTab('inpainting'));
}
toast({
title: 'Sent to Inpainting',
status: 'success',
duration: 2500,
isClosable: true,
});
};
const handleUseAllParameters = () => {
dispatch(setAllTextToImageParameters(metadata));
metadata && dispatch(setAllTextToImageParameters(metadata));
toast({
title: 'Parameters Set',
status: 'success',
@ -124,7 +141,12 @@ const HoverableImage = memo((props: HoverableImageProps) => {
const handleSelectImage = () => dispatch(setCurrentImage(image));
return (
<ContextMenu.Root>
<ContextMenu.Root
onOpenChange={(open: boolean) => {
dispatch(setShouldHoldGalleryOpen(open));
dispatch(setShouldShowGallery(true));
}}
>
<ContextMenu.Trigger>
<Box
position={'relative'}
@ -135,7 +157,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
>
<Image
className="hoverable-image-image"
objectFit="cover"
objectFit={galleryImageObjectFit}
rounded={'md'}
src={url}
loading={'lazy'}
@ -150,7 +172,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
/>
)}
</div>
{isHovered && (
{isHovered && galleryImageMinimumWidth >= 64 && (
<div className="hoverable-image-delete-button">
<Tooltip label={'Delete image'} hasArrow>
<DeleteImageModal image={image}>
@ -167,7 +189,10 @@ const HoverableImage = memo((props: HoverableImageProps) => {
)}
</Box>
</ContextMenu.Trigger>
<ContextMenu.Content className="hoverable-image-context-menu">
<ContextMenu.Content
className="hoverable-image-context-menu"
sticky={'always'}
>
<ContextMenu.Item
onClickCapture={handleUsePrompt}
disabled={image?.metadata?.image?.prompt === undefined}
@ -200,6 +225,9 @@ const HoverableImage = memo((props: HoverableImageProps) => {
<ContextMenu.Item onClickCapture={handleSendToImageToImage}>
Send to Image To Image
</ContextMenu.Item>
<ContextMenu.Item onClickCapture={handleSendToInpainting}>
Send to Inpainting
</ContextMenu.Item>
<DeleteImageModal image={image}>
<ContextMenu.Item data-warning>Delete Image</ContextMenu.Item>
</DeleteImageModal>

View File

@ -1,64 +1,159 @@
@use '../../styles/Mixins/' as *;
.image-gallery-area-enter {
transform: translateX(150%);
}
.image-gallery-area-enter-active {
transform: translateX(0);
transition: all 120ms ease-out;
}
.image-gallery-area-exit {
transform: translateX(0);
}
.image-gallery-area-exit-active {
transform: translateX(150%);
transition: all 120ms ease-out;
}
.image-gallery-area {
.image-gallery-popup-btn {
position: absolute;
top: 50%;
right: 1rem;
border-radius: 0.5rem 0 0 0.5rem;
padding: 0 0.5rem;
@include Button(
$btn-width: 1rem,
$btn-height: 6rem,
$icon-size: 20px,
$btn-color: var(--btn-grey),
$btn-color-hover: var(--btn-grey-hover)
);
z-index: 10;
&[data-pinned='false'] {
position: fixed;
height: 100vh;
top: 0;
right: 0;
.image-gallery-popup {
border-radius: 0;
.image-gallery-container {
max-height: calc($app-height + 5rem);
}
}
}
.image-gallery-popup {
background-color: var(--tab-color);
padding: 1rem;
display: flex;
flex-direction: column;
row-gap: 1rem;
border-radius: 0.5rem;
border-left-width: 0.3rem;
border-color: var(--resizeable-handle-border-color);
&[data-resize-alert='true'] {
border-color: var(--status-bad-color);
}
.image-gallery-header {
display: flex;
align-items: center;
column-gap: 0.5rem;
justify-content: space-between;
div {
display: flex;
column-gap: 0.5rem;
column-gap: 0.5rem;
}
.image-gallery-icon-btn {
background-color: var(--btn-load-more) !important;
&:hover {
background-color: var(--btn-load-more-hover) !important;
}
}
.image-gallery-settings-popover {
display: flex;
flex-direction: column;
row-gap: 0.5rem;
div {
display: flex;
column-gap: 0.5rem;
align-items: center;
justify-content: space-between;
}
}
h1 {
font-weight: bold;
}
}
.image-gallery-container {
display: flex;
flex-direction: column;
max-height: $app-gallery-popover-height;
overflow-y: scroll;
@include HideScrollbar;
.image-gallery-container-placeholder {
display: flex;
flex-direction: column;
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
place-items: center;
padding: 2rem;
text-align: center;
p {
color: var(--subtext-color-bright);
font-family: Inter;
}
svg {
width: 5rem;
height: 5rem;
color: var(--svg-color);
}
}
.image-gallery-load-more-btn {
background-color: var(--btn-load-more) !important;
font-size: 0.85rem !important;
padding: 0.5rem;
margin-top: 1rem;
&:disabled {
&:hover {
background-color: var(--btn-load-more) !important;
}
}
&:hover {
background-color: var(--btn-load-more-hover) !important;
}
}
}
}
}
.image-gallery-popup {
background-color: var(--tab-color);
padding: 1rem;
animation: slideOut 0.3s ease-out;
display: flex;
flex-direction: column;
row-gap: 1rem;
border-radius: 0.5rem;
border-left-width: 0.2rem;
min-width: 300px;
border-color: var(--gallery-resizeable-color);
}
.image-gallery-category-btn-group {
width: 100% !important;
column-gap: 0 !important;
justify-content: stretch !important;
.image-gallery-header {
display: flex;
align-items: center;
h1 {
font-weight: bold;
button {
flex-grow: 1;
&[data-selected='true'] {
background-color: var(--accent-color);
&:hover {
background-color: var(--accent-color-hover);
}
}
}
}
.image-gallery-close-btn {
background-color: var(--btn-load-more) !important;
&:hover {
background-color: var(--btn-load-more-hover) !important;
}
}
.image-gallery-container {
display: flex;
flex-direction: column;
gap: 1rem;
max-height: $app-gallery-popover-height;
overflow-y: scroll;
@include HideScrollbar;
}
// from https://css-tricks.com/a-grid-of-logos-in-squares/
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, auto));
grid-gap: 0.5rem;
.hoverable-image {
padding: 0.5rem;
@ -86,38 +181,3 @@
}
}
}
.image-gallery-load-more-btn {
background-color: var(--btn-load-more) !important;
font-size: 0.85rem !important;
font-family: Inter;
&:disabled {
&:hover {
background-color: var(--btn-load-more) !important;
}
}
&:hover {
background-color: var(--btn-load-more-hover) !important;
}
}
.image-gallery-container-placeholder {
display: flex;
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
place-items: center;
padding: 2rem 0;
p {
color: var(--subtext-color-bright);
font-family: Inter;
}
svg {
width: 5rem;
height: 5rem;
color: var(--svg-color);
}
}

View File

@ -1,47 +1,146 @@
import { Button, IconButton } from '@chakra-ui/button';
import { Resizable } from 're-resizable';
import { Button } from '@chakra-ui/button';
import { NumberSize, Resizable, Size } from 're-resizable';
import React from 'react';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { MdClear, MdPhotoLibrary } from 'react-icons/md';
import { BsPinAngleFill } from 'react-icons/bs';
import { requestImages } from '../../app/socketio/actions';
import { RootState, useAppDispatch, useAppSelector } from '../../app/store';
import { useAppDispatch, useAppSelector } from '../../app/store';
import IAIIconButton from '../../common/components/IAIIconButton';
import { selectNextImage, selectPrevImage } from './gallerySlice';
import {
selectNextImage,
selectPrevImage,
setCurrentCategory,
setGalleryImageMinimumWidth,
setGalleryImageObjectFit,
setGalleryScrollPosition,
setGalleryWidth,
setShouldAutoSwitchToNewImages,
setShouldHoldGalleryOpen,
setShouldPinGallery,
} from './gallerySlice';
import HoverableImage from './HoverableImage';
import { setShouldShowGallery } from '../options/optionsSlice';
import { setShouldShowGallery } from '../gallery/gallerySlice';
import { ButtonGroup, useToast } from '@chakra-ui/react';
import { CSSTransition } from 'react-transition-group';
import { Direction } from 're-resizable/lib/resizer';
import { imageGallerySelector } from './gallerySliceSelectors';
import { FaImage, FaUser, FaWrench } from 'react-icons/fa';
import IAIPopover from '../../common/components/IAIPopover';
import IAISlider from '../../common/components/IAISlider';
import { BiReset } from 'react-icons/bi';
import IAICheckbox from '../../common/components/IAICheckbox';
import { setNeedsCache } from '../tabs/Inpainting/inpaintingSlice';
import _ from 'lodash';
const GALLERY_SHOW_BUTTONS_MIN_WIDTH = 320;
export default function ImageGallery() {
const { images, currentImageUuid, areMoreImagesAvailable } = useAppSelector(
(state: RootState) => state.gallery
);
const shouldShowGallery = useAppSelector(
(state: RootState) => state.options.shouldShowGallery
);
const activeTab = useAppSelector(
(state: RootState) => state.options.activeTab
);
const dispatch = useAppDispatch();
const toast = useToast();
const handleShowGalleryToggle = () => {
dispatch(setShouldShowGallery(!shouldShowGallery));
const {
images,
currentCategory,
currentImageUuid,
shouldPinGallery,
shouldShowGallery,
galleryScrollPosition,
galleryImageMinimumWidth,
galleryGridTemplateColumns,
activeTabName,
galleryImageObjectFit,
shouldHoldGalleryOpen,
shouldAutoSwitchToNewImages,
areMoreImagesAvailable,
galleryWidth,
} = useAppSelector(imageGallerySelector);
const [galleryMinWidth, setGalleryMinWidth] = useState<number>(300);
const [galleryMaxWidth, setGalleryMaxWidth] = useState<number>(590);
const [shouldShowButtons, setShouldShowButtons] = useState<boolean>(
galleryWidth >= GALLERY_SHOW_BUTTONS_MIN_WIDTH
);
useEffect(() => {
if (!shouldPinGallery) return;
if (activeTabName === 'inpainting') {
dispatch(setGalleryWidth(220));
setGalleryMinWidth(220);
setGalleryMaxWidth(220);
} else if (activeTabName === 'img2img') {
dispatch(
setGalleryWidth(Math.min(Math.max(Number(galleryWidth), 0), 490))
);
setGalleryMaxWidth(490);
} else {
dispatch(
setGalleryWidth(Math.min(Math.max(Number(galleryWidth), 0), 590))
);
setGalleryMaxWidth(590);
}
dispatch(setNeedsCache(true));
}, [dispatch, activeTabName, shouldPinGallery, galleryWidth]);
useEffect(() => {
if (!shouldPinGallery) {
setGalleryMaxWidth(window.innerWidth);
}
}, [shouldPinGallery]);
const galleryRef = useRef<HTMLDivElement>(null);
const galleryContainerRef = useRef<HTMLDivElement>(null);
const timeoutIdRef = useRef<number | null>(null);
const handleSetShouldPinGallery = () => {
dispatch(setNeedsCache(true));
dispatch(setShouldPinGallery(!shouldPinGallery));
};
const handleGalleryClose = () => {
const handleToggleGallery = () => {
shouldShowGallery ? handleCloseGallery() : handleOpenGallery();
};
const handleOpenGallery = () => {
dispatch(setShouldShowGallery(true));
dispatch(setNeedsCache(true));
};
const handleCloseGallery = () => {
dispatch(
setGalleryScrollPosition(
galleryContainerRef.current ? galleryContainerRef.current.scrollTop : 0
)
);
dispatch(setShouldShowGallery(false));
dispatch(setShouldHoldGalleryOpen(false));
dispatch(setNeedsCache(true));
};
const handleClickLoadMore = () => {
dispatch(requestImages());
dispatch(requestImages(currentCategory));
};
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
dispatch(setNeedsCache(true));
};
const setCloseGalleryTimer = () => {
timeoutIdRef.current = window.setTimeout(() => handleCloseGallery(), 500);
};
const cancelCloseGalleryTimer = () => {
timeoutIdRef.current && window.clearTimeout(timeoutIdRef.current);
};
useHotkeys(
'g',
() => {
handleShowGalleryToggle();
handleToggleGallery();
},
[shouldShowGallery]
);
@ -49,81 +148,353 @@ export default function ImageGallery() {
useHotkeys(
'left',
() => {
dispatch(selectPrevImage());
dispatch(selectPrevImage(currentCategory));
},
[]
[currentCategory]
);
useHotkeys(
'right',
() => {
dispatch(selectNextImage());
dispatch(selectNextImage(currentCategory));
},
[]
[currentCategory]
);
return (
<div className="image-gallery-area">
{!shouldShowGallery && (
<IAIIconButton
tooltip="Show Gallery"
tooltipPlacement="top"
aria-label="Show Gallery"
onClick={handleShowGalleryToggle}
className="image-gallery-popup-btn"
>
<MdPhotoLibrary />
</IAIIconButton>
)}
useHotkeys(
'shift+p',
() => {
handleSetShouldPinGallery();
},
[shouldPinGallery]
);
{shouldShowGallery && (
const IMAGE_SIZE_STEP = 32;
useHotkeys(
'shift+up',
() => {
if (galleryImageMinimumWidth >= 256) {
return;
}
if (galleryImageMinimumWidth < 256) {
const newMinWidth = galleryImageMinimumWidth + IMAGE_SIZE_STEP;
if (newMinWidth <= 256) {
dispatch(setGalleryImageMinimumWidth(newMinWidth));
toast({
title: `Gallery Thumbnail Size set to ${newMinWidth}`,
status: 'success',
duration: 1000,
isClosable: true,
});
} else {
dispatch(setGalleryImageMinimumWidth(256));
toast({
title: `Gallery Thumbnail Size set to 256`,
status: 'success',
duration: 1000,
isClosable: true,
});
}
}
},
[galleryImageMinimumWidth]
);
useHotkeys(
'shift+down',
() => {
if (galleryImageMinimumWidth <= 32) {
return;
}
if (galleryImageMinimumWidth > 32) {
const newMinWidth = galleryImageMinimumWidth - IMAGE_SIZE_STEP;
if (newMinWidth > 32) {
dispatch(setGalleryImageMinimumWidth(newMinWidth));
toast({
title: `Gallery Thumbnail Size set to ${newMinWidth}`,
status: 'success',
duration: 1000,
isClosable: true,
});
} else {
dispatch(setGalleryImageMinimumWidth(32));
toast({
title: `Gallery Thumbnail Size set to 32`,
status: 'success',
duration: 1000,
isClosable: true,
});
}
}
},
[galleryImageMinimumWidth]
);
useHotkeys(
'shift+r',
() => {
dispatch(setGalleryImageMinimumWidth(64));
toast({
title: `Reset Gallery Image Size`,
status: 'success',
duration: 2500,
isClosable: true,
});
},
[galleryImageMinimumWidth]
);
// set gallery scroll position
useEffect(() => {
if (!galleryContainerRef.current) return;
galleryContainerRef.current.scrollTop = galleryScrollPosition;
}, [galleryScrollPosition, shouldShowGallery]);
return (
<CSSTransition
nodeRef={galleryRef}
in={shouldShowGallery || (shouldHoldGalleryOpen && !shouldPinGallery)}
unmountOnExit
timeout={200}
classNames="image-gallery-area"
>
<div
className="image-gallery-area"
data-pinned={shouldPinGallery}
ref={galleryRef}
onMouseLeave={!shouldPinGallery ? setCloseGalleryTimer : undefined}
onMouseEnter={!shouldPinGallery ? cancelCloseGalleryTimer : undefined}
onMouseOver={!shouldPinGallery ? cancelCloseGalleryTimer : undefined}
>
<Resizable
defaultSize={{ width: '300', height: '100%' }}
minWidth={'300'}
maxWidth={activeTab == 1 ? '300' : '600'}
className="image-gallery-popup"
minWidth={galleryMinWidth}
maxWidth={galleryMaxWidth}
// maxHeight={'100%'}
className={'image-gallery-popup'}
handleStyles={{ left: { width: '15px' } }}
enable={{
top: false,
right: false,
bottom: false,
left: true,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
}}
size={{
width: galleryWidth,
height: shouldPinGallery ? '100%' : '100vh',
}}
onResizeStop={(
_event: MouseEvent | TouchEvent,
_direction: Direction,
elementRef: HTMLElement,
delta: NumberSize
) => {
dispatch(
setGalleryWidth(
_.clamp(
Number(galleryWidth) + delta.width,
0,
Number(galleryMaxWidth)
)
)
);
elementRef.removeAttribute('data-resize-alert');
}}
onResize={(
_event: MouseEvent | TouchEvent,
_direction: Direction,
elementRef: HTMLElement,
delta: NumberSize
) => {
const newWidth = _.clamp(
Number(galleryWidth) + delta.width,
0,
Number(galleryMaxWidth)
);
if (newWidth >= 320 && !shouldShowButtons) {
setShouldShowButtons(true);
} else if (newWidth < 320 && shouldShowButtons) {
setShouldShowButtons(false);
}
if (newWidth >= galleryMaxWidth) {
elementRef.setAttribute('data-resize-alert', 'true');
} else {
elementRef.removeAttribute('data-resize-alert');
}
}}
>
<div className="image-gallery-header">
<h1>Your Invocations</h1>
<IconButton
size={'sm'}
aria-label={'Close Gallery'}
onClick={handleGalleryClose}
className="image-gallery-close-btn"
icon={<MdClear />}
/>
</div>
<div className="image-gallery-container">
{images.length ? (
<div className="image-gallery">
{images.map((image) => {
const { uuid } = image;
const isSelected = currentImageUuid === uuid;
return (
<HoverableImage
key={uuid}
image={image}
isSelected={isSelected}
<div>
<ButtonGroup
size="sm"
isAttached
variant="solid"
className="image-gallery-category-btn-group"
>
{shouldShowButtons ? (
<>
<Button
data-selected={currentCategory === 'result'}
onClick={() => dispatch(setCurrentCategory('result'))}
>
Invocations
</Button>
<Button
data-selected={currentCategory === 'user'}
onClick={() => dispatch(setCurrentCategory('user'))}
>
User
</Button>
</>
) : (
<>
<IAIIconButton
aria-label="Show Invocations"
tooltip="Show Invocations"
data-selected={currentCategory === 'result'}
icon={<FaImage />}
onClick={() => dispatch(setCurrentCategory('result'))}
/>
);
})}
</div>
<IAIIconButton
aria-label="Show Uploads"
tooltip="Show Uploads"
data-selected={currentCategory === 'user'}
icon={<FaUser />}
onClick={() => dispatch(setCurrentCategory('user'))}
/>
</>
)}
</ButtonGroup>
</div>
<div>
<IAIPopover
trigger="hover"
hasArrow={activeTabName === 'inpainting' ? false : true}
placement={'left'}
triggerComponent={
<IAIIconButton
size={'sm'}
aria-label={'Gallery Settings'}
icon={<FaWrench />}
className="image-gallery-icon-btn"
cursor={'pointer'}
/>
}
>
<div className="image-gallery-settings-popover">
<div>
<IAISlider
value={galleryImageMinimumWidth}
onChange={handleChangeGalleryImageMinimumWidth}
min={32}
max={256}
width={100}
label={'Image Size'}
formLabelProps={{ style: { fontSize: '0.9rem' } }}
sliderThumbTooltipProps={{
label: `${galleryImageMinimumWidth}px`,
}}
/>
<IAIIconButton
size={'sm'}
aria-label={'Reset'}
tooltip={'Reset Size'}
onClick={() => dispatch(setGalleryImageMinimumWidth(64))}
icon={<BiReset />}
data-selected={shouldPinGallery}
styleClass="image-gallery-icon-btn"
/>
</div>
<div>
<IAICheckbox
label="Maintain Aspect Ratio"
isChecked={galleryImageObjectFit === 'contain'}
onChange={() =>
dispatch(
setGalleryImageObjectFit(
galleryImageObjectFit === 'contain'
? 'cover'
: 'contain'
)
)
}
/>
</div>
<div>
<IAICheckbox
label="Auto-Switch to New Images"
isChecked={shouldAutoSwitchToNewImages}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(
setShouldAutoSwitchToNewImages(e.target.checked)
)
}
/>
</div>
</div>
</IAIPopover>
<IAIIconButton
size={'sm'}
aria-label={'Pin Gallery'}
tooltip={'Pin Gallery (Shift+P)'}
onClick={handleSetShouldPinGallery}
icon={<BsPinAngleFill />}
data-selected={shouldPinGallery}
/>
<IAIIconButton
size={'sm'}
aria-label={'Close Gallery'}
tooltip={'Close Gallery (G)'}
onClick={handleCloseGallery}
className="image-gallery-icon-btn"
icon={<MdClear />}
/>
</div>
</div>
<div className="image-gallery-container" ref={galleryContainerRef}>
{images.length || areMoreImagesAvailable ? (
<>
<div
className="image-gallery"
style={{ gridTemplateColumns: galleryGridTemplateColumns }}
>
{images.map((image) => {
const { uuid } = image;
const isSelected = currentImageUuid === uuid;
return (
<HoverableImage
key={uuid}
image={image}
isSelected={isSelected}
/>
);
})}
</div>
<Button
onClick={handleClickLoadMore}
isDisabled={!areMoreImagesAvailable}
className="image-gallery-load-more-btn"
>
{areMoreImagesAvailable ? 'Load More' : 'All Images Loaded'}
</Button>
</>
) : (
<div className="image-gallery-container-placeholder">
<MdPhotoLibrary />
<p>No Images In Gallery</p>
</div>
)}
<Button
onClick={handleClickLoadMore}
isDisabled={!areMoreImagesAvailable}
className="image-gallery-load-more-btn"
>
{areMoreImagesAvailable ? 'Load More' : 'All Images Loaded'}
</Button>
</div>
</Resizable>
)}
</div>
</div>
</CSSTransition>
);
}

View File

@ -1,129 +0,0 @@
import {
Button,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
useDisclosure,
} from '@chakra-ui/react';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { MdPhotoLibrary } from 'react-icons/md';
import { requestImages } from '../../app/socketio/actions';
import { RootState, useAppDispatch } from '../../app/store';
import { useAppSelector } from '../../app/store';
import { selectNextImage, selectPrevImage } from './gallerySlice';
import HoverableImage from './HoverableImage';
/**
* Simple image gallery.
*/
const ImageGalleryOld = () => {
const { images, currentImageUuid, areMoreImagesAvailable } = useAppSelector(
(state: RootState) => state.gallery
);
const dispatch = useAppDispatch();
const { isOpen, onOpen, onClose } = useDisclosure();
/**
* I don't like that this needs to rerender whenever the current image is changed.
* What if we have a large number of images? I suppose pagination (planned) will
* mitigate this issue.
*
* TODO: Refactor if performance complaints, or after migrating to new API which supports pagination.
*/
const handleClickLoadMore = () => {
dispatch(requestImages());
};
useHotkeys(
'g',
() => {
if (isOpen) {
onClose();
} else {
onOpen();
}
},
[isOpen]
);
useHotkeys(
'left',
() => {
dispatch(selectPrevImage());
},
[]
);
useHotkeys(
'right',
() => {
dispatch(selectNextImage());
},
[]
);
return (
<div className="image-gallery-area">
<Button
colorScheme="teal"
onClick={onOpen}
className="image-gallery-popup-btn"
>
<MdPhotoLibrary />
</Button>
<Drawer
isOpen={isOpen}
placement="right"
onClose={onClose}
autoFocus={false}
trapFocus={false}
closeOnOverlayClick={false}
>
<DrawerContent className="image-gallery-popup">
<div className="image-gallery-header">
<DrawerHeader>Your Invocations</DrawerHeader>
<DrawerCloseButton />
</div>
<DrawerBody className="image-gallery-body">
<div className="image-gallery-container">
{images.length ? (
<div className="image-gallery">
{images.map((image) => {
const { uuid } = image;
const isSelected = currentImageUuid === uuid;
return (
<HoverableImage
key={uuid}
image={image}
isSelected={isSelected}
/>
);
})}
</div>
) : (
<div className="image-gallery-container-placeholder">
<MdPhotoLibrary />
<p>No Images In Gallery</p>
</div>
)}
<Button
onClick={handleClickLoadMore}
isDisabled={!areMoreImagesAvailable}
className="image-gallery-load-more-btn"
>
{areMoreImagesAvailable ? 'Load More' : 'All Images Loaded'}
</Button>
</div>
</DrawerBody>
</DrawerContent>
</Drawer>
</div>
);
};
export default ImageGalleryOld;

View File

@ -1,12 +1,15 @@
@use '../../../styles/Mixins/' as *;
.image-metadata-viewer {
position: absolute;
top: 0;
width: 100%;
border-radius: 0.5rem;
padding: 1rem;
background-color: var(--metadata-bg-color);
overflow: scroll;
max-height: $app-metadata-height;
height: 100%;
z-index: 10;
}

View File

@ -20,7 +20,6 @@ import {
setHeight,
setHiresFix,
setImg2imgStrength,
setInitialImagePath,
setMaskPath,
setPrompt,
setSampler,
@ -32,6 +31,7 @@ import {
setUpscalingLevel,
setUpscalingStrength,
setWidth,
setInitialImage,
} from '../../options/optionsSlice';
import promptToString from '../../../common/util/promptToString';
import { seedWeightsToString } from '../../../common/util/seedWeightPairs';
@ -248,7 +248,7 @@ const ImageMetadataViewer = memo(
label="Initial image"
value={init_image_path}
isLink
onClick={() => dispatch(setInitialImagePath(init_image_path))}
onClick={() => dispatch(setInitialImage(init_image_path))}
/>
)}
{mask_image_path && (

View File

@ -0,0 +1,20 @@
@use '../../styles/Mixins/' as *;
.show-hide-gallery-button {
position: absolute !important;
top: 50%;
right: -1rem;
transform: translate(0, -50%);
z-index: 10;
border-radius: 0.5rem 0 0 0.5rem !important;
padding: 0 0.5rem;
@include Button(
$btn-width: 1rem,
$btn-height: 12rem,
$icon-size: 20px,
$btn-color: var(--btn-grey),
$btn-color-hover: var(--btn-grey-hover)
);
}

View File

@ -0,0 +1,57 @@
import { useHotkeys } from 'react-hotkeys-hook';
import { MdPhotoLibrary } from 'react-icons/md';
import { RootState, useAppDispatch, useAppSelector } from '../../app/store';
import IAIIconButton from '../../common/components/IAIIconButton';
import { setShouldShowGallery } from '../gallery/gallerySlice';
import { selectNextImage, selectPrevImage } from './gallerySlice';
const ShowHideGalleryButton = () => {
const dispatch = useAppDispatch();
const { shouldPinGallery, shouldShowGallery } = useAppSelector(
(state: RootState) => state.gallery
);
const handleShowGalleryToggle = () => {
dispatch(setShouldShowGallery(!shouldShowGallery));
};
// useHotkeys(
// 'g',
// () => {
// handleShowGalleryToggle();
// },
// [shouldShowGallery]
// );
// useHotkeys(
// 'left',
// () => {
// dispatch(selectPrevImage());
// },
// []
// );
// useHotkeys(
// 'right',
// () => {
// dispatch(selectNextImage());
// },
// []
// );
return (
<IAIIconButton
tooltip="Show Gallery (G)"
tooltipPlacement="top"
aria-label="Show Gallery"
onClick={handleShowGalleryToggle}
styleClass="show-hide-gallery-button"
onMouseOver={!shouldPinGallery ? handleShowGalleryToggle : undefined}
>
<MdPhotoLibrary />
</IAIIconButton>
);
};
export default ShowHideGalleryButton;

View File

@ -3,20 +3,67 @@ import type { PayloadAction } from '@reduxjs/toolkit';
import _, { clamp } from 'lodash';
import * as InvokeAI from '../../app/invokeai';
export type GalleryCategory = 'user' | 'result';
export type AddImagesPayload = {
images: Array<InvokeAI.Image>;
areMoreImagesAvailable: boolean;
category: GalleryCategory;
};
type GalleryImageObjectFitType = 'contain' | 'cover';
export type Gallery = {
images: InvokeAI.Image[];
latest_mtime?: number;
earliest_mtime?: number;
areMoreImagesAvailable: boolean;
};
export interface GalleryState {
currentImage?: InvokeAI.Image;
currentImageUuid: string;
images: Array<InvokeAI.Image>;
intermediateImage?: InvokeAI.Image;
areMoreImagesAvailable: boolean;
latest_mtime?: number;
earliest_mtime?: number;
shouldPinGallery: boolean;
shouldShowGallery: boolean;
galleryScrollPosition: number;
galleryImageMinimumWidth: number;
galleryImageObjectFit: GalleryImageObjectFitType;
shouldHoldGalleryOpen: boolean;
shouldAutoSwitchToNewImages: boolean;
categories: {
user: Gallery;
result: Gallery;
};
currentCategory: GalleryCategory;
galleryWidth: number;
}
const initialState: GalleryState = {
currentImageUuid: '',
images: [],
areMoreImagesAvailable: true,
shouldPinGallery: true,
shouldShowGallery: true,
galleryScrollPosition: 0,
galleryImageMinimumWidth: 64,
galleryImageObjectFit: 'cover',
shouldHoldGalleryOpen: false,
shouldAutoSwitchToNewImages: true,
currentCategory: 'result',
categories: {
user: {
images: [],
latest_mtime: undefined,
earliest_mtime: undefined,
areMoreImagesAvailable: true,
},
result: {
images: [],
latest_mtime: undefined,
earliest_mtime: undefined,
areMoreImagesAvailable: true,
},
},
galleryWidth: 300,
};
export const gallerySlice = createSlice({
@ -27,10 +74,15 @@ export const gallerySlice = createSlice({
state.currentImage = action.payload;
state.currentImageUuid = action.payload.uuid;
},
removeImage: (state, action: PayloadAction<string>) => {
const uuid = action.payload;
removeImage: (
state,
action: PayloadAction<InvokeAI.ImageDeletedResponse>
) => {
const { uuid, category } = action.payload;
const newImages = state.images.filter((image) => image.uuid !== uuid);
const tempImages = state.categories[category as GalleryCategory].images;
const newImages = tempImages.filter((image) => image.uuid !== uuid);
if (uuid === state.currentImageUuid) {
/**
@ -42,7 +94,7 @@ export const gallerySlice = createSlice({
*
* Get the currently selected image's index.
*/
const imageToDeleteIndex = state.images.findIndex(
const imageToDeleteIndex = tempImages.findIndex(
(image) => image.uuid === uuid
);
@ -68,22 +120,35 @@ export const gallerySlice = createSlice({
: '';
}
state.images = newImages;
state.categories[category as GalleryCategory].images = newImages;
},
addImage: (state, action: PayloadAction<InvokeAI.Image>) => {
const newImage = action.payload;
addImage: (
state,
action: PayloadAction<{
image: InvokeAI.Image;
category: GalleryCategory;
}>
) => {
const { image: newImage, category } = action.payload;
const { uuid, url, mtime } = newImage;
const tempCategory = state.categories[category as GalleryCategory];
// Do not add duplicate images
if (state.images.find((i) => i.url === url && i.mtime === mtime)) {
if (tempCategory.images.find((i) => i.url === url && i.mtime === mtime)) {
return;
}
state.images.unshift(newImage);
state.currentImageUuid = uuid;
tempCategory.images.unshift(newImage);
if (state.shouldAutoSwitchToNewImages) {
state.currentImageUuid = uuid;
state.currentImage = newImage;
if (category === 'result') {
state.currentCategory = 'result';
}
}
state.intermediateImage = undefined;
state.currentImage = newImage;
state.latest_mtime = mtime;
tempCategory.latest_mtime = mtime;
},
setIntermediateImage: (state, action: PayloadAction<InvokeAI.Image>) => {
state.intermediateImage = action.payload;
@ -91,49 +156,53 @@ export const gallerySlice = createSlice({
clearIntermediateImage: (state) => {
state.intermediateImage = undefined;
},
selectNextImage: (state) => {
const { images, currentImage } = state;
selectNextImage: (state, action: PayloadAction<GalleryCategory>) => {
const category = action.payload;
const { currentImage } = state;
const tempImages = state.categories[category].images;
if (currentImage) {
const currentImageIndex = images.findIndex(
const currentImageIndex = tempImages.findIndex(
(i) => i.uuid === currentImage.uuid
);
if (_.inRange(currentImageIndex, 0, images.length)) {
const newCurrentImage = images[currentImageIndex + 1];
if (_.inRange(currentImageIndex, 0, tempImages.length)) {
const newCurrentImage = tempImages[currentImageIndex + 1];
state.currentImage = newCurrentImage;
state.currentImageUuid = newCurrentImage.uuid;
}
}
},
selectPrevImage: (state) => {
const { images, currentImage } = state;
selectPrevImage: (state, action: PayloadAction<GalleryCategory>) => {
const category = action.payload;
const { currentImage } = state;
const tempImages = state.categories[category].images;
if (currentImage) {
const currentImageIndex = images.findIndex(
const currentImageIndex = tempImages.findIndex(
(i) => i.uuid === currentImage.uuid
);
if (_.inRange(currentImageIndex, 1, images.length + 1)) {
const newCurrentImage = images[currentImageIndex - 1];
if (_.inRange(currentImageIndex, 1, tempImages.length + 1)) {
const newCurrentImage = tempImages[currentImageIndex - 1];
state.currentImage = newCurrentImage;
state.currentImageUuid = newCurrentImage.uuid;
}
}
},
addGalleryImages: (
state,
action: PayloadAction<{
images: Array<InvokeAI.Image>;
areMoreImagesAvailable: boolean;
}>
) => {
const { images, areMoreImagesAvailable } = action.payload;
addGalleryImages: (state, action: PayloadAction<AddImagesPayload>) => {
const { images, areMoreImagesAvailable, category } = action.payload;
const tempImages = state.categories[category].images;
// const prevImages = category === 'user' ? state.userImages : state.resultImages
if (images.length > 0) {
// Filter images that already exist in the gallery
const newImages = images.filter(
(newImage) =>
!state.images.find(
!tempImages.find(
(i) => i.url === newImage.url && i.mtime === newImage.mtime
)
);
state.images = state.images
state.categories[category].images = tempImages
.concat(newImages)
.sort((a, b) => b.mtime - a.mtime);
@ -144,13 +213,46 @@ export const gallerySlice = createSlice({
}
// keep track of the timestamps of latest and earliest images received
state.latest_mtime = images[0].mtime;
state.earliest_mtime = images[images.length - 1].mtime;
state.categories[category].latest_mtime = images[0].mtime;
state.categories[category].earliest_mtime =
images[images.length - 1].mtime;
}
if (areMoreImagesAvailable !== undefined) {
state.areMoreImagesAvailable = areMoreImagesAvailable;
state.categories[category].areMoreImagesAvailable =
areMoreImagesAvailable;
}
},
setShouldPinGallery: (state, action: PayloadAction<boolean>) => {
state.shouldPinGallery = action.payload;
},
setShouldShowGallery: (state, action: PayloadAction<boolean>) => {
state.shouldShowGallery = action.payload;
},
setGalleryScrollPosition: (state, action: PayloadAction<number>) => {
state.galleryScrollPosition = action.payload;
},
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
state.galleryImageMinimumWidth = action.payload;
},
setGalleryImageObjectFit: (
state,
action: PayloadAction<GalleryImageObjectFitType>
) => {
state.galleryImageObjectFit = action.payload;
},
setShouldHoldGalleryOpen: (state, action: PayloadAction<boolean>) => {
state.shouldHoldGalleryOpen = action.payload;
},
setShouldAutoSwitchToNewImages: (state, action: PayloadAction<boolean>) => {
state.shouldAutoSwitchToNewImages = action.payload;
},
setCurrentCategory: (state, action: PayloadAction<GalleryCategory>) => {
state.currentCategory = action.payload;
},
setGalleryWidth: (state, action: PayloadAction<number>) => {
state.galleryWidth = action.payload;
},
},
});
@ -163,6 +265,15 @@ export const {
setIntermediateImage,
selectNextImage,
selectPrevImage,
setShouldPinGallery,
setShouldShowGallery,
setGalleryScrollPosition,
setGalleryImageMinimumWidth,
setGalleryImageObjectFit,
setShouldHoldGalleryOpen,
setShouldAutoSwitchToNewImages,
setCurrentCategory,
setGalleryWidth,
} = gallerySlice.actions;
export default gallerySlice.reducer;

View File

@ -0,0 +1,55 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '../../app/store';
import { OptionsState } from '../options/optionsSlice';
import { tabMap } from '../tabs/InvokeTabs';
import { GalleryState } from './gallerySlice';
export const imageGallerySelector = createSelector(
[(state: RootState) => state.gallery, (state: RootState) => state.options],
(gallery: GalleryState, options: OptionsState) => {
const {
categories,
currentCategory,
currentImageUuid,
shouldPinGallery,
shouldShowGallery,
galleryScrollPosition,
galleryImageMinimumWidth,
galleryImageObjectFit,
shouldHoldGalleryOpen,
shouldAutoSwitchToNewImages,
galleryWidth,
} = gallery;
const { activeTab } = options;
return {
currentImageUuid,
shouldPinGallery,
shouldShowGallery,
galleryScrollPosition,
galleryImageMinimumWidth,
galleryImageObjectFit,
galleryGridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, auto))`,
activeTabName: tabMap[activeTab],
shouldHoldGalleryOpen,
shouldAutoSwitchToNewImages,
images: categories[currentCategory].images,
areMoreImagesAvailable:
categories[currentCategory].areMoreImagesAvailable,
currentCategory,
galleryWidth,
};
}
);
export const hoverableImageSelector = createSelector(
[(state: RootState) => state.options, (state: RootState) => state.gallery],
(options: OptionsState, gallery: GalleryState) => {
return {
galleryImageObjectFit: gallery.galleryImageObjectFit,
galleryImageMinimumWidth: gallery.galleryImageMinimumWidth,
activeTabName: tabMap[options.activeTab],
};
}
);

View File

@ -8,7 +8,7 @@ import {
import IAISwitch from '../../../../common/components/IAISwitch';
import { setShouldRunFacetool } from '../../optionsSlice';
export default function FaceRestore() {
export default function FaceRestoreHeader() {
const isGFPGANAvailable = useAppSelector(
(state: RootState) => state.system.isGFPGANAvailable
);

View File

@ -1,39 +0,0 @@
import { Flex } from '@chakra-ui/layout';
import React, { ChangeEvent } from 'react';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAISwitch from '../../../../common/components/IAISwitch';
import { setShouldUseInitImage } from '../../optionsSlice';
export default function ImageToImageAccordion() {
const dispatch = useAppDispatch();
const initialImagePath = useAppSelector(
(state: RootState) => state.options.initialImagePath
);
const shouldUseInitImage = useAppSelector(
(state: RootState) => state.options.shouldUseInitImage
);
const handleChangeShouldUseInitImage = (e: ChangeEvent<HTMLInputElement>) =>
dispatch(setShouldUseInitImage(e.target.checked));
return (
<Flex
justifyContent={'space-between'}
alignItems={'center'}
width={'100%'}
mr={2}
>
<p>Image to Image</p>
<IAISwitch
isDisabled={!initialImagePath}
isChecked={shouldUseInitImage}
onChange={handleChangeShouldUseInitImage}
/>
</Flex>
);
}

View File

@ -1,19 +0,0 @@
import { Flex } from '@chakra-ui/react';
import InitAndMaskImage from '../../InitAndMaskImage';
import ImageFit from './ImageFit';
import ImageToImageStrength from './ImageToImageStrength';
/**
* Options for img2img generation (strength, fit, init/mask upload).
*/
const ImageToImageOptions = () => {
return (
<Flex direction={'column'} gap={2}>
<ImageToImageStrength />
<ImageFit />
<InitAndMaskImage />
</Flex>
);
};
export default ImageToImageOptions;

View File

@ -30,7 +30,7 @@ export default function ImageToImageStrength(props: ImageToImageStrengthProps) {
max={0.99}
onChange={handleChangeStrength}
value={img2imgStrength}
width="90px"
width="100%"
isInteger={false}
styleClass={styleClass}
/>

View File

@ -0,0 +1,43 @@
.inpainting-bounding-box-settings {
display: flex;
flex-direction: column;
border-radius: 0.4rem;
border: 2px solid var(--tab-color);
}
.inpainting-bounding-box-header {
background-color: var(--tab-color);
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0.5rem 1rem;
border-radius: 0.4rem 0.4rem 0 0;
p {
font-weight: bold;
}
}
.inpainting-bounding-box-settings-items {
padding: 1rem;
display: flex;
flex-direction: column;
row-gap: 1rem;
.inpainting-bounding-box-reset-icon-btn {
background-color: var(--btn-load-more) !important;
&:hover {
background-color: var(--btn-load-more-hover) !important;
}
}
}
.inpainting-bounding-box-dimensions-slider-numberinput {
display: grid;
grid-template-columns: repeat(3, auto);
column-gap: 1rem;
}
.inpainting-bounding-box-darken {
width: max-content;
}

View File

@ -0,0 +1,202 @@
import { Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { ChangeEvent } from 'react';
import { BiReset } from 'react-icons/bi';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAICheckbox from '../../../../common/components/IAICheckbox';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import IAINumberInput from '../../../../common/components/IAINumberInput';
import IAISlider from '../../../../common/components/IAISlider';
import IAISwitch from '../../../../common/components/IAISwitch';
import { roundDownToMultiple } from '../../../../common/util/roundDownToMultiple';
import {
InpaintingState,
setBoundingBoxDimensions,
setShouldLockBoundingBox,
setShouldShowBoundingBox,
setShouldShowBoundingBoxFill,
} from '../../../tabs/Inpainting/inpaintingSlice';
const boundingBoxDimensionsSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const {
canvasDimensions,
boundingBoxDimensions,
shouldShowBoundingBox,
shouldShowBoundingBoxFill,
pastLines,
futureLines,
shouldLockBoundingBox,
} = inpainting;
return {
canvasDimensions,
boundingBoxDimensions,
shouldShowBoundingBox,
shouldShowBoundingBoxFill,
pastLines,
futureLines,
shouldLockBoundingBox,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const BoundingBoxSettings = () => {
const dispatch = useAppDispatch();
const {
canvasDimensions,
boundingBoxDimensions,
shouldShowBoundingBox,
shouldShowBoundingBoxFill,
shouldLockBoundingBox,
} = useAppSelector(boundingBoxDimensionsSelector);
const handleChangeBoundingBoxWidth = (v: number) => {
dispatch(setBoundingBoxDimensions({ ...boundingBoxDimensions, width: Math.floor(v) }));
};
const handleChangeBoundingBoxHeight = (v: number) => {
dispatch(setBoundingBoxDimensions({ ...boundingBoxDimensions, height: Math.floor(v) }));
};
const handleShowBoundingBox = (e: ChangeEvent<HTMLInputElement>) =>
dispatch(setShouldShowBoundingBox(e.target.checked));
const handleChangeShouldShowBoundingBoxFill = () => {
dispatch(setShouldShowBoundingBoxFill(!shouldShowBoundingBoxFill));
};
const handleChangeShouldLockBoundingBox = () => {
dispatch(setShouldLockBoundingBox(!shouldLockBoundingBox));
};
const handleResetWidth = () => {
dispatch(
setBoundingBoxDimensions({
...boundingBoxDimensions,
width: Math.floor(canvasDimensions.width),
})
);
};
const handleResetHeight = () => {
dispatch(
setBoundingBoxDimensions({
...boundingBoxDimensions,
height: Math.floor(canvasDimensions.height),
})
);
};
return (
<div className="inpainting-bounding-box-settings">
<div className="inpainting-bounding-box-header">
<p>Inpaint Box</p>
<IAISwitch
isChecked={shouldShowBoundingBox}
width={'auto'}
onChange={handleShowBoundingBox}
/>
</div>
<div className="inpainting-bounding-box-settings-items">
<div className="inpainting-bounding-box-dimensions-slider-numberinput">
<IAISlider
label="Box W"
min={64}
max={roundDownToMultiple(canvasDimensions.width, 64)}
step={64}
value={boundingBoxDimensions.width}
onChange={handleChangeBoundingBoxWidth}
isDisabled={!shouldShowBoundingBox}
width={'5rem'}
/>
<IAINumberInput
value={boundingBoxDimensions.width}
onChange={handleChangeBoundingBoxWidth}
min={64}
max={roundDownToMultiple(canvasDimensions.width, 64)}
step={64}
isDisabled={!shouldShowBoundingBox}
width={'5rem'}
/>
<IAIIconButton
size={'sm'}
aria-label={'Reset Width'}
tooltip={'Reset Width'}
onClick={handleResetWidth}
icon={<BiReset />}
styleClass="inpainting-bounding-box-reset-icon-btn"
isDisabled={
!shouldShowBoundingBox ||
canvasDimensions.width === boundingBoxDimensions.width
}
/>
</div>
<div className="inpainting-bounding-box-dimensions-slider-numberinput">
<IAISlider
label="Box H"
min={64}
max={roundDownToMultiple(canvasDimensions.height, 64)}
step={64}
value={boundingBoxDimensions.height}
onChange={handleChangeBoundingBoxHeight}
isDisabled={!shouldShowBoundingBox}
width={'5rem'}
/>
<IAINumberInput
value={boundingBoxDimensions.height}
onChange={handleChangeBoundingBoxHeight}
min={64}
max={roundDownToMultiple(canvasDimensions.height, 64)}
step={64}
padding="0"
isDisabled={!shouldShowBoundingBox}
width={'5rem'}
/>
<IAIIconButton
size={'sm'}
aria-label={'Reset Height'}
tooltip={'Reset Height'}
onClick={handleResetHeight}
icon={<BiReset />}
styleClass="inpainting-bounding-box-reset-icon-btn"
isDisabled={
!shouldShowBoundingBox ||
canvasDimensions.height === boundingBoxDimensions.height
}
/>
</div>
<Flex alignItems={'center'} justifyContent={'space-between'}>
<IAICheckbox
label="Darken Outside Box"
isChecked={shouldShowBoundingBoxFill}
onChange={handleChangeShouldShowBoundingBoxFill}
styleClass="inpainting-bounding-box-darken"
isDisabled={!shouldShowBoundingBox}
/>
<IAICheckbox
label="Lock Bounding Box"
isChecked={shouldLockBoundingBox}
onChange={handleChangeShouldLockBoundingBox}
styleClass="inpainting-bounding-box-darken"
isDisabled={!shouldShowBoundingBox}
/>
</Flex>
</div>
</div>
);
};
export default BoundingBoxSettings;

View File

@ -0,0 +1,95 @@
import { useToast } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import React, { ChangeEvent } from 'react';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIButton from '../../../../common/components/IAIButton';
import {
InpaintingState,
setClearBrushHistory,
setInpaintReplace,
setShouldUseInpaintReplace,
} from '../../../tabs/Inpainting/inpaintingSlice';
import BoundingBoxSettings from './BoundingBoxSettings';
import _ from 'lodash';
import IAINumberInput from '../../../../common/components/IAINumberInput';
import IAISwitch from '../../../../common/components/IAISwitch';
const inpaintingSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const { pastLines, futureLines, inpaintReplace, shouldUseInpaintReplace } =
inpainting;
return {
pastLines,
futureLines,
inpaintReplace,
shouldUseInpaintReplace,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function InpaintingSettings() {
const dispatch = useAppDispatch();
const toast = useToast();
const { pastLines, futureLines, inpaintReplace, shouldUseInpaintReplace } =
useAppSelector(inpaintingSelector);
const handleClearBrushHistory = () => {
dispatch(setClearBrushHistory());
toast({
title: 'Brush Stroke History Cleared',
status: 'success',
duration: 2500,
isClosable: true,
});
};
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '0 1rem 0 0.2rem',
}}
>
<IAINumberInput
label="Inpaint Replace"
value={inpaintReplace}
min={0}
max={1.0}
step={0.05}
width={'auto'}
formControlProps={{ style: { paddingRight: '1rem' } }}
isInteger={false}
isDisabled={!shouldUseInpaintReplace}
onChange={(v: number) => {
dispatch(setInpaintReplace(v));
}}
/>
<IAISwitch
isChecked={shouldUseInpaintReplace}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(setShouldUseInpaintReplace(e.target.checked))
}
/>
</div>
<BoundingBoxSettings />
<IAIButton
label="Clear Brush History"
onClick={handleClearBrushHistory}
tooltip="Clears brush stroke history"
disabled={futureLines.length > 0 || pastLines.length > 0 ? false : true}
/>
</>
);
}

View File

@ -1,12 +1,15 @@
import { Flex } from '@chakra-ui/react';
import { RootState } from '../../app/store';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { setHiresFix } from './optionsSlice';
import { ChangeEvent } from 'react';
import IAISwitch from '../../common/components/IAISwitch';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAISwitch from '../../../../common/components/IAISwitch';
import { setHiresFix } from '../../optionsSlice';
/**
* Image output options. Includes width, height, seamless tiling.
* Hires Fix Toggle
*/
const HiresOptions = () => {
const dispatch = useAppDispatch();
@ -16,7 +19,6 @@ const HiresOptions = () => {
const handleChangeHiresFix = (e: ChangeEvent<HTMLInputElement>) =>
dispatch(setHiresFix(e.target.checked));
return (
<Flex gap={2} direction={'column'}>
<IAISwitch

View File

@ -0,0 +1,11 @@
import { Box } from '@chakra-ui/react';
const OutputHeader = () => {
return (
<Box flex="1" textAlign="left">
Other Options
</Box>
);
};
export default OutputHeader;

View File

@ -1,10 +1,8 @@
import { Flex } from '@chakra-ui/react';
import HiresOptions from './HiresOptions';
import SeamlessOptions from './SeamlessOptions';
const OutputOptions = () => {
return (
<Flex gap={2} direction={'column'}>
<SeamlessOptions />

View File

@ -1,10 +1,16 @@
import { Flex } from '@chakra-ui/react';
import { RootState } from '../../app/store';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { setSeamless } from './optionsSlice';
import { ChangeEvent } from 'react';
import IAISwitch from '../../common/components/IAISwitch';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAISwitch from '../../../../common/components/IAISwitch';
import { setSeamless } from '../../optionsSlice';
/**
* Seamless tiling toggle
*/
const SeamlessOptions = () => {
const dispatch = useAppDispatch();
@ -25,4 +31,4 @@ const SeamlessOptions = () => {
);
};
export default SeamlessOptions;
export default SeamlessOptions;

View File

@ -0,0 +1,11 @@
import { Box } from '@chakra-ui/react';
const SeedHeader = () => {
return (
<Box flex="1" textAlign="left">
Seed
</Box>
);
};
export default SeedHeader;

View File

@ -8,7 +8,7 @@ import {
import IAISwitch from '../../../../common/components/IAISwitch';
import { setShouldRunESRGAN } from '../../optionsSlice';
export default function Upscale() {
export default function UpscaleHeader() {
const isESRGANAvailable = useAppSelector(
(state: RootState) => state.system.isESRGANAvailable
);

View File

@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react';
import React from 'react';
import GenerateVariations from './GenerateVariations';
export default function Variations() {
export default function VariationsHeader() {
return (
<Flex
justifyContent={'space-between'}

View File

@ -1,67 +0,0 @@
import { Box } from '@chakra-ui/react';
import { cloneElement, ReactElement, SyntheticEvent, useCallback } from 'react';
import { FileRejection, useDropzone } from 'react-dropzone';
type ImageUploaderProps = {
/**
* Component which, on click, should open the upload interface.
*/
children: ReactElement;
/**
* Callback to handle uploading the selected file.
*/
fileAcceptedCallback: (file: File) => void;
/**
* Callback to handle a file being rejected.
*/
fileRejectionCallback: (rejection: FileRejection) => void;
// Styling
styleClass?: string;
};
/**
* File upload using react-dropzone.
* Needs a child to be the button to activate the upload interface.
*/
const ImageUploader = ({
children,
fileAcceptedCallback,
fileRejectionCallback,
styleClass,
}: ImageUploaderProps) => {
const onDrop = useCallback(
(acceptedFiles: Array<File>, fileRejections: Array<FileRejection>) => {
fileRejections.forEach((rejection: FileRejection) => {
fileRejectionCallback(rejection);
});
acceptedFiles.forEach((file: File) => {
fileAcceptedCallback(file);
});
},
[fileAcceptedCallback, fileRejectionCallback]
);
const { getRootProps, getInputProps, open } = useDropzone({
onDrop,
accept: {
'image/jpeg': ['.jpg', '.jpeg', '.png'],
},
});
const handleClickUploadIcon = (e: SyntheticEvent) => {
e.stopPropagation();
open();
};
return (
<Box {...getRootProps()} flexGrow={3} className={`${styleClass}`}>
<input {...getInputProps({ multiple: false })} />
{cloneElement(children, {
onClick: handleClickUploadIcon,
})}
</Box>
);
};
export default ImageUploader;

View File

@ -1,20 +0,0 @@
.checkerboard {
background-position: 0px 0px, 10px 10px;
background-size: 20px 20px;
background-image: linear-gradient(
45deg,
#eee 25%,
transparent 25%,
transparent 75%,
#eee 75%,
#eee 100%
),
linear-gradient(
45deg,
#eee 25%,
white 25%,
white 75%,
#eee 75%,
#eee 100%
);
}

View File

@ -1,70 +0,0 @@
import { Flex, Image } from '@chakra-ui/react';
import { useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { RootState } from '../../app/store';
import { OptionsState, setInitialImagePath, setMaskPath } from './optionsSlice';
import './InitAndMaskImage.css';
import { createSelector } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import InitAndMaskUploadButtons from './InitAndMaskUploadButtons';
const optionsSelector = createSelector(
(state: RootState) => state.options,
(options: OptionsState) => {
return {
initialImagePath: options.initialImagePath,
maskPath: options.maskPath,
};
},
{ memoizeOptions: { resultEqualityCheck: isEqual } }
);
/**
* Displays init and mask images and buttons to upload/delete them.
*/
const InitAndMaskImage = () => {
const dispatch = useAppDispatch();
const { initialImagePath, maskPath } = useAppSelector(optionsSelector);
const [shouldShowMask, setShouldShowMask] = useState<boolean>(false);
const handleInitImageOnError = () => {
dispatch(setInitialImagePath(''));
};
const handleMaskImageOnError = () => {
dispatch(setMaskPath(''));
};
return (
<Flex direction={'column'} alignItems={'center'} gap={2}>
<InitAndMaskUploadButtons setShouldShowMask={setShouldShowMask} />
{initialImagePath && (
<Flex position={'relative'} width={'100%'}>
<Image
fit={'contain'}
src={initialImagePath}
rounded={'md'}
className={'checkerboard'}
maxWidth={320}
onError={handleInitImageOnError}
/>
{shouldShowMask && maskPath && (
<Image
position={'absolute'}
top={0}
left={0}
maxWidth={320}
fit={'contain'}
src={maskPath}
rounded={'md'}
zIndex={1}
onError={handleMaskImageOnError}
/>
)}
</Flex>
)}
</Flex>
);
};
export default InitAndMaskImage;

View File

@ -1,147 +0,0 @@
import { Button, Flex, IconButton, useToast } from '@chakra-ui/react';
import { SyntheticEvent, useCallback } from 'react';
import { FaTrash, FaUpload } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { RootState } from '../../app/store';
import { OptionsState, setInitialImagePath, setMaskPath } from './optionsSlice';
import {
uploadInitialImage,
uploadMaskImage,
} from '../../app/socketio/actions';
import { createSelector } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import ImageUploader from './ImageUploader';
import { FileRejection } from 'react-dropzone';
const optionsSelector = createSelector(
(state: RootState) => state.options,
(options: OptionsState) => {
return {
initialImagePath: options.initialImagePath,
maskPath: options.maskPath,
};
},
{ memoizeOptions: { resultEqualityCheck: isEqual } }
);
type InitAndMaskUploadButtonsProps = {
setShouldShowMask: (b: boolean) => void;
};
/**
* Init and mask image upload buttons.
*/
const InitAndMaskUploadButtons = ({
setShouldShowMask,
}: InitAndMaskUploadButtonsProps) => {
const dispatch = useAppDispatch();
const { initialImagePath, maskPath } = useAppSelector(optionsSelector);
// Use a toast to alert user when a file upload is rejected
const toast = useToast();
// Clear the init and mask images
const handleClickResetInitialImage = (e: SyntheticEvent) => {
e.stopPropagation();
dispatch(setInitialImagePath(''));
};
// Clear the init and mask images
const handleClickResetMask = (e: SyntheticEvent) => {
e.stopPropagation();
dispatch(setMaskPath(''));
};
// Handle hover to view initial image and mask image
const handleMouseOverInitialImageUploadButton = () =>
setShouldShowMask(false);
const handleMouseOutInitialImageUploadButton = () => setShouldShowMask(true);
const handleMouseOverMaskUploadButton = () => setShouldShowMask(true);
const handleMouseOutMaskUploadButton = () => setShouldShowMask(true);
// Callbacks to for handling file upload attempts
const initImageFileAcceptedCallback = useCallback(
(file: File) => dispatch(uploadInitialImage(file)),
[dispatch]
);
const maskImageFileAcceptedCallback = useCallback(
(file: File) => dispatch(uploadMaskImage(file)),
[dispatch]
);
const fileRejectionCallback = useCallback(
(rejection: FileRejection) => {
const msg = rejection.errors.reduce(
(acc: string, cur: { message: string }) => acc + '\n' + cur.message,
''
);
toast({
title: 'Upload failed',
description: msg,
status: 'error',
isClosable: true,
});
},
[toast]
);
return (
<Flex gap={2} justifyContent={'space-between'} width={'100%'}>
<ImageUploader
fileAcceptedCallback={initImageFileAcceptedCallback}
fileRejectionCallback={fileRejectionCallback}
>
<Button
size={'sm'}
fontSize={'md'}
fontWeight={'normal'}
onMouseOver={handleMouseOverInitialImageUploadButton}
onMouseOut={handleMouseOutInitialImageUploadButton}
leftIcon={<FaUpload />}
width={'100%'}
>
Image
</Button>
</ImageUploader>
<IconButton
isDisabled={!initialImagePath}
size={'sm'}
aria-label={'Reset mask'}
onClick={handleClickResetInitialImage}
icon={<FaTrash />}
/>
<ImageUploader
fileAcceptedCallback={maskImageFileAcceptedCallback}
fileRejectionCallback={fileRejectionCallback}
>
<Button
isDisabled={!initialImagePath}
size={'sm'}
fontSize={'md'}
fontWeight={'normal'}
onMouseOver={handleMouseOverMaskUploadButton}
onMouseOut={handleMouseOutMaskUploadButton}
leftIcon={<FaUpload />}
width={'100%'}
>
Mask
</Button>
</ImageUploader>
<IconButton
isDisabled={!maskPath}
size={'sm'}
aria-label={'Reset mask'}
onClick={handleClickResetMask}
icon={<FaTrash />}
/>
</Flex>
);
};
export default InitAndMaskUploadButtons;

View File

@ -1,8 +1,10 @@
import { Checkbox } from '@chakra-ui/react';
import React, { ChangeEvent } from 'react';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import IAICheckbox from '../../../common/components/IAICheckbox';
import { setShowAdvancedOptions } from '../optionsSlice';
export default function MainAdvancedOptions() {
export default function MainAdvancedOptionsCheckbox() {
const showAdvancedOptions = useAppSelector(
(state: RootState) => state.options.showAdvancedOptions
);
@ -12,15 +14,11 @@ export default function MainAdvancedOptions() {
dispatch(setShowAdvancedOptions(e.target.checked));
return (
<div className="advanced_options_checker">
<input
type="checkbox"
name="advanced_options"
id=""
onChange={handleShowAdvancedOptions}
checked={showAdvancedOptions}
/>
<label htmlFor="advanced_options">Advanced Options</label>
</div>
<IAICheckbox
label="Advanced Options"
styleClass="advanced-options-checkbox"
onChange={handleShowAdvancedOptions}
isChecked={showAdvancedOptions}
/>
);
}

View File

@ -14,7 +14,7 @@ export default function MainCFGScale() {
<IAINumberInput
label="CFG Scale"
step={0.5}
min={1}
min={1.01}
max={30}
onChange={handleChangeCfgScale}
value={cfgScale}

View File

@ -2,11 +2,14 @@ import React, { ChangeEvent } from 'react';
import { HEIGHTS } from '../../../app/constants';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import IAISelect from '../../../common/components/IAISelect';
import { tabMap } from '../../tabs/InvokeTabs';
import { setHeight } from '../optionsSlice';
import { fontSize } from './MainOptions';
export default function MainHeight() {
const height = useAppSelector((state: RootState) => state.options.height);
const { activeTab, height } = useAppSelector(
(state: RootState) => state.options
);
const dispatch = useAppDispatch();
const handleChangeHeight = (e: ChangeEvent<HTMLSelectElement>) =>
@ -14,6 +17,7 @@ export default function MainHeight() {
return (
<IAISelect
isDisabled={tabMap[activeTab] === 'inpainting'}
label="Height"
value={height}
flexGrow={1}

View File

@ -22,10 +22,10 @@
grid-template-columns: auto !important;
row-gap: 0.4rem;
.number-input-label,
.iai-select-label {
.invokeai__number-input-form-label,
.invokeai__select-label {
width: 100%;
font-size: 0.9rem;
font-size: 0.9rem !important;
font-weight: bold;
}
@ -40,43 +40,7 @@
}
}
.advanced_options_checker {
display: grid;
grid-template-columns: repeat(2, max-content);
column-gap: 0.5rem;
align-items: center;
background-color: var(--background-color-secondary);
.advanced-options-checkbox {
padding: 1rem;
font-weight: bold;
border-radius: 0.5rem;
input[type='checkbox'] {
-webkit-appearance: none;
appearance: none;
background-color: var(--input-checkbox-bg);
width: 1rem;
height: 1rem;
border-radius: 0.2rem;
display: grid;
place-content: center;
&::before {
content: '';
width: 1rem;
height: 1rem;
transform: scale(0);
transition: 120ms transform ease-in-out;
border-radius: 0.2rem;
box-shadow: inset 1rem 1rem var(--input-checkbox-checked-tick);
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
&:checked {
background-color: var(--input-checkbox-checked-bg);
&::before {
transform: scale(0.7);
}
}
}
}

View File

@ -2,11 +2,14 @@ import React, { ChangeEvent } from 'react';
import { WIDTHS } from '../../../app/constants';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import IAISelect from '../../../common/components/IAISelect';
import { tabMap } from '../../tabs/InvokeTabs';
import { setWidth } from '../optionsSlice';
import { fontSize } from './MainOptions';
export default function MainWidth() {
const width = useAppSelector((state: RootState) => state.options.width);
const { width, activeTab } = useAppSelector(
(state: RootState) => state.options
);
const dispatch = useAppDispatch();
const handleChangeWidth = (e: ChangeEvent<HTMLSelectElement>) =>
@ -14,6 +17,7 @@ export default function MainWidth() {
return (
<IAISelect
isDisabled={tabMap[activeTab] === 'inpainting'}
label="Width"
value={width}
flexGrow={1}

View File

@ -1,24 +1,42 @@
import React from 'react';
import { MdCancel } from 'react-icons/md';
import { cancelProcessing } from '../../../app/socketio/actions';
import { useAppDispatch, useAppSelector } from '../../../app/store';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import IAIIconButton from '../../../common/components/IAIIconButton';
import { systemSelector } from '../../../common/hooks/useCheckParameters';
import { useHotkeys } from 'react-hotkeys-hook';
import { createSelector } from '@reduxjs/toolkit';
import { SystemState } from '../../system/systemSlice';
import _ from 'lodash';
const cancelButtonSelector = createSelector(
(state: RootState) => state.system,
(system: SystemState) => {
return {
isProcessing: system.isProcessing,
isConnected: system.isConnected,
isCancelable: system.isCancelable,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function CancelButton() {
const dispatch = useAppDispatch();
const { isProcessing, isConnected } = useAppSelector(systemSelector);
const { isProcessing, isConnected, isCancelable } =
useAppSelector(cancelButtonSelector);
const handleClickCancel = () => dispatch(cancelProcessing());
useHotkeys(
'shift+x',
() => {
if (isConnected || isProcessing) {
if ((isConnected || isProcessing) && isCancelable) {
handleClickCancel();
}
},
[isConnected, isProcessing]
[isConnected, isProcessing, isCancelable]
);
return (
@ -26,9 +44,9 @@ export default function CancelButton() {
icon={<MdCancel />}
tooltip="Cancel"
aria-label="Cancel"
isDisabled={!isConnected || !isProcessing}
isDisabled={!isConnected || !isProcessing || !isCancelable}
onClick={handleClickCancel}
className="cancel-btn"
styleClass="cancel-btn"
/>
);
}

View File

@ -1,15 +1,19 @@
import React from 'react';
import { generateImage } from '../../../app/socketio/actions';
import { useAppDispatch } from '../../../app/store';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import IAIButton from '../../../common/components/IAIButton';
import useCheckParameters from '../../../common/hooks/useCheckParameters';
import { tabMap } from '../../tabs/InvokeTabs';
export default function InvokeButton() {
const dispatch = useAppDispatch();
const isReady = useCheckParameters();
const activeTab = useAppSelector(
(state: RootState) => state.options.activeTab
);
const handleClickGenerate = () => {
dispatch(generateImage());
dispatch(generateImage(tabMap[activeTab]));
};
return (

View File

@ -7,16 +7,16 @@
.invoke-btn {
@include Button(
$btn-color: var(--btn-purple),
$btn-color-hover: var(--btn-purple-hover),
$btn-color: var(--accent-color),
$btn-color-hover: var(--accent-color-hover),
$btn-width: 5rem
);
}
.cancel-btn {
@include Button(
$btn-color: var(--btn-red),
$btn-color-hover: var(--btn-red-hover),
$btn-color: var(--destructive-color),
$btn-color-hover: var(--destructive-color-hover),
$btn-width: 3rem
);
}

View File

@ -13,8 +13,8 @@
}
&:focus-visible {
border: 2px solid var(--prompt-border-color);
box-shadow: 0 0 10px 0 var(--prompt-box-shadow-color);
border: 2px solid var(--input-border-color);
box-shadow: 0 0 10px 0 var(--input-box-shadow-color);
}
&[aria-invalid='true'] {

View File

@ -5,22 +5,22 @@ import { generateImage } from '../../../app/socketio/actions';
import { OptionsState, setPrompt } from '../optionsSlice';
import { createSelector } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import useCheckParameters, {
systemSelector,
} from '../../../common/hooks/useCheckParameters';
import _ from 'lodash';
import useCheckParameters from '../../../common/hooks/useCheckParameters';
import { useHotkeys } from 'react-hotkeys-hook';
import { tabMap } from '../../tabs/InvokeTabs';
export const optionsSelector = createSelector(
const promptInputSelector = createSelector(
(state: RootState) => state.options,
(options: OptionsState) => {
return {
prompt: options.prompt,
activeTabName: tabMap[options.activeTab],
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
resultEqualityCheck: _.isEqual,
},
}
);
@ -30,8 +30,7 @@ export const optionsSelector = createSelector(
*/
const PromptInput = () => {
const promptRef = useRef<HTMLTextAreaElement>(null);
const { prompt } = useAppSelector(optionsSelector);
const { isProcessing } = useAppSelector(systemSelector);
const { prompt, activeTabName } = useAppSelector(promptInputSelector);
const dispatch = useAppDispatch();
const isReady = useCheckParameters();
@ -40,13 +39,13 @@ const PromptInput = () => {
};
useHotkeys(
'ctrl+enter',
'ctrl+enter, cmd+enter',
() => {
if (isReady) {
dispatch(generateImage());
dispatch(generateImage(activeTabName));
}
},
[isReady]
[isReady, activeTabName]
);
useHotkeys(
@ -60,7 +59,7 @@ const PromptInput = () => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && e.shiftKey === false && isReady) {
e.preventDefault();
dispatch(generateImage());
dispatch(generateImage(activeTabName));
}
};

View File

@ -1,104 +0,0 @@
import { Flex } from '@chakra-ui/react';
import { RootState } from '../../app/store';
import { useAppDispatch, useAppSelector } from '../../app/store';
import {
setCfgScale,
setSampler,
setThreshold,
setPerlin,
setSteps,
OptionsState,
} from './optionsSlice';
import { SAMPLERS } from '../../app/constants';
import { createSelector } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { ChangeEvent } from 'react';
import IAINumberInput from '../../common/components/IAINumberInput';
import IAISelect from '../../common/components/IAISelect';
const optionsSelector = createSelector(
(state: RootState) => state.options,
(options: OptionsState) => {
return {
steps: options.steps,
cfgScale: options.cfgScale,
sampler: options.sampler,
threshold: options.threshold,
perlin: options.perlin,
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
/**
* Sampler options. Includes steps, CFG scale, sampler.
*/
const SamplerOptions = () => {
const dispatch = useAppDispatch();
const { steps, cfgScale, sampler, threshold, perlin } = useAppSelector(optionsSelector);
const handleChangeSteps = (v: string | number) =>
dispatch(setSteps(Number(v)));
const handleChangeCfgScale = (v: string | number) =>
dispatch(setCfgScale(Number(v)));
const handleChangeSampler = (e: ChangeEvent<HTMLSelectElement>) =>
dispatch(setSampler(e.target.value));
const handleChangeThreshold = (v: string | number) =>
dispatch(setThreshold(Number(v)));
const handleChangePerlin = (v: string | number) =>
dispatch(setPerlin(Number(v)));
return (
<Flex gap={2} direction={'column'}>
{/* <IAINumberInput
label="Steps"
min={1}
step={1}
precision={0}
onChange={handleChangeSteps}
value={steps}
/> */}
{/* <IAINumberInput
label="CFG scale"
step={0.5}
onChange={handleChangeCfgScale}
value={cfgScale}
/> */}
<IAISelect
label="Sampler"
value={sampler}
onChange={handleChangeSampler}
validValues={SAMPLERS}
/>
{/* <IAINumberInput
label='Threshold'
min={0}
step={0.1}
onChange={handleChangeThreshold}
value={threshold}
/> */}
{/* <IAINumberInput
label='Perlin'
min={0}
max={1}
step={0.05}
onChange={handleChangePerlin}
value={perlin}
/> */}
</Flex>
);
};
export default SamplerOptions;

View File

@ -4,6 +4,7 @@ import * as InvokeAI from '../../app/invokeai';
import promptToString from '../../common/util/promptToString';
import { seedWeightsToString } from '../../common/util/seedWeightPairs';
import { FACETOOL_TYPES } from '../../app/constants';
import { InvokeTabName, tabMap } from '../tabs/InvokeTabs';
export type UpscalingLevel = 2 | 4;
@ -26,8 +27,7 @@ export interface OptionsState {
codeformerFidelity: number;
upscalingLevel: UpscalingLevel;
upscalingStrength: number;
shouldUseInitImage: boolean;
initialImagePath: string | null;
initialImage?: InvokeAI.Image | string; // can be an Image or url
maskPath: string;
seamless: boolean;
hiresFix: boolean;
@ -41,7 +41,7 @@ export interface OptionsState {
showAdvancedOptions: boolean;
activeTab: number;
shouldShowImageDetails: boolean;
shouldShowGallery: boolean;
showDualDisplay: boolean;
}
const initialOptionsState: OptionsState = {
@ -57,9 +57,7 @@ const initialOptionsState: OptionsState = {
seed: 0,
seamless: false,
hiresFix: false,
shouldUseInitImage: false,
img2imgStrength: 0.75,
initialImagePath: null,
maskPath: '',
shouldFitToWidthHeight: true,
shouldGenerateVariations: false,
@ -76,7 +74,7 @@ const initialOptionsState: OptionsState = {
showAdvancedOptions: true,
activeTab: 0,
shouldShowImageDetails: false,
shouldShowGallery: false,
showDualDisplay: true,
};
const initialState: OptionsState = initialOptionsState;
@ -136,14 +134,6 @@ export const optionsSlice = createSlice({
setUpscalingStrength: (state, action: PayloadAction<number>) => {
state.upscalingStrength = action.payload;
},
setShouldUseInitImage: (state, action: PayloadAction<boolean>) => {
state.shouldUseInitImage = action.payload;
},
setInitialImagePath: (state, action: PayloadAction<string | null>) => {
const newInitialImagePath = action.payload;
state.shouldUseInitImage = newInitialImagePath ? true : false;
state.initialImagePath = newInitialImagePath;
},
setMaskPath: (state, action: PayloadAction<string>) => {
state.maskPath = action.payload;
},
@ -169,9 +159,6 @@ export const optionsSlice = createSlice({
if (key === 'seed') {
temp.shouldRandomizeSeed = false;
}
if (key === 'initialImagePath' && value === '') {
temp.shouldUseInitImage = false;
}
return temp;
},
setShouldGenerateVariations: (state, action: PayloadAction<boolean>) => {
@ -235,13 +222,10 @@ export const optionsSlice = createSlice({
action.payload.image;
if (type === 'img2img') {
if (init_image_path) state.initialImagePath = init_image_path;
if (init_image_path) state.initialImage = init_image_path;
if (mask_image_path) state.maskPath = mask_image_path;
if (strength) state.img2imgStrength = strength;
if (typeof fit === 'boolean') state.shouldFitToWidthHeight = fit;
state.shouldUseInitImage = true;
} else {
state.shouldUseInitImage = false;
}
},
setAllParameters: (state, action: PayloadAction<InvokeAI.Metadata>) => {
@ -266,13 +250,10 @@ export const optionsSlice = createSlice({
} = action.payload.image;
if (type === 'img2img') {
if (init_image_path) state.initialImagePath = init_image_path;
if (init_image_path) state.initialImage = init_image_path;
if (mask_image_path) state.maskPath = mask_image_path;
if (strength) state.img2imgStrength = strength;
if (typeof fit === 'boolean') state.shouldFitToWidthHeight = fit;
state.shouldUseInitImage = true;
} else {
state.shouldUseInitImage = false;
}
if (variations && variations.length > 0) {
@ -321,14 +302,27 @@ export const optionsSlice = createSlice({
setShowAdvancedOptions: (state, action: PayloadAction<boolean>) => {
state.showAdvancedOptions = action.payload;
},
setActiveTab: (state, action: PayloadAction<number>) => {
state.activeTab = action.payload;
setActiveTab: (state, action: PayloadAction<number | InvokeTabName>) => {
if (typeof action.payload === 'number') {
state.activeTab = action.payload;
} else {
state.activeTab = tabMap.indexOf(action.payload);
}
},
setShouldShowImageDetails: (state, action: PayloadAction<boolean>) => {
state.shouldShowImageDetails = action.payload;
},
setShouldShowGallery: (state, action: PayloadAction<boolean>) => {
state.shouldShowGallery = action.payload;
setShowDualDisplay: (state, action: PayloadAction<boolean>) => {
state.showDualDisplay = action.payload;
},
setInitialImage: (
state,
action: PayloadAction<InvokeAI.Image | string>
) => {
state.initialImage = action.payload;
},
clearInitialImage: (state) => {
state.initialImage = undefined;
},
},
});
@ -352,8 +346,6 @@ export const {
setCodeformerFidelity,
setUpscalingLevel,
setUpscalingStrength,
setShouldUseInitImage,
setInitialImagePath,
setMaskPath,
resetSeed,
resetOptionsState,
@ -369,9 +361,11 @@ export const {
setShowAdvancedOptions,
setActiveTab,
setShouldShowImageDetails,
setShouldShowGallery,
setAllTextToImageParameters,
setAllImageToImageParameters,
setShowDualDisplay,
setInitialImage,
clearInitialImage,
} = optionsSlice.actions;
export default optionsSlice.reducer;

View File

@ -1,4 +1,5 @@
.console {
width: 100vw;
display: flex;
flex-direction: column;
background: var(--console-bg-color);
@ -7,7 +8,7 @@
font-family: monospace;
padding: 0 1rem 1rem 3rem;
border-top-width: 0.3rem;
border-color: var(--console-border-color);
border-color: var(--resizeable-handle-border-color);
.console-info-color {
color: var(--error-level-info);
@ -64,9 +65,9 @@
}
&.autoscroll-enabled {
background: var(--btn-purple) !important;
background: var(--accent-color) !important;
&:hover {
background: var(--btn-purple-hover) !important;
background: var(--accent-color-hover) !important;
}
}
}

View File

@ -1,6 +1,8 @@
@use '../../../styles/Mixins/' as *;
.hotkeys-modal {
width: 36rem !important;
max-width: 36rem !important;
display: grid;
padding: 1rem;
background-color: var(--settings-modal-bg) !important;
@ -11,14 +13,47 @@
font-size: 1.2rem;
font-weight: bold;
}
h2 {
font-weight: bold;
}
}
.hotkeys-modal-button {
display: flex;
align-items: center;
justify-content: space-between;
}
.hotkeys-modal-items {
display: grid;
row-gap: 0.5rem;
max-height: 32rem;
max-height: 36rem;
overflow-y: scroll;
@include HideScrollbar;
.chakra-accordion {
display: grid;
row-gap: 0.5rem;
}
.chakra-accordion__item {
border: none;
border-radius: 0.3rem;
background-color: var(--tab-hover-color);
}
button {
border-radius: 0.3rem !important;
&[aria-expanded='true'] {
background-color: var(--tab-hover-color);
border-radius: 0.3rem;
}
}
}
.hotkey-modal-category {
display: grid;
row-gap: 0.5rem;
}
.hotkey-modal-item {

View File

@ -1,4 +1,9 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Modal,
ModalCloseButton,
ModalContent,
@ -13,6 +18,12 @@ type HotkeysModalProps = {
children: ReactElement;
};
type HotkeyList = {
title: string;
desc: string;
hotkey: string;
};
export default function HotkeysModal({ children }: HotkeysModalProps) {
const {
isOpen: isHotkeyModalOpen,
@ -20,51 +31,18 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
onClose: onHotkeysModalClose,
} = useDisclosure();
const hotkeys = [
const appHotkeys = [
{ title: 'Invoke', desc: 'Generate an image', hotkey: 'Ctrl+Enter' },
{ title: 'Cancel', desc: 'Cancel image generation', hotkey: 'Shift+X' },
{
title: 'Toggle Gallery',
desc: 'Open and close the gallery drawer',
hotkey: 'G',
},
{
title: 'Set Seed',
desc: 'Use the seed of the current image',
hotkey: 'S',
},
{
title: 'Set Parameters',
desc: 'Use all parameters of the current image',
hotkey: 'A',
},
{ title: 'Restore Faces', desc: 'Restore the current image', hotkey: 'R' },
{ title: 'Upscale', desc: 'Upscale the current image', hotkey: 'U' },
{
title: 'Show Info',
desc: 'Show metadata info of the current image',
hotkey: 'I',
},
{
title: 'Send To Image To Image',
desc: 'Send the current image to Image to Image module',
hotkey: 'Shift+I',
},
{ title: 'Delete Image', desc: 'Delete the current image', hotkey: 'Del' },
{
title: 'Focus Prompt',
desc: 'Focus the prompt input area',
hotkey: 'Alt+A',
},
{
title: 'Previous Image',
desc: 'Display the previous image in the gallery',
hotkey: 'Arrow left',
},
{
title: 'Next Image',
desc: 'Display the next image in the gallery',
hotkey: 'Arrow right',
title: 'Toggle Gallery',
desc: 'Open and close the gallery drawer',
hotkey: 'G',
},
{
title: 'Change Tabs',
@ -83,7 +61,144 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
},
];
const renderHotkeyModalItems = () => {
const generalHotkeys = [
{
title: 'Set Parameters',
desc: 'Use all parameters of the current image',
hotkey: 'A',
},
{
title: 'Set Seed',
desc: 'Use the seed of the current image',
hotkey: 'S',
},
{ title: 'Restore Faces', desc: 'Restore the current image', hotkey: 'R' },
{ title: 'Upscale', desc: 'Upscale the current image', hotkey: 'U' },
{
title: 'Show Info',
desc: 'Show metadata info of the current image',
hotkey: 'I',
},
{
title: 'Send To Image To Image',
desc: 'Send current image to Image to Image',
hotkey: 'Shift+I',
},
{ title: 'Delete Image', desc: 'Delete the current image', hotkey: 'Del' },
];
const galleryHotkeys = [
{
title: 'Previous Image',
desc: 'Display the previous image in gallery',
hotkey: 'Arrow left',
},
{
title: 'Next Image',
desc: 'Display the next image in gallery',
hotkey: 'Arrow right',
},
{
title: 'Toggle Gallery Pin',
desc: 'Pins and unpins the gallery to the UI',
hotkey: 'Shift+P',
},
{
title: 'Increase Gallery Image Size',
desc: 'Increases gallery thumbnails size',
hotkey: 'Shift+Up',
},
{
title: 'Decrease Gallery Image Size',
desc: 'Decreases gallery thumbnails size',
hotkey: 'Shift+Down',
},
{
title: 'Reset Gallery Image Size',
desc: 'Resets image gallery size',
hotkey: 'Shift+R',
},
];
const inpaintingHotkeys = [
{
title: 'Select Brush',
desc: 'Selects the inpainting brush',
hotkey: 'B',
},
{
title: 'Select Eraser',
desc: 'Selects the inpainting eraser',
hotkey: 'E',
},
{
title: 'Quick Toggle Brush/Eraser',
desc: 'Quick toggle between brush and eraser',
hotkey: 'Z',
},
{
title: 'Decrease Brush Size',
desc: 'Decreases the size of the inpainting brush/eraser',
hotkey: '[',
},
{
title: 'Increase Brush Size',
desc: 'Increases the size of the inpainting brush/eraser',
hotkey: ']',
},
{
title: 'Hide Mask',
desc: 'Hide and unhide mask',
hotkey: 'H',
},
{
title: 'Decrease Mask Opacity',
desc: 'Decreases the opacity of the mask',
hotkey: 'Shift+[',
},
{
title: 'Increase Mask Opacity',
desc: 'Increases the opacity of the mask',
hotkey: 'Shift+]',
},
{
title: 'Invert Mask',
desc: 'Invert the mask preview',
hotkey: 'Shift+M',
},
{
title: 'Clear Mask',
desc: 'Clear the entire mask',
hotkey: 'Shift+C',
},
{
title: 'Undo Stroke',
desc: 'Undo a brush stroke',
hotkey: 'Ctrl+Z',
},
{
title: 'Redo Stroke',
desc: 'Redo a brush stroke',
hotkey: 'Ctrl+Shift+Z, Ctrl+Y',
},
{
title: 'Lock Bounding Box',
desc: 'Locks the bounding box',
hotkey: 'M',
},
{
title: 'Quick Toggle Lock Bounding Box',
desc: 'Hold to toggle locking the bounding box',
hotkey: 'Space',
},
{
title: 'Expand Inpainting Area',
desc: 'Expand your inpainting work area',
hotkey: 'Shift+J',
},
];
const renderHotkeyModalItems = (hotkeys: HotkeyList[]) => {
const hotkeyModalItemsToRender: ReactElement[] = [];
hotkeys.forEach((hotkey, i) => {
@ -97,7 +212,9 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
);
});
return hotkeyModalItemsToRender;
return (
<div className="hotkey-modal-category">{hotkeyModalItemsToRender}</div>
);
};
return (
@ -109,8 +226,51 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
<ModalOverlay />
<ModalContent className="hotkeys-modal">
<ModalCloseButton />
<h1>Keyboard Shorcuts</h1>
<div className="hotkeys-modal-items">{renderHotkeyModalItems()}</div>
<div className="hotkeys-modal-items">
<Accordion allowMultiple>
<AccordionItem>
<AccordionButton className="hotkeys-modal-button">
<h2>App Hotkeys</h2>
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
{renderHotkeyModalItems(appHotkeys)}
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton className="hotkeys-modal-button">
<h2>General Hotkeys</h2>
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
{renderHotkeyModalItems(generalHotkeys)}
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton className="hotkeys-modal-button">
<h2>Gallery Hotkeys</h2>
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
{renderHotkeyModalItems(galleryHotkeys)}
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton className="hotkeys-modal-button">
<h2>Inpainting Hotkeys</h2>
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
{renderHotkeyModalItems(inpaintingHotkeys)}
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
</ModalContent>
</Modal>
</>

View File

@ -1,7 +1,19 @@
@use '../../styles/Mixins/' as *;
.progress-bar {
background-color: var(--root-bg-color);
height: $progress-bar-thickness !important;
div {
background-color: var(--progress-bar-color);
&[data-indeterminate] {
background-color: unset;
background-image: linear-gradient(
to right,
transparent 0%,
var(--progress-bar-color) 50%,
transparent 100%
);
}
}
}

View File

@ -28,7 +28,6 @@ const ProgressBar = () => {
return (
<Progress
height="4px"
value={value}
isIndeterminate={isProcessing && !currentStatusHasSteps}
className="progress-bar"

View File

@ -0,0 +1,70 @@
.model-list {
.chakra-accordion {
display: grid;
row-gap: 0.5rem;
}
.chakra-accordion__item {
border: none;
border-radius: 0.3rem;
background-color: var(--tab-hover-color);
}
button {
border-radius: 0.3rem !important;
&[aria-expanded='true'] {
background-color: var(--tab-hover-color);
border-radius: 0.3rem;
}
}
.model-list-button {
display: flex;
flex-direction: row;
row-gap: 0.5rem;
justify-content: space-between;
align-items: center;
width: 100%;
}
.model-list-header-hint {
color: var(--text-color-secondary);
font-weight: normal;
}
.model-list-list {
display: flex;
flex-direction: column;
row-gap: 0.5rem;
.model-list-item {
display: flex;
column-gap: 0.5rem;
width: 100%;
justify-content: space-between;
align-items: center;
.model-list-item-name {
}
.model-list-item-description {
font-size: 0.9rem;
}
.model-list-item-status {
&.active {
color: var(--status-good-color);
}
&.cached {
color: var(--status-working-color);
}
&.not-loaded {
color: var(--text-color-secondary);
}
}
.model-list-item-load-btn {
}
}
}
}

View File

@ -0,0 +1,104 @@
import {
Button,
Tooltip,
Spacer,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { ModelStatus } from '../../../app/invokeai';
import { requestModelChange } from '../../../app/socketio/actions';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import { SystemState } from '../systemSlice';
type ModelListItemProps = {
name: string;
status: ModelStatus;
description: string;
};
const ModelListItem = (props: ModelListItemProps) => {
const { isProcessing, isConnected } = useAppSelector(
(state: RootState) => state.system
);
const dispatch = useAppDispatch();
const { name, status, description } = props;
const handleChangeModel = () => {
dispatch(requestModelChange(name));
};
return (
<div className="model-list-item">
<Tooltip label={description} hasArrow placement="bottom">
<div className="model-list-item-name">{name}</div>
</Tooltip>
<Spacer />
<div className={`model-list-item-status ${status.split(' ').join('-')}`}>
{status}
</div>
<div className="model-list-item-load-btn">
<Button
size={'sm'}
onClick={handleChangeModel}
isDisabled={status === 'active' || isProcessing || !isConnected}
>
Load
</Button>
</div>
</div>
);
};
const modelListSelector = createSelector(
(state: RootState) => state.system,
(system: SystemState) => {
const models = _.map(system.model_list, (model, key) => {
return { name: key, ...model };
});
const activeModel = models.find((model) => model.status === 'active');
return {
models,
activeModel: activeModel,
};
}
);
const ModelList = () => {
const { models } = useAppSelector(modelListSelector);
return (
<div className="model-list">
<Accordion allowToggle>
<AccordionItem>
<AccordionButton>
<div className="model-list-button">
<h2>Models</h2>
<AccordionIcon />
</div>
</AccordionButton>
<AccordionPanel>
<div className="model-list-list">
{models.map((model, i) => (
<ModelListItem
key={i}
name={model.name}
status={model.status}
description={model.description}
/>
))}
</div>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
);
};
export default ModelList;

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