merge with main

This commit is contained in:
Lincoln Stein
2023-06-06 22:18:41 -04:00
198 changed files with 5781 additions and 1409 deletions

View File

@ -40,13 +40,13 @@ from .widgets import (
TextBox,
set_min_terminal_size,
)
from invokeai.app.services.config import get_invokeai_config
from invokeai.app.services.config import InvokeAIAppConfig
# minimum size for the UI
MIN_COLS = 120
MIN_LINES = 45
config = get_invokeai_config()
config = InvokeAIAppConfig.get_config()
class addModelsForm(npyscreen.FormMultiPage):
# for responsive resizing - disabled

View File

@ -20,12 +20,12 @@ from npyscreen import widget
from omegaconf import OmegaConf
import invokeai.backend.util.logging as logger
from invokeai.services.config import get_invokeai_config
from invokeai.services.config import InvokeAIAppConfig
from ...backend.model_management import ModelManager
from ...frontend.install.widgets import FloatTitleSlider
DEST_MERGED_MODEL_DIR = "merged_models"
config = get_invokeai_config()
config = InvokeAIAppConfig.get_config()
def merge_diffusion_models(
model_ids_or_paths: List[Union[str, Path]],

View File

@ -22,7 +22,7 @@ from omegaconf import OmegaConf
import invokeai.backend.util.logging as logger
from invokeai.app.services.config import get_invokeai_config
from invokeai.app.services.config import InvokeAIAppConfig
from ...backend.training import (
do_textual_inversion_training,
parse_args
@ -423,7 +423,7 @@ def do_front_end(args: Namespace):
save_args(args)
try:
do_textual_inversion_training(get_invokeai_config(),**args)
do_textual_inversion_training(InvokeAIAppConfig.get_config(),**args)
copy_to_embeddings_folder(args)
except Exception as e:
logger.error("An exception occurred during training. The exception was:")
@ -436,7 +436,7 @@ def main():
global config
args = parse_args()
config = get_invokeai_config(argv=[])
config = InvokeAIAppConfig.get_config()
# change root if needed
if args.root_dir:

View File

@ -26,10 +26,10 @@ We need to start the nodes web server, which serves the OpenAPI schema to the ge
```bash
# from the repo root
python scripts/invoke-new.py --web
python scripts/invokeai-web.py
```
2. Generate the API client.
2. Generate the API client.
```bash
# from invokeai/frontend/web/

View File

@ -12,7 +12,14 @@ Code in `invokeai/frontend/web/` if you want to have a look.
## Stack
State management is Redux via [Redux Toolkit](https://github.com/reduxjs/redux-toolkit). Communication with server is a mix of HTTP and [socket.io](https://github.com/socketio/socket.io-client) (with a custom redux middleware to help).
State management is Redux via [Redux Toolkit](https://github.com/reduxjs/redux-toolkit). We lean heavily on RTK:
- `createAsyncThunk` for HTTP requests
- `createEntityAdapter` for fetching images and models
- `createListenerMiddleware` for workflows
The API client and associated types are generated from the OpenAPI schema. See API_CLIENT.md.
Communication with server is a mix of HTTP and [socket.io](https://github.com/socketio/socket.io-client) (with a simple socket.io redux middleware to help).
[Chakra-UI](https://github.com/chakra-ui/chakra-ui) for components and styling.
@ -37,9 +44,15 @@ From `invokeai/frontend/web/` run `yarn install` to get everything set up.
Start everything in dev mode:
1. Start the dev server: `yarn dev`
2. Start the InvokeAI Nodes backend: `python scripts/invokeai-new.py --web # run from the repo root`
2. Start the InvokeAI Nodes backend: `python scripts/invokeai-web.py # run from the repo root`
3. Point your browser to the dev server address e.g. <http://localhost:5173/>
#### VSCode Remote Dev
We've noticed an intermittent issue with the VSCode Remote Dev port forwarding. If you use this feature of VSCode, you may intermittently click the Invoke button and then get nothing until the request times out. Suggest disabling the IDE's port forwarding feature and doing it manually via SSH:
`ssh -L 9090:localhost:9090 -L 5173:localhost:5173 user@host`
### Production builds
For a number of technical and logistical reasons, we need to commit UI build artefacts to the repo.

View File

@ -60,6 +60,8 @@
"@chakra-ui/styled-system": "^2.9.0",
"@chakra-ui/theme-tools": "^2.0.16",
"@dagrejs/graphlib": "^2.1.12",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^6.0.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@floating-ui/react-dom": "^2.0.0",
@ -87,7 +89,7 @@
"react-dropzone": "^14.2.3",
"react-hotkeys-hook": "4.4.0",
"react-i18next": "^12.2.2",
"react-icons": "^4.7.1",
"react-icons": "^4.9.0",
"react-konva": "^18.2.7",
"react-redux": "^8.0.5",
"react-resizable-panels": "^0.0.42",

View File

@ -21,6 +21,7 @@ import { ReactNode, memo, useCallback, useEffect, useState } from 'react';
import { APP_HEIGHT, APP_WIDTH } from 'theme/util/constants';
import GlobalHotkeys from './GlobalHotkeys';
import Toaster from './Toaster';
import DeleteImageModal from 'features/gallery/components/DeleteImageModal';
const DEFAULT_CONFIG = {};
@ -76,18 +77,21 @@ const App = ({
{isLightboxEnabled && <Lightbox />}
<ImageUploader>
<Grid
gap={4}
p={4}
gridAutoRows="min-content auto"
w={APP_WIDTH}
h={APP_HEIGHT}
sx={{
gap: 4,
p: 4,
gridAutoRows: 'min-content auto',
w: 'full',
h: 'full',
}}
>
{headerComponent || <SiteHeader />}
<Flex
gap={4}
w={{ base: '100vw', xl: 'full' }}
h="full"
flexDir={{ base: 'column', xl: 'row' }}
sx={{
gap: 4,
w: 'full',
h: 'full',
}}
>
<InvokeTabs />
</Flex>
@ -130,6 +134,7 @@ const App = ({
<FloatingGalleryButton />
</Portal>
</Grid>
<DeleteImageModal />
<Toaster />
<GlobalHotkeys />
</>

View File

@ -0,0 +1,71 @@
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
MouseSensor,
TouchSensor,
pointerWithin,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { PropsWithChildren, memo, useCallback, useState } from 'react';
import OverlayDragImage from './OverlayDragImage';
import { ImageDTO } from 'services/api';
import { isImageDTO } from 'services/types/guards';
import { snapCenterToCursor } from '@dnd-kit/modifiers';
type ImageDndContextProps = PropsWithChildren;
const ImageDndContext = (props: ImageDndContextProps) => {
const [draggedImage, setDraggedImage] = useState<ImageDTO | null>(null);
const handleDragStart = useCallback((event: DragStartEvent) => {
const dragData = event.active.data.current;
if (dragData && 'image' in dragData && isImageDTO(dragData.image)) {
setDraggedImage(dragData.image);
}
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const handleDrop = event.over?.data.current?.handleDrop;
if (handleDrop && typeof handleDrop === 'function' && draggedImage) {
handleDrop(draggedImage);
}
setDraggedImage(null);
},
[draggedImage]
);
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { distance: 15 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { distance: 15 },
});
// TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos
// Alternatively, fix `rectIntersection` collection detection to work with the drag overlay
// (currently the drag element collision rect is not correctly calculated)
// const keyboardSensor = useSensor(KeyboardSensor);
const sensors = useSensors(mouseSensor, touchSensor);
return (
<DndContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
sensors={sensors}
collisionDetection={pointerWithin}
>
{props.children}
<DragOverlay dropAnimation={null} modifiers={[snapCenterToCursor]}>
{draggedImage && <OverlayDragImage image={draggedImage} />}
</DragOverlay>
</DndContext>
);
};
export default memo(ImageDndContext);

View File

@ -0,0 +1,36 @@
import { Box, Image } from '@chakra-ui/react';
import { memo } from 'react';
import { ImageDTO } from 'services/api';
type OverlayDragImageProps = {
image: ImageDTO;
};
const OverlayDragImage = (props: OverlayDragImageProps) => {
return (
<Box
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
cursor: 'grabbing',
opacity: 0.5,
}}
>
<Image
sx={{
maxW: 36,
maxH: 36,
borderRadius: 'base',
shadow: 'dark-lg',
}}
src={props.image.thumbnail_url}
/>
</Box>
);
};
export default memo(OverlayDragImage);

View File

@ -16,6 +16,11 @@ import { PartialAppConfig } from 'app/types/invokeai';
import '../../i18n';
import { socketMiddleware } from 'services/events/middleware';
import { Middleware } from '@reduxjs/toolkit';
import ImageDndContext from './ImageDnd/ImageDndContext';
import {
DeleteImageContext,
DeleteImageContextProvider,
} from 'app/contexts/DeleteImageContext';
const App = lazy(() => import('./App'));
const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider'));
@ -69,11 +74,15 @@ const InvokeAIUI = ({
<Provider store={store}>
<React.Suspense fallback={<Loading />}>
<ThemeLocaleProvider>
<App
config={config}
headerComponent={headerComponent}
setIsReady={setIsReady}
/>
<ImageDndContext>
<DeleteImageContextProvider>
<App
config={config}
headerComponent={headerComponent}
setIsReady={setIsReady}
/>
</DeleteImageContextProvider>
</ImageDndContext>
</ThemeLocaleProvider>
</React.Suspense>
</Provider>

View File

@ -0,0 +1,203 @@
import { useDisclosure } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { requestedImageDeletion } from 'features/gallery/store/actions';
import { systemSelector } from 'features/system/store/systemSelectors';
import {
PropsWithChildren,
createContext,
useCallback,
useEffect,
useState,
} from 'react';
import { ImageDTO } from 'services/api';
import { RootState } from 'app/store/store';
import { canvasSelector } from 'features/canvas/store/canvasSelectors';
import { controlNetSelector } from 'features/controlNet/store/controlNetSlice';
import { nodesSelecter } from 'features/nodes/store/nodesSlice';
import { generationSelector } from 'features/parameters/store/generationSelectors';
import { some } from 'lodash-es';
export type ImageUsage = {
isInitialImage: boolean;
isCanvasImage: boolean;
isNodesImage: boolean;
isControlNetImage: boolean;
};
export const selectImageUsage = createSelector(
[
generationSelector,
canvasSelector,
nodesSelecter,
controlNetSelector,
(state: RootState, image_name?: string) => image_name,
],
(generation, canvas, nodes, controlNet, image_name) => {
const isInitialImage = generation.initialImage?.image_name === image_name;
const isCanvasImage = canvas.layerState.objects.some(
(obj) => obj.kind === 'image' && obj.image.image_name === image_name
);
const isNodesImage = nodes.nodes.some((node) => {
return some(
node.data.inputs,
(input) =>
input.type === 'image' && input.value?.image_name === image_name
);
});
const isControlNetImage = some(
controlNet.controlNets,
(c) =>
c.controlImage?.image_name === image_name ||
c.processedControlImage?.image_name === image_name
);
const imageUsage: ImageUsage = {
isInitialImage,
isCanvasImage,
isNodesImage,
isControlNetImage,
};
return imageUsage;
},
defaultSelectorOptions
);
type DeleteImageContextValue = {
/**
* Whether the delete image dialog is open.
*/
isOpen: boolean;
/**
* Closes the delete image dialog.
*/
onClose: () => void;
/**
* Opens the delete image dialog and handles all deletion-related checks.
*/
onDelete: (image?: ImageDTO) => void;
/**
* The image pending deletion
*/
image?: ImageDTO;
/**
* The features in which this image is used
*/
imageUsage?: ImageUsage;
/**
* Immediately deletes an image.
*
* You probably don't want to use this - use `onDelete` instead.
*/
onImmediatelyDelete: () => void;
};
export const DeleteImageContext = createContext<DeleteImageContextValue>({
isOpen: false,
onClose: () => undefined,
onImmediatelyDelete: () => undefined,
onDelete: () => undefined,
});
const selector = createSelector(
[systemSelector],
(system) => {
const { isProcessing, isConnected, shouldConfirmOnDelete } = system;
return {
canDeleteImage: isConnected && !isProcessing,
shouldConfirmOnDelete,
};
},
defaultSelectorOptions
);
type Props = PropsWithChildren;
export const DeleteImageContextProvider = (props: Props) => {
const { canDeleteImage, shouldConfirmOnDelete } = useAppSelector(selector);
const [imageToDelete, setImageToDelete] = useState<ImageDTO>();
const dispatch = useAppDispatch();
const { isOpen, onOpen, onClose } = useDisclosure();
// Check where the image to be deleted is used (eg init image, controlnet, etc.)
const imageUsage = useAppSelector((state) =>
selectImageUsage(state, imageToDelete?.image_name)
);
// Clean up after deleting or dismissing the modal
const closeAndClearImageToDelete = useCallback(() => {
setImageToDelete(undefined);
onClose();
}, [onClose]);
// Dispatch the actual deletion action, to be handled by listener middleware
const handleActualDeletion = useCallback(
(image: ImageDTO) => {
dispatch(requestedImageDeletion({ image, imageUsage }));
closeAndClearImageToDelete();
},
[closeAndClearImageToDelete, dispatch, imageUsage]
);
// This is intended to be called by the delete button in the dialog
const onImmediatelyDelete = useCallback(() => {
if (canDeleteImage && imageToDelete) {
handleActualDeletion(imageToDelete);
}
closeAndClearImageToDelete();
}, [
canDeleteImage,
imageToDelete,
closeAndClearImageToDelete,
handleActualDeletion,
]);
const handleGatedDeletion = useCallback(
(image: ImageDTO) => {
if (shouldConfirmOnDelete || some(imageUsage)) {
// If we should confirm on delete, or if the image is in use, open the dialog
onOpen();
} else {
handleActualDeletion(image);
}
},
[imageUsage, shouldConfirmOnDelete, onOpen, handleActualDeletion]
);
// Consumers of the context call this to delete an image
const onDelete = useCallback((image?: ImageDTO) => {
if (!image) {
return;
}
// Set the image to delete, then let the effect call the actual deletion
setImageToDelete(image);
}, []);
useEffect(() => {
// We need to use an effect here to trigger the image usage selector, else we get a stale value
if (imageToDelete) {
handleGatedDeletion(imageToDelete);
}
}, [handleGatedDeletion, imageToDelete]);
return (
<DeleteImageContext.Provider
value={{
isOpen,
image: imageToDelete,
onClose: closeAndClearImageToDelete,
onDelete,
onImmediatelyDelete,
imageUsage,
}}
>
{props.children}
</DeleteImageContext.Provider>
);
};

View File

@ -1,4 +1,5 @@
import { canvasPersistDenylist } from 'features/canvas/store/canvasPersistDenylist';
import { controlNetDenylist } from 'features/controlNet/store/controlNetDenylist';
import { galleryPersistDenylist } from 'features/gallery/store/galleryPersistDenylist';
import { lightboxPersistDenylist } from 'features/lightbox/store/lightboxPersistDenylist';
import { nodesPersistDenylist } from 'features/nodes/store/nodesPersistDenylist';
@ -23,6 +24,7 @@ const serializationDenylist: {
system: systemPersistDenylist,
// config: configPersistDenyList,
ui: uiPersistDenylist,
controlNet: controlNetDenylist,
// hotkeys: hotkeysPersistDenylist,
};

View File

@ -1,4 +1,5 @@
import { initialCanvasState } from 'features/canvas/store/canvasSlice';
import { initialControlNetState } from 'features/controlNet/store/controlNetSlice';
import { initialGalleryState } from 'features/gallery/store/gallerySlice';
import { initialImagesState } from 'features/gallery/store/imagesSlice';
import { initialLightboxState } from 'features/lightbox/store/lightboxSlice';
@ -28,6 +29,7 @@ const initialStates: {
ui: initialUIState,
hotkeys: initialHotkeysState,
images: initialImagesState,
controlNet: initialControlNetState,
};
export const unserialize: UnserializeFunction = (data, key) => {

View File

@ -70,6 +70,9 @@ import {
import { addStagingAreaImageSavedListener } from './listeners/stagingAreaImageSaved';
import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener';
import { addImageCategoriesChangedListener } from './listeners/imageCategoriesChanged';
import { addControlNetImageProcessedListener } from './listeners/controlNetImageProcessed';
import { addControlNetAutoProcessListener } from './listeners/controlNetAutoProcess';
import { addUpdateImageUrlsOnConnectListener } from './listeners/updateImageUrlsOnConnect';
export const listenerMiddleware = createListenerMiddleware();
@ -173,3 +176,10 @@ addReceivedPageOfImagesRejectedListener();
// Gallery
addImageCategoriesChangedListener();
// ControlNet
addControlNetImageProcessedListener();
addControlNetAutoProcessListener();
// Update image URLs on connect
addUpdateImageUrlsOnConnectListener();

View File

@ -28,6 +28,13 @@ export const addCanvasCopiedToClipboardListener = () => {
}
copyBlobToClipboard(blob);
dispatch(
addToast({
title: 'Canvas Copied to Clipboard',
status: 'success',
})
);
},
});
};

View File

@ -27,7 +27,8 @@ export const addCanvasDownloadedAsImageListener = () => {
return;
}
downloadBlob(blob, 'mergedCanvas.png');
downloadBlob(blob, 'canvas.png');
dispatch(addToast({ title: 'Canvas Downloaded', status: 'success' }));
},
});
};

View File

