Merges development

This commit is contained in:
psychedelicious 2022-10-27 15:24:00 +11:00
parent fe7ab6e480
commit e5dcae5fff
119 changed files with 4981 additions and 1058 deletions

View File

@ -5,6 +5,8 @@ import shutil
import mimetypes
import traceback
import math
import io
import base64
from flask import Flask, redirect, send_from_directory
from flask_socketio import SocketIO
@ -64,10 +66,7 @@ class InvokeAIWebServer:
__name__, static_url_path='', static_folder='../frontend/dist/'
)
self.socketio = SocketIO(
self.app,
**socketio_args
)
self.socketio = SocketIO(self.app, **socketio_args)
# Keep Server Alive Route
@self.app.route('/flaskwebgui-keep-server-alive')
@ -102,6 +101,7 @@ class InvokeAIWebServer:
close_server_on_exit = False
try:
from flaskwebgui import FlaskUI
FlaskUI(
app=self.app,
socketio=self.socketio,
@ -186,11 +186,14 @@ class InvokeAIWebServer:
for path in image_paths:
metadata = retrieve_metadata(path)
(width, height) = Image.open(path).size
image_array.append(
{
'url': self.get_url_from_image_path(path),
'mtime': os.path.getmtime(path),
'metadata': metadata['sd-metadata'],
'width': width,
'height': height,
}
)
@ -233,11 +236,16 @@ class InvokeAIWebServer:
for path in image_paths:
metadata = retrieve_metadata(path)
(width, height) = Image.open(path).size
image_array.append(
{
'url': self.get_url_from_image_path(path),
'mtime': os.path.getmtime(path),
'metadata': metadata['sd-metadata'],
'width': width,
'height': height,
}
)
@ -260,11 +268,24 @@ class InvokeAIWebServer:
generation_parameters, esrgan_parameters, facetool_parameters
):
try:
print(
f'>> Image generation requested: {generation_parameters}\nESRGAN parameters: {esrgan_parameters}\nFacetool parameters: {facetool_parameters}'
)
# truncate long init_mask base64 if needed
if 'init_mask' in generation_parameters:
printable_parameters = {
**generation_parameters,
'init_mask': generation_parameters['init_mask'][:20]
+ '...',
}
print(
f'>> Image generation requested: {printable_parameters}\nESRGAN parameters: {esrgan_parameters}\nFacetool parameters: {facetool_parameters}'
)
else:
print(
f'>> Image generation requested: {generation_parameters}\nESRGAN parameters: {esrgan_parameters}\nFacetool parameters: {facetool_parameters}'
)
self.generate_images(
generation_parameters, esrgan_parameters, facetool_parameters
generation_parameters,
esrgan_parameters,
facetool_parameters,
)
except Exception as e:
self.socketio.emit('error', {'message': (str(e))})
@ -321,16 +342,24 @@ class InvokeAIWebServer:
elif postprocessing_parameters['type'] == 'gfpgan':
image = self.gfpgan.process(
image=image,
strength=postprocessing_parameters['facetool_strength'],
strength=postprocessing_parameters[
'facetool_strength'
],
seed=seed,
)
elif postprocessing_parameters['type'] == 'codeformer':
image = self.codeformer.process(
image=image,
strength=postprocessing_parameters['facetool_strength'],
fidelity=postprocessing_parameters['codeformer_fidelity'],
strength=postprocessing_parameters[
'facetool_strength'
],
fidelity=postprocessing_parameters[
'codeformer_fidelity'
],
seed=seed,
device='cpu' if str(self.generate.device) == 'mps' else self.generate.device
device='cpu'
if str(self.generate.device) == 'mps'
else self.generate.device,
)
else:
raise TypeError(
@ -349,6 +378,8 @@ class InvokeAIWebServer:
command = parameters_to_command(postprocessing_parameters)
(width, height) = image.size
path = self.save_result_image(
image,
command,
@ -371,6 +402,8 @@ class InvokeAIWebServer:
'url': self.get_url_from_image_path(path),
'mtime': os.path.getmtime(path),
'metadata': metadata,
'width': width,
'height': height,
},
)
except Exception as e:
@ -482,19 +515,41 @@ class InvokeAIWebServer:
if 'init_img' in generation_parameters:
init_img_url = generation_parameters['init_img']
generation_parameters[
'init_img'
] = self.get_image_path_from_url(
generation_parameters['init_img']
)
init_img_path = self.get_image_path_from_url(init_img_url)
generation_parameters['init_img'] = init_img_path
# if 'init_mask' in generation_parameters:
# mask_img_url = generation_parameters['init_mask']
# generation_parameters[
# 'init_mask'
# ] = self.get_image_path_from_url(
# generation_parameters['init_mask']
# )
if 'init_mask' in generation_parameters:
mask_img_url = generation_parameters['init_mask']
generation_parameters[
'init_mask'
] = self.get_image_path_from_url(
generation_parameters['init_mask']
# grab an Image of the init image
original_image = Image.open(init_img_path)
# copy a region from it which we will inpaint
cropped_init_image = copy_image_from_bounding_box(
original_image, **generation_parameters['bounding_box']
)
generation_parameters['init_img'] = cropped_init_image
# grab an Image of the mask
mask_image = Image.open(
io.BytesIO(
base64.decodebytes(
bytes(generation_parameters['init_mask'], 'utf-8')
)
)
)
# crop the mask image
cropped_mask_image = copy_image_from_bounding_box(
mask_image, **generation_parameters['bounding_box']
)
generation_parameters['init_mask'] = cropped_mask_image
totalSteps = self.calculate_real_steps(
steps=generation_parameters['steps'],
@ -532,6 +587,8 @@ class InvokeAIWebServer:
)
command = parameters_to_command(generation_parameters)
(width, height) = image.size
path = self.save_result_image(
image,
command,
@ -548,6 +605,8 @@ class InvokeAIWebServer:
'url': self.get_url_from_image_path(path),
'mtime': os.path.getmtime(path),
'metadata': metadata,
'width': width,
'height': height
},
)
self.socketio.emit(
@ -625,7 +684,9 @@ class InvokeAIWebServer:
if facetool_parameters['type'] == 'gfpgan':
progress.set_current_status('Restoring Faces (GFPGAN)')
elif facetool_parameters['type'] == 'codeformer':
progress.set_current_status('Restoring Faces (Codeformer)')
progress.set_current_status(
'Restoring Faces (Codeformer)'
)
progress.set_current_status_has_steps(False)
self.socketio.emit(
@ -643,11 +704,17 @@ class InvokeAIWebServer:
image = self.codeformer.process(
image=image,
strength=facetool_parameters['strength'],
fidelity=facetool_parameters['codeformer_fidelity'],
fidelity=facetool_parameters[
'codeformer_fidelity'
],
seed=seed,
device='cpu' if str(self.generate.device) == 'mps' else self.generate.device,
device='cpu'
if str(self.generate.device) == 'mps'
else self.generate.device,
)
all_parameters['codeformer_fidelity'] = facetool_parameters['codeformer_fidelity']
all_parameters[
'codeformer_fidelity'
] = facetool_parameters['codeformer_fidelity']
postprocessing = True
all_parameters['facetool_strength'] = facetool_parameters[
@ -663,12 +730,20 @@ class InvokeAIWebServer:
)
eventlet.sleep(0)
# paste the inpainting image back onto the original
if 'init_mask' in generation_parameters:
image = paste_image_into_bounding_box(
Image.open(init_img_path),
image,
**generation_parameters['bounding_box'],
)
# restore the stashed URLS and discard the paths, we are about to send the result to client
if 'init_img' in all_parameters:
all_parameters['init_img'] = init_img_url
if 'init_mask' in all_parameters:
all_parameters['init_mask'] = mask_img_url
all_parameters['init_mask'] = '' #
metadata = self.parameters_to_generated_image_metadata(
all_parameters
@ -676,6 +751,8 @@ class InvokeAIWebServer:
command = parameters_to_command(all_parameters)
(width, height) = image.size
path = self.save_result_image(
image,
command,
@ -705,6 +782,8 @@ class InvokeAIWebServer:
'url': self.get_url_from_image_path(path),
'mtime': os.path.getmtime(path),
'metadata': metadata,
'width': width,
'height': height,
},
)
eventlet.sleep(0)
@ -766,12 +845,14 @@ class InvokeAIWebServer:
# 'postprocessing' is either null or an
if 'facetool_strength' in parameters:
facetool_parameters = {
'type': str(parameters['facetool_type']),
'strength': float(parameters['facetool_strength']),
}
'type': str(parameters['facetool_type']),
'strength': float(parameters['facetool_strength']),
}
if parameters['facetool_type'] == 'codeformer':
facetool_parameters['fidelity'] = float(parameters['codeformer_fidelity'])
facetool_parameters['fidelity'] = float(
parameters['codeformer_fidelity']
)
postprocessing.append(facetool_parameters)
@ -792,7 +873,9 @@ class InvokeAIWebServer:
rfc_dict['sampler'] = parameters['sampler_name']
# display weighted subprompts (liable to change)
subprompts = split_weighted_subprompts(parameters['prompt'], skip_normalize=True)
subprompts = split_weighted_subprompts(
parameters['prompt'], skip_normalize=True
)
subprompts = [{'prompt': x[0], 'weight': x[1]} for x in subprompts]
rfc_dict['prompt'] = subprompts
@ -817,13 +900,13 @@ class InvokeAIWebServer:
rfc_dict['init_image_path'] = parameters[
'init_img'
] # TODO: Noncompliant
if 'init_mask' in parameters:
rfc_dict['mask_hash'] = calculate_init_img_hash(
self.get_image_path_from_url(parameters['init_mask'])
) # TODO: Noncompliant
rfc_dict['mask_image_path'] = parameters[
'init_mask'
] # TODO: Noncompliant
# if 'init_mask' in parameters:
# rfc_dict['mask_hash'] = calculate_init_img_hash(
# self.get_image_path_from_url(parameters['init_mask'])
# ) # TODO: Noncompliant
# rfc_dict['mask_image_path'] = parameters[
# 'init_mask'
# ] # TODO: Noncompliant
else:
rfc_dict['type'] = 'txt2img'
@ -875,7 +958,9 @@ class InvokeAIWebServer:
postprocessing_metadata['strength'] = parameters[
'facetool_strength'
]
postprocessing_metadata['fidelity'] = parameters['codeformer_fidelity']
postprocessing_metadata['fidelity'] = parameters[
'codeformer_fidelity'
]
else:
raise TypeError(f"Invalid type: {parameters['type']}")
@ -1119,3 +1204,29 @@ class Progress:
class CanceledException(Exception):
pass
"""
Crops an image to a bounding box.
"""
def copy_image_from_bounding_box(image, x, y, width, height):
with image as im:
bounds = (x, y, x + width, y + height)
im_cropped = im.crop(bounds)
return im_cropped
"""
Pastes an image onto another with a bounding box.
"""
def paste_image_into_bounding_box(
recipient_image, donor_image, x, y, width, height
):
with recipient_image as im:
bounds = (x, y, x + width, y + height)
im.paste(donor_image, bounds)
return recipient_image

View File

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

View File

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

517
frontend/dist/assets/index.38ff1a03.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

@ -5,9 +5,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InvokeAI - A Stable Diffusion Toolkit</title>
<link rel="shortcut icon" type="icon" href="./assets/favicon.0d253ced.ico" />
<script type="module" crossorigin src="./assets/index.0a6593a2.js"></script>
<link rel="stylesheet" href="./assets/index.193aec6f.css">
<link rel="shortcut icon" type="icon" href="/assets/favicon.0d253ced.ico" />
<script type="module" crossorigin src="/assets/index.38ff1a03.js"></script>
<link rel="stylesheet" href="/assets/index.8391cc9a.css">
</head>
<body>

View File

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

View File

@ -111,6 +111,8 @@ export declare type Image = {
url: string;
mtime: number;
metadata: Metadata;
width: number;
height: number;
};
// GalleryImages is an array of Image.
@ -154,6 +156,8 @@ export declare type ImageResultResponse = {
url: string;
mtime: number;
metadata: Metadata;
width: number;
height: number;
};
export declare type ErrorResponse = {

View File

@ -1,4 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
import { InvokeTabName } from '../../features/tabs/InvokeTabs';
import * as InvokeAI from '../invokeai';
/**
@ -8,13 +9,13 @@ import * as InvokeAI from '../invokeai';
* by the middleware.
*/
export const generateImage = createAction<undefined>('socketio/generateImage');
export const generateImage = createAction<InvokeTabName>(
'socketio/generateImage'
);
export const runESRGAN = createAction<InvokeAI.Image>('socketio/runESRGAN');
export const runFacetool = createAction<InvokeAI.Image>('socketio/runFacetool');
export const deleteImage = createAction<InvokeAI.Image>('socketio/deleteImage');
export const requestImages = createAction<undefined>(
'socketio/requestImages'
);
export const requestImages = createAction<undefined>('socketio/requestImages');
export const requestNewImages = createAction<undefined>(
'socketio/requestNewImages'
);

View File

@ -1,13 +1,19 @@
import { AnyAction, Dispatch, MiddlewareAPI } from '@reduxjs/toolkit';
import dateFormat from 'dateformat';
import { Socket } from 'socket.io-client';
import { frontendToBackendParameters } from '../../common/util/parameterTranslation';
import {
frontendToBackendParameters,
FrontendToBackendParametersConfig,
} from '../../common/util/parameterTranslation';
import {
addLogEntry,
errorOccurred,
setIsProcessing,
} from '../../features/system/systemSlice';
import { tabMap, tab_dict } from '../../features/tabs/InvokeTabs';
import { inpaintingImageElementRef } from '../../features/tabs/Inpainting/InpaintingCanvas';
import { InvokeTabName } from '../../features/tabs/InvokeTabs';
import * as InvokeAI from '../invokeai';
import { RootState } from '../store';
/**
* Returns an object containing all functions which use `socketio.emit()`.
@ -21,17 +27,56 @@ const makeSocketIOEmitters = (
const { dispatch, getState } = store;
return {
emitGenerateImage: () => {
emitGenerateImage: (generationMode: InvokeTabName) => {
dispatch(setIsProcessing(true));
const options = { ...getState().options };
const state: RootState = getState();
if (tabMap[options.activeTab] !== 'img2img') {
options.shouldUseInitImage = false;
const {
options: optionsState,
system: systemState,
inpainting: inpaintingState,
gallery: galleryState,
} = state;
const frontendToBackendParametersConfig: FrontendToBackendParametersConfig =
{
generationMode,
optionsState,
inpaintingState,
systemState,
};
if (generationMode === 'inpainting') {
if (
!inpaintingImageElementRef.current ||
!inpaintingState.imageToInpaint?.url
) {
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: 'Inpainting image not loaded, cannot generate image.',
level: 'error',
})
);
dispatch(errorOccurred());
return;
}
frontendToBackendParametersConfig.imageToProcessUrl =
inpaintingState.imageToInpaint.url;
frontendToBackendParametersConfig.maskImageElement =
inpaintingImageElementRef.current;
} else if (!['txt2img', 'img2img'].includes(generationMode)) {
if (!galleryState.currentImage?.url) return;
frontendToBackendParametersConfig.imageToProcessUrl =
galleryState.currentImage.url;
}
const { generationParameters, esrganParameters, facetoolParameters } =
frontendToBackendParameters(options, getState().system);
frontendToBackendParameters(frontendToBackendParametersConfig);
socketio.emit(
'generateImage',
@ -40,6 +85,14 @@ const makeSocketIOEmitters = (
facetoolParameters
);
// we need to truncate the init_mask base64 else it takes up the whole log
// TODO: handle maintaining masks for reproducibility in future
if (generationParameters.init_mask) {
generationParameters.init_mask = generationParameters.init_mask
.substr(0, 20)
.concat('...');
}
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),

View File

@ -79,21 +79,16 @@ const makeSocketIOListeners = (
*/
onGenerationResult: (data: InvokeAI.ImageResultResponse) => {
try {
const { url, mtime, metadata } = data;
const newUuid = uuidv4();
dispatch(
addImage({
uuid: newUuid,
url,
mtime,
metadata: metadata,
uuid: uuidv4(),
...data,
})
);
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Image generated: ${url}`,
message: `Image generated: ${data.url}`,
})
);
} catch (e) {
@ -105,20 +100,16 @@ const makeSocketIOListeners = (
*/
onIntermediateResult: (data: InvokeAI.ImageResultResponse) => {
try {
const uuid = uuidv4();
const { url, metadata, mtime } = data;
dispatch(
setIntermediateImage({
uuid,
url,
mtime,
metadata,
uuid: uuidv4(),
...data,
})
);
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Intermediate image generated: ${url}`,
message: `Intermediate image generated: ${data.url}`,
})
);
} catch (e) {
@ -130,21 +121,17 @@ const makeSocketIOListeners = (
*/
onPostprocessingResult: (data: InvokeAI.ImageResultResponse) => {
try {
const { url, metadata, mtime } = data;
dispatch(
addImage({
uuid: uuidv4(),
url,
mtime,
metadata,
...data,
})
);
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Postprocessed: ${url}`,
message: `Postprocessed: ${data.url}`,
})
);
} catch (e) {
@ -200,12 +187,14 @@ const makeSocketIOListeners = (
// Generate a UUID for each image
const preparedImages = images.map((image): InvokeAI.Image => {
const { url, metadata, mtime } = image;
const { url, metadata, mtime, width, height } = image;
return {
uuid: uuidv4(),
url,
mtime,
metadata,
width,
height,
};
});

View File

@ -121,7 +121,7 @@ export const socketioMiddleware = () => {
*/
switch (action.type) {
case 'socketio/generateImage': {
emitGenerateImage();
emitGenerateImage(action.payload);
break;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,40 @@
@use '../../styles/Mixins/' as *;
.invokeai__slider-form-control {
display: flex;
column-gap: 1rem;
justify-content: space-between;
align-items: center;
width: max-content;
padding-right: 0.25rem;
.invokeai__slider-inner-container {
display: flex;
column-gap: 0.5rem;
.invokeai__slider-form-label {
color: var(--text-color-secondary);
margin: 0;
margin-right: 0.5rem;
margin-bottom: 0.1rem;
}
.invokeai__slider-root {
.invokeai__slider-filled-track {
background-color: var(--accent-color-hover);
}
.invokeai__slider-track {
background-color: var(--text-color-secondary);
height: 5px;
border-radius: 9999px;
}
.invokeai__slider-thumb {
}
}
}
}
.invokeai__slider-thumb-tooltip {
}

View File

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

View File

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

View File

@ -24,20 +24,24 @@ const IAISwitch = (props: Props) => {
...rest
} = props;
return (
<FormControl isDisabled={isDisabled} width={width}>
<Flex justifyContent={'space-between'} alignItems={'center'}>
{label && (
<FormLabel
fontSize={fontSize}
marginBottom={1}
flexGrow={2}
whiteSpace="nowrap"
>
{label}
</FormLabel>
)}
<Switch size={size} className="switch-button" {...rest} />
</Flex>
<FormControl
isDisabled={isDisabled}
width={width}
className="invokeai__switch-form-control"
>
<FormLabel
className="invokeai__switch-form-label"
fontSize={fontSize}
whiteSpace="nowrap"
>
{label}
<Switch
className="invokeai__switch-root"
size={size}
// className="switch-button"
{...rest}
/>
</FormLabel>
</FormControl>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -3,9 +3,12 @@ import { isEqual } from 'lodash';
import { useMemo } from 'react';
import { useAppSelector } from '../../app/store';
import { RootState } from '../../app/store';
import { GalleryState } from '../../features/gallery/gallerySlice';
import { OptionsState } from '../../features/options/optionsSlice';
import { SystemState } from '../../features/system/systemSlice';
import { InpaintingState } from '../../features/tabs/Inpainting/inpaintingSlice';
import { tabMap } from '../../features/tabs/InvokeTabs';
import { validateSeedWeights } from '../util/seedWeightPairs';
export const optionsSelector = createSelector(
@ -18,7 +21,7 @@ export const optionsSelector = createSelector(
maskPath: options.maskPath,
initialImagePath: options.initialImagePath,
seed: options.seed,
activeTab: options.activeTab,
activeTabName: tabMap[options.activeTab],
};
},
{
@ -43,31 +46,66 @@ export const systemSelector = createSelector(
}
);
export const inpaintingSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
return {
isMaskEmpty: inpainting.lines.length === 0,
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
export const gallerySelector = createSelector(
(state: RootState) => state.gallery,
(gallery: GalleryState) => {
return {
hasCurrentImage: Boolean(gallery.currentImage),
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
/**
* Checks relevant pieces of state to confirm generation will not deterministically fail.
* This is used to prevent the 'Generate' button from being clicked.
*/
const useCheckParameters = (): boolean => {
const { prompt } = useAppSelector(optionsSelector);
const {
prompt,
shouldGenerateVariations,
seedWeights,
maskPath,
initialImagePath,
seed,
activeTab,
activeTabName,
} = useAppSelector(optionsSelector);
const { isProcessing, isConnected } = useAppSelector(systemSelector);
const { isMaskEmpty } = useAppSelector(inpaintingSelector);
const { hasCurrentImage } = useAppSelector(gallerySelector);
return useMemo(() => {
// Cannot generate without a prompt
if (!prompt || Boolean(prompt.match(/^[\s\r\n]+$/))) {
return false;
}
if (prompt && !initialImagePath && activeTab === 1) {
if (activeTabName === 'img2img' && !initialImagePath) {
return false;
}
if (activeTabName === 'inpainting' && (!hasCurrentImage || isMaskEmpty)) {
return false;
}
@ -106,7 +144,9 @@ const useCheckParameters = (): boolean => {
shouldGenerateVariations,
seedWeights,
seed,
activeTab,
activeTabName,
hasCurrentImage,
isMaskEmpty,
]);
};

View File

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

View File

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

View File

@ -17,12 +17,22 @@ import { SystemState } from '../system/systemSlice';
import IAIButton from '../../common/components/IAIButton';
import { runESRGAN, runFacetool } from '../../app/socketio/actions';
import IAIIconButton from '../../common/components/IAIIconButton';
import { MdDelete, MdFace, MdHd, MdImage, MdInfo } from 'react-icons/md';
import {
MdDelete,
MdFace,
MdHd,
MdImage,
MdInfo,
MdSettings,
} from 'react-icons/md';
import InvokePopover from './InvokePopover';
import UpscaleOptions from '../options/AdvancedOptions/Upscale/UpscaleOptions';
import FaceRestoreOptions from '../options/AdvancedOptions/FaceRestore/FaceRestoreOptions';
import { useHotkeys } from 'react-hotkeys-hook';
import { useToast } from '@chakra-ui/react';
import { FaPaintBrush, FaSeedling } from 'react-icons/fa';
import { setImageToInpaint } from '../tabs/Inpainting/inpaintingSlice';
import { hoverableImageSelector } from './gallerySliceSelectors';
const systemSelector = createSelector(
(state: RootState) => state.system,
@ -51,6 +61,7 @@ type CurrentImageButtonsProps = {
*/
const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
const dispatch = useAppDispatch();
const { activeTabName } = useAppSelector(hoverableImageSelector);
const shouldShowImageDetails = useAppSelector(
(state: RootState) => state.options.shouldShowImageDetails
@ -221,6 +232,19 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
const handleClickShowImageDetails = () =>
dispatch(setShouldShowImageDetails(!shouldShowImageDetails));
const handleSendToInpainting = () => {
dispatch(setImageToInpaint(image));
if (activeTabName !== 'inpainting') {
dispatch(setActiveTab('inpainting'));
}
toast({
title: 'Sent to Inpainting',
status: 'success',
duration: 2500,
isClosable: true,
});
};
useHotkeys(
'i',
() => {
@ -247,7 +271,32 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
onClick={handleClickUseAsInitialImage}
/>
<IAIButton
<IAIIconButton
icon={<FaPaintBrush />}
tooltip="Send To Inpainting"
aria-label="Send To Inpainting"
onClick={handleSendToInpainting}
/>
<IAIIconButton
icon={<MdSettings />}
tooltip="Use All"
aria-label="Use All"
isDisabled={
!['txt2img', 'img2img'].includes(image?.metadata?.image?.type)
}
onClick={handleClickUseAllParameters}
/>
<IAIIconButton
icon={<FaSeedling />}
tooltip="Use Seed"
aria-label="Use Seed"
isDisabled={!image?.metadata?.image?.seed}
onClick={handleClickUseSeed}
/>
{/* <IAIButton
label="Use All"
isDisabled={
!['txt2img', 'img2img'].includes(image?.metadata?.image?.type)
@ -259,7 +308,7 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
label="Use Seed"
isDisabled={!image?.metadata?.image?.seed}
onClick={handleClickUseSeed}
/>
/> */}
<InvokePopover
title="Restore Faces"

View File

@ -1,29 +1,19 @@
@use '../../styles/Mixins/' as *;
.current-image-display {
display: grid;
grid-template-areas:
'current-image-tools'
'current-image-preview';
grid-template-rows: auto 1fr;
justify-items: center;
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
}
.current-image-tools {
width: 100%;
height: 100%;
display: grid;
justify-content: center;
}
.current-image-options {
display: grid;
grid-auto-flow: column;
width: 100%;
display: flex;
justify-content: center;
column-gap: 0.5rem;
padding: 1rem;
height: fit-content;
gap: 0.5rem;
button {
@include Button(
@ -35,13 +25,22 @@
}
}
.current-image-viewer {
position: relative;
width: 100%;
height: 100%;
}
.current-image-preview {
position: absolute;
top:0;
grid-area: current-image-preview;
position: relative;
justify-content: center;
align-items: center;
display: grid;
width: 100%;
height: 100%;
grid-template-areas: 'current-image-content';
img {
@ -49,8 +48,8 @@
background-color: var(--img2img-img-bg-color);
border-radius: 0.5rem;
object-fit: contain;
width: auto;
height: $app-gallery-height;
// width: auto;
// height: $app-gallery-height;
max-height: $app-gallery-height;
}
}

View File

@ -23,13 +23,15 @@ const CurrentImageDisplay = () => {
<div className="current-image-tools">
<CurrentImageButtons image={imageToDisplay} />
</div>
<CurrentImagePreview imageToDisplay={imageToDisplay} />
{shouldShowImageDetails && (
<ImageMetadataViewer
image={imageToDisplay}
styleClass="current-image-metadata"
/>
)}
<div className="current-image-viewer">
<CurrentImagePreview imageToDisplay={imageToDisplay} />
{shouldShowImageDetails && (
<ImageMetadataViewer
image={imageToDisplay}
styleClass="current-image-metadata"
/>
)}
</div>
</div>
) : (
<div className="current-image-display-placeholder">

View File

@ -1,5 +1,5 @@
import { IconButton, Image } from '@chakra-ui/react';
import React, { useState } from 'react';
import { useState } from 'react';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { RootState, useAppDispatch, useAppSelector } from '../../app/store';
import { GalleryState, selectNextImage, selectPrevImage } from './gallerySlice';

View File

@ -22,6 +22,8 @@ import {
import * as InvokeAI from '../../app/invokeai';
import * as ContextMenu from '@radix-ui/react-context-menu';
import { tabMap } from '../tabs/InvokeTabs';
import { setImageToInpaint } from '../tabs/Inpainting/inpaintingSlice';
import { hoverableImageSelector } from './gallerySliceSelectors';
interface HoverableImageProps {
image: InvokeAI.Image;
@ -38,9 +40,7 @@ const memoEqualityCheck = (
*/
const HoverableImage = memo((props: HoverableImageProps) => {
const dispatch = useAppDispatch();
const activeTab = useAppSelector(
(state: RootState) => state.options.activeTab
);
const { activeTabName } = useAppSelector(hoverableImageSelector);
const [isHovered, setIsHovered] = useState<boolean>(false);
@ -75,8 +75,8 @@ const HoverableImage = memo((props: HoverableImageProps) => {
const handleSendToImageToImage = () => {
dispatch(setInitialImagePath(image.url));
if (activeTab !== 1) {
dispatch(setActiveTab(1));
if (activeTabName !== 'img2img') {
dispatch(setActiveTab('img2img'));
}
toast({
title: 'Sent to Image To Image',
@ -86,6 +86,19 @@ const HoverableImage = memo((props: HoverableImageProps) => {
});
};
const handleSendToInpainting = () => {
dispatch(setImageToInpaint(image));
if (activeTabName !== 'inpainting') {
dispatch(setActiveTab('inpainting'));
}
toast({
title: 'Sent to Inpainting',
status: 'success',
duration: 2500,
isClosable: true,
});
};
const handleUseAllParameters = () => {
dispatch(setAllTextToImageParameters(metadata));
toast({
@ -200,6 +213,9 @@ const HoverableImage = memo((props: HoverableImageProps) => {
<ContextMenu.Item onClickCapture={handleSendToImageToImage}>
Send to Image To Image
</ContextMenu.Item>
<ContextMenu.Item onClickCapture={handleSendToInpainting}>
Send to Inpainting
</ContextMenu.Item>
<DeleteImageModal image={image}>
<ContextMenu.Item data-warning>Delete Image</ContextMenu.Item>
</DeleteImageModal>

View File

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

View File

@ -1,36 +1,121 @@
import { Button, IconButton } from '@chakra-ui/button';
import { Resizable } from 're-resizable';
import { Button } from '@chakra-ui/button';
import { NumberSize, Resizable, Size } from 're-resizable';
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { MdClear, MdPhotoLibrary } from 'react-icons/md';
import { BsPinAngleFill } from 'react-icons/bs';
import { requestImages } from '../../app/socketio/actions';
import { RootState, useAppDispatch, useAppSelector } from '../../app/store';
import { useAppDispatch, useAppSelector } from '../../app/store';
import IAIIconButton from '../../common/components/IAIIconButton';
import { selectNextImage, selectPrevImage } from './gallerySlice';
import {
selectNextImage,
selectPrevImage,
setGalleryImageMinimumWidth,
setGalleryScrollPosition,
setShouldPinGallery,
} from './gallerySlice';
import HoverableImage from './HoverableImage';
import { setShouldShowGallery } from '../options/optionsSlice';
import { setShouldShowGallery } from '../gallery/gallerySlice';
import { 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 IAIPopover from '../../common/components/IAIPopover';
import IAISlider from '../../common/components/IAISlider';
import { BiReset } from 'react-icons/bi';
export default function ImageGallery() {
const { images, currentImageUuid, areMoreImagesAvailable } = useAppSelector(
(state: RootState) => state.gallery
);
const shouldShowGallery = useAppSelector(
(state: RootState) => state.options.shouldShowGallery
);
const activeTab = useAppSelector(
(state: RootState) => state.options.activeTab
);
const dispatch = useAppDispatch();
const toast = useToast();
const handleShowGalleryToggle = () => {
dispatch(setShouldShowGallery(!shouldShowGallery));
const {
images,
currentImageUuid,
areMoreImagesAvailable,
shouldPinGallery,
shouldShowGallery,
galleryScrollPosition,
galleryImageMinimumWidth,
galleryGridTemplateColumns,
activeTabName,
} = useAppSelector(imageGallerySelector);
const [gallerySize, setGallerySize] = useState<Size>({
width: '300',
height: '100%',
});
const [galleryMaxSize, setGalleryMaxSize] = useState<Size>({
width: '590', // keep max at 590 for any tab
height: '100%',
});
const [galleryMinSize, setGalleryMinSize] = useState<Size>({
width: '300', // keep max at 590 for any tab
height: '100%',
});
useEffect(() => {
if (activeTabName === 'inpainting' && shouldPinGallery) {
setGalleryMinSize((prevSize) => {
return { ...prevSize, width: '200' };
});
setGalleryMaxSize((prevSize) => {
return { ...prevSize, width: '200' };
});
setGallerySize((prevSize) => {
return { ...prevSize, width: Math.min(Number(prevSize.width), 200) };
});
} else {
setGalleryMaxSize((prevSize) => {
return { ...prevSize, width: '590', height: '100%' };
});
setGallerySize((prevSize) => {
return { ...prevSize, width: Math.min(Number(prevSize.width), 590) };
});
}
}, [activeTabName, shouldPinGallery, setGalleryMaxSize]);
useEffect(() => {
if (!shouldPinGallery) {
setGalleryMaxSize((prevSize) => {
// calculate vh in px
return {
...prevSize,
width: window.innerWidth,
};
});
}
}, [shouldPinGallery]);
const galleryRef = useRef<HTMLDivElement>(null);
const galleryContainerRef = useRef<HTMLDivElement>(null);
const timeoutIdRef = useRef<number | null>(null);
const handleSetShouldPinGallery = () => {
dispatch(setShouldPinGallery(!shouldPinGallery));
setGallerySize({
...gallerySize,
height: shouldPinGallery ? '100vh' : '100%',
});
};
const handleGalleryClose = () => {
const handleToggleGallery = () => {
shouldShowGallery ? handleCloseGallery() : handleOpenGallery();
};
const handleOpenGallery = () => {
dispatch(setShouldShowGallery(true));
};
const handleCloseGallery = () => {
dispatch(
setGalleryScrollPosition(
galleryContainerRef.current ? galleryContainerRef.current.scrollTop : 0
)
);
dispatch(setShouldShowGallery(false));
};
@ -38,92 +123,281 @@ export default function ImageGallery() {
dispatch(requestImages());
};
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
};
const setCloseGalleryTimer = () => {
timeoutIdRef.current = window.setTimeout(() => handleCloseGallery(), 500);
};
const cancelCloseGalleryTimer = () => {
timeoutIdRef.current && window.clearTimeout(timeoutIdRef.current);
};
useHotkeys(
'g',
() => {
handleShowGalleryToggle();
handleToggleGallery();
},
[shouldShowGallery]
);
useHotkeys('left', () => {
dispatch(selectPrevImage());
});
useHotkeys('right', () => {
dispatch(selectNextImage());
});
useHotkeys(
'left',
'shift+p',
() => {
dispatch(selectPrevImage());
handleSetShouldPinGallery();
},
[]
[shouldPinGallery]
);
const IMAGE_SIZE_STEP = 32;
useHotkeys(
'shift+up',
() => {
if (galleryImageMinimumWidth >= 256) {
return;
}
if (galleryImageMinimumWidth < 256) {
const newMinWidth = galleryImageMinimumWidth + IMAGE_SIZE_STEP;
if (newMinWidth <= 256) {
dispatch(setGalleryImageMinimumWidth(newMinWidth));
toast({
title: `Gallery Thumbnail Size set to ${newMinWidth}`,
status: 'success',
duration: 1000,
isClosable: true,
});
} else {
dispatch(setGalleryImageMinimumWidth(256));
toast({
title: `Gallery Thumbnail Size set to 256`,
status: 'success',
duration: 1000,
isClosable: true,
});
}
}
},
[galleryImageMinimumWidth]
);
useHotkeys(
'right',
'shift+down',
() => {
dispatch(selectNextImage());
if (galleryImageMinimumWidth <= 32) {
return;
}
if (galleryImageMinimumWidth > 32) {
const newMinWidth = galleryImageMinimumWidth - IMAGE_SIZE_STEP;
if (newMinWidth > 32) {
dispatch(setGalleryImageMinimumWidth(newMinWidth));
toast({
title: `Gallery Thumbnail Size set to ${newMinWidth}`,
status: 'success',
duration: 1000,
isClosable: true,
});
} else {
dispatch(setGalleryImageMinimumWidth(32));
toast({
title: `Gallery Thumbnail Size set to 32`,
status: 'success',
duration: 1000,
isClosable: true,
});
}
}
},
[]
[galleryImageMinimumWidth]
);
useHotkeys(
'shift+r',
() => {
dispatch(setGalleryImageMinimumWidth(64));
toast({
title: `Reset Gallery Image Size`,
status: 'success',
duration: 2500,
isClosable: true,
});
},
[galleryImageMinimumWidth]
);
// set gallery scroll position
useEffect(() => {
if (!galleryContainerRef.current) return;
galleryContainerRef.current.scrollTop = galleryScrollPosition;
}, [galleryScrollPosition, shouldShowGallery]);
return (
<div className="image-gallery-area">
{!shouldShowGallery && (
<IAIIconButton
tooltip="Show Gallery"
tooltipPlacement="top"
aria-label="Show Gallery"
onClick={handleShowGalleryToggle}
className="image-gallery-popup-btn"
>
<MdPhotoLibrary />
</IAIIconButton>
)}
{shouldShowGallery && (
<CSSTransition
nodeRef={galleryRef}
in={shouldShowGallery}
unmountOnExit
timeout={200}
classNames="image-gallery-area"
>
<div
className="image-gallery-area"
data-pinned={shouldPinGallery}
ref={galleryRef}
onMouseLeave={!shouldPinGallery ? setCloseGalleryTimer : undefined}
onMouseEnter={!shouldPinGallery ? cancelCloseGalleryTimer : undefined}
>
<Resizable
defaultSize={{ width: '300', height: '100%' }}
minWidth={'300'}
maxWidth={activeTab == 1 ? '300' : '600'}
className="image-gallery-popup"
minWidth={galleryMinSize.width}
maxWidth={galleryMaxSize.width}
maxHeight={'100%'}
className={'image-gallery-popup'}
handleStyles={{ left: { width: '20px' } }}
enable={{
top: false,
right: false,
bottom: false,
left: true,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
}}
size={gallerySize}
onResizeStop={(
_event: MouseEvent | TouchEvent,
_direction: Direction,
elementRef: HTMLElement,
delta: NumberSize
) => {
setGallerySize({
width: Number(gallerySize.width) + delta.width,
height: '100%',
});
elementRef.removeAttribute('data-resize-alert');
}}
onResize={(
_event: MouseEvent | TouchEvent,
_direction: Direction,
elementRef: HTMLElement,
delta: NumberSize
) => {
const newWidth = Number(gallerySize.width) + delta.width;
if (newWidth >= galleryMaxSize.width) {
elementRef.setAttribute('data-resize-alert', 'true');
} else {
elementRef.removeAttribute('data-resize-alert');
}
}}
>
<div className="image-gallery-header">
<h1>Your Invocations</h1>
<IconButton
{activeTabName !== 'inpainting' ? (
<>
<h1>Your Invocations</h1>
<Spacer />
</>
) : null}
<IAIPopover
trigger="click"
hasArrow={activeTabName === 'inpainting' ? false : true}
triggerComponent={
<IAIIconButton
size={'sm'}
aria-label={'Gallery Settings'}
icon={<FaWrench />}
className="image-gallery-icon-btn"
cursor={'pointer'}
/>
}
styleClass="image-gallery-size-popover"
>
<IAISlider
value={galleryImageMinimumWidth}
onChange={handleChangeGalleryImageMinimumWidth}
min={32}
max={256}
width={100}
label={'Image Size'}
formLabelProps={{ style: { fontSize: '0.9rem' } }}
sliderThumbTooltipProps={{
label: `${galleryImageMinimumWidth}px`,
}}
/>
<IAIIconButton
size={'sm'}
aria-label={'Reset'}
tooltip={'Reset Size'}
onClick={() => dispatch(setGalleryImageMinimumWidth(64))}
icon={<BiReset />}
data-selected={shouldPinGallery}
styleClass="image-gallery-icon-btn"
/>
</IAIPopover>
<IAIIconButton
size={'sm'}
aria-label={'Pin Gallery'}
tooltip={'Pin Gallery (Shift+P)'}
onClick={handleSetShouldPinGallery}
icon={<BsPinAngleFill />}
data-selected={shouldPinGallery}
/>
<IAIIconButton
size={'sm'}
aria-label={'Close Gallery'}
onClick={handleGalleryClose}
className="image-gallery-close-btn"
tooltip={'Close Gallery (G)'}
onClick={handleCloseGallery}
className="image-gallery-icon-btn"
icon={<MdClear />}
/>
</div>
<div className="image-gallery-container">
{images.length ? (
<div className="image-gallery">
{images.map((image) => {
const { uuid } = image;
const isSelected = currentImageUuid === uuid;
return (
<HoverableImage
key={uuid}
image={image}
isSelected={isSelected}
/>
);
})}
</div>
<div className="image-gallery-container" ref={galleryContainerRef}>
{images.length || areMoreImagesAvailable ? (
<>
<div
className="image-gallery"
style={{ gridTemplateColumns: galleryGridTemplateColumns }}
>
{images.map((image) => {
const { uuid } = image;
const isSelected = currentImageUuid === uuid;
return (
<HoverableImage
key={uuid}
image={image}
isSelected={isSelected}
/>
);
})}
</div>
<Button
onClick={handleClickLoadMore}
isDisabled={!areMoreImagesAvailable}
className="image-gallery-load-more-btn"
>
{areMoreImagesAvailable ? 'Load More' : 'All Images Loaded'}
</Button>
</>
) : (
<div className="image-gallery-container-placeholder">
<MdPhotoLibrary />
<p>No Images In Gallery</p>
</div>
)}
<Button
onClick={handleClickLoadMore}
isDisabled={!areMoreImagesAvailable}
className="image-gallery-load-more-btn"
>
{areMoreImagesAvailable ? 'Load More' : 'All Images Loaded'}
</Button>
</div>
</Resizable>
)}
</div>
</div>
</CSSTransition>
);
}

View File

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

View File

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

View File

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

View File

@ -11,12 +11,20 @@ export interface GalleryState {
areMoreImagesAvailable: boolean;
latest_mtime?: number;
earliest_mtime?: number;
shouldPinGallery: boolean;
shouldShowGallery: boolean;
galleryScrollPosition: number;
galleryImageMinimumWidth: number;
}
const initialState: GalleryState = {
currentImageUuid: '',
images: [],
areMoreImagesAvailable: true,
shouldPinGallery: true,
shouldShowGallery: true,
galleryScrollPosition: 0,
galleryImageMinimumWidth: 64,
};
export const gallerySlice = createSlice({
@ -151,6 +159,18 @@ export const gallerySlice = createSlice({
state.areMoreImagesAvailable = areMoreImagesAvailable;
}
},
setShouldPinGallery: (state, action: PayloadAction<boolean>) => {
state.shouldPinGallery = action.payload;
},
setShouldShowGallery: (state, action: PayloadAction<boolean>) => {
state.shouldShowGallery = action.payload;
},
setGalleryScrollPosition: (state, action: PayloadAction<number>) => {
state.galleryScrollPosition = action.payload;
},
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
state.galleryImageMinimumWidth = action.payload;
},
},
});
@ -163,6 +183,10 @@ export const {
setIntermediateImage,
selectNextImage,
selectPrevImage,
setShouldPinGallery,
setShouldShowGallery,
setGalleryScrollPosition,
setGalleryImageMinimumWidth,
} = gallerySlice.actions;
export default gallerySlice.reducer;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,13 @@
.inpainting-bounding-box-dimensions {
display: flex;
flex-direction: column;
row-gap: 1rem;
max-width: 100%;
}
.inpainting-bounding-box-dimensions-slider-numberinput {
display: flex;
flex-direction: row;
column-gap: 0.5rem;
width: 100%;
}

View File

@ -0,0 +1,89 @@
import { FormLabel } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAINumberInput from '../../../../common/components/IAINumberInput';
import IAISlider from '../../../../common/components/IAISlider';
import { roundDownToMultiple } from '../../../../common/util/roundDownToMultiple';
import {
InpaintingState,
setBoundingBoxDimensions,
} from '../../../tabs/Inpainting/inpaintingSlice';
const boundingBoxDimensionsSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const { canvasDimensions, boundingBoxDimensions } = inpainting;
return { canvasDimensions, boundingBoxDimensions };
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const BoundingBoxDimensions = () => {
const dispatch = useAppDispatch();
const { canvasDimensions, boundingBoxDimensions } = useAppSelector(
boundingBoxDimensionsSelector
);
const handleChangeBoundingBoxWidth = (v: number) => {
dispatch(setBoundingBoxDimensions({ ...boundingBoxDimensions, width: v }));
};
const handleChangeBoundingBoxHeight = (v: number) => {
dispatch(setBoundingBoxDimensions({ ...boundingBoxDimensions, height: v }));
};
return (
<div className="inpainting-bounding-box-dimensions">
Inpainting Bounding Box
<div className="inpainting-bounding-box-dimensions-slider-numberinput">
<IAISlider
label="Width"
width={'8rem'}
min={64}
max={roundDownToMultiple(canvasDimensions.width, 64)}
step={64}
value={boundingBoxDimensions.width}
onChange={handleChangeBoundingBoxWidth}
/>
<IAINumberInput
value={boundingBoxDimensions.width}
onChange={handleChangeBoundingBoxWidth}
min={64}
max={roundDownToMultiple(canvasDimensions.width, 64)}
step={64}
width={'5.5rem'}
/>
</div>
<div className="inpainting-bounding-box-dimensions-slider-numberinput">
<IAISlider
label="Height"
width={'8rem'}
min={64}
max={roundDownToMultiple(canvasDimensions.height, 64)}
step={64}
value={boundingBoxDimensions.height}
onChange={handleChangeBoundingBoxHeight}
/>
<IAINumberInput
value={boundingBoxDimensions.height}
onChange={handleChangeBoundingBoxHeight}
min={64}
max={roundDownToMultiple(canvasDimensions.height, 64)}
step={64}
width={'5.5rem'}
/>
</div>
</div>
);
};
export default BoundingBoxDimensions;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,7 @@ export default function CancelButton() {
aria-label="Cancel"
isDisabled={!isConnected || !isProcessing}
onClick={handleClickCancel}
className="cancel-btn"
styleClass="cancel-btn"
/>
);
}

View File

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

View File

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

View File

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

View File

@ -10,12 +10,15 @@ import useCheckParameters, {
systemSelector,
} from '../../../common/hooks/useCheckParameters';
import { useHotkeys } from 'react-hotkeys-hook';
import { tabMap } from '../../tabs/InvokeTabs';
export const optionsSelector = createSelector(
(state: RootState) => state.options,
(options: OptionsState) => {
const { prompt, activeTab } = options;
return {
prompt: options.prompt,
prompt,
activeTabName: tabMap[activeTab],
};
},
{
@ -30,7 +33,7 @@ export const optionsSelector = createSelector(
*/
const PromptInput = () => {
const promptRef = useRef<HTMLTextAreaElement>(null);
const { prompt } = useAppSelector(optionsSelector);
const { prompt, activeTabName } = useAppSelector(optionsSelector);
const { isProcessing } = useAppSelector(systemSelector);
const dispatch = useAppDispatch();
const isReady = useCheckParameters();
@ -40,13 +43,13 @@ const PromptInput = () => {
};
useHotkeys(
'ctrl+enter',
'ctrl+enter, cmd+enter',
() => {
if (isReady) {
dispatch(generateImage());
dispatch(generateImage(activeTabName));
}
},
[isReady]
[isReady, activeTabName]
);
useHotkeys(
@ -60,7 +63,7 @@ const PromptInput = () => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && e.shiftKey === false && isReady) {
e.preventDefault();
dispatch(generateImage());
dispatch(generateImage(activeTabName));
}
};

View File

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

View File

@ -4,6 +4,7 @@ import * as InvokeAI from '../../app/invokeai';
import promptToString from '../../common/util/promptToString';
import { seedWeightsToString } from '../../common/util/seedWeightPairs';
import { FACETOOL_TYPES } from '../../app/constants';
import { InvokeTabName, tabMap } from '../tabs/InvokeTabs';
export type UpscalingLevel = 2 | 4;
@ -41,7 +42,7 @@ export interface OptionsState {
showAdvancedOptions: boolean;
activeTab: number;
shouldShowImageDetails: boolean;
shouldShowGallery: boolean;
showDualDisplay: boolean;
}
const initialOptionsState: OptionsState = {
@ -76,7 +77,7 @@ const initialOptionsState: OptionsState = {
showAdvancedOptions: true,
activeTab: 0,
shouldShowImageDetails: false,
shouldShowGallery: false,
showDualDisplay: true,
};
const initialState: OptionsState = initialOptionsState;
@ -321,14 +322,18 @@ export const optionsSlice = createSlice({
setShowAdvancedOptions: (state, action: PayloadAction<boolean>) => {
state.showAdvancedOptions = action.payload;
},
setActiveTab: (state, action: PayloadAction<number>) => {
state.activeTab = action.payload;
setActiveTab: (state, action: PayloadAction<number | InvokeTabName>) => {
if (typeof action.payload === 'number') {
state.activeTab = action.payload;
} else {
state.activeTab = tabMap.indexOf(action.payload);
}
},
setShouldShowImageDetails: (state, action: PayloadAction<boolean>) => {
state.shouldShowImageDetails = action.payload;
},
setShouldShowGallery: (state, action: PayloadAction<boolean>) => {
state.shouldShowGallery = action.payload;
setShowDualDisplay: (state, action: PayloadAction<boolean>) => {
state.showDualDisplay = action.payload;
},
},
});
@ -369,9 +374,9 @@ export const {
setShowAdvancedOptions,
setActiveTab,
setShouldShowImageDetails,
setShouldShowGallery,
setAllTextToImageParameters,
setAllImageToImageParameters,
setShowDualDisplay,
} = optionsSlice.actions;
export default optionsSlice.reducer;

View File

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

View File

@ -34,8 +34,8 @@
button {
@include Button(
$btn-color: var(--btn-red),
$btn-color-hover: var(--btn-red-hover)
$btn-color: var(--destructive-color),
$btn-color-hover: var(--destructive-color-hover)
);
}
}

View File

@ -90,7 +90,7 @@ export const systemSlice = createSlice({
state.currentIteration = 0;
state.totalIterations = 0;
state.currentStatusHasSteps = false;
state.currentStatus = 'Server error';
state.currentStatus = 'Error';
state.wasErrorSeen = false;
},
errorSeen: (state) => {

View File

@ -10,7 +10,6 @@
display: grid;
row-gap: 1rem;
grid-auto-rows: max-content;
width: $options-bar-max-width;
height: $app-content-height;
overflow-y: scroll;
@include HideScrollbar;
@ -141,3 +140,7 @@
}
}
}
.image-to-image-current-image-display {
position: relative;
}

View File

@ -1,28 +0,0 @@
import React from 'react';
import ImageToImagePanel from './ImageToImagePanel';
import ImageToImageDisplay from './ImageToImageDisplay';
import ImageGallery from '../../gallery/ImageGallery';
import { RootState, useAppSelector } from '../../../app/store';
export default function ImageToImage() {
const shouldShowGallery = useAppSelector(
(state: RootState) => state.options.shouldShowGallery
);
return (
<div className="image-to-image-workarea">
<ImageToImagePanel />
<div
className="image-to-image-display-area"
style={
shouldShowGallery
? { gridTemplateColumns: 'auto max-content' }
: { gridTemplateColumns: 'auto' }
}
>
<ImageToImageDisplay />
<ImageGallery />
</div>
</div>
);
}

View File

@ -43,14 +43,14 @@ export default function ImageToImageDisplay() {
<InitImagePreview />
<div className="image-to-image-current-image-display">
<CurrentImagePreview imageToDisplay={imageToDisplay} />
{shouldShowImageDetails && (
<ImageMetadataViewer
image={imageToDisplay}
styleClass="img2img-metadata"
/>
)}
</div>
</div>
{shouldShowImageDetails && (
<ImageMetadataViewer
image={imageToDisplay}
styleClass="img2img-metadata"
/>
)}
</div>
</>
) : (

View File

@ -1,20 +1,20 @@
import { Box } from '@chakra-ui/react';
import React from 'react';
import { Feature } from '../../../app/features';
import { RootState, useAppSelector } from '../../../app/store';
import FaceRestore from '../../options/AdvancedOptions/FaceRestore/FaceRestore';
import FaceRestoreHeader from '../../options/AdvancedOptions/FaceRestore/FaceRestoreHeader';
import FaceRestoreOptions from '../../options/AdvancedOptions/FaceRestore/FaceRestoreOptions';
import ImageFit from '../../options/AdvancedOptions/ImageToImage/ImageFit';
import ImageToImageStrength from '../../options/AdvancedOptions/ImageToImage/ImageToImageStrength';
import OutputHeader from '../../options/AdvancedOptions/Output/OutputHeader';
import OutputOptions from '../../options/AdvancedOptions/Output/OutputOptions';
import SeedHeader from '../../options/AdvancedOptions/Seed/SeedHeader';
import SeedOptions from '../../options/AdvancedOptions/Seed/SeedOptions';
import Upscale from '../../options/AdvancedOptions/Upscale/Upscale';
import UpscaleHeader from '../../options/AdvancedOptions/Upscale/UpscaleHeader';
import UpscaleOptions from '../../options/AdvancedOptions/Upscale/UpscaleOptions';
import Variations from '../../options/AdvancedOptions/Variations/Variations';
import VariationsHeader from '../../options/AdvancedOptions/Variations/VariationsHeader';
import VariationsOptions from '../../options/AdvancedOptions/Variations/VariationsOptions';
import MainAdvancedOptions from '../../options/MainOptions/MainAdvancedOptions';
import MainAdvancedOptionsCheckbox from '../../options/MainOptions/MainAdvancedOptionsCheckbox';
import MainOptions from '../../options/MainOptions/MainOptions';
import OptionsAccordion from '../../options/OptionsAccordion';
import OutputOptions from '../../options/OutputOptions';
import ProcessButtons from '../../options/ProcessButtons/ProcessButtons';
import PromptInput from '../../options/PromptInput/PromptInput';
@ -25,35 +25,27 @@ export default function ImageToImagePanel() {
const imageToImageAccordions = {
seed: {
header: (
<Box flex="1" textAlign="left">
Seed
</Box>
),
header: <SeedHeader />,
feature: Feature.SEED,
options: <SeedOptions />,
},
variations: {
header: <Variations />,
header: <VariationsHeader />,
feature: Feature.VARIATIONS,
options: <VariationsOptions />,
},
face_restore: {
header: <FaceRestore />,
header: <FaceRestoreHeader />,
feature: Feature.FACE_CORRECTION,
options: <FaceRestoreOptions />,
},
upscale: {
header: <Upscale />,
header: <UpscaleHeader />,
feature: Feature.UPSCALE,
options: <UpscaleOptions />,
},
other: {
header: (
<Box flex="1" textAlign="left">
Other
</Box>
),
header: <OutputHeader /> ,
feature: Feature.OTHER,
options: <OutputOptions />,
},
@ -69,7 +61,7 @@ export default function ImageToImagePanel() {
styleClass="main-option-block image-to-image-strength-main-option"
/>
<ImageFit />
<MainAdvancedOptions />
<MainAdvancedOptionsCheckbox />
{showAdvancedOptions ? (
<OptionsAccordion accordionInfo={imageToImageAccordions} />
) : null}

View File

@ -0,0 +1,12 @@
import React from 'react';
import ImageToImagePanel from './ImageToImagePanel';
import ImageToImageDisplay from './ImageToImageDisplay';
import InvokeWorkarea from '../InvokeWorkarea';
export default function ImageToImageWorkarea() {
return (
<InvokeWorkarea optionsPanel={<ImageToImagePanel />}>
<ImageToImageDisplay />
</InvokeWorkarea>
);
}

View File

@ -0,0 +1,169 @@
@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;
display: flex;
flex-direction: column;
background-color: var(--inpaint-bg-color);
border-radius: 0.5rem;
}
.inpainting-canvas-container {
width: 100%;
height: 100%;
padding-left: 1rem;
padding-right: 1rem;
padding-bottom: 1rem;
display: flex;
align-items: center;
justify-content: center;
.inpainting-canvas-wrapper {
position: relative;
width: min-content;
height: min-content;
border-radius: 0.5rem;
.inpainting-alerts {
position: absolute;
top: 0;
left: 0;
display: flex;
column-gap: 0.5rem;
z-index: 2;
padding: 0.5rem;
pointer-events: none;
font-size: 0.9rem;
font-weight: bold;
div {
background-color: var(--accent-color);
color: var(--text-color);
padding: 0.2rem 0.6rem;
border-radius: 0.25rem;
}
}
.inpainting-canvas-stage {
canvas {
border-radius: 0.5rem;
}
}
}
}
// .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 {
.image-gallery-area {
.chakra-popover__popper {
inset: 0 auto auto -75px !important;
}
}
.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

@ -0,0 +1,289 @@
// lib
import {
KeyboardEvent,
MutableRefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import Konva from 'konva';
import { Layer, Stage } from 'react-konva';
import { Image as KonvaImage } from 'react-konva';
import { Stage as StageType } from 'konva/lib/Stage';
// app
import { useAppDispatch, useAppSelector } from '../../../app/store';
import {
addLine,
addPointToCurrentLine,
setBoundingBoxCoordinate,
setCursorPosition,
setIsMovingBoundingBox,
setTool,
} from './inpaintingSlice';
import { inpaintingCanvasSelector } from './inpaintingSliceSelectors';
// component
import InpaintingCanvasLines from './components/InpaintingCanvasLines';
import InpaintingCanvasBrushPreview from './components/InpaintingCanvasBrushPreview';
import InpaintingCanvasBrushPreviewOutline from './components/InpaintingCanvasBrushPreviewOutline';
import Cacher from './components/Cacher';
import { Vector2d } from 'konva/lib/types';
import getScaledCursorPosition from './util/getScaledCursorPosition';
import _ from 'lodash';
import InpaintingBoundingBoxPreview from './components/InpaintingBoundingBoxPreview';
import { KonvaEventObject } from 'konva/lib/Node';
import KeyboardEventManager from './components/KeyboardEventManager';
// Use a closure allow other components to use these things... not ideal...
export let stageRef: MutableRefObject<StageType | null>;
export let maskLayerRef: MutableRefObject<Konva.Layer | null>;
export let inpaintingImageElementRef: MutableRefObject<HTMLImageElement | null>;
const InpaintingCanvas = () => {
const dispatch = useAppDispatch();
const {
tool,
brushSize,
shouldInvertMask,
shouldShowMask,
shouldShowCheckboardTransparency,
maskOpacity,
imageToInpaint,
isMovingBoundingBox,
boundingBoxDimensions,
canvasDimensions,
boundingBoxCoordinate,
stageScale,
} = useAppSelector(inpaintingCanvasSelector);
// set the closure'd refs
stageRef = useRef<StageType>(null);
maskLayerRef = useRef<Konva.Layer>(null);
inpaintingImageElementRef = useRef<HTMLImageElement>(null);
const lastCursorPosition = useRef<Vector2d>({ x: 0, y: 0 });
// Use refs for values that do not affect rendering, other values in redux
const didMouseMoveRef = useRef<boolean>(false);
const isDrawing = useRef<boolean>(false);
// Load the image into this
const [canvasBgImage, setCanvasBgImage] = useState<HTMLImageElement | null>(
null
);
// Load the image and set the options panel width & height
useEffect(() => {
if (imageToInpaint) {
const image = new Image();
image.onload = () => {
inpaintingImageElementRef.current = image;
setCanvasBgImage(image);
};
image.src = imageToInpaint.url;
}
}, [imageToInpaint, dispatch, stageScale]);
const handleMouseDown = useCallback(() => {
if (!stageRef.current) return;
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition || !maskLayerRef.current) return;
isDrawing.current = true;
// Add a new line starting from the current cursor position.
dispatch(
addLine({
tool,
strokeWidth: brushSize / 2,
points: [scaledCursorPosition.x, scaledCursorPosition.y],
})
);
}, [dispatch, brushSize, tool]);
const handleMouseMove = useCallback(() => {
if (!stageRef.current) return;
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition) return;
dispatch(setCursorPosition(scaledCursorPosition));
if (!maskLayerRef.current) {
return;
}
const deltaX = lastCursorPosition.current.x - scaledCursorPosition.x;
const deltaY = lastCursorPosition.current.y - scaledCursorPosition.y;
lastCursorPosition.current = scaledCursorPosition;
if (isMovingBoundingBox) {
const x = _.clamp(
Math.floor(boundingBoxCoordinate.x - deltaX),
0,
canvasDimensions.width - boundingBoxDimensions.width
);
const y = _.clamp(
Math.floor(boundingBoxCoordinate.y - deltaY),
0,
canvasDimensions.height - boundingBoxDimensions.height
);
dispatch(
setBoundingBoxCoordinate({
x,
y,
})
);
return;
}
if (!isDrawing.current) return;
didMouseMoveRef.current = true;
// Extend the current line
dispatch(
addPointToCurrentLine([scaledCursorPosition.x, scaledCursorPosition.y])
);
}, [
dispatch,
isMovingBoundingBox,
boundingBoxDimensions,
canvasDimensions,
boundingBoxCoordinate,
]);
const handleMouseUp = useCallback(() => {
if (!didMouseMoveRef.current && isDrawing.current && stageRef.current) {
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition || !maskLayerRef.current) return;
/**
* Extend the current line.
* In this case, the mouse didn't move, so we append the same point to
* the line's existing points. This allows the line to render as a circle
* centered on that point.
*/
dispatch(
addPointToCurrentLine([scaledCursorPosition.x, scaledCursorPosition.y])
);
} else {
didMouseMoveRef.current = false;
}
isDrawing.current = false;
}, [dispatch]);
const handleMouseOutCanvas = useCallback(() => {
dispatch(setCursorPosition(null));
dispatch(setIsMovingBoundingBox(false));
isDrawing.current = false;
}, [dispatch]);
const handleMouseEnter = useCallback(
(e: KonvaEventObject<MouseEvent>) => {
if (e.evt.buttons === 1) {
if (!stageRef.current) return;
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition || !maskLayerRef.current) return;
isDrawing.current = true;
// Add a new line starting from the current cursor position.
dispatch(
addLine({
tool,
strokeWidth: brushSize / 2,
points: [scaledCursorPosition.x, scaledCursorPosition.y],
})
);
}
},
[dispatch, brushSize, tool]
);
return (
<div className="inpainting-canvas-wrapper checkerboard" tabIndex={1}>
<div className="inpainting-alerts">
{!shouldShowMask && <div>Mask Hidden (H)</div>}
{shouldInvertMask && <div>Mask Inverted (Shift+M)</div>}
</div>
{canvasBgImage && (
<Stage
width={Math.floor(canvasBgImage.width * stageScale)}
height={Math.floor(canvasBgImage.height * stageScale)}
scale={{ x: stageScale, y: stageScale }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter}
onMouseUp={handleMouseUp}
onMouseOut={handleMouseOutCanvas}
onMouseLeave={handleMouseOutCanvas}
style={{ cursor: shouldShowMask ? 'none' : 'default' }}
className="inpainting-canvas-stage"
ref={stageRef}
>
{!shouldInvertMask && !shouldShowCheckboardTransparency && (
<Layer name={'image-layer'} listening={false}>
<KonvaImage listening={false} image={canvasBgImage} />
</Layer>
)}
{shouldShowMask && (
<>
<Layer
name={'mask-layer'}
listening={false}
opacity={
shouldShowCheckboardTransparency || shouldInvertMask
? 1
: maskOpacity
}
ref={maskLayerRef}
>
<InpaintingCanvasLines />
<InpaintingCanvasBrushPreview />
{shouldInvertMask && (
<KonvaImage
image={canvasBgImage}
listening={false}
globalCompositeOperation="source-in"
/>
)}
{!shouldInvertMask && shouldShowCheckboardTransparency && (
<KonvaImage
image={canvasBgImage}
listening={false}
globalCompositeOperation="source-out"
/>
)}
</Layer>
<Layer name={'preview-layer'} listening={false}>
<InpaintingCanvasBrushPreviewOutline />
<InpaintingBoundingBoxPreview />
</Layer>
</>
)}
</Stage>
)}
<Cacher />
<KeyboardEventManager />
</div>
);
};
// </div>
export default InpaintingCanvas;

View File

@ -0,0 +1,34 @@
import { Spinner } from '@chakra-ui/react';
import { useLayoutEffect, useRef } from 'react';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import { setStageScale } from './inpaintingSlice';
const InpaintingCanvasPlaceholder = () => {
const dispatch = useAppDispatch();
const { needsRepaint, imageToInpaint } = useAppSelector(
(state: RootState) => state.inpainting
);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!ref.current || !imageToInpaint) return;
const width = ref.current.clientWidth;
const height = ref.current.clientHeight;
const scale = Math.min(
1,
Math.min(width / imageToInpaint.width, height / imageToInpaint.height)
);
dispatch(setStageScale(scale));
}, [dispatch, imageToInpaint, needsRepaint]);
return (
<div ref={ref} className="inpainting-canvas-container">
<Spinner thickness="2px" speed="1s" size="xl" />
</div>
);
};
export default InpaintingCanvasPlaceholder;

View File

@ -0,0 +1,384 @@
import { useToast } from '@chakra-ui/react';
import { useHotkeys } from 'react-hotkeys-hook';
import {
FaEraser,
FaPaintBrush,
FaPalette,
FaPlus,
FaRedo,
FaUndo,
} from 'react-icons/fa';
import { BiHide, BiShow } from 'react-icons/bi';
import { VscSplitHorizontal } from 'react-icons/vsc';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import IAIIconButton from '../../../common/components/IAIIconButton';
import {
clearMask,
redo,
setMaskColor,
setBrushSize,
setMaskOpacity,
setShouldShowBrushPreview,
setTool,
undo,
setShouldShowMask,
setShouldInvertMask,
setNeedsRepaint,
} from './inpaintingSlice';
import { tabMap } from '../InvokeTabs';
import {
MdInvertColors,
MdInvertColorsOff,
MdOutlineCloseFullscreen,
} from 'react-icons/md';
import IAISlider from '../../../common/components/IAISlider';
import IAINumberInput from '../../../common/components/IAINumberInput';
import { inpaintingControlsSelector } from './inpaintingSliceSelectors';
import IAIPopover from '../../../common/components/IAIPopover';
import IAIColorPicker from '../../../common/components/IAIColorPicker';
import { RgbaColor } from 'react-colorful';
import { setShowDualDisplay } from '../../options/optionsSlice';
import { useEffect } from 'react';
const InpaintingControls = () => {
const {
tool,
brushSize,
maskColor,
maskOpacity,
shouldInvertMask,
shouldShowMask,
canUndo,
canRedo,
isMaskEmpty,
activeTabName,
showDualDisplay,
} = useAppSelector(inpaintingControlsSelector);
const dispatch = useAppDispatch();
const toast = useToast();
// Hotkeys
useHotkeys(
'[',
(e: KeyboardEvent) => {
e.preventDefault();
if (brushSize - 5 > 0) {
handleChangeBrushSize(brushSize - 5);
} else {
handleChangeBrushSize(1);
}
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, brushSize]
);
useHotkeys(
']',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeBrushSize(brushSize + 5);
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, brushSize]
);
useHotkeys(
'shift+[',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeMaskOpacity(Math.max(maskOpacity - 0.05, 0));
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, maskOpacity]
);
useHotkeys(
'shift+]',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeMaskOpacity(Math.min(maskOpacity + 0.05, 100));
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, maskOpacity]
);
useHotkeys(
'e',
(e: KeyboardEvent) => {
e.preventDefault();
if (activeTabName !== 'inpainting' || !shouldShowMask) return;
handleSelectEraserTool();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask]
);
useHotkeys(
'b',
(e: KeyboardEvent) => {
e.preventDefault();
handleSelectBrushTool();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask]
);
useHotkeys(
'cmd+z, control+z',
(e: KeyboardEvent) => {
e.preventDefault();
handleUndo();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask && canUndo,
},
[activeTabName, shouldShowMask, canUndo]
);
useHotkeys(
'cmd+shift+z, control+shift+z, control+y, cmd+y',
(e: KeyboardEvent) => {
e.preventDefault();
handleRedo();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask && canRedo,
},
[activeTabName, shouldShowMask, canRedo]
);
useHotkeys(
'h',
(e: KeyboardEvent) => {
e.preventDefault();
handleToggleShouldShowMask();
},
{
enabled: activeTabName === 'inpainting',
},
[activeTabName, shouldShowMask]
);
useHotkeys(
'shift+m',
(e: KeyboardEvent) => {
e.preventDefault();
handleToggleShouldInvertMask();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldInvertMask, shouldShowMask]
);
useHotkeys(
'shift+c',
(e: KeyboardEvent) => {
e.preventDefault();
handleClearMask();
toast({
title: 'Mask Cleared',
status: 'success',
duration: 2500,
isClosable: true,
});
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask && !isMaskEmpty,
},
[activeTabName, isMaskEmpty, shouldShowMask]
);
useHotkeys(
'shift+j',
() => {
handleDualDisplay();
},
[showDualDisplay]
);
const handleClearMask = () => {
dispatch(clearMask());
};
const handleSelectEraserTool = () => dispatch(setTool('eraser'));
const handleSelectBrushTool = () => dispatch(setTool('brush'));
const handleChangeBrushSize = (v: number) => {
dispatch(setShouldShowBrushPreview(true));
dispatch(setBrushSize(v));
};
const handleChangeMaskOpacity = (v: number) => {
dispatch(setMaskOpacity(v));
};
const handleToggleShouldShowMask = () =>
dispatch(setShouldShowMask(!shouldShowMask));
const handleToggleShouldInvertMask = () =>
dispatch(setShouldInvertMask(!shouldInvertMask));
const handleShowBrushPreview = () => {
dispatch(setShouldShowBrushPreview(true));
};
const handleHideBrushPreview = () => {
dispatch(setShouldShowBrushPreview(false));
};
const handleChangeBrushColor = (newColor: RgbaColor) => {
const { r, g, b, a: maskOpacity } = newColor;
dispatch(setMaskColor({ r, g, b }));
dispatch(setMaskOpacity(maskOpacity));
};
const handleUndo = () => dispatch(undo());
const handleRedo = () => dispatch(redo());
const handleDualDisplay = () => {
dispatch(setShowDualDisplay(!showDualDisplay));
dispatch(setNeedsRepaint(true));
};
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, a: maskOpacity }}
onChange={handleChangeBrushColor}
/>
</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>
</div>
);
};
export default InpaintingControls;

View File

@ -0,0 +1,69 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { useLayoutEffect } from 'react';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import CurrentImageDisplay from '../../gallery/CurrentImageDisplay';
import { OptionsState } from '../../options/optionsSlice';
import InpaintingCanvas from './InpaintingCanvas';
import InpaintingCanvasPlaceholder from './InpaintingCanvasPlaceholder';
import InpaintingControls from './InpaintingControls';
import { InpaintingState, setNeedsRepaint } from './inpaintingSlice';
const inpaintingDisplaySelector = createSelector(
[(state: RootState) => state.inpainting, (state: RootState) => state.options],
(inpainting: InpaintingState, options: OptionsState) => {
const { needsRepaint } = inpainting;
const { showDualDisplay } = options;
return {
needsRepaint,
showDualDisplay,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const InpaintingDisplay = () => {
const dispatch = useAppDispatch();
const { showDualDisplay, needsRepaint } = useAppSelector(
inpaintingDisplaySelector
);
useLayoutEffect(() => {
const resizeCallback = _.debounce(
() => dispatch(setNeedsRepaint(true)),
250
);
window.addEventListener('resize', resizeCallback);
return () => window.removeEventListener('resize', resizeCallback);
}, [dispatch]);
return (
<div
className="inpainting-display"
style={
showDualDisplay
? { gridTemplateColumns: '1fr 1fr' }
: { gridTemplateColumns: 'auto' }
}
>
<div className="inpainting-toolkit">
<InpaintingControls />
<div className="inpainting-canvas-container">
{needsRepaint ? (
<InpaintingCanvasPlaceholder />
) : (
<InpaintingCanvas />
)}
</div>
</div>
{showDualDisplay && <CurrentImageDisplay />}
</div>
);
};
export default InpaintingDisplay;

View File

@ -0,0 +1,63 @@
import { Feature } from '../../../app/features';
import { RootState, useAppSelector } from '../../../app/store';
import FaceRestoreHeader from '../../options/AdvancedOptions/FaceRestore/FaceRestoreHeader';
import FaceRestoreOptions from '../../options/AdvancedOptions/FaceRestore/FaceRestoreOptions';
import ImageToImageStrength from '../../options/AdvancedOptions/ImageToImage/ImageToImageStrength';
import BoundingBoxDimensions from '../../options/AdvancedOptions/Inpainting/BoundingBoxDimensions';
import SeedHeader from '../../options/AdvancedOptions/Seed/SeedHeader';
import SeedOptions from '../../options/AdvancedOptions/Seed/SeedOptions';
import UpscaleHeader from '../../options/AdvancedOptions/Upscale/UpscaleHeader';
import UpscaleOptions from '../../options/AdvancedOptions/Upscale/UpscaleOptions';
import VariationsHeader from '../../options/AdvancedOptions/Variations/VariationsHeader';
import VariationsOptions from '../../options/AdvancedOptions/Variations/VariationsOptions';
import MainAdvancedOptionsCheckbox from '../../options/MainOptions/MainAdvancedOptionsCheckbox';
import MainOptions from '../../options/MainOptions/MainOptions';
import OptionsAccordion from '../../options/OptionsAccordion';
import ProcessButtons from '../../options/ProcessButtons/ProcessButtons';
import PromptInput from '../../options/PromptInput/PromptInput';
export default function InpaintingPanel() {
const showAdvancedOptions = useAppSelector(
(state: RootState) => state.options.showAdvancedOptions
);
const imageToImageAccordions = {
seed: {
header: <SeedHeader />,
feature: Feature.SEED,
options: <SeedOptions />,
},
variations: {
header: <VariationsHeader />,
feature: Feature.VARIATIONS,
options: <VariationsOptions />,
},
face_restore: {
header: <FaceRestoreHeader />,
feature: Feature.FACE_CORRECTION,
options: <FaceRestoreOptions />,
},
upscale: {
header: <UpscaleHeader />,
feature: Feature.UPSCALE,
options: <UpscaleOptions />,
},
};
return (
<div className="image-to-image-panel">
<PromptInput />
<ProcessButtons />
<MainOptions />
<BoundingBoxDimensions />
<ImageToImageStrength
label="Image To Image Strength"
styleClass="main-option-block image-to-image-strength-main-option"
/>
<MainAdvancedOptionsCheckbox />
{showAdvancedOptions ? (
<OptionsAccordion accordionInfo={imageToImageAccordions} />
) : null}
</div>
);
}

View File

@ -0,0 +1,56 @@
import { useLayoutEffect } from 'react';
import { RootState, useAppSelector } from '../../../../app/store';
import { maskLayerRef } from '../InpaintingCanvas';
/**
* Konva's cache() method basically rasterizes an object/canvas.
* This is needed to rasterize the mask, before setting the opacity.
* If we do not cache the maskLayer, the brush strokes will have opacity
* set individually.
*
* This logical component simply uses useLayoutEffect() to synchronously
* cache the mask layer every time something that changes how it should draw
* is changed.
*/
const Cacher = () => {
const {
tool,
lines,
cursorPosition,
brushSize,
canvasDimensions: { width, height },
maskColor,
shouldInvertMask,
shouldShowMask,
shouldShowBrushPreview,
shouldShowCheckboardTransparency,
imageToInpaint,
} = useAppSelector((state: RootState) => state.inpainting);
useLayoutEffect(() => {
if (!maskLayerRef.current) return;
maskLayerRef.current.cache({
x: 0,
y: 0,
width,
height,
});
}, [
lines,
cursorPosition,
width,
height,
tool,
brushSize,
maskColor,
shouldInvertMask,
shouldShowMask,
shouldShowBrushPreview,
shouldShowCheckboardTransparency,
imageToInpaint,
]);
return null;
};
export default Cacher;

View File

@ -0,0 +1,189 @@
import { createSelector } from '@reduxjs/toolkit';
import Konva from 'konva';
import _ from 'lodash';
import { useEffect, useRef } from 'react';
import { Group, Rect } from 'react-konva';
import { RootState, useAppSelector } from '../../../../app/store';
import { InpaintingState } from '../inpaintingSlice';
import { rgbaColorToString } from '../util/colorToString';
import { DASH_WIDTH, MARCHING_ANTS_SPEED } from '../util/constants';
const boundingBoxPreviewSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const {
boundingBoxCoordinate,
boundingBoxDimensions,
boundingBoxPreviewFill,
canvasDimensions,
stageScale,
} = inpainting;
return {
boundingBoxCoordinate,
boundingBoxDimensions,
boundingBoxPreviewFillString: rgbaColorToString(boundingBoxPreviewFill),
canvasDimensions,
dash: DASH_WIDTH / stageScale, // scale dash lengths
strokeWidth: 1 / stageScale, // scale stroke thickness
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
/**
* Shades the area around the mask.
*/
const InpaintingBoundingBoxPreviewOverlay = () => {
const {
boundingBoxCoordinate,
boundingBoxDimensions,
boundingBoxPreviewFillString,
canvasDimensions,
} = useAppSelector(boundingBoxPreviewSelector);
return (
<Group>
<Rect
x={0}
y={0}
height={canvasDimensions.height}
width={canvasDimensions.width}
fill={boundingBoxPreviewFillString}
/>
<Rect
x={boundingBoxCoordinate.x}
y={boundingBoxCoordinate.y}
width={boundingBoxDimensions.width}
height={boundingBoxDimensions.height}
fill={'rgb(255,255,255)'}
listening={false}
globalCompositeOperation={'destination-out'}
/>
</Group>
);
};
/**
* Draws marching ants around the mask.
*/
const InpaintingBoundingBoxPreviewMarchingAnts = () => {
const { boundingBoxCoordinate, boundingBoxDimensions } = useAppSelector(
boundingBoxPreviewSelector
);
const blackStrokeRectRef = useRef<Konva.Rect>(null);
const whiteStrokeRectRef = useRef<Konva.Rect>(null);
useEffect(() => {
const blackStrokeRect = blackStrokeRectRef.current;
const whiteStrokeRect = whiteStrokeRectRef.current;
const anim = new Konva.Animation((frame) => {
if (!frame) return;
blackStrokeRect?.dashOffset(
-1 * (Math.floor(frame.time / MARCHING_ANTS_SPEED) % 16)
);
whiteStrokeRect?.dashOffset(
-1 * ((Math.floor(frame.time / MARCHING_ANTS_SPEED) % 16) + 4)
);
});
anim.start();
return () => {
anim.stop();
};
}, []);
return (
<Group>
<Rect
x={boundingBoxCoordinate.x}
y={boundingBoxCoordinate.y}
width={boundingBoxDimensions.width}
height={boundingBoxDimensions.height}
stroke={'black'}
strokeWidth={1}
dash={[4, 4]}
ref={blackStrokeRectRef}
listening={false}
/>
<Rect
x={boundingBoxCoordinate.x}
y={boundingBoxCoordinate.y}
width={boundingBoxDimensions.width}
height={boundingBoxDimensions.height}
stroke={'white'}
dash={[4, 4]}
strokeWidth={1}
ref={whiteStrokeRectRef}
listening={false}
/>
</Group>
);
};
/**
* Draws non-marching ants around the mask.
*/
const InpaintingBoundingBoxPreviewAnts = () => {
const { boundingBoxCoordinate, boundingBoxDimensions, dash, strokeWidth } =
useAppSelector(boundingBoxPreviewSelector);
return (
<Group>
<Rect
x={boundingBoxCoordinate.x}
y={boundingBoxCoordinate.y}
width={boundingBoxDimensions.width}
height={boundingBoxDimensions.height}
stroke={'black'}
strokeWidth={strokeWidth}
dash={[dash, dash]}
dashOffset={0}
listening={false}
/>
<Rect
x={boundingBoxCoordinate.x}
y={boundingBoxCoordinate.y}
width={boundingBoxDimensions.width}
height={boundingBoxDimensions.height}
stroke={'white'}
dash={[dash, dash]}
strokeWidth={strokeWidth}
dashOffset={dash}
listening={false}
/>
</Group>
);
};
const boundingBoxPreviewTypeSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => inpainting.boundingBoxPreviewType
);
const InpaintingBoundingBoxPreview = () => {
const boundingBoxPreviewType = useAppSelector(boundingBoxPreviewTypeSelector);
switch (boundingBoxPreviewType) {
case 'overlay': {
return <InpaintingBoundingBoxPreviewOverlay />;
}
case 'ants': {
return <InpaintingBoundingBoxPreviewAnts />;
}
case 'marchingAnts': {
return <InpaintingBoundingBoxPreviewMarchingAnts />;
}
default:
return null;
}
};
export default InpaintingBoundingBoxPreview;

View File

@ -0,0 +1,67 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { Circle } from 'react-konva';
import { RootState, useAppSelector } from '../../../../app/store';
import { InpaintingState } from '../inpaintingSlice';
import { rgbColorToString } from '../util/colorToString';
const inpaintingCanvasBrushPreviewSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const {
cursorPosition,
canvasDimensions: { width, height },
shouldShowBrushPreview,
brushSize,
maskColor,
tool,
} = inpainting;
return {
cursorPosition,
width,
height,
shouldShowBrushPreview,
brushSize,
maskColorString: rgbColorToString(maskColor),
tool,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
/**
* Draws a black circle around the canvas brush preview.
*/
const InpaintingCanvasBrushPreview = () => {
const {
cursorPosition,
width,
height,
shouldShowBrushPreview,
brushSize,
maskColorString,
tool,
} = useAppSelector(inpaintingCanvasBrushPreviewSelector);
if (!(cursorPosition || shouldShowBrushPreview)) return null;
return (
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={brushSize / 2}
fill={maskColorString}
listening={false}
globalCompositeOperation={
tool === 'eraser' ? 'destination-out' : 'source-over'
}
/>
);
};
export default InpaintingCanvasBrushPreview;

View File

@ -0,0 +1,62 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { Circle } from 'react-konva';
import { RootState, useAppSelector } from '../../../../app/store';
import { InpaintingState } from '../inpaintingSlice';
const inpaintingCanvasBrushPreviewSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const {
cursorPosition,
canvasDimensions: { width, height },
shouldShowBrushPreview,
brushSize,
stageScale,
} = inpainting;
return {
cursorPosition,
width,
height,
shouldShowBrushPreview,
brushSize,
strokeWidth: 1 / stageScale, // scale stroke thickness
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
/**
* Draws the canvas brush preview outline.
*/
const InpaintingCanvasBrushPreviewOutline = () => {
const {
cursorPosition,
width,
height,
shouldShowBrushPreview,
brushSize,
strokeWidth,
} = useAppSelector(inpaintingCanvasBrushPreviewSelector);
if (!((cursorPosition || shouldShowBrushPreview) && width && height))
return null;
return (
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={brushSize / 2}
stroke={'rgba(0,0,0,1)'}
strokeWidth={strokeWidth}
strokeEnabled={true}
listening={false}
/>
);
};
export default InpaintingCanvasBrushPreviewOutline;

View File

@ -0,0 +1,38 @@
import { Line } from 'react-konva';
import { RootState, useAppSelector } from '../../../../app/store';
/**
* Draws the lines which comprise the mask.
*
* Uses globalCompositeOperation to handle the brush and eraser tools.
*/
const InpaintingCanvasLines = () => {
const { lines, maskColor } = useAppSelector(
(state: RootState) => state.inpainting
);
const { r, g, b } = maskColor;
const maskColorString = `rgb(${r},${g},${b})`;
return (
<>
{lines.map((line, i) => (
<Line
key={i}
points={line.points}
stroke={maskColorString}
strokeWidth={line.strokeWidth * 2}
tension={0}
lineCap="round"
lineJoin="round"
shadowForStrokeEnabled={false}
listening={false}
globalCompositeOperation={
line.tool === 'brush' ? 'source-over' : 'destination-out'
}
/>
))}
</>
);
};
export default InpaintingCanvasLines;

View File

@ -0,0 +1,101 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { useEffect, useRef } from 'react';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import { OptionsState } from '../../../options/optionsSlice';
import { tabMap } from '../../InvokeTabs';
import {
InpaintingState,
toggleIsMovingBoundingBox,
toggleTool,
} from '../inpaintingSlice';
const keyboardEventManagerSelector = createSelector(
[(state: RootState) => state.options, (state: RootState) => state.inpainting],
(options: OptionsState, inpainting: InpaintingState) => {
const { shouldShowMask, cursorPosition } = inpainting;
return {
activeTabName: tabMap[options.activeTab],
shouldShowMask,
isCursorOnCanvas: Boolean(cursorPosition),
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const KeyboardEventManager = () => {
const dispatch = useAppDispatch();
const { shouldShowMask, activeTabName, isCursorOnCanvas } = useAppSelector(
keyboardEventManagerSelector
);
const isFirstEvent = useRef<boolean>(true);
const wasLastEventOverCanvas = useRef<boolean>(false);
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (!isCursorOnCanvas) {
wasLastEventOverCanvas.current = false;
if (isFirstEvent.current) {
isFirstEvent.current = false;
}
return;
}
if (isFirstEvent.current) {
wasLastEventOverCanvas.current = true;
isFirstEvent.current = false;
}
if (
!['Alt', ' '].includes(e.key) ||
activeTabName !== 'inpainting' ||
!shouldShowMask ||
e.repeat
) {
return;
}
if (!wasLastEventOverCanvas.current) {
wasLastEventOverCanvas.current = true;
return;
}
e.preventDefault();
switch (e.key) {
case 'Alt': {
dispatch(toggleTool());
break;
}
case ' ': {
dispatch(toggleIsMovingBoundingBox());
break;
}
}
};
console.log('adding listeners');
document.addEventListener('keydown', listener);
document.addEventListener('keyup', listener);
return () => {
document.removeEventListener('keydown', listener);
document.removeEventListener('keyup', listener);
};
}, [dispatch, activeTabName, shouldShowMask, isCursorOnCanvas]);
return null;
};
export default KeyboardEventManager;

View File

@ -0,0 +1,146 @@
import { useToast } from '@chakra-ui/react';
import { useHotkeys } from 'react-hotkeys-hook';
type UseInpaintingHotkeysConfig = {
activeTab: string;
brushSize: number;
handleChangeBrushSize: (newBrushSize: number) => void;
handleSelectEraserTool: () => void;
handleSelectBrushTool: () => void;
canUndo: boolean;
handleUndo: () => void;
canRedo: boolean;
handleRedo: () => void;
canClearMask: boolean;
handleClearMask: () => void;
};
const useInpaintingHotkeys = (config: UseInpaintingHotkeysConfig) => {
const {
activeTab,
brushSize,
handleChangeBrushSize,
handleSelectEraserTool,
handleSelectBrushTool,
canUndo,
handleUndo,
canRedo,
handleRedo,
canClearMask,
handleClearMask,
} = config;
const toast = useToast();
// Hotkeys
useHotkeys(
'[',
() => {
if (activeTab === 'inpainting' && brushSize - 5 > 0) {
handleChangeBrushSize(brushSize - 5);
} else {
handleChangeBrushSize(1);
}
},
[brushSize]
);
useHotkeys(
']',
() => {
if (activeTab === 'inpainting') {
handleChangeBrushSize(brushSize + 5);
}
},
[brushSize]
);
useHotkeys('e', () => {
if (activeTab === 'inpainting') {
handleSelectEraserTool();
}
});
useHotkeys('b', () => {
if (activeTab === 'inpainting') {
handleSelectBrushTool();
}
});
useHotkeys(
'cmd+z',
() => {
if (activeTab === 'inpainting' && canUndo) {
handleUndo();
}
},
[canUndo]
);
useHotkeys(
'control+z',
() => {
if (activeTab === 'inpainting' && canUndo) {
handleUndo();
}
},
[canUndo]
);
useHotkeys(
'cmd+shift+z',
() => {
if (activeTab === 'inpainting' && canRedo) {
handleRedo();
}
},
[canRedo]
);
useHotkeys(
'control+shift+z',
() => {
if (activeTab === 'inpainting' && canRedo) {
handleRedo();
}
},
[canRedo]
);
useHotkeys(
'control+y',
() => {
if (activeTab === 'inpainting' && canRedo) {
handleRedo();
}
},
[canRedo]
);
useHotkeys(
'cmd+y',
() => {
if (activeTab === 'inpainting' && canRedo) {
handleRedo();
}
},
[canRedo]
);
useHotkeys(
'c',
() => {
if (activeTab === 'inpainting' && canClearMask) {
handleClearMask();
toast({
title: 'Mask Cleared',
status: 'success',
duration: 2500,
isClosable: true,
});
}
},
[canClearMask]
);
};
export default useInpaintingHotkeys;

View File

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

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