Merge branch 'user-image-uploads' into inpainting-rebase

Adds
- Separate user uploads gallery
- Drag and drop uploads for img2img and inpainting
- Many bugfixes, scss refactored
This commit is contained in:
psychedelicious 2022-10-29 04:40:38 +11:00
commit 8dee3387fd
48 changed files with 1392 additions and 2267 deletions

View File

@ -160,23 +160,34 @@ class InvokeAIWebServer:
@socketio.on("requestModelChange")
def handle_set_model(model_name: str):
print(f">> Model change requested: {model_name}")
model = self.generate.set_model(model_name)
model_list = self.generate.model_cache.list_models()
if model is None:
socketio.emit(
"modelChangeFailed",
{"model_name": model_name, "model_list": model_list},
)
else:
socketio.emit(
"modelChanged", {"model_name": model_name, "model_list": model_list}
)
try:
print(f">> Model change requested: {model_name}")
model = self.generate.set_model(model_name)
model_list = self.generate.model_cache.list_models()
if model is None:
socketio.emit(
"modelChangeFailed",
{"model_name": model_name, "model_list": model_list},
)
else:
socketio.emit(
"modelChanged",
{"model_name": model_name, "model_list": model_list},
)
except Exception as e:
self.socketio.emit("error", {"message": (str(e))})
print("\n")
traceback.print_exc()
print("\n")
@socketio.on("requestLatestImages")
def handle_request_latest_images(latest_mtime):
def handle_request_latest_images(category, latest_mtime):
try:
paths = glob.glob(os.path.join(self.result_path, "*.png"))
base_path = (
self.result_path if category == "result" else self.init_image_path
)
paths = glob.glob(os.path.join(base_path, "*.png"))
image_paths = sorted(
paths, key=lambda x: os.path.getmtime(x), reverse=True
@ -201,14 +212,13 @@ class InvokeAIWebServer:
"metadata": metadata["sd-metadata"],
"width": width,
"height": height,
"category": category,
}
)
socketio.emit(
"galleryImages",
{
"images": image_array,
},
{"images": image_array, "category": category},
)
except Exception as e:
self.socketio.emit("error", {"message": (str(e))})
@ -218,11 +228,15 @@ class InvokeAIWebServer:
print("\n")
@socketio.on("requestImages")
def handle_request_images(earliest_mtime=None):
def handle_request_images(category, earliest_mtime=None):
try:
page_size = 50
paths = glob.glob(os.path.join(self.result_path, "*.png"))
base_path = (
self.result_path if category == "result" else self.init_image_path
)
paths = glob.glob(os.path.join(base_path, "*.png"))
image_paths = sorted(
paths, key=lambda x: os.path.getmtime(x), reverse=True
@ -253,6 +267,7 @@ class InvokeAIWebServer:
"metadata": metadata["sd-metadata"],
"width": width,
"height": height,
"category": category,
}
)
@ -261,6 +276,7 @@ class InvokeAIWebServer:
{
"images": image_array,
"areMoreImagesAvailable": areMoreImagesAvailable,
"category": category,
},
)
except Exception as e:
@ -416,14 +432,17 @@ class InvokeAIWebServer:
# TODO: I think this needs a safety mechanism.
@socketio.on("deleteImage")
def handle_delete_image(url, uuid):
def handle_delete_image(url, uuid, category):
try:
print(f'>> Delete requested "{url}"')
from send2trash import send2trash
path = self.get_image_path_from_url(url)
print(path)
send2trash(path)
socketio.emit("imageDeleted", {"url": url, "uuid": uuid})
socketio.emit(
"imageDeleted", {"url": url, "uuid": uuid, "category": category}
)
except Exception as e:
self.socketio.emit("error", {"message": (str(e))})
print("\n")
@ -432,18 +451,25 @@ class InvokeAIWebServer:
print("\n")
# TODO: I think this needs a safety mechanism.
@socketio.on("uploadInitialImage")
def handle_upload_initial_image(bytes, name):
@socketio.on("uploadImage")
def handle_upload_image(bytes, name, destination):
try:
print(f'>> Init image upload requested "{name}"')
print(f'>> Image upload requested "{name}"')
file_path = self.save_file_unique_uuid_name(
bytes=bytes, name=name, path=self.init_image_path
)
mtime = os.path.getmtime(file_path)
(width, height) = Image.open(file_path).size
print(file_path)
socketio.emit(
"initialImageUploaded",
"imageUploaded",
{
"url": self.get_url_from_image_path(file_path),
"mtime": mtime,
"width": width,
"height": height,
"category": "user",
"destination": destination,
},
)
except Exception as e:

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)

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.3e1dd33d.js vendored Normal file