@ -1,22 +1,20 @@
import { canvasMerged } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger';
import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob';
import { addToast } from 'features/system/store/systemSlice';
import { imageUploaded } from 'services/thunks/image';
import { v4 as uuidv4 } from 'uuid';
import { setMergedCanvas } from 'features/canvas/store/canvasSlice';
import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob';
const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' });
export const MERGED_CANVAS_FILENAME = 'mergedCanvas.png';
export const addCanvasMergedListener = () => {
startAppListening({
actionCreator: canvasMerged,
effect: async (action, { dispatch, getState, take }) => {
const state = getState();
const blob = await getBaseLayerBlob(state, true);
const blob = await getFullBaseLayerBlob();
if (!blob) {
moduleLog.error('Problem getting base layer blob');
@ -48,12 +46,12 @@ export const addCanvasMergedListener = () => {
relativeTo: canvasBaseLayer.getParent(),
});
const filename = `mergedCanvas_${uuidv4()}.png`;
dispatch(
const imageUploadedRequest = dispatch(
imageUploaded({
formData: {
file: new File([blob], filename, { type: 'image/png' }),
file: new File([blob], MERGED_CANVAS_FILENAME, {
type: 'image/png',
}),
},
imageCategory: 'general',
isIntermediate: true,
@ -61,9 +59,11 @@ export const addCanvasMergedListener = () => {
);
const [{ payload }] = await take(
(action): action is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(action) &&
action.meta.arg.formData.file.name === filename
(
uploadedImageAction
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(uploadedImageAction) &&
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
);
const mergedCanvasImage = payload;

View File

@ -4,9 +4,10 @@ import { log } from 'app/logging/useLogger';
import { imageUploaded } from 'services/thunks/image';
import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob';
import { addToast } from 'features/system/store/systemSlice';
import { v4 as uuidv4 } from 'uuid';
import { imageUpserted } from 'features/gallery/store/imagesSlice';
export const SAVED_CANVAS_FILENAME = 'savedCanvas.png';
const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' });
export const addCanvasSavedToGalleryListener = () => {
@ -15,7 +16,7 @@ export const addCanvasSavedToGalleryListener = () => {
effect: async (action, { dispatch, getState, take }) => {
const state = getState();
const blob = await getBaseLayerBlob(state, true);
const blob = await getBaseLayerBlob(state);
if (!blob) {
moduleLog.error('Problem getting base layer blob');
@ -29,12 +30,12 @@ export const addCanvasSavedToGalleryListener = () => {
return;
}
const filename = `mergedCanvas_${uuidv4()}.png`;
dispatch(
const imageUploadedRequest = dispatch(
imageUploaded({
formData: {
file: new File([blob], filename, { type: 'image/png' }),
file: new File([blob], SAVED_CANVAS_FILENAME, {
type: 'image/png',
}),
},
imageCategory: 'general',
isIntermediate: false,
@ -42,9 +43,11 @@ export const addCanvasSavedToGalleryListener = () => {
);
const [{ payload: uploadedImageDTO }] = await take(
(action): action is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(action) &&
action.meta.arg.formData.file.name === filename
(
uploadedImageAction
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(uploadedImageAction) &&
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
);
dispatch(imageUpserted(uploadedImageDTO));

View File

@ -0,0 +1,59 @@
import { AnyAction } from '@reduxjs/toolkit';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger';
import { controlNetImageProcessed } from 'features/controlNet/store/actions';
import {
controlNetImageChanged,
controlNetProcessorParamsChanged,
controlNetProcessorTypeChanged,
} from 'features/controlNet/store/controlNetSlice';
import { RootState } from 'app/store/store';
const moduleLog = log.child({ namespace: 'controlNet' });
const predicate = (action: AnyAction, state: RootState) => {
const isActionMatched =
controlNetProcessorParamsChanged.match(action) ||
controlNetImageChanged.match(action) ||
controlNetProcessorTypeChanged.match(action);
if (!isActionMatched) {
return false;
}
const { controlImage, processorType } =
state.controlNet.controlNets[action.payload.controlNetId];
const isProcessorSelected = processorType !== 'none';
const isBusy = state.system.isProcessing;
const hasControlImage = Boolean(controlImage);
return isProcessorSelected && !isBusy && hasControlImage;
};
/**
* Listener that automatically processes a ControlNet image when its processor parameters are changed.
*
* The network request is debounced by 1 second.
*/
export const addControlNetAutoProcessListener = () => {
startAppListening({
predicate,
effect: async (
action,
{ dispatch, getState, cancelActiveListeners, delay }
) => {
const { controlNetId } = action.payload;
// Cancel any in-progress instances of this listener
cancelActiveListeners();
// Delay before starting actual work
await delay(300);
dispatch(controlNetImageProcessed({ controlNetId }));
},
});
};

View File

@ -0,0 +1,93 @@
import { startAppListening } from '..';
import { imageMetadataReceived } from 'services/thunks/image';
import { log } from 'app/logging/useLogger';
import { controlNetImageProcessed } from 'features/controlNet/store/actions';
import { Graph } from 'services/api';
import { sessionCreated } from 'services/thunks/session';
import { sessionReadyToInvoke } from 'features/system/store/actions';
import { socketInvocationComplete } from 'services/events/actions';
import { isImageOutput } from 'services/types/guards';
import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice';
import { pick } from 'lodash-es';
const moduleLog = log.child({ namespace: 'controlNet' });
export const addControlNetImageProcessedListener = () => {
startAppListening({
actionCreator: controlNetImageProcessed,
effect: async (
action,
{ dispatch, getState, take, unsubscribe, subscribe }
) => {
const { controlNetId } = action.payload;
const controlNet = getState().controlNet.controlNets[controlNetId];
if (!controlNet.controlImage) {
moduleLog.error('Unable to process ControlNet image');
return;
}
// ControlNet one-off procressing graph is just the processor node, no edges.
// Also we need to grab the image.
const graph: Graph = {
nodes: {
[controlNet.processorNode.id]: {
...controlNet.processorNode,
is_intermediate: true,
image: pick(controlNet.controlImage, [
'image_name',
'image_origin',
]),
},
},
};
// Create a session to run the graph & wait til it's ready to invoke
const sessionCreatedAction = dispatch(sessionCreated({ graph }));
const [sessionCreatedFulfilledAction] = await take(
(action): action is ReturnType<typeof sessionCreated.fulfilled> =>
sessionCreated.fulfilled.match(action) &&
action.meta.requestId === sessionCreatedAction.requestId
);
const sessionId = sessionCreatedFulfilledAction.payload.id;
// Invoke the session & wait til it's complete
dispatch(sessionReadyToInvoke());
const [invocationCompleteAction] = await take(
(action): action is ReturnType<typeof socketInvocationComplete> =>
socketInvocationComplete.match(action) &&
action.payload.data.graph_execution_state_id === sessionId
);
// We still have to check the output type
if (isImageOutput(invocationCompleteAction.payload.data.result)) {
const { image_name } =
invocationCompleteAction.payload.data.result.image;
// Wait for the ImageDTO to be received
const [imageMetadataReceivedAction] = await take(
(
action
): action is ReturnType<typeof imageMetadataReceived.fulfilled> =>
imageMetadataReceived.fulfilled.match(action) &&
action.payload.image_name === image_name
);
const processedControlImage = imageMetadataReceivedAction.payload;
moduleLog.debug(
{ data: { arg: action.payload, processedControlImage } },
'ControlNet image processed'
);
// Update the processed image in the store
dispatch(
controlNetProcessedImageChanged({
controlNetId,
processedControlImage,
})
);
}
},
});
};

View File

@ -6,10 +6,13 @@ import { clamp } from 'lodash-es';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import {
imageRemoved,
imagesAdapter,
selectImagesEntities,
selectImagesIds,
} from 'features/gallery/store/imagesSlice';
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
const moduleLog = log.child({ namespace: 'addRequestedImageDeletionListener' });
@ -20,11 +23,7 @@ export const addRequestedImageDeletionListener = () => {
startAppListening({
actionCreator: requestedImageDeletion,
effect: (action, { dispatch, getState }) => {
const image = action.payload;
if (!image) {
moduleLog.warn('No image provided');
return;
}
const { image, imageUsage } = action.payload;
const { image_name, image_origin } = image;
@ -58,8 +57,28 @@ export const addRequestedImageDeletionListener = () => {
}
}
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
if (imageUsage.isCanvasImage) {
dispatch(resetCanvas());
}
if (imageUsage.isControlNetImage) {
dispatch(controlNetReset());
}
if (imageUsage.isInitialImage) {
dispatch(clearInitialImage());
}
if (imageUsage.isNodesImage) {
dispatch(nodeEditorReset());
}
// Preemptively remove from gallery
dispatch(imageRemoved(image_name));
// Delete from server
dispatch(
imageDeleted({ imageName: image_name, imageOrigin: image_origin })
);
@ -74,9 +93,7 @@ export const addImageDeletedPendingListener = () => {
startAppListening({
actionCreator: imageDeleted.pending,
effect: (action, { dispatch, getState }) => {
const { imageName, imageOrigin } = action.meta.arg;
// Preemptively remove the image from the gallery
imagesAdapter.removeOne(getState().images, imageName);
//
},
});
};

View File

@ -1,6 +1,6 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageMetadataReceived } from 'services/thunks/image';
import { imageMetadataReceived, imageUpdated } from 'services/thunks/image';
import { imageUpserted } from 'features/gallery/store/imagesSlice';
const moduleLog = log.child({ namespace: 'image' });
@ -10,10 +10,29 @@ export const addImageMetadataReceivedFulfilledListener = () => {
actionCreator: imageMetadataReceived.fulfilled,
effect: (action, { getState, dispatch }) => {
const image = action.payload;
if (image.is_intermediate) {
const state = getState();
if (
image.session_id === state.canvas.layerState.stagingArea.sessionId &&
state.canvas.shouldAutoSave
) {
dispatch(
imageUpdated({
imageName: image.image_name,
imageOrigin: image.image_origin,
requestBody: { is_intermediate: false },
})
);
} else if (image.is_intermediate) {
// No further actions needed for intermediate images
moduleLog.trace(
{ data: { image } },
'Image metadata received (intermediate), skipping'
);
return;
}
moduleLog.debug({ data: { image } }, 'Image metadata received');
dispatch(imageUpserted(image));
},

View File

@ -3,6 +3,8 @@ import { imageUploaded } from 'services/thunks/image';
import { addToast } from 'features/system/store/systemSlice';
import { log } from 'app/logging/useLogger';
import { imageUpserted } from 'features/gallery/store/imagesSlice';
import { SAVED_CANVAS_FILENAME } from './canvasSavedToGallery';
import { MERGED_CANVAS_FILENAME } from './canvasMerged';
const moduleLog = log.child({ namespace: 'image' });
@ -19,9 +21,22 @@ export const addImageUploadedFulfilledListener = () => {
return;
}
const state = getState();
const originalFileName = action.meta.arg.formData.file.name;
dispatch(imageUpserted(image));
if (originalFileName === SAVED_CANVAS_FILENAME) {
dispatch(
addToast({ title: 'Canvas Saved to Gallery', status: 'success' })
);
return;
}
if (originalFileName === MERGED_CANVAS_FILENAME) {
dispatch(addToast({ title: 'Canvas Merged', status: 'success' }));
return;
}
dispatch(addToast({ title: 'Image Uploaded', status: 'success' }));
},
});

View File

@ -1,7 +1,7 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageUrlsReceived } from 'services/thunks/image';
import { imagesAdapter } from 'features/gallery/store/imagesSlice';
import { imageUpdatedOne } from 'features/gallery/store/imagesSlice';
const moduleLog = log.child({ namespace: 'image' });
@ -14,13 +14,12 @@ export const addImageUrlsReceivedFulfilledListener = () => {
const { image_name, image_url, thumbnail_url } = image;
imagesAdapter.updateOne(getState().images, {
id: image_name,
changes: {
image_url,
thumbnail_url,
},
});
dispatch(
imageUpdatedOne({
id: image_name,
changes: { image_url, thumbnail_url },
})
);
},
});
};

View File

@ -2,12 +2,10 @@ import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { t } from 'i18next';
import { addToast } from 'features/system/store/systemSlice';
import { startAppListening } from '..';
import {
initialImageSelected,
isImageDTO,
} from 'features/parameters/store/actions';
import { initialImageSelected } from 'features/parameters/store/actions';
import { makeToast } from 'app/components/Toaster';
import { selectImagesById } from 'features/gallery/store/imagesSlice';
import { isImageDTO } from 'services/types/guards';
export const addInitialImageSelectedListener = () => {
startAppListening({

View File

@ -0,0 +1,93 @@
import { socketConnected } from 'services/events/actions';
import { startAppListening } from '..';
import { createSelector } from '@reduxjs/toolkit';
import { generationSelector } from 'features/parameters/store/generationSelectors';
import { canvasSelector } from 'features/canvas/store/canvasSelectors';
import { nodesSelecter } from 'features/nodes/store/nodesSlice';
import { controlNetSelector } from 'features/controlNet/store/controlNetSlice';
import { ImageDTO } from 'services/api';
import { forEach, uniqBy } from 'lodash-es';
import { imageUrlsReceived } from 'services/thunks/image';
import { log } from 'app/logging/useLogger';
import { selectImagesEntities } from 'features/gallery/store/imagesSlice';
const moduleLog = log.child({ namespace: 'images' });
const selectAllUsedImages = createSelector(
[
generationSelector,
canvasSelector,
nodesSelecter,
controlNetSelector,
selectImagesEntities,
],
(generation, canvas, nodes, controlNet, imageEntities) => {
const allUsedImages: ImageDTO[] = [];
if (generation.initialImage) {
allUsedImages.push(generation.initialImage);
}
canvas.layerState.objects.forEach((obj) => {
if (obj.kind === 'image') {
allUsedImages.push(obj.image);
}
});
nodes.nodes.forEach((node) => {
forEach(node.data.inputs, (input) => {
if (input.type === 'image' && input.value) {
allUsedImages.push(input.value);
}
});
});
forEach(controlNet.controlNets, (c) => {
if (c.controlImage) {
allUsedImages.push(c.controlImage);
}
if (c.processedControlImage) {
allUsedImages.push(c.processedControlImage);
}
});
forEach(imageEntities, (image) => {
if (image) {
allUsedImages.push(image);
}
});
const uniqueImages = uniqBy(allUsedImages, 'image_name');
return uniqueImages;
}
);
export const addUpdateImageUrlsOnConnectListener = () => {
startAppListening({
actionCreator: socketConnected,
effect: async (action, { dispatch, getState, take }) => {
const state = getState();
if (!state.config.shouldUpdateImagesOnConnect) {
return;
}
const allUsedImages = selectAllUsedImages(state);
moduleLog.trace(
{ data: allUsedImages },
`Fetching new image URLs for ${allUsedImages.length} images`
);
allUsedImages.forEach(({ image_name, image_origin }) => {
dispatch(
imageUrlsReceived({
imageName: image_name,
imageOrigin: image_origin,
})
);
});
},
});
};

View File

@ -13,6 +13,7 @@ import galleryReducer from 'features/gallery/store/gallerySlice';
import imagesReducer from 'features/gallery/store/imagesSlice';
import lightboxReducer from 'features/lightbox/store/lightboxSlice';
import generationReducer from 'features/parameters/store/generationSlice';
import controlNetReducer from 'features/controlNet/store/controlNetSlice';
import postprocessingReducer from 'features/parameters/store/postprocessingSlice';
import systemReducer from 'features/system/store/systemSlice';
// import sessionReducer from 'features/system/store/sessionSlice';
@ -45,6 +46,7 @@ const allReducers = {
ui: uiReducer,
hotkeys: hotkeysReducer,
images: imagesReducer,
controlNet: controlNetReducer,
// session: sessionReducer,
};
@ -62,6 +64,7 @@ const rememberedKeys: (keyof typeof allReducers)[] = [
'postprocessing',
'system',
'ui',
'controlNet',
// 'hotkeys',
// 'config',
];

View File

@ -95,6 +95,7 @@ export type AppFeature =
* A disable-able Stable Diffusion feature
*/
export type SDFeature =
| 'controlNet'
| 'noise'
| 'variation'
| 'symmetry'
@ -107,12 +108,9 @@ export type SDFeature =
*/
export type AppConfig = {
/**
* Whether or not URLs should be transformed to use a different host
*/
shouldTransformUrls: boolean;
/**
* Whether or not we need to re-fetch images
* Whether or not we should update image urls when image loading errors
*/
shouldUpdateImagesOnConnect: boolean;
disabledTabs: InvokeTabName[];
disabledFeatures: AppFeature[];
disabledSDFeatures: SDFeature[];

View File

@ -1,17 +0,0 @@
import { Checkbox, CheckboxProps } from '@chakra-ui/react';
import { memo, ReactNode } from 'react';
type IAICheckboxProps = CheckboxProps & {
label: string | ReactNode;
};
const IAICheckbox = (props: IAICheckboxProps) => {
const { label, ...rest } = props;
return (
<Checkbox colorScheme="accent" {...rest}>
{label}
</Checkbox>
);
};
export default memo(IAICheckbox);

View File

@ -49,7 +49,7 @@ const IAICollapse = (props: IAIToggleCollapseProps) => {
/>
)}
</Flex>
<Collapse in={isOpen} animateOpacity>
<Collapse in={isOpen} animateOpacity style={{ overflow: 'unset' }}>
<Box sx={{ p: 4, borderBottomRadius: 'base', bg: 'base.800' }}>
{children}
</Box>

View File

@ -1,4 +1,4 @@
import { CheckIcon } from '@chakra-ui/icons';
import { CheckIcon, ChevronUpIcon } from '@chakra-ui/icons';
import {
Box,
Flex,
@ -10,7 +10,6 @@ import {
GridItem,
List,
ListItem,
Select,
Text,
Tooltip,
TooltipProps,
@ -19,7 +18,8 @@ import { autoUpdate, offset, shift, useFloating } from '@floating-ui/react-dom';
import { useSelect } from 'downshift';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { getInputOutlineStyles } from 'theme/util/getInputOutlineStyles';
export type ItemTooltips = { [key: string]: string };
@ -34,6 +34,7 @@ type IAICustomSelectProps = {
buttonProps?: FlexProps;
tooltip?: string;
tooltipProps?: Omit<TooltipProps, 'children'>;
ellipsisPosition?: 'start' | 'end';
};
const IAICustomSelect = (props: IAICustomSelectProps) => {
@ -48,6 +49,7 @@ const IAICustomSelect = (props: IAICustomSelectProps) => {
tooltip,
buttonProps,
tooltipProps,
ellipsisPosition = 'end',
} = props;
const {
@ -69,6 +71,14 @@ const IAICustomSelect = (props: IAICustomSelectProps) => {
middleware: [offset(4), shift({ crossAxis: true, padding: 8 })],
});
const labelTextDirection = useMemo(() => {
if (ellipsisPosition === 'start') {
return document.dir === 'rtl' ? 'ltr' : 'rtl';
}
return document.dir;
}, [ellipsisPosition]);
return (
<FormControl sx={{ w: 'full' }} {...formControlProps}>
{label && (
@ -82,20 +92,44 @@ const IAICustomSelect = (props: IAICustomSelectProps) => {
</FormLabel>
)}
<Tooltip label={tooltip} {...tooltipProps}>
<Select
<Flex
{...getToggleButtonProps({ ref: refs.setReference })}
{...buttonProps}
as={Flex}
sx={{
alignItems: 'center',
userSelect: 'none',
cursor: 'pointer',
overflow: 'hidden',
width: 'full',
py: 1,
px: 2,
gap: 2,
justifyContent: 'space-between',
...getInputOutlineStyles(),
}}
>
<Text sx={{ fontSize: 'sm', fontWeight: 500, color: 'base.100' }}>
<Text
sx={{
fontSize: 'sm',
fontWeight: 500,
color: 'base.100',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
direction: labelTextDirection,
}}
>
{selectedItem}
</Text>
</Select>
<ChevronUpIcon
sx={{
color: 'base.300',
transform: isOpen ? 'rotate(0deg)' : 'rotate(180deg)',
transitionProperty: 'common',
transitionDuration: 'normal',
}}
/>
</Flex>
</Tooltip>
<Box {...getMenuProps()}>
{isOpen && (
@ -104,11 +138,10 @@ const IAICustomSelect = (props: IAICustomSelectProps) => {
ref={refs.setFloating}
sx={{
...floatingStyles,
width: 'max-content',
top: 0,
left: 0,
insetInlineStart: 0,
flexDirection: 'column',
zIndex: 1,
zIndex: 2,
bg: 'base.800',
borderRadius: 'base',
border: '1px',
@ -118,61 +151,72 @@ const IAICustomSelect = (props: IAICustomSelectProps) => {
px: 0,
h: 'fit-content',
maxH: 64,
minW: 48,
}}
>
<OverlayScrollbarsComponent>
{items.map((item, index) => (
<Tooltip
isDisabled={!itemTooltips}
key={`${item}${index}`}
label={itemTooltips?.[item]}
hasArrow
placement="right"
>
<ListItem
sx={{
bg: highlightedIndex === index ? 'base.700' : undefined,
py: 1,
paddingInlineStart: 3,
paddingInlineEnd: 6,
cursor: 'pointer',
transitionProperty: 'common',
transitionDuration: '0.15s',
}}
{items.map((item, index) => {
const isSelected = selectedItem === item;
const isHighlighted = highlightedIndex === index;
const fontWeight = isSelected ? 700 : 500;
const bg = isHighlighted
? 'base.700'
: isSelected
? 'base.750'
: undefined;
return (
<Tooltip
isDisabled={!itemTooltips}
key={`${item}${index}`}
{...getItemProps({ item, index })}
label={itemTooltips?.[item]}
hasArrow
placement="right"
>
{withCheckIcon ? (
<Grid gridTemplateColumns="1.25rem auto">
<GridItem>
{selectedItem === item && <CheckIcon boxSize={2} />}
</GridItem>
<GridItem>
<Text
sx={{
fontSize: 'sm',
color: 'base.100',
fontWeight: 500,
}}
>
{item}
</Text>
</GridItem>
</Grid>
) : (
<Text
sx={{
fontSize: 'sm',
color: 'base.100',
fontWeight: 500,
}}
>
{item}
</Text>
)}
</ListItem>
</Tooltip>
))}
<ListItem
sx={{
bg,
py: 1,
paddingInlineStart: 3,
paddingInlineEnd: 6,
cursor: 'pointer',
transitionProperty: 'common',
transitionDuration: '0.15s',
}}
key={`${item}${index}`}
{...getItemProps({ item, index })}
>
{withCheckIcon ? (
<Grid gridTemplateColumns="1.25rem auto">
<GridItem>
{isSelected && <CheckIcon boxSize={2} />}
</GridItem>
<GridItem>
<Text
sx={{
fontSize: 'sm',
color: 'base.100',
fontWeight,
}}
>
{item}
</Text>
</GridItem>
</Grid>
) : (
<Text
sx={{
fontSize: 'sm',
color: 'base.50',
fontWeight,
}}
>
{item}
</Text>
)}
</ListItem>
</Tooltip>
);
})}
</OverlayScrollbarsComponent>
</List>
)}

View File

@ -0,0 +1,167 @@
import { Box, Flex, Icon, IconButtonProps, Image } from '@chakra-ui/react';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { useCombinedRefs } from '@dnd-kit/utilities';
import IAIIconButton from 'common/components/IAIIconButton';
import { IAIImageFallback } from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { AnimatePresence } from 'framer-motion';
import { ReactElement, SyntheticEvent } from 'react';
import { memo, useRef } from 'react';
import { FaImage, FaTimes } from 'react-icons/fa';
import { ImageDTO } from 'services/api';
import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay';
type IAIDndImageProps = {
image: ImageDTO | null | undefined;
onDrop: (droppedImage: ImageDTO) => void;
onReset?: () => void;
onError?: (event: SyntheticEvent<HTMLImageElement>) => void;
onLoad?: (event: SyntheticEvent<HTMLImageElement>) => void;
resetIconSize?: IconButtonProps['size'];
withResetIcon?: boolean;
withMetadataOverlay?: boolean;
isDragDisabled?: boolean;
isDropDisabled?: boolean;
fallback?: ReactElement;
payloadImage?: ImageDTO | null | undefined;
minSize?: number;
};
const IAIDndImage = (props: IAIDndImageProps) => {
const {
image,
onDrop,
onReset,
onError,
resetIconSize = 'md',
withResetIcon = false,
withMetadataOverlay = false,
isDropDisabled = false,
isDragDisabled = false,
fallback = <IAIImageFallback />,
payloadImage,
minSize = 24,
} = props;
const dndId = useRef(uuidv4());
const {
isOver,
setNodeRef: setDroppableRef,
active,
} = useDroppable({
id: dndId.current,
disabled: isDropDisabled,
data: {
handleDrop: onDrop,
},
});
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
} = useDraggable({
id: dndId.current,
data: {
image: payloadImage ? payloadImage : image,
},
disabled: isDragDisabled,
});
const setNodeRef = useCombinedRefs(setDroppableRef, setDraggableRef);
return (
<Flex
sx={{
width: 'full',
height: 'full',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
minW: minSize,
minH: minSize,
userSelect: 'none',
cursor: 'grab',
}}
{...attributes}
{...listeners}
ref={setNodeRef}
>
{image && (
<Flex
sx={{
w: 'full',
h: 'full',
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Image
src={image.image_url}
fallbackStrategy="beforeLoadOrError"
fallback={fallback}
onError={onError}
objectFit="contain"
draggable={false}
sx={{
maxW: 'full',
maxH: 'full',
borderRadius: 'base',
}}
/>
{withMetadataOverlay && <ImageMetadataOverlay image={image} />}
{onReset && withResetIcon && (
<Box
sx={{
position: 'absolute',
top: 0,
right: 0,
p: 2,
}}
>
<IAIIconButton
size={resetIconSize}
tooltip="Reset Image"
aria-label="Reset Image"
icon={<FaTimes />}
onClick={onReset}
/>
</Box>
)}
<AnimatePresence>
{active && <IAIDropOverlay isOver={isOver} />}
</AnimatePresence>
</Flex>
)}
{!image && (
<>
<Flex
sx={{
minH: minSize,
bg: 'base.850',
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
}}
>
<Icon
as={FaImage}
sx={{
boxSize: 24,
color: 'base.500',
}}
/>
</Flex>
<AnimatePresence>
{active && <IAIDropOverlay isOver={isOver} />}
</AnimatePresence>
</>
)}
</Flex>
);
};
export default memo(IAIDndImage);

View File

@ -0,0 +1,91 @@
import { Flex, Text } from '@chakra-ui/react';
import { motion } from 'framer-motion';
import { memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
type Props = {
isOver: boolean;
label?: string;
};
export const IAIDropOverlay = (props: Props) => {
const { isOver, label = 'Drop' } = props;
const motionId = useRef(uuidv4());
return (
<motion.div
key={motionId.current}
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
transition: { duration: 0.1 },
}}
exit={{
opacity: 0,
transition: { duration: 0.1 },
}}
>
<Flex
sx={{
position: 'absolute',
top: 0,
left: 0,
w: 'full',
h: 'full',
}}
>
<Flex
sx={{
position: 'absolute',
top: 0,
left: 0,
w: 'full',
h: 'full',
bg: 'base.900',
opacity: 0.7,
borderRadius: 'base',
alignItems: 'center',
justifyContent: 'center',
transitionProperty: 'common',
transitionDuration: '0.1s',
}}
/>
<Flex
sx={{
position: 'absolute',
top: 0,
left: 0,
w: 'full',
h: 'full',
opacity: 1,
borderWidth: 2,
borderColor: isOver ? 'base.200' : 'base.500',
borderRadius: 'base',
borderStyle: 'dashed',
transitionProperty: 'common',
transitionDuration: '0.1s',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text
sx={{
fontSize: '2xl',
fontWeight: 600,
transform: isOver ? 'scale(1.1)' : 'scale(1)',
color: isOver ? 'base.100' : 'base.500',
transitionProperty: 'common',
transitionDuration: '0.1s',
}}
>
{label}
</Text>
</Flex>
</Flex>
</motion.div>
);
};
export default memo(IAIDropOverlay);

View File

@ -0,0 +1,25 @@
import {
Checkbox,
CheckboxProps,
FormControl,
FormControlProps,
FormLabel,
} from '@chakra-ui/react';
import { memo, ReactNode } from 'react';
type IAIFullCheckboxProps = CheckboxProps & {
label: string | ReactNode;
formControlProps?: FormControlProps;
};
const IAIFullCheckbox = (props: IAIFullCheckboxProps) => {
const { label, formControlProps, ...rest } = props;
return (
<FormControl {...formControlProps}>
<FormLabel>{label}</FormLabel>
<Checkbox colorScheme="accent" {...rest} />
</FormControl>
);
};
export default memo(IAIFullCheckbox);

View File

@ -0,0 +1,27 @@
import { Flex, FlexProps, Spinner, SpinnerProps } from '@chakra-ui/react';
type Props = FlexProps & {
spinnerProps?: SpinnerProps;
};
export const IAIImageFallback = (props: Props) => {
const { spinnerProps, ...rest } = props;
const { sx, ...restFlexProps } = rest;
return (
<Flex
sx={{
bg: 'base.900',
opacity: 0.7,
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
...sx,
}}
{...restFlexProps}
>
<Spinner size="xl" {...spinnerProps} />
</Flex>
);
};

View File

@ -0,0 +1,19 @@
import { Checkbox, CheckboxProps, Text } from '@chakra-ui/react';
import { memo, ReactElement } from 'react';
type IAISimpleCheckboxProps = CheckboxProps & {
label: string | ReactElement;
};
const IAISimpleCheckbox = (props: IAISimpleCheckboxProps) => {
const { label, ...rest } = props;
return (
<Checkbox colorScheme="accent" {...rest}>
<Text color="base.200" fontSize="md">
{label}
</Text>
</Checkbox>
);
};
export default memo(IAISimpleCheckbox);

View File

@ -40,7 +40,7 @@ import IAIIconButton, { IAIIconButtonProps } from './IAIIconButton';
import { roundDownToMultiple } from 'common/util/roundDownToMultiple';
export type IAIFullSliderProps = {
label: string;
label?: string;
value: number;
min?: number;
max?: number;
@ -178,9 +178,11 @@ const IAISlider = (props: IAIFullSliderProps) => {
isDisabled={isDisabled}
{...sliderFormControlProps}
>
<FormLabel {...sliderFormLabelProps} mb={-1}>
{label}
</FormLabel>
{label && (
<FormLabel {...sliderFormLabelProps} mb={-1}>
{label}
</FormLabel>
)}
<HStack w="100%" gap={2} alignItems="center">
<Slider
@ -203,6 +205,7 @@ const IAISlider = (props: IAIFullSliderProps) => {
sx={{
insetInlineStart: '0 !important',
insetInlineEnd: 'unset !important',
mt: 1.5,
}}
{...sliderMarkProps}
>
@ -213,6 +216,7 @@ const IAISlider = (props: IAIFullSliderProps) => {
sx={{
insetInlineStart: 'unset !important',
insetInlineEnd: '0 !important',
mt: 1.5,
}}
{...sliderMarkProps}
>

View File

@ -5,6 +5,7 @@ import {
FormLabelProps,
Switch,
SwitchProps,
Tooltip,
} from '@chakra-ui/react';
import { memo } from 'react';
@ -13,6 +14,7 @@ interface Props extends SwitchProps {
width?: string | number;
formControlProps?: FormControlProps;
formLabelProps?: FormLabelProps;
tooltip?: string;
}
/**
@ -25,22 +27,27 @@ const IAISwitch = (props: Props) => {
width = 'auto',
formControlProps,
formLabelProps,
tooltip,
...rest
} = props;
return (
<FormControl
isDisabled={isDisabled}
width={width}
display="flex"
gap={4}
alignItems="center"
{...formControlProps}
>
<FormLabel my={1} flexGrow={1} {...formLabelProps}>
{label}
</FormLabel>
<Switch {...rest} />
</FormControl>
<Tooltip label={tooltip} hasArrow placement="top" isDisabled={!tooltip}>
<FormControl
isDisabled={isDisabled}
width={width}
display="flex"
gap={4}
alignItems="center"
{...formControlProps}
>
{label && (
<FormLabel my={1} flexGrow={1} {...formLabelProps}>
{label}
</FormLabel>
)}
<Switch {...rest} />
</FormControl>
</Tooltip>
);
};

View File

@ -1,5 +1,5 @@
import { Badge, Flex } from '@chakra-ui/react';
import { isNumber, isString } from 'lodash-es';
import { isString } from 'lodash-es';
import { useMemo } from 'react';
import { ImageDTO } from 'services/api';
@ -8,14 +8,6 @@ type ImageMetadataOverlayProps = {
};
const ImageMetadataOverlay = ({ image }: ImageMetadataOverlayProps) => {
const dimensions = useMemo(() => {
if (!isNumber(image.metadata?.width) || isNumber(!image.metadata?.height)) {
return;
}
return `${image.metadata?.width} × ${image.metadata?.height}`;
}, [image.metadata]);
const model = useMemo(() => {
if (!isString(image.metadata?.model)) {
return;
@ -31,17 +23,15 @@ const ImageMetadataOverlay = ({ image }: ImageMetadataOverlayProps) => {
flexDirection: 'column',
position: 'absolute',
top: 0,
right: 0,
insetInlineStart: 0,
p: 2,
alignItems: 'flex-end',
alignItems: 'flex-start',
gap: 2,
}}
>
{dimensions && (
<Badge variant="solid" colorScheme="base">
{dimensions}
</Badge>
)}
<Badge variant="solid" colorScheme="base">
{image.width} × {image.height}
</Badge>
{model && (
<Badge variant="solid" colorScheme="base">
{model}

View File

@ -1,42 +0,0 @@
import { ButtonGroup, Flex, Spacer, Text } from '@chakra-ui/react';
import IAIIconButton from 'common/components/IAIIconButton';
import { useTranslation } from 'react-i18next';
import { FaUndo, FaUpload } from 'react-icons/fa';
import { useAppDispatch } from 'app/store/storeHooks';
import { useCallback } from 'react';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import useImageUploader from 'common/hooks/useImageUploader';
const InitialImageButtons = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { openUploader } = useImageUploader();
const handleResetInitialImage = useCallback(() => {
dispatch(clearInitialImage());
}, [dispatch]);
return (
<Flex w="full" alignItems="center">
<Text size="sm" fontWeight={500} color="base.300">
{t('parameters.initialImage')}
</Text>
<Spacer />
<ButtonGroup>
<IAIIconButton
icon={<FaUndo />}
aria-label={t('accessibility.reset')}
onClick={handleResetInitialImage}
/>
<IAIIconButton
icon={<FaUpload />}
onClick={openUploader}
aria-label={t('common.upload')}
/>
</ButtonGroup>
</Flex>
);
};
export default InitialImageButtons;

View File

@ -1,12 +1,12 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { validateSeedWeights } from 'common/util/seedWeightPairs';
import { generationSelector } from 'features/parameters/store/generationSelectors';
import { systemSelector } from 'features/system/store/systemSelectors';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { isEqual } from 'lodash-es';
export const readinessSelector = createSelector(
const readinessSelector = createSelector(
[generationSelector, systemSelector, activeTabNameSelector],
(generation, system, activeTabName) => {
const {
@ -60,3 +60,8 @@ export const readinessSelector = createSelector(
},
defaultSelectorOptions
);
export const useIsReadyToInvoke = () => {
const { isReady } = useAppSelector(readinessSelector);
return isReady;
};

View File

@ -1,34 +0,0 @@
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { useCallback } from 'react';
import { OpenAPI } from 'services/api';
export const getUrlAlt = (url: string, shouldTransformUrls: boolean) => {
if (OpenAPI.BASE && shouldTransformUrls) {
return [OpenAPI.BASE, url].join('/');
}
return url;
};
export const useGetUrl = () => {
const shouldTransformUrls = useAppSelector(
(state: RootState) => state.config.shouldTransformUrls
);
const getUrl = useCallback(
(url?: string) => {
if (OpenAPI.BASE && shouldTransformUrls) {
return [OpenAPI.BASE, url].join('/');
}
return url;
},
[shouldTransformUrls]
);
return {
shouldTransformUrls,
getUrl,
};
};

View File

@ -1,6 +1,5 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useGetUrl } from 'common/util/getUrl';
import { canvasSelector } from 'features/canvas/store/canvasSelectors';
import { rgbaColorToString } from 'features/canvas/util/colorToString';
import { isEqual } from 'lodash-es';
@ -33,7 +32,6 @@ const selector = createSelector(
const IAICanvasObjectRenderer = () => {
const { objects } = useAppSelector(selector);
const { getUrl } = useGetUrl();
if (!objects) return null;
@ -46,7 +44,7 @@ const IAICanvasObjectRenderer = () => {
key={i}
x={obj.x}
y={obj.y}
url={getUrl(obj.image.image_url)}
url={obj.image.image_url}
/>
);
} else if (isCanvasBaseLine(obj)) {

View File

@ -1,6 +1,5 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useGetUrl } from 'common/util/getUrl';
import { canvasSelector } from 'features/canvas/store/canvasSelectors';
import { GroupConfig } from 'konva/lib/Group';
import { isEqual } from 'lodash-es';
@ -56,13 +55,12 @@ const IAICanvasStagingArea = (props: Props) => {
width,
height,
} = useAppSelector(selector);
const { getUrl } = useGetUrl();
return (
<Group {...rest}>
{shouldShowStagingImage && currentStagingAreaImage && (
<IAICanvasImage
url={getUrl(currentStagingAreaImage.image.image_url) ?? ''}
url={currentStagingAreaImage.image.image_url}
x={x}
y={y}
/>

View File

@ -2,7 +2,7 @@ import { ButtonGroup, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import IAICheckbox from 'common/components/IAICheckbox';
import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
import IAIColorPicker from 'common/components/IAIColorPicker';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
@ -117,12 +117,12 @@ const IAICanvasMaskOptions = () => {
}
>
<Flex direction="column" gap={2}>
<IAICheckbox
<IAISimpleCheckbox
label={`${t('unifiedCanvas.enableMask')} (H)`}
isChecked={isMaskEnabled}
onChange={handleToggleEnableMask}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('unifiedCanvas.preserveMaskedArea')}
isChecked={shouldPreserveMaskedArea}
onChange={(e) =>

View File

@ -1,7 +1,7 @@
import { Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAICheckbox from 'common/components/IAICheckbox';
import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import { canvasSelector } from 'features/canvas/store/canvasSelectors';
@ -102,50 +102,50 @@ const IAICanvasSettingsButtonPopover = () => {
}
>
<Flex direction="column" gap={2}>
<IAICheckbox
<IAISimpleCheckbox
label={t('unifiedCanvas.showIntermediates')}
isChecked={shouldShowIntermediates}
onChange={(e) =>
dispatch(setShouldShowIntermediates(e.target.checked))
}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('unifiedCanvas.showGrid')}
isChecked={shouldShowGrid}
onChange={(e) => dispatch(setShouldShowGrid(e.target.checked))}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('unifiedCanvas.snapToGrid')}
isChecked={shouldSnapToGrid}
onChange={handleChangeShouldSnapToGrid}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('unifiedCanvas.darkenOutsideSelection')}
isChecked={shouldDarkenOutsideBoundingBox}
onChange={(e) =>
dispatch(setShouldDarkenOutsideBoundingBox(e.target.checked))
}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('unifiedCanvas.autoSaveToGallery')}
isChecked={shouldAutoSave}
onChange={(e) => dispatch(setShouldAutoSave(e.target.checked))}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('unifiedCanvas.saveBoxRegionOnly')}
isChecked={shouldCropToBoundingBoxOnSave}
onChange={(e) =>
dispatch(setShouldCropToBoundingBoxOnSave(e.target.checked))
}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('unifiedCanvas.limitStrokesToBox')}
isChecked={shouldRestrictStrokesToBox}
onChange={(e) =>
dispatch(setShouldRestrictStrokesToBox(e.target.checked))
}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('unifiedCanvas.showCanvasDebugInfo')}
isChecked={shouldShowCanvasDebugInfo}
onChange={(e) =>
@ -153,7 +153,7 @@ const IAICanvasSettingsButtonPopover = () => {
}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('unifiedCanvas.antialiasing')}
isChecked={shouldAntialias}
onChange={(e) => dispatch(setShouldAntialias(e.target.checked))}

View File

@ -1,4 +1,4 @@
import { ButtonGroup, Flex } from '@chakra-ui/react';
import { Box, ButtonGroup, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
@ -210,16 +210,19 @@ const IAICanvasToolbar = () => {
sx={{
alignItems: 'center',
gap: 2,
flexWrap: 'wrap',
}}
>
<IAISelect
tooltip={`${t('unifiedCanvas.layer')} (Q)`}
tooltipProps={{ hasArrow: true, placement: 'top' }}
value={layer}
validValues={LAYER_NAMES_DICT}
onChange={handleChangeLayer}
isDisabled={isStaging}
/>
<Box w={24}>
<IAISelect
tooltip={`${t('unifiedCanvas.layer')} (Q)`}
tooltipProps={{ hasArrow: true, placement: 'top' }}
value={layer}
validValues={LAYER_NAMES_DICT}
onChange={handleChangeLayer}
isDisabled={isStaging}
/>
</Box>
<IAICanvasMaskOptions />
<IAICanvasToolChooserOptions />

View File

@ -30,6 +30,8 @@ import {
} from './canvasTypes';
import { ImageDTO } from 'services/api';
import { sessionCanceled } from 'services/thunks/session';
import { setShouldUseCanvasBetaLayout } from 'features/ui/store/uiSlice';
import { imageUrlsReceived } from 'services/thunks/image';
export const initialLayerState: CanvasLayerState = {
objects: [],
@ -851,6 +853,30 @@ export const canvasSlice = createSlice({
state.layerState.stagingArea = initialLayerState.stagingArea;
}
});
builder.addCase(setShouldUseCanvasBetaLayout, (state, action) => {
state.doesCanvasNeedScaling = true;
});
builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
const { image_name, image_origin, image_url, thumbnail_url } =
action.payload;
state.layerState.objects.forEach((object) => {
if (object.kind === 'image') {
if (object.image.image_name === image_name) {
object.image.image_url = image_url;
object.image.thumbnail_url = thumbnail_url;
}
}
});
state.layerState.stagingArea.images.forEach((stagedImage) => {
if (stagedImage.image.image_name === image_name) {
stagedImage.image.image_url = image_url;
stagedImage.image.thumbnail_url = thumbnail_url;
}
});
});
},
});

View File

@ -2,10 +2,10 @@ import { getCanvasBaseLayer } from './konvaInstanceProvider';
import { RootState } from 'app/store/store';
import { konvaNodeToBlob } from './konvaNodeToBlob';
export const getBaseLayerBlob = async (
state: RootState,
withoutBoundingBox?: boolean
) => {
/**
* Get the canvas base layer blob, with or without bounding box according to `shouldCropToBoundingBoxOnSave`
*/
export const getBaseLayerBlob = async (state: RootState) => {
const canvasBaseLayer = getCanvasBaseLayer();
if (!canvasBaseLayer) {
@ -24,15 +24,14 @@ export const getBaseLayerBlob = async (
const absPos = clonedBaseLayer.getAbsolutePosition();
const boundingBox =
shouldCropToBoundingBoxOnSave && !withoutBoundingBox
? {
x: boundingBoxCoordinates.x + absPos.x,
y: boundingBoxCoordinates.y + absPos.y,
width: boundingBoxDimensions.width,
height: boundingBoxDimensions.height,
}
: clonedBaseLayer.getClientRect();
const boundingBox = shouldCropToBoundingBoxOnSave
? {
x: boundingBoxCoordinates.x + absPos.x,
y: boundingBoxCoordinates.y + absPos.y,
width: boundingBoxDimensions.width,
height: boundingBoxDimensions.height,
}
: clonedBaseLayer.getClientRect();
return konvaNodeToBlob(clonedBaseLayer, boundingBox);
};

View File

@ -0,0 +1,19 @@
import { getCanvasBaseLayer } from './konvaInstanceProvider';
import { konvaNodeToBlob } from './konvaNodeToBlob';
/**
* Gets the canvas base layer blob, without bounding box
*/
export const getFullBaseLayerBlob = async () => {
const canvasBaseLayer = getCanvasBaseLayer();
if (!canvasBaseLayer) {
return;
}
const clonedBaseLayer = canvasBaseLayer.clone();
clonedBaseLayer.scale({ x: 1, y: 1 });
return konvaNodeToBlob(clonedBaseLayer, clonedBaseLayer.getClientRect());
};

View File

@ -0,0 +1,258 @@
import { memo, useCallback } from 'react';
import {
ControlNetConfig,
controlNetAdded,
controlNetRemoved,
controlNetToggled,
} from '../store/controlNetSlice';
import { useAppDispatch } from 'app/store/storeHooks';
import ParamControlNetModel from './parameters/ParamControlNetModel';
import ParamControlNetWeight from './parameters/ParamControlNetWeight';
import {
Checkbox,
Flex,
FormControl,
FormLabel,
HStack,
TabList,
TabPanels,
Tabs,
Tab,
TabPanel,
Box,
} from '@chakra-ui/react';
import { FaCopy, FaPlus, FaTrash, FaWrench } from 'react-icons/fa';
import ParamControlNetBeginEnd from './parameters/ParamControlNetBeginEnd';
import ControlNetImagePreview from './ControlNetImagePreview';
import IAIIconButton from 'common/components/IAIIconButton';
import { v4 as uuidv4 } from 'uuid';
import { useToggle } from 'react-use';
import ParamControlNetProcessorSelect from './parameters/ParamControlNetProcessorSelect';
import ControlNetProcessorComponent from './ControlNetProcessorComponent';
import ControlNetPreprocessButton from './ControlNetPreprocessButton';
import IAIButton from 'common/components/IAIButton';
import IAISwitch from 'common/components/IAISwitch';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
type ControlNetProps = {
controlNet: ControlNetConfig;
};
const ControlNet = (props: ControlNetProps) => {
const {
controlNetId,
isEnabled,
model,
weight,
beginStepPct,
endStepPct,
controlImage,
processedControlImage,
processorNode,
processorType,
} = props.controlNet;
const dispatch = useAppDispatch();
const [shouldShowAdvanced, onToggleAdvanced] = useToggle(false);
const handleDelete = useCallback(() => {
dispatch(controlNetRemoved({ controlNetId }));
}, [controlNetId, dispatch]);
const handleDuplicate = useCallback(() => {
dispatch(
controlNetAdded({ controlNetId: uuidv4(), controlNet: props.controlNet })
);
}, [dispatch, props.controlNet]);
const handleToggleIsEnabled = useCallback(() => {
dispatch(controlNetToggled({ controlNetId }));
}, [controlNetId, dispatch]);
return (
<Flex
sx={{
flexDir: 'column',
gap: 2,
p: 3,
bg: 'base.850',
borderRadius: 'base',
}}
>
<Flex sx={{ gap: 2 }}>
<IAISwitch
tooltip="Toggle"
aria-label="Toggle"
isChecked={isEnabled}
onChange={handleToggleIsEnabled}
/>
<Box
sx={{
w: 'full',
minW: 0,
opacity: isEnabled ? 1 : 0.5,
pointerEvents: isEnabled ? 'auto' : 'none',
transitionProperty: 'common',
transitionDuration: '0.1s',
}}
>
<ParamControlNetModel controlNetId={controlNetId} model={model} />
</Box>
<IAIIconButton
size="sm"
tooltip="Duplicate"
aria-label="Duplicate"
onClick={handleDuplicate}
icon={<FaCopy />}
/>
<IAIIconButton
size="sm"
tooltip="Delete"
aria-label="Delete"
colorScheme="error"
onClick={handleDelete}
icon={<FaTrash />}
/>
<IAIIconButton
size="sm"
aria-label="Expand"
onClick={onToggleAdvanced}
variant="link"
icon={
<ChevronUpIcon
sx={{
boxSize: 4,
color: 'base.300',
transform: shouldShowAdvanced
? 'rotate(0deg)'
: 'rotate(180deg)',
transitionProperty: 'common',
transitionDuration: 'normal',
}}
/>
}
/>
</Flex>
{isEnabled && (
<>
<Flex sx={{ gap: 4 }}>
<Flex
sx={{
flexDir: 'column',
gap: 2,
w: 'full',
h: 24,
paddingInlineStart: 1,
paddingInlineEnd: shouldShowAdvanced ? 1 : 0,
pb: 2,
justifyContent: 'space-between',
}}
>
<ParamControlNetWeight
controlNetId={controlNetId}
weight={weight}
mini
/>
<ParamControlNetBeginEnd
controlNetId={controlNetId}
beginStepPct={beginStepPct}
endStepPct={endStepPct}
mini
/>
</Flex>
{!shouldShowAdvanced && (
<Flex
sx={{
alignItems: 'center',
justifyContent: 'center',
h: 24,
w: 24,
aspectRatio: '1/1',
}}
>
<ControlNetImagePreview controlNet={props.controlNet} />
</Flex>
)}
</Flex>
{shouldShowAdvanced && (
<>
<Box pt={2}>
<ControlNetImagePreview controlNet={props.controlNet} />
</Box>
<ParamControlNetProcessorSelect
controlNetId={controlNetId}
processorNode={processorNode}
/>
<ControlNetProcessorComponent
controlNetId={controlNetId}
processorNode={processorNode}
/>
</>
)}
</>
)}
</Flex>
);
return (
<Flex sx={{ flexDir: 'column', gap: 3 }}>
<ControlNetImagePreview controlNet={props.controlNet} />
<ParamControlNetModel controlNetId={controlNetId} model={model} />
<Tabs
isFitted
orientation="horizontal"
variant="enclosed"
size="sm"
colorScheme="accent"
>
<TabList>
<Tab
sx={{ 'button&': { _selected: { borderBottomColor: 'base.800' } } }}
>
Model Config
</Tab>
<Tab
sx={{ 'button&': { _selected: { borderBottomColor: 'base.800' } } }}
>
Preprocess
</Tab>
</TabList>
<TabPanels sx={{ pt: 2 }}>
<TabPanel sx={{ p: 0 }}>
<ParamControlNetWeight
controlNetId={controlNetId}
weight={weight}
/>
<ParamControlNetBeginEnd
controlNetId={controlNetId}
beginStepPct={beginStepPct}
endStepPct={endStepPct}
/>
</TabPanel>
<TabPanel sx={{ p: 0 }}>
<ParamControlNetProcessorSelect
controlNetId={controlNetId}
processorNode={processorNode}
/>
<ControlNetProcessorComponent
controlNetId={controlNetId}
processorNode={processorNode}
/>
<ControlNetPreprocessButton controlNet={props.controlNet} />
{/* <IAIButton
size="sm"
leftIcon={<FaUndo />}
onClick={handleReset}
isDisabled={Boolean(!processedControlImage)}
>
Reset Processing
</IAIButton> */}
</TabPanel>
</TabPanels>
</Tabs>
<IAIButton onClick={handleDelete}>Remove ControlNet</IAIButton>
</Flex>
);
};
export default memo(ControlNet);

View File

@ -0,0 +1,144 @@
import { memo, useCallback, useRef, useState } from 'react';
import { ImageDTO } from 'services/api';
import {
ControlNetConfig,
controlNetImageChanged,
controlNetSelector,
} from '../store/controlNetSlice';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { Box } from '@chakra-ui/react';
import IAIDndImage from 'common/components/IAIDndImage';
import { createSelector } from '@reduxjs/toolkit';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { AnimatePresence, motion } from 'framer-motion';
import { IAIImageFallback } from 'common/components/IAIImageFallback';
import { useHoverDirty } from 'react-use';
const selector = createSelector(
controlNetSelector,
(controlNet) => {
const { isProcessingControlImage } = controlNet;
return { isProcessingControlImage };
},
defaultSelectorOptions
);
type Props = {
controlNet: ControlNetConfig;
};
const ControlNetImagePreview = (props: Props) => {
const { controlNetId, controlImage, processedControlImage, processorType } =
props.controlNet;
const dispatch = useAppDispatch();
const { isProcessingControlImage } = useAppSelector(selector);
const containerRef = useRef<HTMLDivElement>(null);
const isMouseOverImage = useHoverDirty(containerRef);
const handleDrop = useCallback(
(droppedImage: ImageDTO) => {
if (controlImage?.image_name === droppedImage.image_name) {
return;
}
dispatch(
controlNetImageChanged({ controlNetId, controlImage: droppedImage })
);
},
[controlImage, controlNetId, dispatch]
);
const shouldShowProcessedImageBackdrop =
Number(controlImage?.width) > Number(processedControlImage?.width) ||
Number(controlImage?.height) > Number(processedControlImage?.height);
const shouldShowProcessedImage =
controlImage &&
processedControlImage &&
!isMouseOverImage &&
!isProcessingControlImage &&
processorType !== 'none';
return (
<Box
ref={containerRef}
sx={{ position: 'relative', w: 'full', h: 'full', aspectRatio: '1/1' }}
>
<IAIDndImage
image={controlImage}
onDrop={handleDrop}
isDropDisabled={Boolean(
processedControlImage && processorType !== 'none'
)}
/>
<AnimatePresence>
{shouldShowProcessedImage && (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
transition: { duration: 0.1 },
}}
exit={{
opacity: 0,
transition: { duration: 0.1 },
}}
>
<Box
sx={{
position: 'absolute',
w: 'full',
h: 'full',
top: 0,
insetInlineStart: 0,
}}
>
{shouldShowProcessedImageBackdrop && (
<Box
sx={{
w: 'full',
h: 'full',
bg: 'base.900',
opacity: 0.7,
}}
/>
)}
<Box
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
w: 'full',
h: 'full',
}}
>
<IAIDndImage
image={processedControlImage}
onDrop={handleDrop}
payloadImage={controlImage}
/>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
{isProcessingControlImage && (
<Box
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
w: 'full',
h: 'full',
}}
>
<IAIImageFallback />
</Box>
)}
</Box>
);
};
export default memo(ControlNetImagePreview);

View File

@ -0,0 +1,36 @@
import IAIButton from 'common/components/IAIButton';
import { memo, useCallback } from 'react';
import { ControlNetConfig } from '../store/controlNetSlice';
import { useAppDispatch } from 'app/store/storeHooks';
import { controlNetImageProcessed } from '../store/actions';
import { useIsReadyToInvoke } from 'common/hooks/useIsReadyToInvoke';
type Props = {
controlNet: ControlNetConfig;
};
const ControlNetPreprocessButton = (props: Props) => {
const { controlNetId, controlImage } = props.controlNet;
const dispatch = useAppDispatch();
const isReady = useIsReadyToInvoke();
const handleProcess = useCallback(() => {
dispatch(
controlNetImageProcessed({
controlNetId,
})
);
}, [controlNetId, dispatch]);
return (
<IAIButton
size="sm"
onClick={handleProcess}
isDisabled={Boolean(!controlImage) || !isReady}
>
Preprocess
</IAIButton>
);
};
export default memo(ControlNetPreprocessButton);

View File

@ -0,0 +1,131 @@
import { memo } from 'react';
import { RequiredControlNetProcessorNode } from '../store/types';
import CannyProcessor from './processors/CannyProcessor';
import HedProcessor from './processors/HedProcessor';
import LineartProcessor from './processors/LineartProcessor';
import LineartAnimeProcessor from './processors/LineartAnimeProcessor';
import ContentShuffleProcessor from './processors/ContentShuffleProcessor';
import MediapipeFaceProcessor from './processors/MediapipeFaceProcessor';
import MidasDepthProcessor from './processors/MidasDepthProcessor';
import MlsdImageProcessor from './processors/MlsdImageProcessor';
import NormalBaeProcessor from './processors/NormalBaeProcessor';
import OpenposeProcessor from './processors/OpenposeProcessor';
import PidiProcessor from './processors/PidiProcessor';
import ZoeDepthProcessor from './processors/ZoeDepthProcessor';
export type ControlNetProcessorProps = {
controlNetId: string;
processorNode: RequiredControlNetProcessorNode;
};
const ControlNetProcessorComponent = (props: ControlNetProcessorProps) => {
const { controlNetId, processorNode } = props;
if (processorNode.type === 'canny_image_processor') {
return (
<CannyProcessor
controlNetId={controlNetId}
processorNode={processorNode}
/>
);
}
if (processorNode.type === 'hed_image_processor') {
return (
<HedProcessor controlNetId={controlNetId} processorNode={processorNode} />
);
}
if (processorNode.type === 'lineart_image_processor') {
return (
<LineartProcessor
controlNetId={controlNetId}
processorNode={processorNode}
/>
);
}
if (processorNode.type === 'content_shuffle_image_processor') {
return (
<ContentShuffleProcessor
controlNetId={controlNetId}
processorNode={processorNode}
/>
);
}
if (processorNode.type === 'lineart_anime_image_processor') {
return (
<LineartAnimeProcessor
controlNetId={controlNetId}
processorNode={processorNode}
/>
);
}
if (processorNode.type === 'mediapipe_face_processor') {
return (
<MediapipeFaceProcessor
controlNetId={controlNetId}
processorNode={processorNode}
/>
);
}
if (processorNode.type === 'midas_depth_image_processor') {
return (
<MidasDepthProcessor
controlNetId={controlNetId}
processorNode={processorNode}
/>
);
}
if (processorNode.type === 'mlsd_image_processor') {
return (
<MlsdImageProcessor
controlNetId={controlNetId}
processorNode={processorNode}
/>
);
}
if (processorNode.type === 'normalbae_image_processor') {
return (
<NormalBaeProcessor
controlNetId={controlNetId}
processorNode={processorNode}
/>
);
}
if (processorNode.type === 'openpose_image_processor') {
return (
<OpenposeProcessor
controlNetId={controlNetId}
processorNode={processorNode}
/>
);
}
if (processorNode.type === 'pidi_image_processor') {
return (
<PidiProcessor
controlNetId={controlNetId}
processorNode={processorNode}
/>
);
}
if (processorNode.type === 'zoe_depth_image_processor') {
return (
<ZoeDepthProcessor
controlNetId={controlNetId}
processorNode={processorNode}
/>
);
}
return null;
};
export default memo(ControlNetProcessorComponent);

View File

@ -0,0 +1,20 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { controlNetProcessorParamsChanged } from 'features/controlNet/store/controlNetSlice';
import { ControlNetProcessorNode } from 'features/controlNet/store/types';
import { useCallback } from 'react';
export const useProcessorNodeChanged = () => {
const dispatch = useAppDispatch();
const handleProcessorNodeChanged = useCallback(
(controlNetId: string, changes: Partial<ControlNetProcessorNode>) => {
dispatch(
controlNetProcessorParamsChanged({
controlNetId,
changes,
})
);
},
[dispatch]
);
return handleProcessorNodeChanged;
};

View File

@ -0,0 +1,130 @@
import {
FormControl,
FormLabel,
HStack,
RangeSlider,
RangeSliderFilledTrack,
RangeSliderMark,
RangeSliderThumb,
RangeSliderTrack,
Tooltip,
} from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import {
controlNetBeginStepPctChanged,
controlNetEndStepPctChanged,
} from 'features/controlNet/store/controlNetSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BiReset } from 'react-icons/bi';
type Props = {
controlNetId: string;
beginStepPct: number;
endStepPct: number;
mini?: boolean;
};
const formatPct = (v: number) => `${Math.round(v * 100)}%`;
const ParamControlNetBeginEnd = (props: Props) => {
const { controlNetId, beginStepPct, endStepPct, mini = false } = props;
const dispatch = useAppDispatch();
const { t } = useTranslation();
const handleStepPctChanged = useCallback(
(v: number[]) => {
dispatch(
controlNetBeginStepPctChanged({ controlNetId, beginStepPct: v[0] })
);
dispatch(controlNetEndStepPctChanged({ controlNetId, endStepPct: v[1] }));
},
[controlNetId, dispatch]
);
const handleStepPctReset = useCallback(() => {
dispatch(controlNetBeginStepPctChanged({ controlNetId, beginStepPct: 0 }));
dispatch(controlNetEndStepPctChanged({ controlNetId, endStepPct: 1 }));
}, [controlNetId, dispatch]);
return (
<FormControl>
<FormLabel>Begin / End Step Percentage</FormLabel>
<HStack w="100%" gap={2} alignItems="center">
<RangeSlider
aria-label={['Begin Step %', 'End Step %']}
value={[beginStepPct, endStepPct]}
onChange={handleStepPctChanged}
min={0}
max={1}
step={0.01}
minStepsBetweenThumbs={5}
>
<RangeSliderTrack>
<RangeSliderFilledTrack />
</RangeSliderTrack>
<Tooltip label={formatPct(beginStepPct)} placement="top" hasArrow>
<RangeSliderThumb index={0} />
</Tooltip>
<Tooltip label={formatPct(endStepPct)} placement="top" hasArrow>
<RangeSliderThumb index={1} />
</Tooltip>
{!mini && (
<>
<RangeSliderMark
value={0}
sx={{
fontSize: 'xs',
fontWeight: '500',
color: 'base.200',
insetInlineStart: '0 !important',
insetInlineEnd: 'unset !important',
mt: 1.5,
}}
>
0%
</RangeSliderMark>
<RangeSliderMark
value={0.5}
sx={{
fontSize: 'xs',
fontWeight: '500',
color: 'base.200',
mt: 1.5,
}}
>
50%
</RangeSliderMark>
<RangeSliderMark
value={1}
sx={{
fontSize: 'xs',
fontWeight: '500',
color: 'base.200',
insetInlineStart: 'unset !important',
insetInlineEnd: '0 !important',
mt: 1.5,
}}
>
100%
</RangeSliderMark>
</>
)}
</RangeSlider>
{!mini && (
<IAIIconButton
size="sm"
aria-label={t('accessibility.reset')}
tooltip={t('accessibility.reset')}
icon={<BiReset />}
onClick={handleStepPctReset}
/>
)}
</HStack>
</FormControl>
);
};
export default memo(ParamControlNetBeginEnd);

View File

@ -0,0 +1,28 @@
import { useAppDispatch } from 'app/store/storeHooks';
import IAISwitch from 'common/components/IAISwitch';
import { controlNetToggled } from 'features/controlNet/store/controlNetSlice';
import { memo, useCallback } from 'react';
type ParamControlNetIsEnabledProps = {
controlNetId: string;
isEnabled: boolean;
};
const ParamControlNetIsEnabled = (props: ParamControlNetIsEnabledProps) => {
const { controlNetId, isEnabled } = props;
const dispatch = useAppDispatch();
const handleIsEnabledChanged = useCallback(() => {
dispatch(controlNetToggled({ controlNetId }));
}, [dispatch, controlNetId]);
return (
<IAISwitch
label="Enabled"
isChecked={isEnabled}
onChange={handleIsEnabledChanged}
/>
);
};
export default memo(ParamControlNetIsEnabled);

View File

@ -0,0 +1,36 @@
import { useAppDispatch } from 'app/store/storeHooks';
import IAIFullCheckbox from 'common/components/IAIFullCheckbox';
import IAISwitch from 'common/components/IAISwitch';
import {
controlNetToggled,
isControlNetImagePreprocessedToggled,
} from 'features/controlNet/store/controlNetSlice';
import { memo, useCallback } from 'react';
type ParamControlNetIsEnabledProps = {
controlNetId: string;
isControlImageProcessed: boolean;
};
const ParamControlNetIsEnabled = (props: ParamControlNetIsEnabledProps) => {
const { controlNetId, isControlImageProcessed } = props;
const dispatch = useAppDispatch();
const handleIsControlImageProcessedToggled = useCallback(() => {
dispatch(
isControlNetImagePreprocessedToggled({
controlNetId,
})
);
}, [controlNetId, dispatch]);
return (
<IAISwitch
label="Preprocess"
isChecked={isControlImageProcessed}
onChange={handleIsControlImageProcessedToggled}
/>
);
};
export default memo(ParamControlNetIsEnabled);

View File

@ -0,0 +1,41 @@
import { useAppDispatch } from 'app/store/storeHooks';
import IAICustomSelect from 'common/components/IAICustomSelect';
import {
CONTROLNET_MODELS,
ControlNetModel,
} from 'features/controlNet/store/constants';
import { controlNetModelChanged } from 'features/controlNet/store/controlNetSlice';
import { memo, useCallback } from 'react';
type ParamIsControlNetModelProps = {
controlNetId: string;
model: ControlNetModel;
};
const ParamIsControlNetModel = (props: ParamIsControlNetModelProps) => {
const { controlNetId, model } = props;
const dispatch = useAppDispatch();
const handleModelChanged = useCallback(
(val: string | null | undefined) => {
// TODO: do not cast
const model = val as ControlNetModel;
dispatch(controlNetModelChanged({ controlNetId, model }));
},
[controlNetId, dispatch]
);
return (
<IAICustomSelect
tooltip={model}
tooltipProps={{ placement: 'top', hasArrow: true }}
items={CONTROLNET_MODELS}
selectedItem={model}
setSelectedItem={handleModelChanged}
ellipsisPosition="start"
withCheckIcon
/>
);
};
export default memo(ParamIsControlNetModel);

View File

@ -0,0 +1,47 @@
import IAICustomSelect from 'common/components/IAICustomSelect';
import { memo, useCallback } from 'react';
import {
ControlNetProcessorNode,
ControlNetProcessorType,
} from '../../store/types';
import { controlNetProcessorTypeChanged } from '../../store/controlNetSlice';
import { useAppDispatch } from 'app/store/storeHooks';
import { CONTROLNET_PROCESSORS } from '../../store/constants';
type ParamControlNetProcessorSelectProps = {
controlNetId: string;
processorNode: ControlNetProcessorNode;
};
const CONTROLNET_PROCESSOR_TYPES = Object.keys(
CONTROLNET_PROCESSORS
) as ControlNetProcessorType[];
const ParamControlNetProcessorSelect = (
props: ParamControlNetProcessorSelectProps
) => {
const { controlNetId, processorNode } = props;
const dispatch = useAppDispatch();
const handleProcessorTypeChanged = useCallback(
(v: string | null | undefined) => {
dispatch(
controlNetProcessorTypeChanged({
controlNetId,
processorType: v as ControlNetProcessorType,
})
);
},
[controlNetId, dispatch]
);
return (
<IAICustomSelect
label="Processor"
items={CONTROLNET_PROCESSOR_TYPES}
selectedItem={processorNode.type ?? 'canny_image_processor'}
setSelectedItem={handleProcessorTypeChanged}
withCheckIcon
/>
);
};
export default memo(ParamControlNetProcessorSelect);

View File

@ -0,0 +1,57 @@
import { useAppDispatch } from 'app/store/storeHooks';
import IAISlider from 'common/components/IAISlider';
import { controlNetWeightChanged } from 'features/controlNet/store/controlNetSlice';
import { memo, useCallback } from 'react';
type ParamControlNetWeightProps = {
controlNetId: string;
weight: number;
mini?: boolean;
};
const ParamControlNetWeight = (props: ParamControlNetWeightProps) => {
const { controlNetId, weight, mini = false } = props;
const dispatch = useAppDispatch();
const handleWeightChanged = useCallback(
(weight: number) => {
dispatch(controlNetWeightChanged({ controlNetId, weight }));
},
[controlNetId, dispatch]
);
const handleWeightReset = () => {
dispatch(controlNetWeightChanged({ controlNetId, weight: 1 }));
};
if (mini) {
return (
<IAISlider
label={'Weight'}
sliderFormLabelProps={{ pb: 1 }}
value={weight}
onChange={handleWeightChanged}
min={0}
max={1}
step={0.01}
/>
);
}
return (
<IAISlider
label="Weight"
value={weight}
onChange={handleWeightChanged}
withInput
withReset
handleReset={handleWeightReset}
withSliderMarks
min={0}
max={1}
step={0.01}
/>
);
};
export default memo(ParamControlNetWeight);

View File

@ -0,0 +1,72 @@
import IAISlider from 'common/components/IAISlider';
import { CONTROLNET_PROCESSORS } from 'features/controlNet/store/constants';
import { RequiredCannyImageProcessorInvocation } from 'features/controlNet/store/types';
import { memo, useCallback } from 'react';
import { useProcessorNodeChanged } from '../hooks/useProcessorNodeChanged';
import ProcessorWrapper from './common/ProcessorWrapper';
const DEFAULTS = CONTROLNET_PROCESSORS.canny_image_processor.default;
type CannyProcessorProps = {
controlNetId: string;
processorNode: RequiredCannyImageProcessorInvocation;
};
const CannyProcessor = (props: CannyProcessorProps) => {
const { controlNetId, processorNode } = props;
const { low_threshold, high_threshold } = processorNode;
const processorChanged = useProcessorNodeChanged();
const handleLowThresholdChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { low_threshold: v });
},
[controlNetId, processorChanged]
);
const handleLowThresholdReset = useCallback(() => {
processorChanged(controlNetId, {
low_threshold: DEFAULTS.low_threshold,
});
}, [controlNetId, processorChanged]);
const handleHighThresholdChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { high_threshold: v });
},
[controlNetId, processorChanged]
);
const handleHighThresholdReset = useCallback(() => {
processorChanged(controlNetId, {
high_threshold: DEFAULTS.high_threshold,
});
}, [controlNetId, processorChanged]);
return (
<ProcessorWrapper>
<IAISlider
label="Low Threshold"
value={low_threshold}
onChange={handleLowThresholdChanged}
handleReset={handleLowThresholdReset}
withReset
min={0}
max={255}
withInput
/>
<IAISlider
label="High Threshold"
value={high_threshold}
onChange={handleHighThresholdChanged}
handleReset={handleHighThresholdReset}
withReset
min={0}
max={255}
withInput
/>
</ProcessorWrapper>
);
};
export default memo(CannyProcessor);

View File

@ -0,0 +1,141 @@
import IAISlider from 'common/components/IAISlider';
import { CONTROLNET_PROCESSORS } from 'features/controlNet/store/constants';
import { RequiredContentShuffleImageProcessorInvocation } from 'features/controlNet/store/types';
import { memo, useCallback } from 'react';
import { useProcessorNodeChanged } from '../hooks/useProcessorNodeChanged';
import ProcessorWrapper from './common/ProcessorWrapper';
const DEFAULTS = CONTROLNET_PROCESSORS.content_shuffle_image_processor.default;
type Props = {
controlNetId: string;
processorNode: RequiredContentShuffleImageProcessorInvocation;
};
const ContentShuffleProcessor = (props: Props) => {
const { controlNetId, processorNode } = props;
const { image_resolution, detect_resolution, w, h, f } = processorNode;
const processorChanged = useProcessorNodeChanged();
const handleDetectResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { detect_resolution: v });
},
[controlNetId, processorChanged]
);
const handleDetectResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
detect_resolution: DEFAULTS.detect_resolution,
});
}, [controlNetId, processorChanged]);
const handleImageResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { image_resolution: v });
},
[controlNetId, processorChanged]
);
const handleImageResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
image_resolution: DEFAULTS.image_resolution,
});
}, [controlNetId, processorChanged]);
const handleWChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { w: v });
},
[controlNetId, processorChanged]
);
const handleWReset = useCallback(() => {
processorChanged(controlNetId, {
w: DEFAULTS.w,
});
}, [controlNetId, processorChanged]);
const handleHChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { h: v });
},
[controlNetId, processorChanged]
);
const handleHReset = useCallback(() => {
processorChanged(controlNetId, {
h: DEFAULTS.h,
});
}, [controlNetId, processorChanged]);
const handleFChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { f: v });
},
[controlNetId, processorChanged]
);
const handleFReset = useCallback(() => {
processorChanged(controlNetId, {
f: DEFAULTS.f,
});
}, [controlNetId, processorChanged]);
return (
<ProcessorWrapper>
<IAISlider
label="Detect Resolution"
value={detect_resolution}
onChange={handleDetectResolutionChanged}
handleReset={handleDetectResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="Image Resolution"
value={image_resolution}
onChange={handleImageResolutionChanged}
handleReset={handleImageResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="W"
value={w}
onChange={handleWChanged}
handleReset={handleWReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="H"
value={h}
onChange={handleHChanged}
handleReset={handleHReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="F"
value={f}
onChange={handleFChanged}
handleReset={handleFReset}
withReset
min={0}
max={4096}
withInput
/>
</ProcessorWrapper>
);
};
export default memo(ContentShuffleProcessor);

View File

@ -0,0 +1,88 @@
import IAISlider from 'common/components/IAISlider';
import IAISwitch from 'common/components/IAISwitch';
import { CONTROLNET_PROCESSORS } from 'features/controlNet/store/constants';
import { RequiredHedImageProcessorInvocation } from 'features/controlNet/store/types';
import { ChangeEvent, memo, useCallback } from 'react';
import { useProcessorNodeChanged } from '../hooks/useProcessorNodeChanged';
import ProcessorWrapper from './common/ProcessorWrapper';
const DEFAULTS = CONTROLNET_PROCESSORS.hed_image_processor.default;
type HedProcessorProps = {
controlNetId: string;
processorNode: RequiredHedImageProcessorInvocation;
};
const HedPreprocessor = (props: HedProcessorProps) => {
const {
controlNetId,
processorNode: { detect_resolution, image_resolution, scribble },
} = props;
const processorChanged = useProcessorNodeChanged();
const handleDetectResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { detect_resolution: v });
},
[controlNetId, processorChanged]
);
const handleImageResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { image_resolution: v });
},
[controlNetId, processorChanged]
);
const handleScribbleChanged = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
processorChanged(controlNetId, { scribble: e.target.checked });
},
[controlNetId, processorChanged]
);
const handleDetectResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
detect_resolution: DEFAULTS.detect_resolution,
});
}, [controlNetId, processorChanged]);
const handleImageResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
image_resolution: DEFAULTS.image_resolution,
});
}, [controlNetId, processorChanged]);
return (
<ProcessorWrapper>
<IAISlider
label="Detect Resolution"
value={detect_resolution}
onChange={handleDetectResolutionChanged}
handleReset={handleDetectResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="Image Resolution"
value={image_resolution}
onChange={handleImageResolutionChanged}
handleReset={handleImageResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISwitch
label="Scribble"
isChecked={scribble}
onChange={handleScribbleChanged}
/>
</ProcessorWrapper>
);
};
export default memo(HedPreprocessor);

View File

@ -0,0 +1,72 @@
import IAISlider from 'common/components/IAISlider';
import { CONTROLNET_PROCESSORS } from 'features/controlNet/store/constants';
import { RequiredLineartAnimeImageProcessorInvocation } from 'features/controlNet/store/types';
import { memo, useCallback } from 'react';
import { useProcessorNodeChanged } from '../hooks/useProcessorNodeChanged';
import ProcessorWrapper from './common/ProcessorWrapper';
const DEFAULTS = CONTROLNET_PROCESSORS.lineart_anime_image_processor.default;
type Props = {
controlNetId: string;
processorNode: RequiredLineartAnimeImageProcessorInvocation;
};
const LineartAnimeProcessor = (props: Props) => {
const { controlNetId, processorNode } = props;
const { image_resolution, detect_resolution } = processorNode;
const processorChanged = useProcessorNodeChanged();
const handleDetectResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { detect_resolution: v });
},
[controlNetId, processorChanged]
);
const handleImageResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { image_resolution: v });
},
[controlNetId, processorChanged]
);
const handleDetectResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
detect_resolution: DEFAULTS.detect_resolution,
});
}, [controlNetId, processorChanged]);
const handleImageResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
image_resolution: DEFAULTS.image_resolution,
});
}, [controlNetId, processorChanged]);
return (
<ProcessorWrapper>
<IAISlider
label="Detect Resolution"
value={detect_resolution}
onChange={handleDetectResolutionChanged}
handleReset={handleDetectResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="Image Resolution"
value={image_resolution}
onChange={handleImageResolutionChanged}
handleReset={handleImageResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
</ProcessorWrapper>
);
};
export default memo(LineartAnimeProcessor);

View File

@ -0,0 +1,85 @@
import IAISlider from 'common/components/IAISlider';
import IAISwitch from 'common/components/IAISwitch';
import { CONTROLNET_PROCESSORS } from 'features/controlNet/store/constants';
import { RequiredLineartImageProcessorInvocation } from 'features/controlNet/store/types';
import { ChangeEvent, memo, useCallback } from 'react';
import { useProcessorNodeChanged } from '../hooks/useProcessorNodeChanged';
import ProcessorWrapper from './common/ProcessorWrapper';
const DEFAULTS = CONTROLNET_PROCESSORS.lineart_image_processor.default;
type LineartProcessorProps = {
controlNetId: string;
processorNode: RequiredLineartImageProcessorInvocation;
};
const LineartProcessor = (props: LineartProcessorProps) => {
const { controlNetId, processorNode } = props;
const { image_resolution, detect_resolution, coarse } = processorNode;
const processorChanged = useProcessorNodeChanged();
const handleDetectResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { detect_resolution: v });
},
[controlNetId, processorChanged]
);
const handleImageResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { image_resolution: v });
},
[controlNetId, processorChanged]
);
const handleDetectResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
detect_resolution: DEFAULTS.detect_resolution,
});
}, [controlNetId, processorChanged]);
const handleImageResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
image_resolution: DEFAULTS.image_resolution,
});
}, [controlNetId, processorChanged]);
const handleCoarseChanged = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
processorChanged(controlNetId, { coarse: e.target.checked });
},
[controlNetId, processorChanged]
);
return (
<ProcessorWrapper>
<IAISlider
label="Detect Resolution"
value={detect_resolution}
onChange={handleDetectResolutionChanged}
handleReset={handleDetectResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="Image Resolution"
value={image_resolution}
onChange={handleImageResolutionChanged}
handleReset={handleImageResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISwitch
label="Coarse"
isChecked={coarse}
onChange={handleCoarseChanged}
/>
</ProcessorWrapper>
);
};
export default memo(LineartProcessor);

View File

@ -0,0 +1,69 @@
import IAISlider from 'common/components/IAISlider';
import { CONTROLNET_PROCESSORS } from 'features/controlNet/store/constants';
import { RequiredMediapipeFaceProcessorInvocation } from 'features/controlNet/store/types';
import { memo, useCallback } from 'react';
import { useProcessorNodeChanged } from '../hooks/useProcessorNodeChanged';
import ProcessorWrapper from './common/ProcessorWrapper';
const DEFAULTS = CONTROLNET_PROCESSORS.mediapipe_face_processor.default;
type Props = {
controlNetId: string;
processorNode: RequiredMediapipeFaceProcessorInvocation;
};
const MediapipeFaceProcessor = (props: Props) => {
const { controlNetId, processorNode } = props;
const { max_faces, min_confidence } = processorNode;
const processorChanged = useProcessorNodeChanged();
const handleMaxFacesChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { max_faces: v });
},
[controlNetId, processorChanged]
);
const handleMinConfidenceChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { min_confidence: v });
},
[controlNetId, processorChanged]
);
const handleMaxFacesReset = useCallback(() => {
processorChanged(controlNetId, { max_faces: DEFAULTS.max_faces });
}, [controlNetId, processorChanged]);
const handleMinConfidenceReset = useCallback(() => {
processorChanged(controlNetId, { min_confidence: DEFAULTS.min_confidence });
}, [controlNetId, processorChanged]);
return (
<ProcessorWrapper>
<IAISlider
label="Max Faces"
value={max_faces}
onChange={handleMaxFacesChanged}
handleReset={handleMaxFacesReset}
withReset
min={1}
max={20}
withInput
/>
<IAISlider
label="Min Confidence"
value={min_confidence}
onChange={handleMinConfidenceChanged}
handleReset={handleMinConfidenceReset}
withReset
min={0}
max={1}
step={0.01}
withInput
/>
</ProcessorWrapper>
);
};
export default memo(MediapipeFaceProcessor);

View File

@ -0,0 +1,70 @@
import IAISlider from 'common/components/IAISlider';
import { CONTROLNET_PROCESSORS } from 'features/controlNet/store/constants';
import { RequiredMidasDepthImageProcessorInvocation } from 'features/controlNet/store/types';
import { memo, useCallback } from 'react';
import { useProcessorNodeChanged } from '../hooks/useProcessorNodeChanged';
import ProcessorWrapper from './common/ProcessorWrapper';
const DEFAULTS = CONTROLNET_PROCESSORS.midas_depth_image_processor.default;
type Props = {
controlNetId: string;
processorNode: RequiredMidasDepthImageProcessorInvocation;
};
const MidasDepthProcessor = (props: Props) => {
const { controlNetId, processorNode } = props;
const { a_mult, bg_th } = processorNode;
const processorChanged = useProcessorNodeChanged();
const handleAMultChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { a_mult: v });
},
[controlNetId, processorChanged]
);
const handleBgThChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { bg_th: v });
},
[controlNetId, processorChanged]
);
const handleAMultReset = useCallback(() => {
processorChanged(controlNetId, { a_mult: DEFAULTS.a_mult });
}, [controlNetId, processorChanged]);
const handleBgThReset = useCallback(() => {
processorChanged(controlNetId, { bg_th: DEFAULTS.bg_th });
}, [controlNetId, processorChanged]);
return (
<ProcessorWrapper>
<IAISlider
label="a_mult"
value={a_mult}
onChange={handleAMultChanged}
handleReset={handleAMultReset}
withReset
min={0}
max={20}
step={0.01}
withInput
/>
<IAISlider
label="bg_th"
value={bg_th}
onChange={handleBgThChanged}
handleReset={handleBgThReset}
withReset
min={0}
max={20}
step={0.01}
withInput
/>
</ProcessorWrapper>
);
};
export default memo(MidasDepthProcessor);