File diff suppressed because one or more lines are too long

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.7f3a2425.js"></script>
<link rel="stylesheet" href="./assets/index.25bb1c01.css">
<script type="module" crossorigin src="./assets/index.3e1dd33d.js"></script>
<link rel="stylesheet" href="./assets/index.165b9042.css">
</head>
<body>

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,14 +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.
@ -167,12 +170,10 @@ export declare type SystemStatusResponse = SystemStatus;
export declare type SystemConfigResponse = SystemConfig;
export declare type ImageResultResponse = {
url: string;
mtime: number;
metadata: Metadata;
width: number;
height: number;
export declare type ImageResultResponse = Omit<Image, 'uuid'>;
export declare type ImageUploadResponse = Omit<Image, 'uuid' | 'metadata'> & {
destination: 'img2img' | 'inpainting';
};
export declare type ErrorResponse = {
@ -183,13 +184,20 @@ 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 UploadImagePayload = {
file: File;
destination: 'img2img' | 'inpainting';
};

View File

@ -1,7 +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
@ -15,20 +17,22 @@ export const generateImage = createAction<InvokeTabName>(
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>('socketio/requestImages');
export const requestNewImages = createAction<undefined>(
export const requestImages = createAction<GalleryCategory>(
'socketio/requestImages'
);
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');
export const requestModelChange = createAction<string>(
'socketio/requestModelChange'
);

View File

@ -5,6 +5,11 @@ import {
frontendToBackendParameters,
FrontendToBackendParametersConfig,
} from '../../common/util/parameterTranslation';
import {
GalleryCategory,
GalleryState,
} from '../../features/gallery/gallerySlice';
import { OptionsState } from '../../features/options/optionsSlice';
import {
addLogEntry,
errorOccurred,
@ -108,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],
};
@ -128,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,
@ -156,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);

View File

@ -21,15 +21,18 @@ import {
addGalleryImages,
addImage,
clearIntermediateImage,
GalleryState,
removeImage,
setIntermediateImage,
} from '../../features/gallery/gallerySlice';
import {
setInitialImagePath,
clearInitialImage,
setInitialImage,
setMaskPath,
} from '../../features/options/optionsSlice';
import { requestImages, requestNewImages } from './actions';
import { setImageToInpaint } from '../../features/tabs/Inpainting/inpaintingSlice';
/**
* Returns an object containing listener callbacks for socketio events.
@ -48,10 +51,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);
@ -83,8 +94,11 @@ const makeSocketIOListeners = (
try {
dispatch(
addImage({
uuid: uuidv4(),
...data,
category: 'result',
image: {
uuid: uuidv4(),
...data,
},
})
);
dispatch(
@ -125,8 +139,11 @@ const makeSocketIOListeners = (
try {
dispatch(
addImage({
uuid: uuidv4(),
...data,
category: 'result',
image: {
uuid: uuidv4(),
...data,
},
})
);
@ -180,7 +197,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:
@ -189,19 +206,18 @@ const makeSocketIOListeners = (
// Generate a UUID for each image
const preparedImages = images.map((image): InvokeAI.Image => {
const { url, metadata, mtime, width, height } = image;
return {
uuid: uuidv4(),
url,
mtime,
metadata,
width,
height,
...image,
};
});
dispatch(
addGalleryImages({ images: preparedImages, areMoreImagesAvailable })
addGalleryImages({
images: preparedImages,
areMoreImagesAvailable,
category,
})
);
dispatch(
@ -220,7 +236,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'),
@ -241,14 +262,17 @@ 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;
if (initialImage?.url === url || initialImage === url) {
dispatch(clearInitialImage());
}
if (maskPath === url) {
@ -262,18 +286,36 @@ 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;
}
}
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.

View File

@ -43,7 +43,7 @@ export const socketioMiddleware = () => {
onGalleryImages,
onProcessingCanceled,
onImageDeleted,
onInitialImageUploaded,
onImageUploaded,
onMaskImageUploaded,
onSystemConfig,
onModelChanged,
@ -58,7 +58,7 @@ export const socketioMiddleware = () => {
emitRequestImages,
emitRequestNewImages,
emitCancelProcessing,
emitUploadInitialImage,
emitUploadImage,
emitUploadMaskImage,
emitRequestSystemConfig,
emitRequestModelChange,
@ -100,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);
@ -152,12 +155,12 @@ export const socketioMiddleware = () => {
}
case 'socketio/requestImages': {
emitRequestImages();
emitRequestImages(action.payload);
break;
}
case 'socketio/requestNewImages': {
emitRequestNewImages();
emitRequestNewImages(action.payload);
break;
}
@ -166,8 +169,8 @@ export const socketioMiddleware = () => {
break;
}
case 'socketio/uploadInitialImage': {
emitUploadInitialImage(action.payload);
case 'socketio/uploadImage': {
emitUploadImage(action.payload);
break;
}

View File

@ -0,0 +1,30 @@
.image-upload-zone {
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-child-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.image-upload-child {
display: flex;
flex-direction: column;
row-gap: 3rem;
align-items: center;
justify-content: center;
}

View File

@ -1,29 +1,23 @@
import { Button, useToast } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import { Button, Heading, useToast } from '@chakra-ui/react';
import { useCallback } from 'react';
import { FileRejection } from 'react-dropzone';
import { useAppDispatch } from '../../app/store';
import { FaUpload } from 'react-icons/fa';
import ImageUploader from '../../features/options/ImageUploader';
interface InvokeImageUploaderProps {
label?: string;
icon?: any;
onMouseOver?: any;
OnMouseout?: any;
dispatcher: any;
handleFile: (file: File) => void;
styleClass?: string;
}
export default function InvokeImageUploader(props: InvokeImageUploaderProps) {
const { label, icon, dispatcher, styleClass, onMouseOver, OnMouseout } =
props;
const { handleFile, styleClass } = props;
const toast = useToast();
const dispatch = useAppDispatch();
// Callbacks to for handling file upload attempts
const fileAcceptedCallback = useCallback(
(file: File) => dispatch(dispatcher(file)),
[dispatch, dispatcher]
(file: File) => handleFile(file),
[handleFile]
);
const fileRejectionCallback = useCallback(
@ -44,22 +38,17 @@ export default function InvokeImageUploader(props: InvokeImageUploaderProps) {
);
return (
<ImageUploader
fileAcceptedCallback={fileAcceptedCallback}
fileRejectionCallback={fileRejectionCallback}
styleClass={styleClass}
>
<Button
size={'sm'}
fontSize={'md'}
fontWeight={'normal'}
onMouseOver={onMouseOver}
onMouseOut={OnMouseout}
leftIcon={icon}
width={'100%'}
<div className="image-upload-zone">
<ImageUploader
fileAcceptedCallback={fileAcceptedCallback}
fileRejectionCallback={fileRejectionCallback}
styleClass={`${styleClass} image-upload-child-wrapper`}
>
{label ? label : null}
</Button>
</ImageUploader>
<div className="image-upload-child">
<FaUpload size={'7rem'} />
<Heading size={'lg'}>Upload or Drop Image Here</Heading>
</div>
</ImageUploader>
</div>
);
}

View File

@ -23,7 +23,7 @@ export const useCheckParametersSelector = createSelector(
shouldGenerateVariations: options.shouldGenerateVariations,
seedWeights: options.seedWeights,
maskPath: options.maskPath,
initialImagePath: options.initialImagePath,
initialImage: options.initialImage,
seed: options.seed,
activeTabName: tabMap[options.activeTab],
// system
@ -49,7 +49,7 @@ const useCheckParameters = (): boolean => {
shouldGenerateVariations,
seedWeights,
maskPath,
initialImagePath,
initialImage,
seed,
activeTabName,
isProcessing,
@ -63,7 +63,7 @@ const useCheckParameters = (): boolean => {
return false;
}
if (activeTabName === 'img2img' && !initialImagePath) {
if (activeTabName === 'img2img' && !initialImage) {
return false;
}
@ -72,7 +72,7 @@ const useCheckParameters = (): boolean => {
}
// Cannot generate with a mask without img2img
if (maskPath && !initialImagePath) {
if (maskPath && !initialImage) {
return false;
}
@ -100,8 +100,8 @@ const useCheckParameters = (): boolean => {
}, [
prompt,
maskPath,
initialImagePath,
isProcessing,
initialImage,
isConnected,
shouldGenerateVariations,
seedWeights,

View File

@ -47,7 +47,7 @@ export const frontendToBackendParameters = (
seamless,
hiresFix,
img2imgStrength,
initialImagePath,
initialImage,
shouldFitToWidthHeight,
shouldGenerateVariations,
variationAmount,
@ -89,8 +89,9 @@ export const frontendToBackendParameters = (
}
// img2img exclusive parameters
if (generationMode === 'img2img') {
generationParameters.init_img = initialImagePath;
if (generationMode === 'img2img' && initialImage) {
generationParameters.init_img =
typeof initialImage === 'string' ? initialImage : initialImage.url;
generationParameters.strength = img2imgStrength;
generationParameters.fit = shouldFitToWidthHeight;
}

View File

@ -8,7 +8,7 @@ import { RootState } from '../../app/store';
import {
setActiveTab,
setAllParameters,
setInitialImagePath,
setInitialImage,
setSeed,
setShouldShowImageDetails,
} from '../options/optionsSlice';
@ -85,7 +85,7 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
useAppSelector(systemSelector);
const handleClickUseAsInitialImage = () => {
dispatch(setInitialImagePath(image.url));
dispatch(setInitialImage(image));
dispatch(setActiveTab(1));
};
@ -114,7 +114,8 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
);
const handleClickUseAllParameters = () =>
dispatch(setAllParameters(image.metadata));
image.metadata && dispatch(setAllParameters(image.metadata));
useHotkeys(
'a',
() => {
@ -139,9 +140,7 @@ 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',
() => {

View File

@ -1,28 +1,23 @@
@use '../../styles/Mixins/' as *;
.current-image-display {
.current-image-area {
display: flex;
flex-direction: column;
height: 100%;
row-gap: 1rem;
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
&[data-tab-name='txt2img'] {
height: $app-text-to-image-height;
}
}
.current-image-options {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
column-gap: 0.5rem;
padding: 1rem;
button {
@include Button(
$btn-width: 3rem,
$icon-size: 22px,
$btn-color: var(--btn-grey),
$btn-color-hover: var(--btn-grey-hover)
);
}
}
.current-image-viewer {
@ -32,25 +27,18 @@
}
.current-image-preview {
position: absolute;
top:0;
grid-area: current-image-preview;
position: relative;
justify-content: center;
align-items: center;
display: grid;
display: flex;
width: 100%;
height: 100%;
grid-template-areas: 'current-image-content';
// height: 100%;
height: $app-text-to-image-height;
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;
}
}

View File

@ -3,39 +3,65 @@ 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,
shouldShowImageDetails,
} = useAppSelector(currentImageDisplaySelector);
const imageToDisplay = intermediateImage || currentImage;
return imageToDisplay ? (
<div className="current-image-display">
<div className="current-image-tools">
<CurrentImageButtons image={imageToDisplay} />
</div>
<div className="current-image-viewer">
<CurrentImagePreview imageToDisplay={imageToDisplay} />
{shouldShowImageDetails && (
<ImageMetadataViewer
image={imageToDisplay}
styleClass="current-image-metadata"
/>
)}
</div>
</div>
) : (
<div className="current-image-display-placeholder">
<MdPhoto />
return (
<div className="current-image-area" data-tab-name={activeTabName}>
{imageToDisplay ? (
<>
<CurrentImageButtons image={imageToDisplay} />
<div className="current-image-viewer">
<CurrentImagePreview imageToDisplay={imageToDisplay} />
{shouldShowImageDetails && (
<ImageMetadataViewer
image={imageToDisplay}
styleClass="current-image-metadata"
/>
)}
</div>
</>
) : (
<div className="current-image-display-placeholder">
<MdPhoto />
</div>
)}
</div>
);
};

View File

@ -6,18 +6,25 @@ 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';
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 +42,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,15 +61,15 @@ 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"

View File

@ -19,7 +19,7 @@ import {
setActiveTab,
setAllImageToImageParameters,
setAllTextToImageParameters,
setInitialImagePath,
setInitialImage,
setPrompt,
setSeed,
} from '../options/optionsSlice';
@ -58,7 +58,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
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',
@ -68,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',
@ -78,7 +78,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
};
const handleSendToImageToImage = () => {
dispatch(setInitialImagePath(image.url));
dispatch(setInitialImage(image));
if (activeTabName !== 'img2img') {
dispatch(setActiveTab('img2img'));
}
@ -104,7 +104,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
};
const handleUseAllParameters = () => {
dispatch(setAllTextToImageParameters(metadata));
metadata && dispatch(setAllTextToImageParameters(metadata));
toast({
title: 'Parameters Set',
status: 'success',

View File

@ -126,6 +126,22 @@
}
}
.image-gallery-category-btn-group {
width: 100% !important;
column-gap: 0 !important;
justify-content: stretch !important;
button {
flex-grow: 1;
&[data-selected='true'] {
background-color: var(--accent-color);
&:hover {
background-color: var(--accent-color-hover);
}
}
}
}
// from https://css-tricks.com/a-grid-of-logos-in-squares/
.image-gallery {
display: grid;

View File

@ -11,6 +11,7 @@ import IAIIconButton from '../../common/components/IAIIconButton';
import {
selectNextImage,
selectPrevImage,
setCurrentCategory,
setGalleryImageMinimumWidth,
setGalleryImageObjectFit,
setGalleryScrollPosition,
@ -20,11 +21,11 @@ import {
} from './gallerySlice';
import HoverableImage from './HoverableImage';
import { setShouldShowGallery } from '../gallery/gallerySlice';
import { Spacer, useToast } from '@chakra-ui/react';
import { ButtonGroup, Spacer, useToast } from '@chakra-ui/react';
import { CSSTransition } from 'react-transition-group';
import { Direction } from 're-resizable/lib/resizer';
import { imageGallerySelector } from './gallerySliceSelectors';
import { FaWrench } from 'react-icons/fa';
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';
@ -36,8 +37,8 @@ export default function ImageGallery() {
const {
images,
currentCategory,
currentImageUuid,
areMoreImagesAvailable,
shouldPinGallery,
shouldShowGallery,
galleryScrollPosition,
@ -47,6 +48,7 @@ export default function ImageGallery() {
galleryImageObjectFit,
shouldHoldGalleryOpen,
shouldAutoSwitchToNewImages,
areMoreImagesAvailable,
} = useAppSelector(imageGallerySelector);
const [gallerySize, setGallerySize] = useState<Size>({
@ -128,7 +130,7 @@ export default function ImageGallery() {
};
const handleClickLoadMore = () => {
dispatch(requestImages());
dispatch(requestImages(currentCategory));
};
const handleChangeGalleryImageMinimumWidth = (v: number) => {
@ -151,13 +153,21 @@ export default function ImageGallery() {
[shouldShowGallery]
);
useHotkeys('left', () => {
dispatch(selectPrevImage());
});
useHotkeys(
'left',
() => {
dispatch(selectPrevImage(currentCategory));
},
[currentCategory]
);
useHotkeys('right', () => {
dispatch(selectNextImage());
});
useHotkeys(
'right',
() => {
dispatch(selectNextImage(currentCategory));
},
[currentCategory]
);
useHotkeys(
'shift+p',
@ -309,17 +319,38 @@ export default function ImageGallery() {
}}
>
<div className="image-gallery-header">
{activeTabName !== 'inpainting' ? (
{/*{activeTabName !== 'inpainting' ? (
<>
<h1>Your Invocations</h1>
<Spacer />
</>
) : null}
) : null}*/}
<div>
<ButtonGroup
size="sm"
isAttached
variant="solid"
className="image-gallery-category-btn-group"
>
<IAIIconButton
aria-label="Show Invocations"
tooltip="Show Invocations"
data-selected={currentCategory === 'result'}
icon={<FaImage />}
onClick={() => dispatch(setCurrentCategory('result'))}
/>
<IAIIconButton
aria-label="Show Uploads"
tooltip="Show Uploads"
data-selected={currentCategory === 'user'}
icon={<FaUser />}
onClick={() => dispatch(setCurrentCategory('user'))}
/>
</ButtonGroup>
</div>
<IAIPopover
trigger="click"
trigger="hover"
hasArrow={activeTabName === 'inpainting' ? false : true}
// styleClass="image-gallery-settings-popover"
triggerComponent={
<IAIIconButton
size={'sm'}
@ -438,3 +469,18 @@ export default function ImageGallery() {
</CSSTransition>
);
}
// <IAIIconButton
// aria-label="Show Invocations"
// tooltip="Show Invocations"
// data-selected={currentCategory === 'result'}
// icon={<FaImage />}
// onClick={() => dispatch(setCurrentCategory('result'))}
// />
// <IAIIconButton
// aria-label="Show Uploads"
// tooltip="Show Uploads"
// data-selected={currentCategory === 'user'}
// icon={<FaUser />}
// onClick={() => dispatch(setCurrentCategory('user'))}
// />

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

@ -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

@ -3,16 +3,27 @@ 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;
@ -20,12 +31,15 @@ export interface GalleryState {
galleryImageObjectFit: GalleryImageObjectFitType;
shouldHoldGalleryOpen: boolean;
shouldAutoSwitchToNewImages: boolean;
categories: {
user: Gallery;
result: Gallery;
};
currentCategory: GalleryCategory;
}
const initialState: GalleryState = {
currentImageUuid: '',
images: [],
areMoreImagesAvailable: true,
shouldPinGallery: true,
shouldShowGallery: true,
galleryScrollPosition: 0,
@ -33,6 +47,21 @@ const initialState: GalleryState = {
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,
},
},
};
export const gallerySlice = createSlice({
@ -43,10 +72,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) {
/**
@ -58,7 +92,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
);
@ -84,24 +118,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);
tempCategory.images.unshift(newImage);
if (state.shouldAutoSwitchToNewImages) {
state.currentImageUuid = uuid;
state.currentImage = newImage;
if (category === 'result') {
state.currentCategory = 'result';
}
}
state.intermediateImage = undefined;
state.latest_mtime = mtime;
tempCategory.latest_mtime = mtime;
},
setIntermediateImage: (state, action: PayloadAction<InvokeAI.Image>) => {
state.intermediateImage = action.payload;
@ -109,49 +154,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);
@ -162,11 +211,14 @@ 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>) => {
@ -193,6 +245,9 @@ export const gallerySlice = createSlice({
setShouldAutoSwitchToNewImages: (state, action: PayloadAction<boolean>) => {
state.shouldAutoSwitchToNewImages = action.payload;
},
setCurrentCategory: (state, action: PayloadAction<GalleryCategory>) => {
state.currentCategory = action.payload;
},
},
});
@ -212,6 +267,7 @@ export const {
setGalleryImageObjectFit,
setShouldHoldGalleryOpen,
setShouldAutoSwitchToNewImages,
setCurrentCategory,
} = gallerySlice.actions;
export default gallerySlice.reducer;

View File

@ -8,24 +8,22 @@ export const imageGallerySelector = createSelector(
[(state: RootState) => state.gallery, (state: RootState) => state.options],
(gallery: GalleryState, options: OptionsState) => {
const {
images,
categories,
currentCategory,
currentImageUuid,
areMoreImagesAvailable,
shouldPinGallery,
shouldShowGallery,
galleryScrollPosition,
galleryImageMinimumWidth,
galleryImageObjectFit,
shouldHoldGalleryOpen,
shouldAutoSwitchToNewImages
shouldAutoSwitchToNewImages,
} = gallery;
const { activeTab } = options;
return {
images,
currentImageUuid,
areMoreImagesAvailable,
shouldPinGallery,
shouldShowGallery,
galleryScrollPosition,
@ -34,7 +32,11 @@ export const imageGallerySelector = createSelector(
galleryGridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, auto))`,
activeTabName: tabMap[activeTab],
shouldHoldGalleryOpen,
shouldAutoSwitchToNewImages
shouldAutoSwitchToNewImages,
images: categories[currentCategory].images,
areMoreImagesAvailable:
categories[currentCategory].areMoreImagesAvailable,
currentCategory,
};
}
);

View File

@ -1,5 +1,11 @@
import { Box } from '@chakra-ui/react';
import { cloneElement, ReactElement, SyntheticEvent, useCallback } from 'react';
import {
cloneElement,
ReactElement,
ReactNode,
SyntheticEvent,
useCallback,
} from 'react';
import { FileRejection, useDropzone } from 'react-dropzone';
type ImageUploaderProps = {
@ -55,7 +61,7 @@ const ImageUploader = ({
};
return (
<Box {...getRootProps()} flexGrow={3} className={`${styleClass}`}>
<Box {...getRootProps()} className={styleClass}>
<input {...getInputProps({ multiple: false })} />
{cloneElement(children, {
onClick: handleClickUploadIcon,

View File

@ -27,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;
@ -58,9 +57,7 @@ const initialOptionsState: OptionsState = {
seed: 0,
seamless: false,
hiresFix: false,
shouldUseInitImage: false,
img2imgStrength: 0.75,
initialImagePath: null,
maskPath: '',
shouldFitToWidthHeight: true,
shouldGenerateVariations: false,
@ -137,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;
},
@ -170,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>) => {
@ -236,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>) => {
@ -267,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) {
@ -335,6 +315,15 @@ export const optionsSlice = createSlice({
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;
},
},
});
@ -357,8 +346,6 @@ export const {
setCodeformerFidelity,
setUpscalingLevel,
setUpscalingStrength,
setShouldUseInitImage,
setInitialImagePath,
setMaskPath,
resetSeed,
resetOptionsState,
@ -377,6 +364,8 @@ export const {
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);

View File

@ -1,9 +1,11 @@
@use '../../../styles/Mixins/' as *;
.image-to-image-workarea {
display: grid;
grid-template-columns: max-content auto;
column-gap: 1rem;
.image-to-image-area {
display: flex;
flex-direction: column;
row-gap: 1rem;
width: 100%;
height: 100%;
}
.image-to-image-panel {
@ -15,16 +17,6 @@
@include HideScrollbar;
}
.image-to-image-display-area {
display: grid;
column-gap: 0.5rem;
.image-gallery-area {
z-index: 2;
height: 100%;
}
}
.image-to-image-strength-main-option {
display: grid;
grid-template-columns: none !important;
@ -34,110 +26,30 @@
}
}
.image-to-image-display {
border-radius: 0.5rem;
background-color: var(--background-color-secondary);
display: grid;
height: $app-content-height;
.init-image-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.current-image-options {
grid-auto-columns: max-content;
justify-self: center;
align-self: start;
h2 {
font-weight: bold;
font-size: 0.9rem;
}
}
.image-to-image-single-preview {
display: grid;
column-gap: 0.5rem;
padding: 0 1rem;
place-content: center;
}
.image-to-image-dual-preview-container {
display: grid;
grid-template-areas: 'img2img-preview';
}
.image-to-image-dual-preview {
grid-area: img2img-preview;
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 0.5rem;
padding: 0 1rem;
place-content: center;
.current-image-preview {
img {
height: calc($app-gallery-height - 2rem);
max-height: calc($app-gallery-height - 2rem);
}
}
}
.img2img-metadata {
grid-area: img2img-preview;
z-index: 3;
}
.init-image-preview {
display: grid;
grid-template-areas: 'init-image-content';
justify-content: center;
align-items: center;
border-radius: 0.5rem;
.init-image-preview-header {
grid-area: init-image-content;
z-index: 2;
display: grid;
grid-template-columns: auto max-content;
height: max-content;
align-items: center;
align-self: start;
padding: 1rem;
border-radius: 0.5rem;
h1 {
padding: 0.2rem 0.6rem;
border-radius: 0.4rem;
background-color: var(--tab-hover-color);
width: max-content;
font-weight: bold;
font-size: 0.85rem;
}
}
.init-image-image {
grid-area: init-image-content;
img {
border-radius: 0.5rem;
object-fit: contain;
background-color: var(--img2img-img-bg-color);
width: auto;
height: calc($app-gallery-height - 2rem);
max-height: calc($app-gallery-height - 2rem);
}
}
}
.image-to-image-upload-btn {
display: grid;
height: 100%;
width: 100%;
height: $app-content-height;
display: flex;
align-items: center;
justify-content: center;
height: $app-text-to-image-height;
button {
overflow: hidden;
width: 100%;
height: 100%;
font-size: 1.5rem;
color: var(--text-color-secondary);
background-color: var(--background-color-secondary);
&:hover {
background-color: var(--img2img-img-bg-color);
}
img {
border-radius: 0.5rem;
object-fit: contain;
width: auto;
}
}

View File

@ -1,74 +1,40 @@
import React from 'react';
import { FaUpload } from 'react-icons/fa';
import { uploadInitialImage } from '../../../app/socketio/actions';
import { RootState, useAppSelector } from '../../../app/store';
import { uploadImage } from '../../../app/socketio/actions';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import InvokeImageUploader from '../../../common/components/InvokeImageUploader';
import CurrentImageButtons from '../../gallery/CurrentImageButtons';
import CurrentImagePreview from '../../gallery/CurrentImagePreview';
import ImageMetadataViewer from '../../gallery/ImageMetaDataViewer/ImageMetadataViewer';
import CurrentImageDisplay from '../../gallery/CurrentImageDisplay';
import InitImagePreview from './InitImagePreview';
export default function ImageToImageDisplay() {
const initialImagePath = useAppSelector(
(state: RootState) => state.options.initialImagePath
const ImageToImageDisplay = () => {
const dispatch = useAppDispatch();
const initialImage = useAppSelector(
(state: RootState) => state.options.initialImage
);
const { currentImage, intermediateImage } = useAppSelector(
(state: RootState) => state.gallery
);
const { currentImage } = useAppSelector((state: RootState) => state.gallery);
const shouldShowImageDetails = useAppSelector(
(state: RootState) => state.options.shouldShowImageDetails
const imageToImageComponent = initialImage ? (
<div className="image-to-image-area">
<InitImagePreview />
</div>
) : (
<InvokeImageUploader
handleFile={(file: File) =>
dispatch(uploadImage({ file, destination: 'img2img' }))
}
/>
);
const imageToDisplay = intermediateImage || currentImage;
return (
<div
className="image-to-image-display"
style={
imageToDisplay
? { gridAutoRows: 'max-content auto' }
: { gridAutoRows: 'auto' }
}
>
{initialImagePath ? (
<>
{imageToDisplay ? (
<>
<CurrentImageButtons image={imageToDisplay} />
<div className="image-to-image-dual-preview-container">
<div className="image-to-image-dual-preview">
<InitImagePreview />
<div className="image-to-image-current-image-display">
<CurrentImagePreview imageToDisplay={imageToDisplay} />
{shouldShowImageDetails && (
<ImageMetadataViewer
image={imageToDisplay}
styleClass="img2img-metadata"
/>
)}
</div>
</div>
</div>
</>
) : (
<div className="image-to-image-single-preview">
<InitImagePreview />
</div>
)}
</>
) : (
<div className="upload-image">
<InvokeImageUploader
label="Upload or Drop Image Here"
icon={<FaUpload />}
styleClass="image-to-image-upload-btn"
dispatcher={uploadInitialImage}
/>
<div className="workarea-split-view">
<div className="workarea-split-view-left">{imageToImageComponent} </div>
{currentImage && (
<div className="workarea-split-view-right">
<CurrentImageDisplay />
</div>
)}
</div>
);
}
};
export default ImageToImageDisplay;

View File

@ -2,12 +2,10 @@ import { IconButton, Image, useToast } from '@chakra-ui/react';
import React, { SyntheticEvent } from 'react';
import { MdClear } from 'react-icons/md';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import { setInitialImagePath } from '../../options/optionsSlice';
import { clearInitialImage } from '../../options/optionsSlice';
export default function InitImagePreview() {
const initialImagePath = useAppSelector(
(state: RootState) => state.options.initialImagePath
);
const { initialImage } = useAppSelector((state: RootState) => state.options);
const dispatch = useAppDispatch();
@ -15,7 +13,7 @@ export default function InitImagePreview() {
const handleClickResetInitialImage = (e: SyntheticEvent) => {
e.stopPropagation();
dispatch(setInitialImagePath(null));
dispatch(clearInitialImage());
};
const alertMissingInitImage = () => {
@ -25,31 +23,33 @@ export default function InitImagePreview() {
status: 'error',
isClosable: true,
});
dispatch(setInitialImagePath(null));
dispatch(clearInitialImage());
};
return (
<div className="init-image-preview">
<>
<div className="init-image-preview-header">
<h1>Initial Image</h1>
<h2>Initial Image</h2>
<IconButton
isDisabled={!initialImagePath}
size={'sm'}
isDisabled={!initialImage}
aria-label={'Reset Initial Image'}
onClick={handleClickResetInitialImage}
icon={<MdClear />}
/>
</div>
{initialImagePath && (
<div className="init-image-image">
{initialImage && (
<div className="init-image-preview">
<Image
fit={'contain'}
src={initialImagePath}
rounded={'md'}
maxWidth={'100%'}
maxHeight={'100%'}
src={
typeof initialImage === 'string' ? initialImage : initialImage.url
}
onError={alertMissingInitImage}
/>
</div>
)}
</div>
</>
);
}

View File

@ -3,14 +3,14 @@ import React from 'react';
import { RootState, useAppSelector } from '../../../app/store';
export default function InitialImageOverlay() {
const initialImagePath = useAppSelector(
(state: RootState) => state.options.initialImagePath
const initialImage = useAppSelector(
(state: RootState) => state.options.initialImage
);
return initialImagePath ? (
return initialImage ? (
<Image
fit={'contain'}
src={initialImagePath}
src={typeof initialImage === 'string' ? initialImage : initialImage.url}
rounded={'md'}
className={'checkerboard'}
/>

View File

@ -1,62 +1,47 @@
@use '../../../styles/Mixins/' as *;
.brush-preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
}
.brush-preview {
border-radius: 50%;
border: 1px black solid;
}
.inpainting-workarea {
display: grid;
grid-template-columns: max-content auto;
column-gap: 1rem;
}
.inpainting-display-area {
display: grid;
column-gap: 0.5rem;
}
.inpainting-display {
border-radius: 0.5rem;
background-color: var(--background-color-secondary);
display: grid;
grid-template-columns: 1fr 1fr;
// column-gap: 1rem;
height: $app-content-height;
}
.inpainting-toolkit {
// display: grid;
// grid-template-rows: auto auto;
.inpainting-main-area {
display: flex;
flex-direction: column;
background-color: var(--inpaint-bg-color);
border-radius: 0.5rem;
}
.inpainting-canvas-container {
align-items: center;
row-gap: 1rem;
width: 100%;
height: 100%;
padding-left: 1rem;
padding-right: 1rem;
padding-bottom: 1rem;
display: flex;
align-items: center;
justify-content: center;
.inpainting-settings {
display: flex;
align-items: center;
column-gap: 1rem;
.inpainting-buttons-group {
display: flex;
align-items: center;
column-gap: 0.5rem;
}
.inpainting-slider-numberinput {
display: flex;
column-gap: 1rem;
align-items: center;
}
}
.inpainting-canvas-area {
display: flex;
flex-direction: column;
align-items: center;
row-gap: 1rem;
width: 100%;
height: 100%;
}
.inpainting-canvas-wrapper {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: min-content;
height: min-content;
height: 100%;
width: 100%;
border-radius: 0.5rem;
.inpainting-alerts {
@ -80,6 +65,7 @@
}
.inpainting-canvas-stage {
border-radius: 0.5rem;
canvas {
border-radius: 0.5rem;
}
@ -87,61 +73,8 @@
}
}
// .canvas-scale-calculator {
// width: calc(100% - 1rem);
// height: calc(100% - 1rem);
// display: flex;
// align-items: center;
// justify-content: center;
// }
.inpainting-canvas-scale-wrapper {
width: calc(100% - 1rem);
height: calc(100% - 1rem);
// width: max-content;
// height: max-content;
display: flex;
justify-content: center;
align-items: center;
}
.inpainting-settings {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
row-gap: 1rem;
padding: 1rem;
.inpainting-buttons {
display: flex;
align-items: center;
column-gap: 0.8rem;
button {
height: 2.4rem;
svg {
width: 15px;
height: 15px;
}
}
.inpainting-buttons-group {
display: flex;
align-items: center;
column-gap: 0.5rem;
}
}
.inpainting-slider-numberinput {
display: flex;
column-gap: 1rem;
align-items: center;
}
}
// Overrides
.inpainting-workarea-container {
.inpainting-workarea-overrides {
.image-gallery-area {
.chakra-popover__popper {
inset: 0 auto auto -75px !important;
@ -149,21 +82,8 @@
}
.current-image-options {
button {
@include Button(
$btn-width: 2.5rem,
$icon-size: 18px,
$btn-color: var(--btn-grey),
$btn-color-hover: var(--btn-grey-hover)
);
}
.chakra-popover__popper {
z-index: 11;
}
}
.current-image-preview {
padding: 0 1rem 1rem 1rem;
}
}

View File

@ -218,7 +218,7 @@ const InpaintingCanvas = () => {
);
return (
<div className="inpainting-canvas-wrapper checkerboard" tabIndex={1}>
<div className="inpainting-canvas-wrapper" tabIndex={1}>
<div className="inpainting-alerts">
{!shouldShowMask && (
<div style={{ pointerEvents: 'none' }}>Mask Hidden (H)</div>
@ -245,7 +245,7 @@ const InpaintingCanvas = () => {
onMouseOut={handleMouseOutCanvas}
onMouseLeave={handleMouseOutCanvas}
style={{ cursor: shouldShowMask ? 'none' : 'default' }}
className="inpainting-canvas-stage"
className="inpainting-canvas-stage checkerboard"
ref={stageRef}
>
{!shouldInvertMask && !shouldShowCheckboardTransparency && (

View File

@ -25,7 +25,7 @@ const InpaintingCanvasPlaceholder = () => {
}, [dispatch, imageToInpaint, needsRepaint]);
return (
<div ref={ref} className="inpainting-canvas-container">
<div ref={ref} className="inpainting-canvas-area">
<Spinner thickness="2px" speed="1s" size="xl" />
</div>
);

View File

@ -6,6 +6,7 @@ import {
FaPalette,
FaPlus,
FaRedo,
FaTrash,
FaUndo,
} from 'react-icons/fa';
import { BiHide, BiShow } from 'react-icons/bi';
@ -24,6 +25,7 @@ import {
setShouldInvertMask,
setNeedsRepaint,
toggleShouldLockBoundingBox,
clearImageToInpaint,
} from './inpaintingSlice';
import { MdInvertColors, MdInvertColorsOff } from 'react-icons/md';
@ -278,125 +280,131 @@ const InpaintingControls = () => {
dispatch(setNeedsRepaint(true));
};
const handleClearImage = () => {
dispatch(clearImageToInpaint());
};
return (
<div className="inpainting-settings">
<div className="inpainting-buttons">
<div className="inpainting-buttons-group">
<IAIPopover
trigger="hover"
onOpen={handleShowBrushPreview}
onClose={handleHideBrushPreview}
triggerComponent={
<IAIIconButton
aria-label="Brush (B)"
tooltip="Brush (B)"
icon={<FaPaintBrush />}
onClick={handleSelectBrushTool}
data-selected={tool === 'brush'}
isDisabled={!shouldShowMask}
/>
}
>
<div className="inpainting-slider-numberinput">
<IAISlider
label="Brush Size"
value={brushSize}
onChange={handleChangeBrushSize}
min={1}
max={200}
width="100px"
focusThumbOnChange={false}
isDisabled={!shouldShowMask}
/>
<IAINumberInput
value={brushSize}
onChange={handleChangeBrushSize}
width={'80px'}
min={1}
max={999}
isDisabled={!shouldShowMask}
/>
</div>
</IAIPopover>
<IAIIconButton
aria-label="Eraser (E)"
tooltip="Eraser (E)"
icon={<FaEraser />}
onClick={handleSelectEraserTool}
data-selected={tool === 'eraser'}
isDisabled={!shouldShowMask}
/>
</div>
<div className="inpainting-buttons-group">
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton
aria-label="Mask Color"
tooltip="Mask Color"
icon={<FaPalette />}
isDisabled={!shouldShowMask}
cursor={'pointer'}
/>
}
>
<IAIColorPicker
color={maskColor}
onChange={handleChangeMaskColor}
<div className="inpainting-buttons-group">
<IAIPopover
trigger="hover"
onOpen={handleShowBrushPreview}
onClose={handleHideBrushPreview}
triggerComponent={
<IAIIconButton
aria-label="Brush (B)"
tooltip="Brush (B)"
icon={<FaPaintBrush />}
onClick={handleSelectBrushTool}
data-selected={tool === 'brush'}
isDisabled={!shouldShowMask}
/>
</IAIPopover>
<IAIIconButton
aria-label="Hide/Show Mask (H)"
tooltip="Hide/Show Mask (H)"
data-selected={!shouldShowMask}
icon={shouldShowMask ? <BiShow size={22} /> : <BiHide size={22} />}
onClick={handleToggleShouldShowMask}
/>
<IAIIconButton
tooltip="Invert Mask Display (Shift+M)"
aria-label="Invert Mask Display (Shift+M)"
data-selected={shouldInvertMask}
icon={
shouldInvertMask ? (
<MdInvertColors size={22} />
) : (
<MdInvertColorsOff size={22} />
)
}
onClick={handleToggleShouldInvertMask}
isDisabled={!shouldShowMask}
/>
</div>
<div className="inpainting-buttons-group">
<IAIIconButton
aria-label="Undo"
tooltip="Undo"
icon={<FaUndo />}
onClick={handleUndo}
isDisabled={!canUndo || !shouldShowMask}
/>
<IAIIconButton
aria-label="Redo"
tooltip="Redo"
icon={<FaRedo />}
onClick={handleRedo}
isDisabled={!canRedo || !shouldShowMask}
/>
<IAIIconButton
aria-label="Clear Mask (Shift + C)"
tooltip="Clear Mask (Shift + C)"
icon={<FaPlus size={18} style={{ transform: 'rotate(45deg)' }} />}
onClick={handleClearMask}
isDisabled={isMaskEmpty || !shouldShowMask}
/>
<IAIIconButton
aria-label="Split Layout (Shift+J)"
tooltip="Split Layout (Shift+J)"
icon={<VscSplitHorizontal />}
data-selected={showDualDisplay}
onClick={handleDualDisplay}
/>
</div>
}
>
<div className="inpainting-slider-numberinput">
<IAISlider
label="Brush Size"
value={brushSize}
onChange={handleChangeBrushSize}
min={1}
max={200}
width="100px"
focusThumbOnChange={false}
isDisabled={!shouldShowMask}
/>
<IAINumberInput
value={brushSize}
onChange={handleChangeBrushSize}
width={'80px'}
min={1}
max={999}
isDisabled={!shouldShowMask}
/>
</div>
</IAIPopover>
<IAIIconButton
aria-label="Eraser (E)"
tooltip="Eraser (E)"
icon={<FaEraser />}
onClick={handleSelectEraserTool}
data-selected={tool === 'eraser'}
isDisabled={!shouldShowMask}
/>
</div>
<div className="inpainting-buttons-group">
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton
aria-label="Mask Color"
tooltip="Mask Color"
icon={<FaPalette />}
isDisabled={!shouldShowMask}
cursor={'pointer'}
/>
}
>
<IAIColorPicker color={maskColor} onChange={handleChangeMaskColor} />
</IAIPopover>
<IAIIconButton
aria-label="Hide/Show Mask (H)"
tooltip="Hide/Show Mask (H)"
data-selected={!shouldShowMask}
icon={shouldShowMask ? <BiShow size={22} /> : <BiHide size={22} />}
onClick={handleToggleShouldShowMask}
/>
<IAIIconButton
tooltip="Invert Mask Display (Shift+M)"
aria-label="Invert Mask Display (Shift+M)"
data-selected={shouldInvertMask}
icon={
shouldInvertMask ? (
<MdInvertColors size={22} />
) : (
<MdInvertColorsOff size={22} />
)
}
onClick={handleToggleShouldInvertMask}
isDisabled={!shouldShowMask}
/>
</div>
<div className="inpainting-buttons-group">
<IAIIconButton
aria-label="Undo"
tooltip="Undo"
icon={<FaUndo />}
onClick={handleUndo}
isDisabled={!canUndo || !shouldShowMask}
/>
<IAIIconButton
aria-label="Redo"
tooltip="Redo"
icon={<FaRedo />}
onClick={handleRedo}
isDisabled={!canRedo || !shouldShowMask}
/>
<IAIIconButton
aria-label="Clear Mask Canvas (Shift + C)"
tooltip="Clear Mask Canvas (Shift + C)"
icon={<FaPlus size={18} style={{ transform: 'rotate(45deg)' }} />}
onClick={handleClearMask}
isDisabled={isMaskEmpty || !shouldShowMask}
/>
<IAIIconButton
aria-label="Clear Image"
tooltip="Clear Image"
icon={<FaTrash size={18} />}
onClick={handleClearImage}
// isDisabled={}
/>
<IAIIconButton
aria-label="Split Layout (Shift+J)"
tooltip="Split Layout (Shift+J)"
icon={<VscSplitHorizontal />}
data-selected={showDualDisplay}
onClick={handleDualDisplay}
/>
</div>
</div>
);

View File

@ -1,7 +1,9 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { useLayoutEffect } from 'react';
import { uploadImage } from '../../../app/socketio/actions';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import InvokeImageUploader from '../../../common/components/InvokeImageUploader';
import CurrentImageDisplay from '../../gallery/CurrentImageDisplay';
import { OptionsState } from '../../options/optionsSlice';
import InpaintingCanvas from './InpaintingCanvas';
@ -12,11 +14,12 @@ import { InpaintingState, setNeedsRepaint } from './inpaintingSlice';
const inpaintingDisplaySelector = createSelector(
[(state: RootState) => state.inpainting, (state: RootState) => state.options],
(inpainting: InpaintingState, options: OptionsState) => {
const { needsRepaint } = inpainting;
const { needsRepaint, imageToInpaint } = inpainting;
const { showDualDisplay } = options;
return {
needsRepaint,
showDualDisplay,
imageToInpaint,
};
},
{
@ -28,7 +31,7 @@ const inpaintingDisplaySelector = createSelector(
const InpaintingDisplay = () => {
const dispatch = useAppDispatch();
const { showDualDisplay, needsRepaint } = useAppSelector(
const { showDualDisplay, needsRepaint, imageToInpaint } = useAppSelector(
inpaintingDisplaySelector
);
@ -41,27 +44,33 @@ const InpaintingDisplay = () => {
return () => window.removeEventListener('resize', resizeCallback);
}, [dispatch]);
const inpaintingComponent = imageToInpaint ? (
<div className="inpainting-main-area">
<InpaintingControls />
<div className="inpainting-canvas-area">
{needsRepaint ? <InpaintingCanvasPlaceholder /> : <InpaintingCanvas />}
</div>
</div>
) : (
<InvokeImageUploader
handleFile={(file: File) =>
dispatch(uploadImage({ file, destination: 'inpainting' }))
}
/>
);
return (
<div
className="inpainting-display"
style={
showDualDisplay
? { gridTemplateColumns: '1fr 1fr' }
: { gridTemplateColumns: 'auto' }
className={
showDualDisplay ? 'workarea-split-view' : 'workarea-single-view'
}
>
<div className="inpainting-toolkit">
<InpaintingControls />
<div className="inpainting-canvas-container">
{needsRepaint ? (
<InpaintingCanvasPlaceholder />
) : (
<InpaintingCanvas />
)}
<div className="workarea-split-view-left">{inpaintingComponent} </div>
{showDualDisplay && (
<div className="workarea-split-view-right">
<CurrentImageDisplay />
</div>
</div>
{showDualDisplay && <CurrentImageDisplay />}
)}
</div>
);
};

View File

@ -4,10 +4,7 @@ import InvokeWorkarea from '../InvokeWorkarea';
export default function InpaintingWorkarea() {
return (
<InvokeWorkarea
optionsPanel={<InpaintingPanel />}
className="inpainting-workarea-container"
>
<InvokeWorkarea optionsPanel={<InpaintingPanel />}>
<InpaintingDisplay />
</InvokeWorkarea>
);

View File

@ -156,10 +156,12 @@ export const inpaintingSlice = createSlice({
setMaskColor: (state, action: PayloadAction<RgbaColor>) => {
state.maskColor = action.payload;
},
// },
setCursorPosition: (state, action: PayloadAction<Vector2d | null>) => {
state.cursorPosition = action.payload;
},
clearImageToInpaint: (state) => {
state.imageToInpaint = undefined;
},
setImageToInpaint: (state, action: PayloadAction<InvokeAI.Image>) => {
const { width: imageWidth, height: imageHeight } = action.payload;
const { width: boundingBoxWidth, height: boundingBoxHeight } =
@ -335,6 +337,7 @@ export const {
setShouldShowBrushPreview,
setMaskColor,
clearMask,
clearImageToInpaint,
undo,
redo,
setCursorPosition,

View File

@ -1,11 +1,11 @@
@use '../../styles/Mixins/' as *;
.workarea-container {
.workarea-wrapper {
position: relative;
width: 100%;
height: 100%;
.workarea {
.workarea-main {
display: flex;
column-gap: 1rem;
@ -15,8 +15,40 @@
flex-shrink: 0;
}
.workarea-content {
.workarea-split-view {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
height: $app-content-height;
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
}
.workarea-single-view {
width: 100%;
height: $app-content-height;
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
}
.workarea-split-view-left,
.workarea-split-view-right {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
row-gap: 1rem;
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1rem;
}
.workarea-split-view-left {
padding-right: 0.5rem;
}
.workarea-split-view-right {
padding-left: 0.5rem;
}
}
}

View File

@ -5,29 +5,23 @@ import ShowHideGalleryButton from '../gallery/ShowHideGalleryButton';
type InvokeWorkareaProps = {
optionsPanel: ReactNode;
className?: string;
children: ReactNode;
};
const InvokeWorkarea = (props: InvokeWorkareaProps) => {
const { optionsPanel, className, children } = props;
const { optionsPanel, children } = props;
const { shouldShowGallery, shouldHoldGalleryOpen } = useAppSelector(
(state: RootState) => state.gallery
);
const { shouldShowGallery, shouldHoldGalleryOpen, shouldPinGallery } =
useAppSelector((state: RootState) => state.gallery);
return (
<div
className={
className ? `workarea-container ${className}` : `workarea-container`
}
>
<div className="workarea">
<div className="workarea-wrapper">
<div className="workarea-main">
<div className="workarea-options-panel">{optionsPanel}</div>
<div className="workarea-content">{children}</div>
{children}
<ImageGallery />
</div>
{!(shouldShowGallery || shouldHoldGalleryOpen) && (
{!(shouldShowGallery || (shouldHoldGalleryOpen && !shouldPinGallery)) && (
<ShowHideGalleryButton />
)}
</div>

View File

@ -1,9 +1,7 @@
@use '../../../styles/Mixins/' as *;
.text-to-image-workarea {
display: grid;
grid-template-columns: max-content auto;
column-gap: 1rem;
.text-to-image-area {
padding: 1rem;
}
.text-to-image-panel {
@ -14,27 +12,3 @@
overflow-y: scroll;
@include HideScrollbar;
}
.text-to-image-display {
display: grid;
grid-template-areas: 'text-to-image-display';
column-gap: 0.5rem;
.current-image-display,
.current-image-display-placeholder {
grid-area: text-to-image-display;
}
.image-gallery-area {
height: 100%;
z-index: 2;
place-self: end;
}
}
// Overrides
.txt-to-image-workarea-container {
.current-image-preview {
padding: 0 1rem 1rem 1rem;
}
}

View File

@ -0,0 +1,13 @@
import CurrentImageDisplay from '../../gallery/CurrentImageDisplay';
const TextToImageDisplay = () => {
return (
<div className="workarea-single-view">
<div className="text-to-image-area">
<CurrentImageDisplay />
</div>
</div>
);
};
export default TextToImageDisplay;

View File

@ -1,14 +1,11 @@
import TextToImagePanel from './TextToImagePanel';
import CurrentImageDisplay from '../../gallery/CurrentImageDisplay';
import InvokeWorkarea from '../InvokeWorkarea';
import TextToImageDisplay from './TextToImageDisplay';
export default function TextToImageWorkarea() {
return (
<InvokeWorkarea
optionsPanel={<TextToImagePanel />}
className="txt-to-image-workarea-container"
>
<CurrentImageDisplay />
<InvokeWorkarea optionsPanel={<TextToImagePanel />}>
<TextToImageDisplay />
</InvokeWorkarea>
);
}

View File

@ -7,9 +7,10 @@ $app-content-height-cutoff: 7rem; // default: 7rem
$app-width: calc(100vw - $app-cutoff);
$app-height: calc(100vh - $app-cutoff);
$app-content-height: calc(100vh - $app-content-height-cutoff);
$app-gallery-height: calc(100vh - ($app-content-height-cutoff + 6rem));
$app-gallery-height: calc(100vh - ($app-content-height-cutoff + 5.5rem));
$app-gallery-popover-height: calc(100vh - ($app-content-height-cutoff + 6rem));
$app-metadata-height: calc(100vh - ($app-content-height-cutoff + 4.4rem));
$app-text-to-image-height: calc(100vh - 9.4375rem - 1.925rem - 1.15rem); // do not touch ffs
// option bar
$options-bar-max-width: 22.5rem;

View File

@ -51,6 +51,7 @@
@use '../common/components/IAICheckbox.scss';
@use '../common/components/IAIPopover.scss';
@use '../common/components/IAIColorPicker.scss';
@use '../common/components/InvokeImageUploader.scss';
@use '../common/components/WorkInProgress/WorkInProgress.scss';
@use '../common/components/GuidePopover.scss';