View File

@ -0,0 +1,116 @@
import IAISlider from 'common/components/IAISlider';
import { CONTROLNET_PROCESSORS } from 'features/controlNet/store/constants';
import { RequiredMlsdImageProcessorInvocation } from 'features/controlNet/store/types';
import { memo, useCallback } from 'react';
import { useProcessorNodeChanged } from '../hooks/useProcessorNodeChanged';
import ProcessorWrapper from './common/ProcessorWrapper';
const DEFAULTS = CONTROLNET_PROCESSORS.mlsd_image_processor.default;
type Props = {
controlNetId: string;
processorNode: RequiredMlsdImageProcessorInvocation;
};
const MlsdImageProcessor = (props: Props) => {
const { controlNetId, processorNode } = props;
const { image_resolution, detect_resolution, thr_d, thr_v } = processorNode;
const processorChanged = useProcessorNodeChanged();
const handleDetectResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { detect_resolution: v });
},
[controlNetId, processorChanged]
);
const handleImageResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { image_resolution: v });
},
[controlNetId, processorChanged]
);
const handleThrDChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { thr_d: v });
},
[controlNetId, processorChanged]
);
const handleThrVChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { thr_v: v });
},
[controlNetId, processorChanged]
);
const handleDetectResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
detect_resolution: DEFAULTS.detect_resolution,
});
}, [controlNetId, processorChanged]);
const handleImageResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
image_resolution: DEFAULTS.image_resolution,
});
}, [controlNetId, processorChanged]);
const handleThrDReset = useCallback(() => {
processorChanged(controlNetId, { thr_d: DEFAULTS.thr_d });
}, [controlNetId, processorChanged]);
const handleThrVReset = useCallback(() => {
processorChanged(controlNetId, { thr_v: DEFAULTS.thr_v });
}, [controlNetId, processorChanged]);
return (
<ProcessorWrapper>
<IAISlider
label="Detect Resolution"
value={detect_resolution}
onChange={handleDetectResolutionChanged}
handleReset={handleDetectResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="Image Resolution"
value={image_resolution}
onChange={handleImageResolutionChanged}
handleReset={handleImageResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="W"
value={thr_d}
onChange={handleThrDChanged}
handleReset={handleThrDReset}
withReset
min={0}
max={1}
step={0.01}
withInput
/>
<IAISlider
label="H"
value={thr_v}
onChange={handleThrVChanged}
handleReset={handleThrVReset}
withReset
min={0}
max={1}
step={0.01}
withInput
/>
</ProcessorWrapper>
);
};
export default memo(MlsdImageProcessor);

View File

@ -0,0 +1,72 @@
import IAISlider from 'common/components/IAISlider';
import { CONTROLNET_PROCESSORS } from 'features/controlNet/store/constants';
import { RequiredNormalbaeImageProcessorInvocation } from 'features/controlNet/store/types';
import { memo, useCallback } from 'react';
import { useProcessorNodeChanged } from '../hooks/useProcessorNodeChanged';
import ProcessorWrapper from './common/ProcessorWrapper';
const DEFAULTS = CONTROLNET_PROCESSORS.normalbae_image_processor.default;
type Props = {
controlNetId: string;
processorNode: RequiredNormalbaeImageProcessorInvocation;
};
const NormalBaeProcessor = (props: Props) => {
const { controlNetId, processorNode } = props;
const { image_resolution, detect_resolution } = processorNode;
const processorChanged = useProcessorNodeChanged();
const handleDetectResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { detect_resolution: v });
},
[controlNetId, processorChanged]
);
const handleImageResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { image_resolution: v });
},
[controlNetId, processorChanged]
);
const handleDetectResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
detect_resolution: DEFAULTS.detect_resolution,
});
}, [controlNetId, processorChanged]);
const handleImageResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
image_resolution: DEFAULTS.image_resolution,
});
}, [controlNetId, processorChanged]);
return (
<ProcessorWrapper>
<IAISlider
label="Detect Resolution"
value={detect_resolution}
onChange={handleDetectResolutionChanged}
handleReset={handleDetectResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="Image Resolution"
value={image_resolution}
onChange={handleImageResolutionChanged}
handleReset={handleImageResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
</ProcessorWrapper>
);
};
export default memo(NormalBaeProcessor);

View File

@ -0,0 +1,85 @@
import IAISlider from 'common/components/IAISlider';
import IAISwitch from 'common/components/IAISwitch';
import { CONTROLNET_PROCESSORS } from 'features/controlNet/store/constants';
import { RequiredOpenposeImageProcessorInvocation } from 'features/controlNet/store/types';
import { ChangeEvent, memo, useCallback } from 'react';
import { useProcessorNodeChanged } from '../hooks/useProcessorNodeChanged';
import ProcessorWrapper from './common/ProcessorWrapper';
const DEFAULTS = CONTROLNET_PROCESSORS.openpose_image_processor.default;
type Props = {
controlNetId: string;
processorNode: RequiredOpenposeImageProcessorInvocation;
};
const OpenposeProcessor = (props: Props) => {
const { controlNetId, processorNode } = props;
const { image_resolution, detect_resolution, hand_and_face } = processorNode;
const processorChanged = useProcessorNodeChanged();
const handleDetectResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { detect_resolution: v });
},
[controlNetId, processorChanged]
);
const handleImageResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { image_resolution: v });
},
[controlNetId, processorChanged]
);
const handleDetectResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
detect_resolution: DEFAULTS.detect_resolution,
});
}, [controlNetId, processorChanged]);
const handleImageResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
image_resolution: DEFAULTS.image_resolution,
});
}, [controlNetId, processorChanged]);
const handleHandAndFaceChanged = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
processorChanged(controlNetId, { hand_and_face: e.target.checked });
},
[controlNetId, processorChanged]
);
return (
<ProcessorWrapper>
<IAISlider
label="Detect Resolution"
value={detect_resolution}
onChange={handleDetectResolutionChanged}
handleReset={handleDetectResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="Image Resolution"
value={image_resolution}
onChange={handleImageResolutionChanged}
handleReset={handleImageResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISwitch
label="Hand and Face"
isChecked={hand_and_face}
onChange={handleHandAndFaceChanged}
/>
</ProcessorWrapper>
);
};
export default memo(OpenposeProcessor);

View File

@ -0,0 +1,93 @@
import IAISlider from 'common/components/IAISlider';
import IAISwitch from 'common/components/IAISwitch';
import { CONTROLNET_PROCESSORS } from 'features/controlNet/store/constants';
import { RequiredPidiImageProcessorInvocation } from 'features/controlNet/store/types';
import { ChangeEvent, memo, useCallback } from 'react';
import { useProcessorNodeChanged } from '../hooks/useProcessorNodeChanged';
import ProcessorWrapper from './common/ProcessorWrapper';
const DEFAULTS = CONTROLNET_PROCESSORS.pidi_image_processor.default;
type Props = {
controlNetId: string;
processorNode: RequiredPidiImageProcessorInvocation;
};
const PidiProcessor = (props: Props) => {
const { controlNetId, processorNode } = props;
const { image_resolution, detect_resolution, scribble, safe } = processorNode;
const processorChanged = useProcessorNodeChanged();
const handleDetectResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { detect_resolution: v });
},
[controlNetId, processorChanged]
);
const handleImageResolutionChanged = useCallback(
(v: number) => {
processorChanged(controlNetId, { image_resolution: v });
},
[controlNetId, processorChanged]
);
const handleDetectResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
detect_resolution: DEFAULTS.detect_resolution,
});
}, [controlNetId, processorChanged]);
const handleImageResolutionReset = useCallback(() => {
processorChanged(controlNetId, {
image_resolution: DEFAULTS.image_resolution,
});
}, [controlNetId, processorChanged]);
const handleScribbleChanged = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
processorChanged(controlNetId, { scribble: e.target.checked });
},
[controlNetId, processorChanged]
);
const handleSafeChanged = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
processorChanged(controlNetId, { safe: e.target.checked });
},
[controlNetId, processorChanged]
);
return (
<ProcessorWrapper>
<IAISlider
label="Detect Resolution"
value={detect_resolution}
onChange={handleDetectResolutionChanged}
handleReset={handleDetectResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISlider
label="Image Resolution"
value={image_resolution}
onChange={handleImageResolutionChanged}
handleReset={handleImageResolutionReset}
withReset
min={0}
max={4096}
withInput
/>
<IAISwitch
label="Scribble"
isChecked={scribble}
onChange={handleScribbleChanged}
/>
<IAISwitch label="Safe" isChecked={safe} onChange={handleSafeChanged} />
</ProcessorWrapper>
);
};
export default memo(PidiProcessor);

View File

@ -0,0 +1,14 @@
import { RequiredZoeDepthImageProcessorInvocation } from 'features/controlNet/store/types';
import { memo } from 'react';
type Props = {
controlNetId: string;
processorNode: RequiredZoeDepthImageProcessorInvocation;
};
const ZoeDepthProcessor = (props: Props) => {
// Has no parameters?
return null;
};
export default memo(ZoeDepthProcessor);

View File

@ -0,0 +1,8 @@
import { Flex } from '@chakra-ui/react';
import { PropsWithChildren } from 'react';
type Props = PropsWithChildren;
export default function ProcessorWrapper(props: Props) {
return <Flex sx={{ flexDirection: 'column', gap: 2 }}>{props.children}</Flex>;
}

View File

@ -0,0 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
export const controlNetImageProcessed = createAction<{
controlNetId: string;
}>('controlNet/imageProcessed');

View File

@ -0,0 +1,212 @@
import {
ControlNetProcessorType,
RequiredCannyImageProcessorInvocation,
RequiredControlNetProcessorNode,
} from './types';
type ControlNetProcessorsDict = Record<
ControlNetProcessorType,
{
type: ControlNetProcessorType;
label: string;
description: string;
default: RequiredControlNetProcessorNode;
}
>;
/**
* A dict of ControlNet processors, including:
* - type
* - label
* - description
* - default values
*
* TODO: Generate from the OpenAPI schema
*/
export const CONTROLNET_PROCESSORS = {
none: {
type: 'none',
label: 'None',
description: '',
default: {
type: 'none',
},
},
canny_image_processor: {
type: 'canny_image_processor',
label: 'Canny',
description: '',
default: {
id: 'canny_image_processor',
type: 'canny_image_processor',
low_threshold: 100,
high_threshold: 200,
},
},
content_shuffle_image_processor: {
type: 'content_shuffle_image_processor',
label: 'Content Shuffle',
description: '',
default: {
id: 'content_shuffle_image_processor',
type: 'content_shuffle_image_processor',
detect_resolution: 512,
image_resolution: 512,
h: 512,
w: 512,
f: 256,
},
},
hed_image_processor: {
type: 'hed_image_processor',
label: 'HED',
description: '',
default: {
id: 'hed_image_processor',
type: 'hed_image_processor',
detect_resolution: 512,
image_resolution: 512,
scribble: false,
},
},
lineart_anime_image_processor: {
type: 'lineart_anime_image_processor',
label: 'Lineart Anime',
description: '',
default: {
id: 'lineart_anime_image_processor',
type: 'lineart_anime_image_processor',
detect_resolution: 512,
image_resolution: 512,
},
},
lineart_image_processor: {
type: 'lineart_image_processor',
label: 'Lineart',
description: '',
default: {
id: 'lineart_image_processor',
type: 'lineart_image_processor',
detect_resolution: 512,
image_resolution: 512,
coarse: false,
},
},
mediapipe_face_processor: {
type: 'mediapipe_face_processor',
label: 'Mediapipe Face',
description: '',
default: {
id: 'mediapipe_face_processor',
type: 'mediapipe_face_processor',
max_faces: 1,
min_confidence: 0.5,
},
},
midas_depth_image_processor: {
type: 'midas_depth_image_processor',
label: 'Depth (Midas)',
description: '',
default: {
id: 'midas_depth_image_processor',
type: 'midas_depth_image_processor',
a_mult: 2,
bg_th: 0.1,
},
},
mlsd_image_processor: {
type: 'mlsd_image_processor',
label: 'MLSD',
description: '',
default: {
id: 'mlsd_image_processor',
type: 'mlsd_image_processor',
detect_resolution: 512,
image_resolution: 512,
thr_d: 0.1,
thr_v: 0.1,
},
},
normalbae_image_processor: {
type: 'normalbae_image_processor',
label: 'NormalBae',
description: '',
default: {
id: 'normalbae_image_processor',
type: 'normalbae_image_processor',
detect_resolution: 512,
image_resolution: 512,
},
},
openpose_image_processor: {
type: 'openpose_image_processor',
label: 'Openpose',
description: '',
default: {
id: 'openpose_image_processor',
type: 'openpose_image_processor',
detect_resolution: 512,
image_resolution: 512,
hand_and_face: false,
},
},
pidi_image_processor: {
type: 'pidi_image_processor',
label: 'PIDI',
description: '',
default: {
id: 'pidi_image_processor',
type: 'pidi_image_processor',
detect_resolution: 512,
image_resolution: 512,
scribble: false,
safe: false,
},
},
zoe_depth_image_processor: {
type: 'zoe_depth_image_processor',
label: 'Depth (Zoe)',
description: '',
default: {
id: 'zoe_depth_image_processor',
type: 'zoe_depth_image_processor',
},
},
};
export const CONTROLNET_MODELS = [
'lllyasviel/control_v11p_sd15_canny',
'lllyasviel/control_v11p_sd15_inpaint',
'lllyasviel/control_v11p_sd15_mlsd',
'lllyasviel/control_v11f1p_sd15_depth',
'lllyasviel/control_v11p_sd15_normalbae',
'lllyasviel/control_v11p_sd15_seg',
'lllyasviel/control_v11p_sd15_lineart',
'lllyasviel/control_v11p_sd15s2_lineart_anime',
'lllyasviel/control_v11p_sd15_scribble',
'lllyasviel/control_v11p_sd15_softedge',
'lllyasviel/control_v11e_sd15_shuffle',
'lllyasviel/control_v11p_sd15_openpose',
'lllyasviel/control_v11f1e_sd15_tile',
'lllyasviel/control_v11e_sd15_ip2p',
'CrucibleAI/ControlNetMediaPipeFace',
];
export type ControlNetModel = (typeof CONTROLNET_MODELS)[number];
export const CONTROLNET_MODEL_MAP: Record<
ControlNetModel,
ControlNetProcessorType
> = {
'lllyasviel/control_v11p_sd15_canny': 'canny_image_processor',
'lllyasviel/control_v11p_sd15_mlsd': 'mlsd_image_processor',
'lllyasviel/control_v11f1p_sd15_depth': 'midas_depth_image_processor',
'lllyasviel/control_v11p_sd15_normalbae': 'normalbae_image_processor',
'lllyasviel/control_v11p_sd15_lineart': 'lineart_image_processor',
'lllyasviel/control_v11p_sd15s2_lineart_anime':
'lineart_anime_image_processor',
'lllyasviel/control_v11p_sd15_softedge': 'hed_image_processor',
'lllyasviel/control_v11e_sd15_shuffle': 'content_shuffle_image_processor',
'lllyasviel/control_v11p_sd15_openpose': 'openpose_image_processor',
'CrucibleAI/ControlNetMediaPipeFace': 'mediapipe_face_processor',
};

View File

@ -0,0 +1,8 @@
import { ControlNetState } from './controlNetSlice';
/**
* ControlNet slice persist denylist
*/
export const controlNetDenylist: (keyof ControlNetState)[] = [
'isProcessingControlImage',
];

View File

@ -0,0 +1,254 @@
import { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { ImageDTO } from 'services/api';
import {
ControlNetProcessorType,
RequiredCannyImageProcessorInvocation,
RequiredControlNetProcessorNode,
} from './types';
import {
CONTROLNET_MODELS,
CONTROLNET_PROCESSORS,
ControlNetModel,
} from './constants';
import { controlNetImageProcessed } from './actions';
import { imageDeleted, imageUrlsReceived } from 'services/thunks/image';
import { forEach } from 'lodash-es';
export const initialControlNet: Omit<ControlNetConfig, 'controlNetId'> = {
isEnabled: true,
model: CONTROLNET_MODELS[0],
weight: 1,
beginStepPct: 0,
endStepPct: 1,
controlImage: null,
processedControlImage: null,
processorType: 'canny_image_processor',
processorNode: CONTROLNET_PROCESSORS.canny_image_processor
.default as RequiredCannyImageProcessorInvocation,
};
export type ControlNetConfig = {
controlNetId: string;
isEnabled: boolean;
model: ControlNetModel;
weight: number;
beginStepPct: number;
endStepPct: number;
controlImage: ImageDTO | null;
processedControlImage: ImageDTO | null;
processorType: ControlNetProcessorType;
processorNode: RequiredControlNetProcessorNode;
};
export type ControlNetState = {
controlNets: Record<string, ControlNetConfig>;
isEnabled: boolean;
isProcessingControlImage: boolean;
};
export const initialControlNetState: ControlNetState = {
controlNets: {},
isEnabled: false,
isProcessingControlImage: false,
};
export const controlNetSlice = createSlice({
name: 'controlNet',
initialState: initialControlNetState,
reducers: {
isControlNetEnabledToggled: (state) => {
state.isEnabled = !state.isEnabled;
},
controlNetAdded: (
state,
action: PayloadAction<{
controlNetId: string;
controlNet?: ControlNetConfig;
}>
) => {
const { controlNetId, controlNet } = action.payload;
state.controlNets[controlNetId] = {
...(controlNet ?? initialControlNet),
controlNetId,
};
},
controlNetAddedFromImage: (
state,
action: PayloadAction<{ controlNetId: string; controlImage: ImageDTO }>
) => {
const { controlNetId, controlImage } = action.payload;
state.controlNets[controlNetId] = {
...initialControlNet,
controlNetId,
controlImage,
};
},
controlNetRemoved: (
state,
action: PayloadAction<{ controlNetId: string }>
) => {
const { controlNetId } = action.payload;
delete state.controlNets[controlNetId];
},
controlNetToggled: (
state,
action: PayloadAction<{ controlNetId: string }>
) => {
const { controlNetId } = action.payload;
state.controlNets[controlNetId].isEnabled =
!state.controlNets[controlNetId].isEnabled;
},
controlNetImageChanged: (
state,
action: PayloadAction<{
controlNetId: string;
controlImage: ImageDTO | null;
}>
) => {
const { controlNetId, controlImage } = action.payload;
state.controlNets[controlNetId].controlImage = controlImage;
state.controlNets[controlNetId].processedControlImage = null;
if (
controlImage !== null &&
state.controlNets[controlNetId].processorType !== 'none'
) {
state.isProcessingControlImage = true;
}
},
controlNetProcessedImageChanged: (
state,
action: PayloadAction<{
controlNetId: string;
processedControlImage: ImageDTO | null;
}>
) => {
const { controlNetId, processedControlImage } = action.payload;
state.controlNets[controlNetId].processedControlImage =
processedControlImage;
state.isProcessingControlImage = false;
},
controlNetModelChanged: (
state,
action: PayloadAction<{ controlNetId: string; model: ControlNetModel }>
) => {
const { controlNetId, model } = action.payload;
state.controlNets[controlNetId].model = model;
},
controlNetWeightChanged: (
state,
action: PayloadAction<{ controlNetId: string; weight: number }>
) => {
const { controlNetId, weight } = action.payload;
state.controlNets[controlNetId].weight = weight;
},
controlNetBeginStepPctChanged: (
state,
action: PayloadAction<{ controlNetId: string; beginStepPct: number }>
) => {
const { controlNetId, beginStepPct } = action.payload;
state.controlNets[controlNetId].beginStepPct = beginStepPct;
},
controlNetEndStepPctChanged: (
state,
action: PayloadAction<{ controlNetId: string; endStepPct: number }>
) => {
const { controlNetId, endStepPct } = action.payload;
state.controlNets[controlNetId].endStepPct = endStepPct;
},
controlNetProcessorParamsChanged: (
state,
action: PayloadAction<{
controlNetId: string;
changes: Omit<
Partial<RequiredControlNetProcessorNode>,
'id' | 'type' | 'is_intermediate'
>;
}>
) => {
const { controlNetId, changes } = action.payload;
const processorNode = state.controlNets[controlNetId].processorNode;
state.controlNets[controlNetId].processorNode = {
...processorNode,
...changes,
};
},
controlNetProcessorTypeChanged: (
state,
action: PayloadAction<{
controlNetId: string;
processorType: ControlNetProcessorType;
}>
) => {
const { controlNetId, processorType } = action.payload;
state.controlNets[controlNetId].processorType = processorType;
state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS[
processorType
].default as RequiredControlNetProcessorNode;
},
controlNetReset: () => {
return { ...initialControlNetState };
},
},
extraReducers: (builder) => {
builder.addCase(controlNetImageProcessed, (state, action) => {
if (
state.controlNets[action.payload.controlNetId].controlImage !== null
) {
state.isProcessingControlImage = true;
}
});
builder.addCase(imageDeleted.pending, (state, action) => {
// Preemptively remove the image from the gallery
const { imageName } = action.meta.arg;
forEach(state.controlNets, (c) => {
if (c.controlImage?.image_name === imageName) {
c.controlImage = null;
c.processedControlImage = null;
}
if (c.processedControlImage?.image_name === imageName) {
c.processedControlImage = null;
}
});
});
builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
const { image_name, image_origin, image_url, thumbnail_url } =
action.payload;
forEach(state.controlNets, (c) => {
if (c.controlImage?.image_name === image_name) {
c.controlImage.image_url = image_url;
c.controlImage.thumbnail_url = thumbnail_url;
}
if (c.processedControlImage?.image_name === image_name) {
c.processedControlImage.image_url = image_url;
c.processedControlImage.thumbnail_url = thumbnail_url;
}
});
});
},
});
export const {
isControlNetEnabledToggled,
controlNetAdded,
controlNetAddedFromImage,
controlNetRemoved,
controlNetImageChanged,
controlNetProcessedImageChanged,
controlNetToggled,
controlNetModelChanged,
controlNetWeightChanged,
controlNetBeginStepPctChanged,
controlNetEndStepPctChanged,
controlNetProcessorParamsChanged,
controlNetProcessorTypeChanged,
controlNetReset,
} = controlNetSlice.actions;
export default controlNetSlice.reducer;
export const controlNetSelector = (state: RootState) => state.controlNet;

View File

@ -0,0 +1,329 @@
import { isObject } from 'lodash-es';
import {
CannyImageProcessorInvocation,
ContentShuffleImageProcessorInvocation,
HedImageProcessorInvocation,
LineartAnimeImageProcessorInvocation,
LineartImageProcessorInvocation,
MediapipeFaceProcessorInvocation,
MidasDepthImageProcessorInvocation,
MlsdImageProcessorInvocation,
NormalbaeImageProcessorInvocation,
OpenposeImageProcessorInvocation,
PidiImageProcessorInvocation,
ZoeDepthImageProcessorInvocation,
} from 'services/api';
import { O } from 'ts-toolbelt';
/**
* Any ControlNet processor node
*/
export type ControlNetProcessorNode =
| CannyImageProcessorInvocation
| ContentShuffleImageProcessorInvocation
| HedImageProcessorInvocation
| LineartAnimeImageProcessorInvocation
| LineartImageProcessorInvocation
| MediapipeFaceProcessorInvocation
| MidasDepthImageProcessorInvocation
| MlsdImageProcessorInvocation
| NormalbaeImageProcessorInvocation
| OpenposeImageProcessorInvocation
| PidiImageProcessorInvocation
| ZoeDepthImageProcessorInvocation;
/**
* Any ControlNet processor type
*/
export type ControlNetProcessorType = NonNullable<
ControlNetProcessorNode['type'] | 'none'
>;
/**
* The Canny processor node, with parameters flagged as required
*/
export type RequiredCannyImageProcessorInvocation = O.Required<
CannyImageProcessorInvocation,
'type' | 'low_threshold' | 'high_threshold'
>;
/**
* The ContentShuffle processor node, with parameters flagged as required
*/
export type RequiredContentShuffleImageProcessorInvocation = O.Required<
ContentShuffleImageProcessorInvocation,
'type' | 'detect_resolution' | 'image_resolution' | 'w' | 'h' | 'f'
>;
/**
* The HED processor node, with parameters flagged as required
*/
export type RequiredHedImageProcessorInvocation = O.Required<
HedImageProcessorInvocation,
'type' | 'detect_resolution' | 'image_resolution' | 'scribble'
>;
/**
* The Lineart Anime processor node, with parameters flagged as required
*/
export type RequiredLineartAnimeImageProcessorInvocation = O.Required<
LineartAnimeImageProcessorInvocation,
'type' | 'detect_resolution' | 'image_resolution'
>;
/**
* The Lineart processor node, with parameters flagged as required
*/
export type RequiredLineartImageProcessorInvocation = O.Required<
LineartImageProcessorInvocation,
'type' | 'detect_resolution' | 'image_resolution' | 'coarse'
>;
/**
* The MediapipeFace processor node, with parameters flagged as required
*/
export type RequiredMediapipeFaceProcessorInvocation = O.Required<
MediapipeFaceProcessorInvocation,
'type' | 'max_faces' | 'min_confidence'
>;
/**
* The MidasDepth processor node, with parameters flagged as required
*/
export type RequiredMidasDepthImageProcessorInvocation = O.Required<
MidasDepthImageProcessorInvocation,
'type' | 'a_mult' | 'bg_th'
>;
/**
* The MLSD processor node, with parameters flagged as required
*/
export type RequiredMlsdImageProcessorInvocation = O.Required<
MlsdImageProcessorInvocation,
'type' | 'detect_resolution' | 'image_resolution' | 'thr_v' | 'thr_d'
>;
/**
* The NormalBae processor node, with parameters flagged as required
*/
export type RequiredNormalbaeImageProcessorInvocation = O.Required<
NormalbaeImageProcessorInvocation,
'type' | 'detect_resolution' | 'image_resolution'
>;
/**
* The Openpose processor node, with parameters flagged as required
*/
export type RequiredOpenposeImageProcessorInvocation = O.Required<
OpenposeImageProcessorInvocation,
'type' | 'detect_resolution' | 'image_resolution' | 'hand_and_face'
>;
/**
* The Pidi processor node, with parameters flagged as required
*/
export type RequiredPidiImageProcessorInvocation = O.Required<
PidiImageProcessorInvocation,
'type' | 'detect_resolution' | 'image_resolution' | 'safe' | 'scribble'
>;
/**
* The ZoeDepth processor node, with parameters flagged as required
*/
export type RequiredZoeDepthImageProcessorInvocation = O.Required<
ZoeDepthImageProcessorInvocation,
'type'
>;
/**
* Any ControlNet Processor node, with its parameters flagged as required
*/
export type RequiredControlNetProcessorNode =
| RequiredCannyImageProcessorInvocation
| RequiredContentShuffleImageProcessorInvocation
| RequiredHedImageProcessorInvocation
| RequiredLineartAnimeImageProcessorInvocation
| RequiredLineartImageProcessorInvocation
| RequiredMediapipeFaceProcessorInvocation
| RequiredMidasDepthImageProcessorInvocation
| RequiredMlsdImageProcessorInvocation
| RequiredNormalbaeImageProcessorInvocation
| RequiredOpenposeImageProcessorInvocation
| RequiredPidiImageProcessorInvocation
| RequiredZoeDepthImageProcessorInvocation;
/**
* Type guard for CannyImageProcessorInvocation
*/
export const isCannyImageProcessorInvocation = (
obj: unknown
): obj is CannyImageProcessorInvocation => {
if (isObject(obj) && 'type' in obj && obj.type === 'canny_image_processor') {
return true;
}
return false;
};
/**
* Type guard for ContentShuffleImageProcessorInvocation
*/
export const isContentShuffleImageProcessorInvocation = (
obj: unknown
): obj is ContentShuffleImageProcessorInvocation => {
if (
isObject(obj) &&
'type' in obj &&
obj.type === 'content_shuffle_image_processor'
) {
return true;
}
return false;
};
/**
* Type guard for HedImageprocessorInvocation
*/
export const isHedImageprocessorInvocation = (
obj: unknown
): obj is HedImageProcessorInvocation => {
if (isObject(obj) && 'type' in obj && obj.type === 'hed_image_processor') {
return true;
}
return false;
};
/**
* Type guard for LineartAnimeImageProcessorInvocation
*/
export const isLineartAnimeImageProcessorInvocation = (
obj: unknown
): obj is LineartAnimeImageProcessorInvocation => {
if (
isObject(obj) &&
'type' in obj &&
obj.type === 'lineart_anime_image_processor'
) {
return true;
}
return false;
};
/**
* Type guard for LineartImageProcessorInvocation
*/
export const isLineartImageProcessorInvocation = (
obj: unknown
): obj is LineartImageProcessorInvocation => {
if (
isObject(obj) &&
'type' in obj &&
obj.type === 'lineart_image_processor'
) {
return true;
}
return false;
};
/**
* Type guard for MediapipeFaceProcessorInvocation
*/
export const isMediapipeFaceProcessorInvocation = (
obj: unknown
): obj is MediapipeFaceProcessorInvocation => {
if (
isObject(obj) &&
'type' in obj &&
obj.type === 'mediapipe_face_processor'
) {
return true;
}
return false;
};
/**
* Type guard for MidasDepthImageProcessorInvocation
*/
export const isMidasDepthImageProcessorInvocation = (
obj: unknown
): obj is MidasDepthImageProcessorInvocation => {
if (
isObject(obj) &&
'type' in obj &&
obj.type === 'midas_depth_image_processor'
) {
return true;
}
return false;
};
/**
* Type guard for MlsdImageProcessorInvocation
*/
export const isMlsdImageProcessorInvocation = (
obj: unknown
): obj is MlsdImageProcessorInvocation => {
if (isObject(obj) && 'type' in obj && obj.type === 'mlsd_image_processor') {
return true;
}
return false;
};
/**
* Type guard for NormalbaeImageProcessorInvocation
*/
export const isNormalbaeImageProcessorInvocation = (
obj: unknown
): obj is NormalbaeImageProcessorInvocation => {
if (
isObject(obj) &&
'type' in obj &&
obj.type === 'normalbae_image_processor'
) {
return true;
}
return false;
};
/**
* Type guard for OpenposeImageProcessorInvocation
*/
export const isOpenposeImageProcessorInvocation = (
obj: unknown
): obj is OpenposeImageProcessorInvocation => {
if (
isObject(obj) &&
'type' in obj &&
obj.type === 'openpose_image_processor'
) {
return true;
}
return false;
};
/**
* Type guard for PidiImageProcessorInvocation
*/
export const isPidiImageProcessorInvocation = (
obj: unknown
): obj is PidiImageProcessorInvocation => {
if (isObject(obj) && 'type' in obj && obj.type === 'pidi_image_processor') {
return true;
}
return false;
};
/**
* Type guard for ZoeDepthImageProcessorInvocation
*/
export const isZoeDepthImageProcessorInvocation = (
obj: unknown
): obj is ZoeDepthImageProcessorInvocation => {
if (
isObject(obj) &&
'type' in obj &&
obj.type === 'zoe_depth_image_processor'
) {
return true;
}
return false;
};

View File

@ -1,13 +1,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { isEqual } from 'lodash-es';
import {
ButtonGroup,
Flex,
FlexProps,
Link,
useDisclosure,
} from '@chakra-ui/react';
import { ButtonGroup, Flex, FlexProps, Link } from '@chakra-ui/react';
// import { runESRGAN, runFacetool } from 'app/socketio/actions';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
@ -45,22 +39,18 @@ import {
FaShareAlt,
} from 'react-icons/fa';
import { gallerySelector } from '../store/gallerySelectors';
import { useCallback } from 'react';
import { useCallback, useContext } from 'react';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { useGetUrl } from 'common/util/getUrl';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import {
requestedImageDeletion,
sentImageToCanvas,
sentImageToImg2Img,
} from '../store/actions';
import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions';
import FaceRestoreSettings from 'features/parameters/components/Parameters/FaceRestore/FaceRestoreSettings';
import UpscaleSettings from 'features/parameters/components/Parameters/Upscale/UpscaleSettings';
import DeleteImageButton from './ImageActionButtons/DeleteImageButton';
import { useAppToaster } from 'app/components/Toaster';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { DeleteImageContext } from 'app/contexts/DeleteImageContext';
import { DeleteImageButton } from './DeleteImageModal';
const currentImageButtonsSelector = createSelector(
[
@ -123,10 +113,6 @@ const currentImageButtonsSelector = createSelector(
type CurrentImageButtonsProps = FlexProps;
/**
* Row of buttons for common actions:
* Use as init image, use all params, use seed, upscale, fix faces, details, delete.
*/
const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
const dispatch = useAppDispatch();
const {
@ -138,13 +124,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
facetoolStrength,
shouldDisableToolbarButtons,
shouldShowImageDetails,
// currentImage,
isLightboxOpen,
activeTabName,
shouldHidePreview,
image,
canDeleteImage,
shouldConfirmOnDelete,
shouldShowProgressInViewer,
} = useAppSelector(currentImageButtonsSelector);
@ -153,20 +136,14 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
const isUpscalingEnabled = useFeatureStatus('upscaling').isFeatureEnabled;
const isFaceRestoreEnabled = useFeatureStatus('faceRestore').isFeatureEnabled;
const { getUrl, shouldTransformUrls } = useGetUrl();
const {
isOpen: isDeleteDialogOpen,
onOpen: onDeleteDialogOpen,
onClose: onDeleteDialogClose,
} = useDisclosure();
const toaster = useAppToaster();
const { t } = useTranslation();
const { recallBothPrompts, recallSeed, recallAllParameters } =
useRecallParameters();
const { onDelete } = useContext(DeleteImageContext);
// const handleCopyImage = useCallback(async () => {
// if (!image?.url) {
// return;
@ -197,10 +174,6 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
return;
}
if (shouldTransformUrls) {
return getUrl(image.image_url);
}
if (image.image_url.startsWith('http')) {
return image.image_url;
}
@ -229,7 +202,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
isClosable: true,
});
});
}, [toaster, shouldTransformUrls, getUrl, t, image]);
}, [toaster, t, image]);
const handleClickUseAllParameters = useCallback(() => {
recallAllParameters(image);
@ -269,6 +242,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
// selectedImage && dispatch(runESRGAN(selectedImage));
}, []);
const handleDelete = useCallback(() => {
onDelete(image);
}, [image, onDelete]);
useHotkeys(
'Shift+U',
() => {
@ -370,31 +347,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
[image, shouldShowImageDetails, toaster]
);
const handleDelete = useCallback(() => {
if (canDeleteImage && image) {
dispatch(requestedImageDeletion(image));
}
}, [image, canDeleteImage, dispatch]);
const handleInitiateDelete = useCallback(() => {
if (shouldConfirmOnDelete) {
onDeleteDialogOpen();
} else {
handleDelete();
}
}, [shouldConfirmOnDelete, onDeleteDialogOpen, handleDelete]);
const handleClickProgressImagesToggle = useCallback(() => {
dispatch(setShouldShowProgressInViewer(!shouldShowProgressInViewer));
}, [dispatch, shouldShowProgressInViewer]);
useHotkeys('delete', handleInitiateDelete, [
image,
shouldConfirmOnDelete,
isConnected,
isProcessing,
]);
const handleLightBox = useCallback(() => {
dispatch(setIsLightboxOpen(!isLightboxOpen));
}, [dispatch, isLightboxOpen]);
@ -461,11 +417,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
{t('parameters.copyImageToLink')}
</IAIButton>
<Link
download={true}
href={getUrl(image?.image_url ?? '')}
target="_blank"
>
<Link download={true} href={image?.image_url} target="_blank">
<IAIButton leftIcon={<FaDownload />} size="sm" w="100%">
{t('parameters.downloadImage')}
</IAIButton>
@ -607,7 +559,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
</ButtonGroup>
<ButtonGroup isAttached={true}>
<DeleteImageButton image={image} />
<DeleteImageButton onClick={handleDelete} />
</ButtonGroup>
</Flex>
</>

View File

@ -1,4 +1,4 @@
import { Flex, Icon } from '@chakra-ui/react';
import { Box, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { systemSelector } from 'features/system/store/systemSelectors';
@ -7,7 +7,7 @@ import { isEqual } from 'lodash-es';
import { gallerySelector } from '../store/gallerySelectors';
import CurrentImageButtons from './CurrentImageButtons';
import CurrentImagePreview from './CurrentImagePreview';
import { FaImage } from 'react-icons/fa';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
export const currentImageDisplaySelector = createSelector(
[systemSelector, gallerySelector],
@ -15,21 +15,20 @@ export const currentImageDisplaySelector = createSelector(
const { progressImage } = system;
return {
hasAnImageToDisplay: gallery.selectedImage || progressImage,
hasSelectedImage: Boolean(gallery.selectedImage),
hasProgressImage: Boolean(progressImage),
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
defaultSelectorOptions
);
/**
* Displays the current image if there is one, plus associated actions.
*/
const CurrentImageDisplay = () => {
const { hasAnImageToDisplay } = useAppSelector(currentImageDisplaySelector);
const { hasSelectedImage, hasProgressImage } = useAppSelector(
currentImageDisplaySelector
);
return (
<Flex
@ -52,23 +51,16 @@ const CurrentImageDisplay = () => {
alignItems: 'center',
justifyContent: 'center',
gap: 4,
position: 'absolute',
}}
>
{hasAnImageToDisplay ? (
<>
<CurrentImageButtons />
<CurrentImagePreview />
</>
) : (
<Icon
as={FaImage}
sx={{
boxSize: 24,
color: 'base.500',
}}
/>
)}
<CurrentImagePreview />
</Flex>
{hasSelectedImage && (
<Box sx={{ position: 'absolute', top: 0 }}>
<CurrentImageButtons />
</Box>
)}
</Flex>
);
};

View File

@ -1,20 +1,20 @@
import { Box, Flex, Image } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useGetUrl } from 'common/util/getUrl';
import { uiSelector } from 'features/ui/store/uiSelectors';
import { isEqual } from 'lodash-es';
import { gallerySelector } from '../store/gallerySelectors';
import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer';
import NextPrevImageButtons from './NextPrevImageButtons';
import { DragEvent, memo, useCallback } from 'react';
import { memo, useCallback } from 'react';
import { systemSelector } from 'features/system/store/systemSelectors';
import ImageFallbackSpinner from './ImageFallbackSpinner';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { configSelector } from '../../system/store/configSelectors';
import { useAppToaster } from 'app/components/Toaster';
import { imageSelected } from '../store/gallerySlice';
import IAIDndImage from 'common/components/IAIDndImage';
import { ImageDTO } from 'services/api';
import { IAIImageFallback } from 'common/components/IAIImageFallback';
export const imagesSelector = createSelector(
[uiSelector, gallerySelector, systemSelector],
@ -46,27 +46,14 @@ const CurrentImagePreview = () => {
const {
shouldShowImageDetails,
image,
shouldHidePreview,
progressImage,
shouldShowProgressInViewer,
shouldAntialiasProgressImage,
} = useAppSelector(imagesSelector);
const { shouldFetchImages } = useAppSelector(configSelector);
const { getUrl } = useGetUrl();
const toaster = useAppToaster();
const dispatch = useAppDispatch();
const handleDragStart = useCallback(
(e: DragEvent<HTMLDivElement>) => {
if (!image) {
return;
}
e.dataTransfer.setData('invokeai/imageName', image.image_name);
e.dataTransfer.effectAllowed = 'move';
},
[image]
);
const handleError = useCallback(() => {
dispatch(imageSelected());
if (shouldFetchImages) {
@ -78,11 +65,21 @@ const CurrentImagePreview = () => {
}
}, [dispatch, toaster, shouldFetchImages]);
const handleDrop = useCallback(
(droppedImage: ImageDTO) => {
if (droppedImage.image_name === image?.image_name) {
return;
}
dispatch(imageSelected(droppedImage));
},
[dispatch, image?.image_name]
);
return (
<Flex
sx={{
width: '100%',
height: '100%',
width: 'full',
height: 'full',
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
@ -95,8 +92,8 @@ const CurrentImagePreview = () => {
height={progressImage.height}
sx={{
objectFit: 'contain',
maxWidth: '100%',
maxHeight: '100%',
maxWidth: 'full',
maxHeight: 'full',
height: 'auto',
position: 'absolute',
borderRadius: 'base',
@ -104,34 +101,29 @@ const CurrentImagePreview = () => {
}}
/>
) : (
image && (
<>
<Image
src={getUrl(image.image_url)}
fallbackStrategy="beforeLoadOrError"
fallback={<ImageFallbackSpinner />}
onDragStart={handleDragStart}
sx={{
objectFit: 'contain',
maxWidth: '100%',
maxHeight: '100%',
height: 'auto',
position: 'absolute',
borderRadius: 'base',
}}
onError={handleError}
/>
<ImageMetadataOverlay image={image} />
</>
)
<Flex
sx={{
width: 'full',
height: 'full',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IAIDndImage
image={image}
onDrop={handleDrop}
onError={handleError}
fallback={<IAIImageFallback sx={{ bg: 'none' }} />}
/>
</Flex>
)}
{shouldShowImageDetails && image && 'metadata' in image && (
{shouldShowImageDetails && image && (
<Box
sx={{
position: 'absolute',
top: '0',
width: '100%',
height: '100%',
width: 'full',
height: 'full',
borderRadius: 'base',
overflow: 'scroll',
}}
@ -139,7 +131,19 @@ const CurrentImagePreview = () => {
<ImageMetadataViewer image={image} />
</Box>
)}
{!shouldShowImageDetails && <NextPrevImageButtons />}
{!shouldShowImageDetails && image && (
<Box
sx={{
position: 'absolute',
top: '0',
width: 'full',
height: 'full',
pointerEvents: 'none',
}}
>
<NextPrevImageButtons />
</Box>
)}
</Flex>
);
};

View File

@ -5,51 +5,81 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Divider,
Flex,
ListItem,
Text,
UnorderedList,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import {
DeleteImageContext,
ImageUsage,
} from 'app/contexts/DeleteImageContext';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIButton from 'common/components/IAIButton';
import IAIIconButton from 'common/components/IAIIconButton';
import IAISwitch from 'common/components/IAISwitch';
import { configSelector } from 'features/system/store/configSelectors';
import { systemSelector } from 'features/system/store/systemSelectors';
import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import { isEqual } from 'lodash-es';
import { some } from 'lodash-es';
import { ChangeEvent, memo, useCallback, useRef } from 'react';
import { ChangeEvent, memo, useCallback, useContext, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { FaTrash } from 'react-icons/fa';
const selector = createSelector(
[systemSelector, configSelector],
(system, config) => {
const { shouldConfirmOnDelete } = system;
const { canRestoreDeletedImagesFromBin } = config;
return { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin };
return {
shouldConfirmOnDelete,
canRestoreDeletedImagesFromBin,
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
defaultSelectorOptions
);
interface DeleteImageModalProps {
isOpen: boolean;
onClose: () => void;
handleDelete: () => void;
}
const ImageInUseMessage = (props: { imageUsage?: ImageUsage }) => {
const { imageUsage } = props;
const DeleteImageModal = ({
isOpen,
onClose,
handleDelete,
}: DeleteImageModalProps) => {
if (!imageUsage) {
return null;
}
if (!some(imageUsage)) {
return null;
}
return (
<>
<Text>This image is currently in use in the following features:</Text>
<UnorderedList sx={{ paddingInlineStart: 6 }}>
{imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
{imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
{imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
{imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
</UnorderedList>
<Text>
If you delete this image, those features will immediately be reset.
</Text>
</>
);
};
const DeleteImageModal = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { isOpen, onClose, onImmediatelyDelete, image, imageUsage } =
useContext(DeleteImageContext);
const { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin } =
useAppSelector(selector);
const cancelRef = useRef<HTMLButtonElement>(null);
const handleChangeShouldConfirmOnDelete = useCallback(
(e: ChangeEvent<HTMLInputElement>) =>
@ -57,10 +87,7 @@ const DeleteImageModal = ({
[dispatch]
);
const handleClickDelete = useCallback(() => {
handleDelete();
onClose();
}, [handleDelete, onClose]);
const cancelRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog
@ -76,15 +103,15 @@ const DeleteImageModal = ({
</AlertDialogHeader>
<AlertDialogBody>
<Flex direction="column" gap={5}>
<Flex direction="column" gap={2}>
<Text>{t('common.areYouSure')}</Text>
<Text>
{canRestoreDeletedImagesFromBin
? t('gallery.deleteImageBin')
: t('gallery.deleteImagePermanent')}
</Text>
</Flex>
<Flex direction="column" gap={3}>
<ImageInUseMessage imageUsage={imageUsage} />
<Divider />
<Text>
{canRestoreDeletedImagesFromBin
? t('gallery.deleteImageBin')
: t('gallery.deleteImagePermanent')}
</Text>
<Text>{t('common.areYouSure')}</Text>
<IAISwitch
label={t('common.dontAskMeAgain')}
isChecked={!shouldConfirmOnDelete}
@ -96,7 +123,7 @@ const DeleteImageModal = ({
<IAIButton ref={cancelRef} onClick={onClose}>
Cancel
</IAIButton>
<IAIButton colorScheme="error" onClick={handleClickDelete} ml={3}>
<IAIButton colorScheme="error" onClick={onImmediatelyDelete} ml={3}>
Delete
</IAIButton>
</AlertDialogFooter>
@ -107,3 +134,33 @@ const DeleteImageModal = ({
};
export default memo(DeleteImageModal);
const deleteImageButtonsSelector = createSelector(
[systemSelector],
(system) => {
const { isProcessing, isConnected } = system;
return isConnected && !isProcessing;
}
);
type DeleteImageButtonProps = {
onClick: () => void;
};
export const DeleteImageButton = (props: DeleteImageButtonProps) => {
const { onClick } = props;
const { t } = useTranslation();
const canDeleteImage = useAppSelector(deleteImageButtonsSelector);
return (
<IAIIconButton
onClick={onClick}
icon={<FaTrash />}
tooltip={`${t('gallery.deleteImage')} (Del)`}
aria-label={`${t('gallery.deleteImage')} (Del)`}
isDisabled={!canDeleteImage}
colorScheme="error"
/>
);
};

View File

@ -1,17 +1,8 @@
import {
Box,
Flex,
Icon,
Image,
MenuItem,
MenuList,
useDisclosure,
} from '@chakra-ui/react';
import { Box, Flex, Icon, Image, MenuItem, MenuList } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { DragEvent, MouseEvent, memo, useCallback, useState } from 'react';
import { memo, useCallback, useContext, useState } from 'react';
import { FaCheck, FaExpand, FaImage, FaShare, FaTrash } from 'react-icons/fa';
import DeleteImageModal from './DeleteImageModal';
import { ContextMenu } from 'chakra-ui-contextmenu';
import {
resizeAndScaleCanvas,
@ -21,7 +12,6 @@ import { gallerySelector } from 'features/gallery/store/gallerySelectors';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { useTranslation } from 'react-i18next';
import IAIIconButton from 'common/components/IAIIconButton';
import { useGetUrl } from 'common/util/getUrl';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { IoArrowUndoCircleOutline } from 'react-icons/io5';
import { createSelector } from '@reduxjs/toolkit';
@ -32,13 +22,11 @@ import { isEqual } from 'lodash-es';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import {
requestedImageDeletion,
sentImageToCanvas,
sentImageToImg2Img,
} from '../store/actions';
import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions';
import { useAppToaster } from 'app/components/Toaster';
import { ImageDTO } from 'services/api';
import { useDraggable } from '@dnd-kit/core';
import { DeleteImageContext } from 'app/contexts/DeleteImageContext';
export const selector = createSelector(
[gallerySelector, systemSelector, lightboxSelector, activeTabNameSelector],
@ -92,66 +80,39 @@ const HoverableImage = memo((props: HoverableImageProps) => {
galleryImageMinimumWidth,
canDeleteImage,
shouldUseSingleGalleryColumn,
shouldConfirmOnDelete,
} = useAppSelector(selector);
const {
isOpen: isDeleteDialogOpen,
onOpen: onDeleteDialogOpen,
onClose: onDeleteDialogClose,
} = useDisclosure();
const { image, isSelected } = props;
const { image_url, thumbnail_url, image_name } = image;
const { getUrl } = useGetUrl();
const [isHovered, setIsHovered] = useState<boolean>(false);
const toaster = useAppToaster();
const { t } = useTranslation();
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled;
const { onDelete } = useContext(DeleteImageContext);
const handleDelete = useCallback(() => {
onDelete(image);
}, [image, onDelete]);
const { recallBothPrompts, recallSeed, recallAllParameters } =
useRecallParameters();
const { attributes, listeners, setNodeRef } = useDraggable({
id: `galleryImage_${image_name}`,
data: {
image,
},
});
const handleMouseOver = () => setIsHovered(true);
const handleMouseOut = () => setIsHovered(false);
// Immediately deletes an image
const handleDelete = useCallback(() => {
if (canDeleteImage && image) {
dispatch(requestedImageDeletion(image));
}
}, [dispatch, image, canDeleteImage]);
// Opens the alert dialog to check if user is sure they want to delete
const handleInitiateDelete = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
if (shouldConfirmOnDelete) {
onDeleteDialogOpen();
} else {
handleDelete();
}
},
[handleDelete, onDeleteDialogOpen, shouldConfirmOnDelete]
);
const handleSelectImage = useCallback(() => {
dispatch(imageSelected(image));
}, [image, dispatch]);
const handleDragStart = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.dataTransfer.setData('invokeai/imageName', image.image_name);
e.dataTransfer.effectAllowed = 'move';
},
[image]
);
// Recall parameters handlers
const handleRecallPrompt = useCallback(() => {
recallBothPrompts(
@ -208,11 +169,16 @@ const HoverableImage = memo((props: HoverableImageProps) => {
};
const handleOpenInNewTab = () => {
window.open(getUrl(image.image_url), '_blank');
window.open(image.image_url, '_blank');
};
return (
<>
<Box
ref={setNodeRef}
{...listeners}
{...attributes}
sx={{ w: 'full', h: 'full', touchAction: 'none' }}
>
<ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => (
@ -278,7 +244,11 @@ const HoverableImage = memo((props: HoverableImageProps) => {
{t('parameters.sendToUnifiedCanvas')}
</MenuItem>
)}
<MenuItem icon={<FaTrash />} onClickCapture={onDeleteDialogOpen}>
<MenuItem
sx={{ color: 'error.300' }}
icon={<FaTrash />}
onClickCapture={handleDelete}
>
{t('gallery.deleteImage')}
</MenuItem>
</MenuList>
@ -291,8 +261,6 @@ const HoverableImage = memo((props: HoverableImageProps) => {
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
userSelect="none"
draggable={true}
onDragStart={handleDragStart}
onClick={handleSelectImage}
ref={ref}
sx={{
@ -312,7 +280,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
shouldUseSingleGalleryColumn ? 'contain' : galleryImageObjectFit
}
rounded="md"
src={getUrl(thumbnail_url || image_url)}
src={thumbnail_url || image_url}
fallback={<FaImage />}
sx={{
width: '100%',
@ -356,7 +324,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
}}
>
<IAIIconButton
onClickCapture={handleInitiateDelete}
onClickCapture={handleDelete}
aria-label={t('gallery.deleteImage')}
icon={<FaTrash />}
size="xs"
@ -368,12 +336,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
</Box>
)}
</ContextMenu>
<DeleteImageModal
isOpen={isDeleteDialogOpen}
onClose={onDeleteDialogClose}
handleDelete={handleDelete}
/>
</>
</Box>
);
}, memoEqualityCheck);

View File

@ -1,92 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { useDisclosure } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import { systemSelector } from 'features/system/store/systemSelectors';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaTrash } from 'react-icons/fa';
import { memo, useCallback } from 'react';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import DeleteImageModal from '../DeleteImageModal';
import { requestedImageDeletion } from 'features/gallery/store/actions';
import { ImageDTO } from 'services/api';
const selector = createSelector(
[systemSelector],
(system) => {
const { isProcessing, isConnected, shouldConfirmOnDelete } = system;
return {
canDeleteImage: isConnected && !isProcessing,
shouldConfirmOnDelete,
isProcessing,
isConnected,
};
},
defaultSelectorOptions
);
type DeleteImageButtonProps = {
image: ImageDTO | undefined;
};
const DeleteImageButton = (props: DeleteImageButtonProps) => {
const { image } = props;
const dispatch = useAppDispatch();
const { isProcessing, isConnected, canDeleteImage, shouldConfirmOnDelete } =
useAppSelector(selector);
const {
isOpen: isDeleteDialogOpen,
onOpen: onDeleteDialogOpen,
onClose: onDeleteDialogClose,
} = useDisclosure();
const { t } = useTranslation();
const handleDelete = useCallback(() => {
if (canDeleteImage && image) {
dispatch(requestedImageDeletion(image));
}
}, [image, canDeleteImage, dispatch]);
const handleInitiateDelete = useCallback(() => {
if (shouldConfirmOnDelete) {
onDeleteDialogOpen();
} else {
handleDelete();
}
}, [shouldConfirmOnDelete, onDeleteDialogOpen, handleDelete]);
useHotkeys('delete', handleInitiateDelete, [
image,
shouldConfirmOnDelete,
isConnected,
isProcessing,
]);
return (
<>
<IAIIconButton
onClick={handleInitiateDelete}
icon={<FaTrash />}
tooltip={`${t('gallery.deleteImage')} (Del)`}
aria-label={`${t('gallery.deleteImage')} (Del)`}
isDisabled={!image || !isConnected}
colorScheme="error"
/>
{image && (
<DeleteImageModal
isOpen={isDeleteDialogOpen}
onClose={onDeleteDialogClose}
handleDelete={handleDelete}
/>
)}
</>
);
};
export default memo(DeleteImageButton);

View File

@ -14,6 +14,8 @@ const ImageFallbackSpinner = (props: ImageFallbackSpinnerProps) => {
justifyContent: 'center',
position: 'absolute',
color: 'base.400',
minH: 36,
minW: 36,
}}
>
<Spinner size={size} {...rest} />

View File

@ -10,7 +10,7 @@ import {
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import IAICheckbox from 'common/components/IAICheckbox';
import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import IAISlider from 'common/components/IAISlider';
@ -233,7 +233,7 @@ const ImageGalleryContent = () => {
withReset
handleReset={() => dispatch(setGalleryImageMinimumWidth(64))}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('gallery.maintainAspectRatio')}
isChecked={galleryImageObjectFit === 'contain'}
onChange={() =>
@ -244,14 +244,14 @@ const ImageGalleryContent = () => {
)
}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('gallery.autoSwitchNewImages')}
isChecked={shouldAutoSwitchToNewImages}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(setShouldAutoSwitchToNewImages(e.target.checked))
}
/>
<IAICheckbox
<IAISimpleCheckbox
label={t('gallery.singleColumnLayout')}
isChecked={shouldUseSingleGalleryColumn}
onChange={(e: ChangeEvent<HTMLInputElement>) =>

View File

@ -9,19 +9,6 @@ import {
Tooltip,
} from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGetUrl } from 'common/util/getUrl';
import promptToString from 'common/util/promptToString';
import {
setCfgScale,
setHeight,
setImg2imgStrength,
setNegativePrompt,
setPositivePrompt,
setScheduler,
setSeed,
setSteps,
setWidth,
} from 'features/parameters/store/generationSlice';
import { setShouldShowImageDetails } from 'features/ui/store/uiSlice';
import { memo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
@ -30,7 +17,6 @@ import { FaCopy } from 'react-icons/fa';
import { IoArrowUndoCircleOutline } from 'react-icons/io5';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { ImageDTO } from 'services/api';
import { Scheduler } from 'app/constants';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
type MetadataItemProps = {
@ -146,7 +132,6 @@ const ImageMetadataViewer = memo(({ image }: ImageMetadataViewerProps) => {
const metadata = image?.metadata;
const { t } = useTranslation();
const { getUrl } = useGetUrl();
const metadataJSON = JSON.stringify(image, null, 2);
@ -168,11 +153,7 @@ const ImageMetadataViewer = memo(({ image }: ImageMetadataViewerProps) => {
>
<Flex gap={2}>
<Text fontWeight="semibold">File:</Text>
<Link
href={getUrl(image.image_url)}
isExternal
maxW="calc(100% - 3rem)"
>
<Link href={image.image_url} isExternal maxW="calc(100% - 3rem)">
{image.image_name}
<ExternalLinkIcon mx="2px" />
</Link>

View File

@ -1,10 +1,15 @@
import { createAction } from '@reduxjs/toolkit';
import { ImageNameAndOrigin } from 'features/parameters/store/actions';
import { ImageUsage } from 'app/contexts/DeleteImageContext';
import { ImageDTO } from 'services/api';
export const requestedImageDeletion = createAction<
ImageDTO | ImageNameAndOrigin | undefined
>('gallery/requestedImageDeletion');
export type RequestedImageDeletionArg = {
image: ImageDTO;
imageUsage: ImageUsage;
};
export const requestedImageDeletion = createAction<RequestedImageDeletionArg>(
'gallery/requestedImageDeletion'
);
export const sentImageToCanvas = createAction('gallery/sentImageToCanvas');

View File

@ -2,6 +2,7 @@ import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { ImageDTO } from 'services/api';
import { imageUpserted } from './imagesSlice';
import { imageUrlsReceived } from 'services/thunks/image';
type GalleryImageObjectFitType = 'contain' | 'cover';
@ -50,10 +51,22 @@ export const gallerySlice = createSlice({
},
extraReducers: (builder) => {
builder.addCase(imageUpserted, (state, action) => {
if (state.shouldAutoSwitchToNewImages) {
if (
state.shouldAutoSwitchToNewImages &&
action.payload.image_category === 'general'
) {
state.selectedImage = action.payload;
}
});
builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
const { image_name, image_origin, image_url, thumbnail_url } =
action.payload;
if (state.selectedImage?.image_name === image_name) {
state.selectedImage.image_url = image_url;
state.selectedImage.thumbnail_url = thumbnail_url;
}
});
},
});

View File

@ -1,5 +1,6 @@
import {
PayloadAction,
Update,
createEntityAdapter,
createSelector,
createSlice,
@ -7,12 +8,17 @@ import {
import { RootState } from 'app/store/store';
import { ImageCategory, ImageDTO } from 'services/api';
import { dateComparator } from 'common/util/dateComparator';
import { isString, keyBy } from 'lodash-es';
import { receivedPageOfImages } from 'services/thunks/image';
import { keyBy } from 'lodash-es';
import {
imageDeleted,
imageMetadataReceived,
imageUrlsReceived,
receivedPageOfImages,
} from 'services/thunks/image';
export const imagesAdapter = createEntityAdapter<ImageDTO>({
selectId: (image) => image.image_name,
sortComparer: (a, b) => dateComparator(b.created_at, a.created_at),
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
});
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
@ -49,13 +55,11 @@ const imagesSlice = createSlice({
imageUpserted: (state, action: PayloadAction<ImageDTO>) => {
imagesAdapter.upsertOne(state, action.payload);
},
imageRemoved: (state, action: PayloadAction<string | ImageDTO>) => {
if (isString(action.payload)) {
imagesAdapter.removeOne(state, action.payload);
return;
}
imagesAdapter.removeOne(state, action.payload.image_name);
imageUpdatedOne: (state, action: PayloadAction<Update<ImageDTO>>) => {
imagesAdapter.updateOne(state, action.payload);
},
imageRemoved: (state, action: PayloadAction<string>) => {
imagesAdapter.removeOne(state, action.payload);
},
imageCategoriesChanged: (state, action: PayloadAction<ImageCategory[]>) => {
state.categories = action.payload;
@ -76,6 +80,20 @@ const imagesSlice = createSlice({
state.total = total;
imagesAdapter.upsertMany(state, items);
});
builder.addCase(imageDeleted.pending, (state, action) => {
// Image deleted
const { imageName } = action.meta.arg;
imagesAdapter.removeOne(state, imageName);
});
builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
const { image_name, image_origin, image_url, thumbnail_url } =
action.payload;
imagesAdapter.updateOne(state, {
id: image_name,
changes: { image_url, thumbnail_url },
});
});
},
});
@ -87,8 +105,12 @@ export const {
selectTotal: selectImagesTotal,
} = imagesAdapter.getSelectors<RootState>((state) => state.images);
export const { imageUpserted, imageRemoved, imageCategoriesChanged } =
imagesSlice.actions;
export const {
imageUpserted,
imageUpdatedOne,
imageRemoved,
imageCategoriesChanged,
} = imagesSlice.actions;
export default imagesSlice.reducer;

View File

@ -1,6 +1,5 @@
import * as React from 'react';
import { TransformComponent, useTransformContext } from 'react-zoom-pan-pinch';
import { useGetUrl } from 'common/util/getUrl';
import { ImageDTO } from 'services/api';
type ReactPanZoomProps = {
@ -23,7 +22,6 @@ export default function ReactPanZoomImage({
scaleY,
}: ReactPanZoomProps) {
const { centerView } = useTransformContext();
const { getUrl } = useGetUrl();
return (
<TransformComponent
@ -37,7 +35,7 @@ export default function ReactPanZoomImage({
transform: `rotate(${rotation}deg) scaleX(${scaleX}) scaleY(${scaleY})`,
width: '100%',
}}
src={getUrl(image.image_url)}
src={image.image_url}
alt={alt}
ref={ref}
className={styleClass ? styleClass : ''}

View File

@ -6,7 +6,7 @@ import { memo } from 'react';
const FieldTypeLegend = () => {
return (
<Flex gap={2} flexDirection={{ base: 'column', xl: 'row' }}>
<Flex sx={{ gap: 2, flexDir: 'column' }}>
{map(FIELDS, ({ title, description, color }, key) => (
<Tooltip key={key} label={description}>
<Badge

View File

@ -11,7 +11,7 @@ const NodeEditor = () => {
sx={{
position: 'relative',
width: 'full',
height: { base: '100vh', xl: 'full' },
height: 'full',
borderRadius: 'md',
bg: 'base.850',
}}

View File

@ -1,54 +1,67 @@
import { Box, Image } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import SelectImagePlaceholder from 'common/components/SelectImagePlaceholder';
import { useGetUrl } from 'common/util/getUrl';
import useGetImageByName from 'features/gallery/hooks/useGetImageByName';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import {
ImageInputFieldTemplate,
ImageInputFieldValue,
} from 'features/nodes/types/types';
import { DragEvent, memo, useCallback, useState } from 'react';
import { memo, useCallback } from 'react';
import { FieldComponentProps } from './types';
import IAIDndImage from 'common/components/IAIDndImage';
import { ImageDTO } from 'services/api';
import { Flex } from '@chakra-ui/react';
const ImageInputFieldComponent = (
props: FieldComponentProps<ImageInputFieldValue, ImageInputFieldTemplate>
) => {
const { nodeId, field } = props;
const getImageByName = useGetImageByName();
const dispatch = useAppDispatch();
const [url, setUrl] = useState<string | undefined>(field.value?.image_url);
const { getUrl } = useGetUrl();
const handleDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
const name = e.dataTransfer.getData('invokeai/imageName');
const image = getImageByName(name);
if (!image) {
(droppedImage: ImageDTO) => {
if (field.value?.image_name === droppedImage.image_name) {
return;
}
setUrl(image.image_url);
dispatch(
fieldValueChanged({
nodeId,
fieldName: field.name,
value: image,
value: droppedImage,
})
);
},
[getImageByName, dispatch, field.name, nodeId]
[dispatch, field.name, field.value?.image_name, nodeId]
);
const handleReset = useCallback(() => {
dispatch(
fieldValueChanged({
nodeId,
fieldName: field.name,
value: undefined,
})
);
}, [dispatch, field.name, nodeId]);
return (
<Box onDrop={handleDrop}>
<Image src={getUrl(url)} fallback={<SelectImagePlaceholder />} />
</Box>
<Flex
sx={{
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IAIDndImage
image={field.value}
onDrop={handleDrop}
onReset={handleReset}
resetIconSize="sm"
/>
</Flex>
);
};

View File

@ -1,11 +1,11 @@
import { Box } from '@chakra-ui/react';
import { readinessSelector } from 'app/selectors/readinessSelector';
import { userInvoked } from 'app/store/actions';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton, { IAIButtonProps } from 'common/components/IAIButton';
import IAIIconButton, {
IAIIconButtonProps,
} from 'common/components/IAIIconButton';
import { useIsReadyToInvoke } from 'common/hooks/useIsReadyToInvoke';
import ProgressBar from 'features/system/components/ProgressBar';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { useCallback } from 'react';
@ -21,9 +21,8 @@ interface InvokeButton
export default function NodeInvokeButton(props: InvokeButton) {
const { iconButton = false, ...rest } = props;
const dispatch = useAppDispatch();
const { isReady } = useAppSelector(readinessSelector);
const activeTabName = useAppSelector(activeTabNameSelector);
const isReady = useIsReadyToInvoke();
const handleInvoke = useCallback(() => {
dispatch(userInvoked('nodes'));
}, [dispatch]);

View File

@ -16,9 +16,10 @@ import { receivedOpenAPISchema } from 'services/thunks/schema';
import { InvocationTemplate, InvocationValue } from '../types/types';
import { parseSchema } from '../util/parseSchema';
import { log } from 'app/logging/useLogger';
import { size } from 'lodash-es';
import { isAnyGraphBuilt } from './actions';
import { forEach, size } from 'lodash-es';
import { RgbaColor } from 'react-colorful';
import { imageUrlsReceived } from 'services/thunks/image';
import { RootState } from 'app/store/store';
export type NodesState = {
nodes: Node<InvocationValue>[];
@ -92,15 +93,29 @@ const nodesSlice = createSlice({
console.error(err);
}
},
nodeEditorReset: () => {
return { ...initialNodesState };
},
},
extraReducers(builder) {
builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => {
state.schema = action.payload;
});
builder.addMatcher(isAnyGraphBuilt, (state, action) => {
// TODO: Achtung! Side effect in a reducer!
log.info({ namespace: 'nodes', data: action.payload }, 'Graph built');
builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
const { image_name, image_origin, image_url, thumbnail_url } =
action.payload;
state.nodes.forEach((node) => {
forEach(node.data.inputs, (input) => {
if (input.type === 'image') {
if (input.value?.image_name === image_name) {
input.value.image_url = image_url;
input.value.thumbnail_url = thumbnail_url;
}
}
});
});
});
},
});
@ -115,6 +130,9 @@ export const {
connectionEnded,
shouldShowGraphOverlayChanged,
parsedOpenAPISchema,
nodeEditorReset,
} = nodesSlice.actions;
export default nodesSlice.reducer;
export const nodesSelecter = (state: RootState) => state.nodes;

View File

@ -0,0 +1,99 @@
import { RootState } from 'app/store/store';
import { forEach, size } from 'lodash-es';
import { CollectInvocation, ControlNetInvocation } from 'services/api';
import { NonNullableGraph } from '../types/types';
const CONTROL_NET_COLLECT = 'control_net_collect';
export const addControlNetToLinearGraph = (
graph: NonNullableGraph,
baseNodeId: string,
state: RootState
): void => {
const { isEnabled: isControlNetEnabled, controlNets } = state.controlNet;
// Add ControlNet
if (isControlNetEnabled) {
if (size(controlNets) > 1) {
const controlNetIterateNode: CollectInvocation = {
id: CONTROL_NET_COLLECT,
type: 'collect',
};
graph.nodes[controlNetIterateNode.id] = controlNetIterateNode;
graph.edges.push({
source: { node_id: controlNetIterateNode.id, field: 'collection' },
destination: {
node_id: baseNodeId,
field: 'control',
},
});
}
forEach(controlNets, (controlNet, index) => {
const {
controlNetId,
isEnabled,
controlImage,
processedControlImage,
beginStepPct,
endStepPct,
model,
processorType,
weight,
} = controlNet;
if (!isEnabled) {
// Skip disabled ControlNets
return;
}
const controlNetNode: ControlNetInvocation = {
id: `control_net_${controlNetId}`,
type: 'controlnet',
begin_step_percent: beginStepPct,
end_step_percent: endStepPct,
control_model: model as ControlNetInvocation['control_model'],
control_weight: weight,
};
if (processedControlImage && processorType !== 'none') {
// We've already processed the image in the app, so we can just use the processed image
const { image_name, image_origin } = processedControlImage;
controlNetNode.image = {
image_name,
image_origin,
};
} else if (controlImage && processorType !== 'none') {
// The control image is preprocessed
const { image_name, image_origin } = controlImage;
controlNetNode.image = {
image_name,
image_origin,
};
} else {
// Skip ControlNets without an unprocessed image - should never happen if everything is working correctly
return;
}
graph.nodes[controlNetNode.id] = controlNetNode;
if (size(controlNets) > 1) {
graph.edges.push({
source: { node_id: controlNetNode.id, field: 'control' },
destination: {
node_id: CONTROL_NET_COLLECT,
field: 'item',
},
});
} else {
graph.edges.push({
source: { node_id: controlNetNode.id, field: 'control' },
destination: {
node_id: baseNodeId,
field: 'control',
},
});
}
});
}
};

View File

@ -14,6 +14,7 @@ import {
import { NonNullableGraph } from 'features/nodes/types/types';
import { log } from 'app/logging/useLogger';
import { set } from 'lodash-es';
import { addControlNetToLinearGraph } from '../addControlNetToLinearGraph';
const moduleLog = log.child({ namespace: 'nodes' });
@ -408,5 +409,7 @@ export const buildImageToImageGraph = (state: RootState): Graph => {
});
}
addControlNetToLinearGraph(graph, LATENTS_TO_LATENTS, state);
return graph;
};

View File

@ -10,6 +10,7 @@ import {
TextToLatentsInvocation,
} from 'services/api';
import { NonNullableGraph } from 'features/nodes/types/types';
import { addControlNetToLinearGraph } from '../addControlNetToLinearGraph';
const POSITIVE_CONDITIONING = 'positive_conditioning';
const NEGATIVE_CONDITIONING = 'negative_conditioning';
@ -308,5 +309,8 @@ export const buildTextToImageGraph = (state: RootState): Graph => {
},
});
}
addControlNetToLinearGraph(graph, TEXT_TO_LATENTS, state);
return graph;
};

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