Merge branch 'main' into release/make-web-dist-startable

This commit is contained in:
Lincoln Stein
2023-06-07 19:21:01 -07:00
committed by GitHub
29 changed files with 545 additions and 286 deletions

View File

@ -19,31 +19,56 @@ An invocation looks like this:
```py ```py
class UpscaleInvocation(BaseInvocation): class UpscaleInvocation(BaseInvocation):
"""Upscales an image.""" """Upscales an image."""
type: Literal['upscale'] = 'upscale'
# fmt: off
type: Literal["upscale"] = "upscale"
# Inputs # Inputs
image: Union[ImageField,None] = Field(description="The input image") image: Union[ImageField, None] = Field(description="The input image", default=None)
strength: float = Field(default=0.75, gt=0, le=1, description="The strength") strength: float = Field(default=0.75, gt=0, le=1, description="The strength")
level: Literal[2,4] = Field(default=2, description = "The upscale level") level: Literal[2, 4] = Field(default=2, description="The upscale level")
# fmt: on
# Schema customisation
class Config(InvocationConfig):
schema_extra = {
"ui": {
"tags": ["upscaling", "image"],
},
}
def invoke(self, context: InvocationContext) -> ImageOutput: def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.services.images.get(self.image.image_type, self.image.image_name) image = context.services.images.get_pil_image(
results = context.services.generate.upscale_and_reconstruct( self.image.image_origin, self.image.image_name
image_list = [[image, 0]], )
upscale = (self.level, self.strength), results = context.services.restoration.upscale_and_reconstruct(
strength = 0.0, # GFPGAN strength image_list=[[image, 0]],
save_original = False, upscale=(self.level, self.strength),
image_callback = None, strength=0.0, # GFPGAN strength
save_original=False,
image_callback=None,
) )
# Results are image and seed, unwrap for now # Results are image and seed, unwrap for now
# TODO: can this return multiple results? # TODO: can this return multiple results?
image_type = ImageType.RESULT image_dto = context.services.images.create(
image_name = context.services.images.create_name(context.graph_execution_state_id, self.id) image=results[0][0],
context.services.images.save(image_type, image_name, results[0][0]) image_origin=ResourceOrigin.INTERNAL,
return ImageOutput( image_category=ImageCategory.GENERAL,
image = ImageField(image_type = image_type, image_name = image_name) node_id=self.id,
session_id=context.graph_execution_state_id,
is_intermediate=self.is_intermediate,
) )
return ImageOutput(
image=ImageField(
image_name=image_dto.image_name,
image_origin=image_dto.image_origin,
),
width=image_dto.width,
height=image_dto.height,
)
``` ```
Each portion is important to implement correctly. Each portion is important to implement correctly.
@ -95,25 +120,67 @@ Finally, note that for all linking, the `type` of the linked fields must match.
If the `name` also matches, then the field can be **automatically linked** to a If the `name` also matches, then the field can be **automatically linked** to a
previous invocation by name and matching. previous invocation by name and matching.
### Config
```py
# Schema customisation
class Config(InvocationConfig):
schema_extra = {
"ui": {
"tags": ["upscaling", "image"],
},
}
```
This is an optional configuration for the invocation. It inherits from
pydantic's model `Config` class, and it used primarily to customize the
autogenerated OpenAPI schema.
The UI relies on the OpenAPI schema in two ways:
- An API client & Typescript types are generated from it. This happens at build
time.
- The node editor parses the schema into a template used by the UI to create the
node editor UI. This parsing happens at runtime.
In this example, a `ui` key has been added to the `schema_extra` dict to provide
some tags for the UI, to facilitate filtering nodes.
See the Schema Generation section below for more information.
### Invoke Function ### Invoke Function
```py ```py
def invoke(self, context: InvocationContext) -> ImageOutput: def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.services.images.get(self.image.image_type, self.image.image_name) image = context.services.images.get_pil_image(
results = context.services.generate.upscale_and_reconstruct( self.image.image_origin, self.image.image_name
image_list = [[image, 0]], )
upscale = (self.level, self.strength), results = context.services.restoration.upscale_and_reconstruct(
strength = 0.0, # GFPGAN strength image_list=[[image, 0]],
save_original = False, upscale=(self.level, self.strength),
image_callback = None, strength=0.0, # GFPGAN strength
save_original=False,
image_callback=None,
) )
# Results are image and seed, unwrap for now # Results are image and seed, unwrap for now
image_type = ImageType.RESULT # TODO: can this return multiple results?
image_name = context.services.images.create_name(context.graph_execution_state_id, self.id) image_dto = context.services.images.create(
context.services.images.save(image_type, image_name, results[0][0]) image=results[0][0],
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
node_id=self.id,
session_id=context.graph_execution_state_id,
is_intermediate=self.is_intermediate,
)
return ImageOutput( return ImageOutput(
image = ImageField(image_type = image_type, image_name = image_name) image=ImageField(
image_name=image_dto.image_name,
image_origin=image_dto.image_origin,
),
width=image_dto.width,
height=image_dto.height,
) )
``` ```
@ -135,9 +202,16 @@ scenarios. If you need functionality, please provide it as a service in the
```py ```py
class ImageOutput(BaseInvocationOutput): class ImageOutput(BaseInvocationOutput):
"""Base class for invocations that output an image""" """Base class for invocations that output an image"""
type: Literal['image'] = 'image'
image: ImageField = Field(default=None, description="The output image") # fmt: off
type: Literal["image_output"] = "image_output"
image: ImageField = Field(default=None, description="The output image")
width: int = Field(description="The width of the image in pixels")
height: int = Field(description="The height of the image in pixels")
# fmt: on
class Config:
schema_extra = {"required": ["type", "image", "width", "height"]}
``` ```
Output classes look like an invocation class without the invoke method. Prefer Output classes look like an invocation class without the invoke method. Prefer
@ -168,35 +242,36 @@ Here's that `ImageOutput` class, without the needed schema customisation:
class ImageOutput(BaseInvocationOutput): class ImageOutput(BaseInvocationOutput):
"""Base class for invocations that output an image""" """Base class for invocations that output an image"""
type: Literal["image"] = "image" # fmt: off
type: Literal["image_output"] = "image_output"
image: ImageField = Field(default=None, description="The output image") image: ImageField = Field(default=None, description="The output image")
width: int = Field(description="The width of the image in pixels")
height: int = Field(description="The height of the image in pixels")
# fmt: on
``` ```
The generated OpenAPI schema, and all clients/types generated from it, will have The OpenAPI schema that results from this `ImageOutput` will have the `type`,
the `type` and `image` properties marked as optional, even though we know they `image`, `width` and `height` properties marked as optional, even though we know
will always have a value by the time we can interact with them via the API. they will always have a value.
Here's the same class, but with the schema customisation added:
```python ```python
class ImageOutput(BaseInvocationOutput): class ImageOutput(BaseInvocationOutput):
"""Base class for invocations that output an image""" """Base class for invocations that output an image"""
type: Literal["image"] = "image" # fmt: off
type: Literal["image_output"] = "image_output"
image: ImageField = Field(default=None, description="The output image") image: ImageField = Field(default=None, description="The output image")
width: int = Field(description="The width of the image in pixels")
height: int = Field(description="The height of the image in pixels")
# fmt: on
# Add schema customization
class Config: class Config:
schema_extra = { schema_extra = {"required": ["type", "image", "width", "height"]}
'required': [
'type',
'image',
]
}
``` ```
The resultant schema (and any API client or types generated from it) will now With the customization in place, the schema will now show these properties as
have see `type` as string literal `"image"` and `image` as an `ImageField` required, obviating the need for extensive null checks in client code.
object.
See this `pydantic` issue for discussion on this solution: See this `pydantic` issue for discussion on this solution:
<https://github.com/pydantic/pydantic/discussions/4577> <https://github.com/pydantic/pydantic/discussions/4577>

View File

@ -506,8 +506,8 @@
"isScheduled": "Canceling", "isScheduled": "Canceling",
"setType": "Set cancel type" "setType": "Set cancel type"
}, },
"promptPlaceholder": "Type prompt here. [negative tokens], (upweight)++, (downweight)--, swap and blend are available (see docs)", "positivePromptPlaceholder": "Positive Prompt",
"negativePrompts": "Negative Prompts", "negativePromptPlaceholder": "Negative Prompt",
"sendTo": "Send to", "sendTo": "Send to",
"sendToImg2Img": "Send to Image to Image", "sendToImg2Img": "Send to Image to Image",
"sendToUnifiedCanvas": "Send To Unified Canvas", "sendToUnifiedCanvas": "Send To Unified Canvas",

View File

@ -3,7 +3,6 @@ import {
DragEndEvent, DragEndEvent,
DragOverlay, DragOverlay,
DragStartEvent, DragStartEvent,
KeyboardSensor,
MouseSensor, MouseSensor,
TouchSensor, TouchSensor,
pointerWithin, pointerWithin,
@ -15,6 +14,7 @@ import OverlayDragImage from './OverlayDragImage';
import { ImageDTO } from 'services/api'; import { ImageDTO } from 'services/api';
import { isImageDTO } from 'services/types/guards'; import { isImageDTO } from 'services/types/guards';
import { snapCenterToCursor } from '@dnd-kit/modifiers'; import { snapCenterToCursor } from '@dnd-kit/modifiers';
import { AnimatePresence, motion } from 'framer-motion';
type ImageDndContextProps = PropsWithChildren; type ImageDndContextProps = PropsWithChildren;
@ -40,11 +40,11 @@ const ImageDndContext = (props: ImageDndContextProps) => {
); );
const mouseSensor = useSensor(MouseSensor, { const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { distance: 15 }, activationConstraint: { delay: 250, tolerance: 5 },
}); });
const touchSensor = useSensor(TouchSensor, { const touchSensor = useSensor(TouchSensor, {
activationConstraint: { distance: 15 }, activationConstraint: { delay: 250, tolerance: 5 },
}); });
// TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos // TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos
// Alternatively, fix `rectIntersection` collection detection to work with the drag overlay // Alternatively, fix `rectIntersection` collection detection to work with the drag overlay
@ -62,7 +62,25 @@ const ImageDndContext = (props: ImageDndContextProps) => {
> >
{props.children} {props.children}
<DragOverlay dropAnimation={null} modifiers={[snapCenterToCursor]}> <DragOverlay dropAnimation={null} modifiers={[snapCenterToCursor]}>
{draggedImage && <OverlayDragImage image={draggedImage} />} <AnimatePresence>
{draggedImage && (
<motion.div
layout
key="overlay-drag-image"
initial={{
opacity: 0,
scale: 0.7,
}}
animate={{
opacity: 1,
scale: 1,
transition: { duration: 0.1 },
}}
>
<OverlayDragImage image={draggedImage} />
</motion.div>
)}
</AnimatePresence>
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
); );

View File

@ -8,7 +8,6 @@ import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob'; import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob';
const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' }); const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' });
export const MERGED_CANVAS_FILENAME = 'mergedCanvas.png';
export const addCanvasMergedListener = () => { export const addCanvasMergedListener = () => {
startAppListening({ startAppListening({
@ -49,12 +48,15 @@ export const addCanvasMergedListener = () => {
const imageUploadedRequest = dispatch( const imageUploadedRequest = dispatch(
imageUploaded({ imageUploaded({
formData: { formData: {
file: new File([blob], MERGED_CANVAS_FILENAME, { file: new File([blob], 'mergedCanvas.png', {
type: 'image/png', type: 'image/png',
}), }),
}, },
imageCategory: 'general', imageCategory: 'general',
isIntermediate: true, isIntermediate: true,
postUploadAction: {
type: 'TOAST_CANVAS_MERGED',
},
}) })
); );

View File

@ -6,8 +6,6 @@ import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imageUpserted } from 'features/gallery/store/imagesSlice'; import { imageUpserted } from 'features/gallery/store/imagesSlice';
export const SAVED_CANVAS_FILENAME = 'savedCanvas.png';
const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' }); const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' });
export const addCanvasSavedToGalleryListener = () => { export const addCanvasSavedToGalleryListener = () => {
@ -33,12 +31,15 @@ export const addCanvasSavedToGalleryListener = () => {
const imageUploadedRequest = dispatch( const imageUploadedRequest = dispatch(
imageUploaded({ imageUploaded({
formData: { formData: {
file: new File([blob], SAVED_CANVAS_FILENAME, { file: new File([blob], 'savedCanvas.png', {
type: 'image/png', type: 'image/png',
}), }),
}, },
imageCategory: 'general', imageCategory: 'general',
isIntermediate: false, isIntermediate: false,
postUploadAction: {
type: 'TOAST_CANVAS_SAVED_TO_GALLERY',
},
}) })
); );

View File

@ -1,9 +1,11 @@
import { AnyAction } from '@reduxjs/toolkit'; import { AnyListenerPredicate } from '@reduxjs/toolkit';
import { startAppListening } from '..'; import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { controlNetImageProcessed } from 'features/controlNet/store/actions'; import { controlNetImageProcessed } from 'features/controlNet/store/actions';
import { import {
controlNetAutoConfigToggled,
controlNetImageChanged, controlNetImageChanged,
controlNetModelChanged,
controlNetProcessorParamsChanged, controlNetProcessorParamsChanged,
controlNetProcessorTypeChanged, controlNetProcessorTypeChanged,
} from 'features/controlNet/store/controlNetSlice'; } from 'features/controlNet/store/controlNetSlice';
@ -11,19 +13,26 @@ import { RootState } from 'app/store/store';
const moduleLog = log.child({ namespace: 'controlNet' }); const moduleLog = log.child({ namespace: 'controlNet' });
const predicate = (action: AnyAction, state: RootState) => { const predicate: AnyListenerPredicate<RootState> = (action, state) => {
const isActionMatched = const isActionMatched =
controlNetProcessorParamsChanged.match(action) || controlNetProcessorParamsChanged.match(action) ||
controlNetModelChanged.match(action) ||
controlNetImageChanged.match(action) || controlNetImageChanged.match(action) ||
controlNetProcessorTypeChanged.match(action); controlNetProcessorTypeChanged.match(action) ||
controlNetAutoConfigToggled.match(action);
if (!isActionMatched) { if (!isActionMatched) {
return false; return false;
} }
const { controlImage, processorType } = const { controlImage, processorType, shouldAutoConfig } =
state.controlNet.controlNets[action.payload.controlNetId]; state.controlNet.controlNets[action.payload.controlNetId];
if (controlNetModelChanged.match(action) && !shouldAutoConfig) {
// do not process if the action is a model change but the processor settings are dirty
return false;
}
const isProcessorSelected = processorType !== 'none'; const isProcessorSelected = processorType !== 'none';
const isBusy = state.system.isProcessing; const isBusy = state.system.isProcessing;
@ -49,7 +58,10 @@ export const addControlNetAutoProcessListener = () => {
// Cancel any in-progress instances of this listener // Cancel any in-progress instances of this listener
cancelActiveListeners(); cancelActiveListeners();
moduleLog.trace(
{ data: action.payload },
'ControlNet auto-process triggered'
);
// Delay before starting actual work // Delay before starting actual work
await delay(300); await delay(300);

View File

@ -3,8 +3,10 @@ import { imageUploaded } from 'services/thunks/image';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imageUpserted } from 'features/gallery/store/imagesSlice'; import { imageUpserted } from 'features/gallery/store/imagesSlice';
import { SAVED_CANVAS_FILENAME } from './canvasSavedToGallery'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { MERGED_CANVAS_FILENAME } from './canvasMerged'; import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
@ -21,23 +23,48 @@ export const addImageUploadedFulfilledListener = () => {
return; return;
} }
const originalFileName = action.meta.arg.formData.file.name;
dispatch(imageUpserted(image)); dispatch(imageUpserted(image));
if (originalFileName === SAVED_CANVAS_FILENAME) { const { postUploadAction } = action.meta.arg;
if (postUploadAction?.type === 'TOAST_CANVAS_SAVED_TO_GALLERY') {
dispatch( dispatch(
addToast({ title: 'Canvas Saved to Gallery', status: 'success' }) addToast({ title: 'Canvas Saved to Gallery', status: 'success' })
); );
return; return;
} }
if (originalFileName === MERGED_CANVAS_FILENAME) { if (postUploadAction?.type === 'TOAST_CANVAS_MERGED') {
dispatch(addToast({ title: 'Canvas Merged', status: 'success' })); dispatch(addToast({ title: 'Canvas Merged', status: 'success' }));
return; return;
} }
dispatch(addToast({ title: 'Image Uploaded', status: 'success' })); if (postUploadAction?.type === 'SET_CANVAS_INITIAL_IMAGE') {
dispatch(setInitialCanvasImage(image));
return;
}
if (postUploadAction?.type === 'SET_CONTROLNET_IMAGE') {
const { controlNetId } = postUploadAction;
dispatch(controlNetImageChanged({ controlNetId, controlImage: image }));
return;
}
if (postUploadAction?.type === 'SET_INITIAL_IMAGE') {
dispatch(initialImageChanged(image));
return;
}
if (postUploadAction?.type === 'SET_NODES_IMAGE') {
const { nodeId, fieldName } = postUploadAction;
dispatch(fieldValueChanged({ nodeId, fieldName, value: image }));
return;
}
if (postUploadAction?.type === 'TOAST_UPLOADED') {
dispatch(addToast({ title: 'Image Uploaded', status: 'success' }));
return;
}
}, },
}); });
}; };

View File

@ -1,16 +1,26 @@
import { Box, Flex, Icon, IconButtonProps, Image } from '@chakra-ui/react'; import {
Box,
ChakraProps,
Flex,
Icon,
IconButtonProps,
Image,
} from '@chakra-ui/react';
import { useDraggable, useDroppable } from '@dnd-kit/core'; import { useDraggable, useDroppable } from '@dnd-kit/core';
import { useCombinedRefs } from '@dnd-kit/utilities'; import { useCombinedRefs } from '@dnd-kit/utilities';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import { IAIImageFallback } from 'common/components/IAIImageFallback'; import { IAIImageFallback } from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import { ReactElement, SyntheticEvent } from 'react'; import { ReactElement, SyntheticEvent, useCallback } from 'react';
import { memo, useRef } from 'react'; import { memo, useRef } from 'react';
import { FaImage, FaTimes } from 'react-icons/fa'; import { FaImage, FaTimes, FaUpload } from 'react-icons/fa';
import { ImageDTO } from 'services/api'; import { ImageDTO } from 'services/api';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay'; import IAIDropOverlay from './IAIDropOverlay';
import { PostUploadAction, imageUploaded } from 'services/thunks/image';
import { useDropzone } from 'react-dropzone';
import { useAppDispatch } from 'app/store/storeHooks';
type IAIDndImageProps = { type IAIDndImageProps = {
image: ImageDTO | null | undefined; image: ImageDTO | null | undefined;
@ -23,9 +33,12 @@ type IAIDndImageProps = {
withMetadataOverlay?: boolean; withMetadataOverlay?: boolean;
isDragDisabled?: boolean; isDragDisabled?: boolean;
isDropDisabled?: boolean; isDropDisabled?: boolean;
isUploadDisabled?: boolean;
fallback?: ReactElement; fallback?: ReactElement;
payloadImage?: ImageDTO | null | undefined; payloadImage?: ImageDTO | null | undefined;
minSize?: number; minSize?: number;
postUploadAction?: PostUploadAction;
imageSx?: ChakraProps['sx'];
}; };
const IAIDndImage = (props: IAIDndImageProps) => { const IAIDndImage = (props: IAIDndImageProps) => {
@ -39,15 +52,20 @@ const IAIDndImage = (props: IAIDndImageProps) => {
withMetadataOverlay = false, withMetadataOverlay = false,
isDropDisabled = false, isDropDisabled = false,
isDragDisabled = false, isDragDisabled = false,
isUploadDisabled = false,
fallback = <IAIImageFallback />, fallback = <IAIImageFallback />,
payloadImage, payloadImage,
minSize = 24, minSize = 24,
postUploadAction,
imageSx,
} = props; } = props;
const dispatch = useAppDispatch();
const dndId = useRef(uuidv4()); const dndId = useRef(uuidv4());
const { const {
isOver, isOver,
setNodeRef: setDroppableRef, setNodeRef: setDroppableRef,
active, active: isDropActive,
} = useDroppable({ } = useDroppable({
id: dndId.current, id: dndId.current,
disabled: isDropDisabled, disabled: isDropDisabled,
@ -60,16 +78,55 @@ const IAIDndImage = (props: IAIDndImageProps) => {
attributes, attributes,
listeners, listeners,
setNodeRef: setDraggableRef, setNodeRef: setDraggableRef,
isDragging,
} = useDraggable({ } = useDraggable({
id: dndId.current, id: dndId.current,
data: { data: {
image: payloadImage ? payloadImage : image, image: payloadImage ? payloadImage : image,
}, },
disabled: isDragDisabled, disabled: isDragDisabled || !image,
});
const handleOnDropAccepted = useCallback(
(files: Array<File>) => {
const file = files[0];
if (!file) {
return;
}
dispatch(
imageUploaded({
formData: { file },
imageCategory: 'user',
isIntermediate: false,
postUploadAction,
})
);
},
[dispatch, postUploadAction]
);
const { getRootProps, getInputProps } = useDropzone({
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
onDropAccepted: handleOnDropAccepted,
noDrag: true,
multiple: false,
disabled: isUploadDisabled,
}); });
const setNodeRef = useCombinedRefs(setDroppableRef, setDraggableRef); const setNodeRef = useCombinedRefs(setDroppableRef, setDraggableRef);
const uploadButtonStyles = isUploadDisabled
? {}
: {
cursor: 'pointer',
bg: 'base.800',
_hover: {
bg: 'base.750',
color: 'base.300',
},
};
return ( return (
<Flex <Flex
sx={{ sx={{
@ -81,7 +138,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
minW: minSize, minW: minSize,
minH: minSize, minH: minSize,
userSelect: 'none', userSelect: 'none',
cursor: 'grab', cursor: isDragDisabled || !image ? 'auto' : 'grab',
}} }}
{...attributes} {...attributes}
{...listeners} {...listeners}
@ -92,7 +149,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
sx={{ sx={{
w: 'full', w: 'full',
h: 'full', h: 'full',
position: 'relative',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}} }}
@ -108,6 +164,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
maxW: 'full', maxW: 'full',
maxH: 'full', maxH: 'full',
borderRadius: 'base', borderRadius: 'base',
...imageSx,
}} }}
/> />
{withMetadataOverlay && <ImageMetadataOverlay image={image} />} {withMetadataOverlay && <ImageMetadataOverlay image={image} />}
@ -130,7 +187,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
</Box> </Box>
)} )}
<AnimatePresence> <AnimatePresence>
{active && <IAIDropOverlay isOver={isOver} />} {isDropActive && <IAIDropOverlay isOver={isOver} />}
</AnimatePresence> </AnimatePresence>
</Flex> </Flex>
)} )}
@ -139,24 +196,28 @@ const IAIDndImage = (props: IAIDndImageProps) => {
<Flex <Flex
sx={{ sx={{
minH: minSize, minH: minSize,
bg: 'base.850',
w: 'full', w: 'full',
h: 'full', h: 'full',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderRadius: 'base', borderRadius: 'base',
transitionProperty: 'common',
transitionDuration: '0.1s',
color: 'base.500',
...uploadButtonStyles,
}} }}
{...getRootProps()}
> >
<input {...getInputProps()} />
<Icon <Icon
as={FaImage} as={isUploadDisabled ? FaImage : FaUpload}
sx={{ sx={{
boxSize: 24, boxSize: 12,
color: 'base.500',
}} }}
/> />
</Flex> </Flex>
<AnimatePresence> <AnimatePresence>
{active && <IAIDropOverlay isOver={isOver} />} {isDropActive && <IAIDropOverlay isOver={isOver} />}
</AnimatePresence> </AnimatePresence>
</> </>
)} )}

View File

@ -30,7 +30,7 @@ export const IAIDropOverlay = (props: Props) => {
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, insetInlineStart: 0,
w: 'full', w: 'full',
h: 'full', h: 'full',
}} }}
@ -39,7 +39,7 @@ export const IAIDropOverlay = (props: Props) => {
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, insetInlineStart: 0,
w: 'full', w: 'full',
h: 'full', h: 'full',
bg: 'base.900', bg: 'base.900',
@ -56,7 +56,7 @@ export const IAIDropOverlay = (props: Props) => {
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, insetInlineStart: 0,
w: 'full', w: 'full',
h: 'full', h: 'full',
opacity: 1, opacity: 1,

View File

@ -1,17 +1,13 @@
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { ImageUploaderTriggerContext } from 'app/contexts/ImageUploaderTriggerContext';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import useImageUploader from 'common/hooks/useImageUploader'; import useImageUploader from 'common/hooks/useImageUploader';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { ResourceKey } from 'i18next';
import { import {
KeyboardEvent, KeyboardEvent,
memo, memo,
ReactNode, ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef,
useState, useState,
} from 'react'; } from 'react';
import { FileRejection, useDropzone } from 'react-dropzone'; import { FileRejection, useDropzone } from 'react-dropzone';
@ -19,10 +15,8 @@ import { useTranslation } from 'react-i18next';
import { imageUploaded } from 'services/thunks/image'; import { imageUploaded } from 'services/thunks/image';
import ImageUploadOverlay from './ImageUploadOverlay'; import ImageUploadOverlay from './ImageUploadOverlay';
import { useAppToaster } from 'app/components/Toaster'; import { useAppToaster } from 'app/components/Toaster';
import { filter, map, some } from 'lodash-es';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { systemSelector } from 'features/system/store/systemSelectors'; import { systemSelector } from 'features/system/store/systemSelectors';
import { ErrorCode } from 'react-dropzone';
const selector = createSelector( const selector = createSelector(
[systemSelector, activeTabNameSelector], [systemSelector, activeTabNameSelector],
@ -71,6 +65,7 @@ const ImageUploader = (props: ImageUploaderProps) => {
formData: { file }, formData: { file },
imageCategory: 'user', imageCategory: 'user',
isIntermediate: false, isIntermediate: false,
postUploadAction: { type: 'TOAST_UPLOADED' },
}) })
); );
}, },

View File

@ -9,25 +9,14 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
const readinessSelector = createSelector( const readinessSelector = createSelector(
[generationSelector, systemSelector, activeTabNameSelector], [generationSelector, systemSelector, activeTabNameSelector],
(generation, system, activeTabName) => { (generation, system, activeTabName) => {
const { const { shouldGenerateVariations, seedWeights, initialImage, seed } =
positivePrompt: prompt, generation;
shouldGenerateVariations,
seedWeights,
initialImage,
seed,
} = generation;
const { isProcessing, isConnected } = system; const { isProcessing, isConnected } = system;
let isReady = true; let isReady = true;
const reasonsWhyNotReady: string[] = []; const reasonsWhyNotReady: string[] = [];
// Cannot generate without a prompt
if (!prompt || Boolean(prompt.match(/^[\s\r\n]+$/))) {
isReady = false;
reasonsWhyNotReady.push('Missing prompt');
}
if (activeTabName === 'img2img' && !initialImage) { if (activeTabName === 'img2img' && !initialImage) {
isReady = false; isReady = false;
reasonsWhyNotReady.push('No initial image selected'); reasonsWhyNotReady.push('No initial image selected');

View File

@ -8,20 +8,8 @@ import {
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import ParamControlNetModel from './parameters/ParamControlNetModel'; import ParamControlNetModel from './parameters/ParamControlNetModel';
import ParamControlNetWeight from './parameters/ParamControlNetWeight'; import ParamControlNetWeight from './parameters/ParamControlNetWeight';
import { import { Flex, Box, ChakraProps } from '@chakra-ui/react';
Checkbox, import { FaCopy, FaTrash } from 'react-icons/fa';
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 ParamControlNetBeginEnd from './parameters/ParamControlNetBeginEnd';
import ControlNetImagePreview from './ControlNetImagePreview'; import ControlNetImagePreview from './ControlNetImagePreview';
@ -30,10 +18,11 @@ import { v4 as uuidv4 } from 'uuid';
import { useToggle } from 'react-use'; import { useToggle } from 'react-use';
import ParamControlNetProcessorSelect from './parameters/ParamControlNetProcessorSelect'; import ParamControlNetProcessorSelect from './parameters/ParamControlNetProcessorSelect';
import ControlNetProcessorComponent from './ControlNetProcessorComponent'; import ControlNetProcessorComponent from './ControlNetProcessorComponent';
import ControlNetPreprocessButton from './ControlNetPreprocessButton';
import IAIButton from 'common/components/IAIButton';
import IAISwitch from 'common/components/IAISwitch'; import IAISwitch from 'common/components/IAISwitch';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'; import { ChevronUpIcon } from '@chakra-ui/icons';
import ParamControlNetShouldAutoConfig from './ParamControlNetShouldAutoConfig';
const expandedControlImageSx: ChakraProps['sx'] = { maxH: 96 };
type ControlNetProps = { type ControlNetProps = {
controlNet: ControlNetConfig; controlNet: ControlNetConfig;
@ -51,9 +40,10 @@ const ControlNet = (props: ControlNetProps) => {
processedControlImage, processedControlImage,
processorNode, processorNode,
processorType, processorType,
shouldAutoConfig,
} = props.controlNet; } = props.controlNet;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [shouldShowAdvanced, onToggleAdvanced] = useToggle(false); const [isExpanded, toggleIsExpanded] = useToggle(false);
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
dispatch(controlNetRemoved({ controlNetId })); dispatch(controlNetRemoved({ controlNetId }));
@ -77,6 +67,7 @@ const ControlNet = (props: ControlNetProps) => {
p: 3, p: 3,
bg: 'base.850', bg: 'base.850',
borderRadius: 'base', borderRadius: 'base',
position: 'relative',
}} }}
> >
<Flex sx={{ gap: 2 }}> <Flex sx={{ gap: 2 }}>
@ -115,27 +106,38 @@ const ControlNet = (props: ControlNetProps) => {
/> />
<IAIIconButton <IAIIconButton
size="sm" size="sm"
aria-label="Expand" aria-label="Show All Options"
onClick={onToggleAdvanced} onClick={toggleIsExpanded}
variant="link" variant="link"
icon={ icon={
<ChevronUpIcon <ChevronUpIcon
sx={{ sx={{
boxSize: 4, boxSize: 4,
color: 'base.300', color: 'base.300',
transform: shouldShowAdvanced transform: isExpanded ? 'rotate(0deg)' : 'rotate(180deg)',
? 'rotate(0deg)'
: 'rotate(180deg)',
transitionProperty: 'common', transitionProperty: 'common',
transitionDuration: 'normal', transitionDuration: 'normal',
}} }}
/> />
} }
/> />
{!shouldAutoConfig && (
<Box
sx={{
position: 'absolute',
w: 1.5,
h: 1.5,
borderRadius: 'full',
bg: 'error.200',
top: 4,
insetInlineEnd: 4,
}}
/>
)}
</Flex> </Flex>
{isEnabled && ( {isEnabled && (
<> <>
<Flex sx={{ gap: 4 }}> <Flex sx={{ gap: 4, w: 'full' }}>
<Flex <Flex
sx={{ sx={{
flexDir: 'column', flexDir: 'column',
@ -143,7 +145,7 @@ const ControlNet = (props: ControlNetProps) => {
w: 'full', w: 'full',
h: 24, h: 24,
paddingInlineStart: 1, paddingInlineStart: 1,
paddingInlineEnd: shouldShowAdvanced ? 1 : 0, paddingInlineEnd: isExpanded ? 1 : 0,
pb: 2, pb: 2,
justifyContent: 'space-between', justifyContent: 'space-between',
}} }}
@ -160,7 +162,7 @@ const ControlNet = (props: ControlNetProps) => {
mini mini
/> />
</Flex> </Flex>
{!shouldShowAdvanced && ( {!isExpanded && (
<Flex <Flex
sx={{ sx={{
alignItems: 'center', alignItems: 'center',
@ -174,10 +176,13 @@ const ControlNet = (props: ControlNetProps) => {
</Flex> </Flex>
)} )}
</Flex> </Flex>
{shouldShowAdvanced && ( {isExpanded && (
<> <>
<Box pt={2}> <Box mt={2}>
<ControlNetImagePreview controlNet={props.controlNet} /> <ControlNetImagePreview
controlNet={props.controlNet}
imageSx={expandedControlImageSx}
/>
</Box> </Box>
<ParamControlNetProcessorSelect <ParamControlNetProcessorSelect
controlNetId={controlNetId} controlNetId={controlNetId}
@ -187,72 +192,16 @@ const ControlNet = (props: ControlNetProps) => {
controlNetId={controlNetId} controlNetId={controlNetId}
processorNode={processorNode} processorNode={processorNode}
/> />
<ParamControlNetShouldAutoConfig
controlNetId={controlNetId}
shouldAutoConfig={shouldAutoConfig}
/>
</> </>
)} )}
</> </>
)} )}
</Flex> </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); export default memo(ControlNet);

View File

@ -1,4 +1,4 @@
import { memo, useCallback, useRef, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import { ImageDTO } from 'services/api'; import { ImageDTO } from 'services/api';
import { import {
ControlNetConfig, ControlNetConfig,
@ -6,41 +6,44 @@ import {
controlNetSelector, controlNetSelector,
} from '../store/controlNetSlice'; } from '../store/controlNetSlice';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { Box } from '@chakra-ui/react'; import { Box, ChakraProps, Flex } from '@chakra-ui/react';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { IAIImageFallback } from 'common/components/IAIImageFallback'; import { IAIImageFallback } from 'common/components/IAIImageFallback';
import { useHoverDirty } from 'react-use'; import IAIIconButton from 'common/components/IAIIconButton';
import { FaUndo } from 'react-icons/fa';
const selector = createSelector( const selector = createSelector(
controlNetSelector, controlNetSelector,
(controlNet) => { (controlNet) => {
const { isProcessingControlImage } = controlNet; const { pendingControlImages } = controlNet;
return { isProcessingControlImage }; return { pendingControlImages };
}, },
defaultSelectorOptions defaultSelectorOptions
); );
type Props = { type Props = {
controlNet: ControlNetConfig; controlNet: ControlNetConfig;
imageSx?: ChakraProps['sx'];
}; };
const ControlNetImagePreview = (props: Props) => { const ControlNetImagePreview = (props: Props) => {
const { imageSx } = props;
const { controlNetId, controlImage, processedControlImage, processorType } = const { controlNetId, controlImage, processedControlImage, processorType } =
props.controlNet; props.controlNet;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { isProcessingControlImage } = useAppSelector(selector); const { pendingControlImages } = useAppSelector(selector);
const containerRef = useRef<HTMLDivElement>(null);
const isMouseOverImage = useHoverDirty(containerRef); const [isMouseOverImage, setIsMouseOverImage] = useState(false);
const handleDrop = useCallback( const handleDrop = useCallback(
(droppedImage: ImageDTO) => { (droppedImage: ImageDTO) => {
if (controlImage?.image_name === droppedImage.image_name) { if (controlImage?.image_name === droppedImage.image_name) {
return; return;
} }
setIsMouseOverImage(false);
dispatch( dispatch(
controlNetImageChanged({ controlNetId, controlImage: droppedImage }) controlNetImageChanged({ controlNetId, controlImage: droppedImage })
); );
@ -48,6 +51,17 @@ const ControlNetImagePreview = (props: Props) => {
[controlImage, controlNetId, dispatch] [controlImage, controlNetId, dispatch]
); );
const handleResetControlImage = useCallback(() => {
dispatch(controlNetImageChanged({ controlNetId, controlImage: null }));
}, [controlNetId, dispatch]);
const handleMouseEnter = useCallback(() => {
setIsMouseOverImage(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsMouseOverImage(false);
}, []);
const shouldShowProcessedImageBackdrop = const shouldShowProcessedImageBackdrop =
Number(controlImage?.width) > Number(processedControlImage?.width) || Number(controlImage?.width) > Number(processedControlImage?.width) ||
Number(controlImage?.height) > Number(processedControlImage?.height); Number(controlImage?.height) > Number(processedControlImage?.height);
@ -56,13 +70,14 @@ const ControlNetImagePreview = (props: Props) => {
controlImage && controlImage &&
processedControlImage && processedControlImage &&
!isMouseOverImage && !isMouseOverImage &&
!isProcessingControlImage && !pendingControlImages.includes(controlNetId) &&
processorType !== 'none'; processorType !== 'none';
return ( return (
<Box <Box
ref={containerRef} onMouseEnter={handleMouseEnter}
sx={{ position: 'relative', w: 'full', h: 'full', aspectRatio: '1/1' }} onMouseLeave={handleMouseLeave}
sx={{ position: 'relative', w: 'full', h: 'full' }}
> >
<IAIDndImage <IAIDndImage
image={controlImage} image={controlImage}
@ -70,10 +85,14 @@ const ControlNetImagePreview = (props: Props) => {
isDropDisabled={Boolean( isDropDisabled={Boolean(
processedControlImage && processorType !== 'none' processedControlImage && processorType !== 'none'
)} )}
isUploadDisabled={Boolean(controlImage)}
postUploadAction={{ type: 'SET_CONTROLNET_IMAGE', controlNetId }}
imageSx={imageSx}
/> />
<AnimatePresence> <AnimatePresence>
{shouldShowProcessedImage && ( {shouldShowProcessedImage && (
<motion.div <motion.div
style={{ width: '100%' }}
initial={{ initial={{
opacity: 0, opacity: 0,
}} }}
@ -86,18 +105,13 @@ const ControlNetImagePreview = (props: Props) => {
transition: { duration: 0.1 }, transition: { duration: 0.1 },
}} }}
> >
<Box <>
sx={{
position: 'absolute',
w: 'full',
h: 'full',
top: 0,
insetInlineStart: 0,
}}
>
{shouldShowProcessedImageBackdrop && ( {shouldShowProcessedImageBackdrop && (
<Box <Box
sx={{ sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
w: 'full', w: 'full',
h: 'full', h: 'full',
bg: 'base.900', bg: 'base.900',
@ -118,13 +132,15 @@ const ControlNetImagePreview = (props: Props) => {
image={processedControlImage} image={processedControlImage}
onDrop={handleDrop} onDrop={handleDrop}
payloadImage={controlImage} payloadImage={controlImage}
isUploadDisabled={true}
imageSx={imageSx}
/> />
</Box> </Box>
</Box> </>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
{isProcessingControlImage && ( {pendingControlImages.includes(controlNetId) && (
<Box <Box
sx={{ sx={{
position: 'absolute', position: 'absolute',
@ -137,6 +153,22 @@ const ControlNetImagePreview = (props: Props) => {
<IAIImageFallback /> <IAIImageFallback />
</Box> </Box>
)} )}
{controlImage && (
<Flex sx={{ position: 'absolute', top: 0, insetInlineEnd: 0 }}>
<IAIIconButton
aria-label="Reset Control Image"
tooltip="Reset Control Image"
size="sm"
onClick={handleResetControlImage}
icon={<FaUndo />}
variant="link"
sx={{
p: 2,
color: 'base.50',
}}
/>
</Flex>
)}
</Box> </Box>
); );
}; };

View File

@ -0,0 +1,29 @@
import { useAppDispatch } from 'app/store/storeHooks';
import IAISwitch from 'common/components/IAISwitch';
import { controlNetAutoConfigToggled } from 'features/controlNet/store/controlNetSlice';
import { memo, useCallback } from 'react';
type Props = {
controlNetId: string;
shouldAutoConfig: boolean;
};
const ParamControlNetShouldAutoConfig = (props: Props) => {
const { controlNetId, shouldAutoConfig } = props;
const dispatch = useAppDispatch();
const handleShouldAutoConfigChanged = useCallback(() => {
dispatch(controlNetAutoConfigToggled({ controlNetId }));
}, [controlNetId, dispatch]);
return (
<IAISwitch
label="Auto configure processor"
aria-label="Auto configure processor"
isChecked={shouldAutoConfig}
onChange={handleShouldAutoConfigChanged}
/>
);
};
export default memo(ParamControlNetShouldAutoConfig);

View File

@ -7,12 +7,12 @@ import {
import { controlNetModelChanged } from 'features/controlNet/store/controlNetSlice'; import { controlNetModelChanged } from 'features/controlNet/store/controlNetSlice';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
type ParamIsControlNetModelProps = { type ParamControlNetModelProps = {
controlNetId: string; controlNetId: string;
model: ControlNetModel; model: ControlNetModel;
}; };
const ParamIsControlNetModel = (props: ParamIsControlNetModelProps) => { const ParamControlNetModel = (props: ParamControlNetModelProps) => {
const { controlNetId, model } = props; const { controlNetId, model } = props;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -38,4 +38,4 @@ const ParamIsControlNetModel = (props: ParamIsControlNetModelProps) => {
); );
}; };
export default memo(ParamIsControlNetModel); export default memo(ParamControlNetModel);

View File

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

View File

@ -9,12 +9,15 @@ import {
} from './types'; } from './types';
import { import {
CONTROLNET_MODELS, CONTROLNET_MODELS,
CONTROLNET_MODEL_MAP,
CONTROLNET_PROCESSORS, CONTROLNET_PROCESSORS,
ControlNetModel, ControlNetModel,
} from './constants'; } from './constants';
import { controlNetImageProcessed } from './actions'; import { controlNetImageProcessed } from './actions';
import { imageDeleted, imageUrlsReceived } from 'services/thunks/image'; import { imageDeleted, imageUrlsReceived } from 'services/thunks/image';
import { forEach } from 'lodash-es'; import { forEach } from 'lodash-es';
import { isAnySessionRejected } from 'services/thunks/session';
import { appSocketInvocationError } from 'services/events/actions';
export const initialControlNet: Omit<ControlNetConfig, 'controlNetId'> = { export const initialControlNet: Omit<ControlNetConfig, 'controlNetId'> = {
isEnabled: true, isEnabled: true,
@ -27,6 +30,7 @@ export const initialControlNet: Omit<ControlNetConfig, 'controlNetId'> = {
processorType: 'canny_image_processor', processorType: 'canny_image_processor',
processorNode: CONTROLNET_PROCESSORS.canny_image_processor processorNode: CONTROLNET_PROCESSORS.canny_image_processor
.default as RequiredCannyImageProcessorInvocation, .default as RequiredCannyImageProcessorInvocation,
shouldAutoConfig: true,
}; };
export type ControlNetConfig = { export type ControlNetConfig = {
@ -40,18 +44,19 @@ export type ControlNetConfig = {
processedControlImage: ImageDTO | null; processedControlImage: ImageDTO | null;
processorType: ControlNetProcessorType; processorType: ControlNetProcessorType;
processorNode: RequiredControlNetProcessorNode; processorNode: RequiredControlNetProcessorNode;
shouldAutoConfig: boolean;
}; };
export type ControlNetState = { export type ControlNetState = {
controlNets: Record<string, ControlNetConfig>; controlNets: Record<string, ControlNetConfig>;
isEnabled: boolean; isEnabled: boolean;
isProcessingControlImage: boolean; pendingControlImages: string[];
}; };
export const initialControlNetState: ControlNetState = { export const initialControlNetState: ControlNetState = {
controlNets: {}, controlNets: {},
isEnabled: false, isEnabled: false,
isProcessingControlImage: false, pendingControlImages: [],
}; };
export const controlNetSlice = createSlice({ export const controlNetSlice = createSlice({
@ -114,7 +119,7 @@ export const controlNetSlice = createSlice({
controlImage !== null && controlImage !== null &&
state.controlNets[controlNetId].processorType !== 'none' state.controlNets[controlNetId].processorType !== 'none'
) { ) {
state.isProcessingControlImage = true; state.pendingControlImages.push(controlNetId);
} }
}, },
controlNetProcessedImageChanged: ( controlNetProcessedImageChanged: (
@ -127,7 +132,9 @@ export const controlNetSlice = createSlice({
const { controlNetId, processedControlImage } = action.payload; const { controlNetId, processedControlImage } = action.payload;
state.controlNets[controlNetId].processedControlImage = state.controlNets[controlNetId].processedControlImage =
processedControlImage; processedControlImage;
state.isProcessingControlImage = false; state.pendingControlImages = state.pendingControlImages.filter(
(id) => id !== controlNetId
);
}, },
controlNetModelChanged: ( controlNetModelChanged: (
state, state,
@ -135,6 +142,21 @@ export const controlNetSlice = createSlice({
) => { ) => {
const { controlNetId, model } = action.payload; const { controlNetId, model } = action.payload;
state.controlNets[controlNetId].model = model; state.controlNets[controlNetId].model = model;
state.controlNets[controlNetId].processedControlImage = null;
if (state.controlNets[controlNetId].shouldAutoConfig) {
const processorType = CONTROLNET_MODEL_MAP[model];
if (processorType) {
state.controlNets[controlNetId].processorType = processorType;
state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS[
processorType
].default as RequiredControlNetProcessorNode;
} else {
state.controlNets[controlNetId].processorType = 'none';
state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS
.none.default as RequiredControlNetProcessorNode;
}
}
}, },
controlNetWeightChanged: ( controlNetWeightChanged: (
state, state,
@ -173,6 +195,7 @@ export const controlNetSlice = createSlice({
...processorNode, ...processorNode,
...changes, ...changes,
}; };
state.controlNets[controlNetId].shouldAutoConfig = false;
}, },
controlNetProcessorTypeChanged: ( controlNetProcessorTypeChanged: (
state, state,
@ -182,10 +205,40 @@ export const controlNetSlice = createSlice({
}> }>
) => { ) => {
const { controlNetId, processorType } = action.payload; const { controlNetId, processorType } = action.payload;
state.controlNets[controlNetId].processedControlImage = null;
state.controlNets[controlNetId].processorType = processorType; state.controlNets[controlNetId].processorType = processorType;
state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS[ state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS[
processorType processorType
].default as RequiredControlNetProcessorNode; ].default as RequiredControlNetProcessorNode;
state.controlNets[controlNetId].shouldAutoConfig = false;
},
controlNetAutoConfigToggled: (
state,
action: PayloadAction<{
controlNetId: string;
}>
) => {
const { controlNetId } = action.payload;
const newShouldAutoConfig =
!state.controlNets[controlNetId].shouldAutoConfig;
if (newShouldAutoConfig) {
// manage the processor for the user
const processorType =
CONTROLNET_MODEL_MAP[state.controlNets[controlNetId].model];
if (processorType) {
state.controlNets[controlNetId].processorType = processorType;
state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS[
processorType
].default as RequiredControlNetProcessorNode;
} else {
state.controlNets[controlNetId].processorType = 'none';
state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS
.none.default as RequiredControlNetProcessorNode;
}
}
state.controlNets[controlNetId].shouldAutoConfig = newShouldAutoConfig;
}, },
controlNetReset: () => { controlNetReset: () => {
return { ...initialControlNetState }; return { ...initialControlNetState };
@ -196,7 +249,7 @@ export const controlNetSlice = createSlice({
if ( if (
state.controlNets[action.payload.controlNetId].controlImage !== null state.controlNets[action.payload.controlNetId].controlImage !== null
) { ) {
state.isProcessingControlImage = true; state.pendingControlImages.push(action.payload.controlNetId);
} }
}); });
@ -229,6 +282,14 @@ export const controlNetSlice = createSlice({
} }
}); });
}); });
builder.addCase(appSocketInvocationError, (state, action) => {
state.pendingControlImages = [];
});
builder.addMatcher(isAnySessionRejected, (state, action) => {
state.pendingControlImages = [];
});
}, },
}); });
@ -247,6 +308,7 @@ export const {
controlNetProcessorParamsChanged, controlNetProcessorParamsChanged,
controlNetProcessorTypeChanged, controlNetProcessorTypeChanged,
controlNetReset, controlNetReset,
controlNetAutoConfigToggled,
} = controlNetSlice.actions; } = controlNetSlice.actions;
export default controlNetSlice.reducer; export default controlNetSlice.reducer;

View File

@ -50,21 +50,8 @@ const CurrentImagePreview = () => {
shouldShowProgressInViewer, shouldShowProgressInViewer,
shouldAntialiasProgressImage, shouldAntialiasProgressImage,
} = useAppSelector(imagesSelector); } = useAppSelector(imagesSelector);
const { shouldFetchImages } = useAppSelector(configSelector);
const toaster = useAppToaster();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleError = useCallback(() => {
dispatch(imageSelected());
if (shouldFetchImages) {
toaster({
title: 'Something went wrong, please refresh',
status: 'error',
isClosable: true,
});
}
}, [dispatch, toaster, shouldFetchImages]);
const handleDrop = useCallback( const handleDrop = useCallback(
(droppedImage: ImageDTO) => { (droppedImage: ImageDTO) => {
if (droppedImage.image_name === image?.image_name) { if (droppedImage.image_name === image?.image_name) {
@ -90,6 +77,7 @@ const CurrentImagePreview = () => {
src={progressImage.dataURL} src={progressImage.dataURL}
width={progressImage.width} width={progressImage.width}
height={progressImage.height} height={progressImage.height}
draggable={false}
sx={{ sx={{
objectFit: 'contain', objectFit: 'contain',
maxWidth: 'full', maxWidth: 'full',
@ -112,8 +100,8 @@ const CurrentImagePreview = () => {
<IAIDndImage <IAIDndImage
image={image} image={image}
onDrop={handleDrop} onDrop={handleDrop}
onError={handleError}
fallback={<IAIImageFallback sx={{ bg: 'none' }} />} fallback={<IAIImageFallback sx={{ bg: 'none' }} />}
isUploadDisabled={true}
/> />
</Flex> </Flex>
)} )}

View File

@ -279,6 +279,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
objectFit={ objectFit={
shouldUseSingleGalleryColumn ? 'contain' : galleryImageObjectFit shouldUseSingleGalleryColumn ? 'contain' : galleryImageObjectFit
} }
draggable={false}
rounded="md" rounded="md"
src={thumbnail_url || image_url} src={thumbnail_url || image_url}
fallback={<FaImage />} fallback={<FaImage />}

View File

@ -13,12 +13,13 @@ const NodeGraphOverlay = () => {
as="pre" as="pre"
fontFamily="monospace" fontFamily="monospace"
position="absolute" position="absolute"
top={10} top={2}
right={2} right={2}
opacity={0.7} opacity={0.7}
background="base.800" background="base.800"
p={2} p={2}
maxHeight={500} maxHeight={500}
maxWidth={500}
overflowY="scroll" overflowY="scroll"
borderRadius="md" borderRadius="md"
> >

View File

@ -60,6 +60,11 @@ const ImageInputFieldComponent = (
onDrop={handleDrop} onDrop={handleDrop}
onReset={handleReset} onReset={handleReset}
resetIconSize="sm" resetIconSize="sm"
postUploadAction={{
type: 'SET_NODES_IMAGE',
nodeId,
fieldName: field.name,
}}
/> />
</Flex> </Flex>
); );

View File

@ -63,7 +63,7 @@ export const addControlNetToLinearGraph = (
image_name, image_name,
image_origin, image_origin,
}; };
} else if (controlImage && processorType !== 'none') { } else if (controlImage) {
// The control image is preprocessed // The control image is preprocessed
const { image_name, image_origin } = controlImage; const { image_name, image_origin } = controlImage;
controlNetNode.image = { controlNetNode.image = {

View File

@ -20,10 +20,7 @@ const ParamNegativeConditioning = () => {
name="negativePrompt" name="negativePrompt"
value={negativePrompt} value={negativePrompt}
onChange={(e) => dispatch(setNegativePrompt(e.target.value))} onChange={(e) => dispatch(setNegativePrompt(e.target.value))}
placeholder={t('parameters.negativePrompts')} placeholder={t('parameters.negativePromptPlaceholder')}
_focusVisible={{
borderColor: 'error.600',
}}
fontSize="sm" fontSize="sm"
minH={16} minH={16}
/> />

View File

@ -70,13 +70,11 @@ const ParamPositiveConditioning = () => {
return ( return (
<Box> <Box>
<FormControl <FormControl>
isInvalid={prompt.length === 0 || Boolean(prompt.match(/^[\s\r\n]+$/))}
>
<IAITextarea <IAITextarea
id="prompt" id="prompt"
name="prompt" name="prompt"
placeholder={t('parameters.promptPlaceholder')} placeholder={t('parameters.positivePromptPlaceholder')}
value={prompt} value={prompt}
onChange={handleChangePrompt} onChange={handleChangePrompt}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}

View File

@ -6,11 +6,8 @@ import {
initialImageChanged, initialImageChanged,
} from 'features/parameters/store/generationSlice'; } from 'features/parameters/store/generationSlice';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { generationSelector } from 'features/parameters/store/generationSelectors'; import { generationSelector } from 'features/parameters/store/generationSelectors';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { configSelector } from '../../../../system/store/configSelectors';
import { useAppToaster } from 'app/components/Toaster';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import { ImageDTO } from 'services/api'; import { ImageDTO } from 'services/api';
import { IAIImageFallback } from 'common/components/IAIImageFallback'; import { IAIImageFallback } from 'common/components/IAIImageFallback';
@ -28,28 +25,7 @@ const selector = createSelector(
const InitialImagePreview = () => { const InitialImagePreview = () => {
const { initialImage } = useAppSelector(selector); const { initialImage } = useAppSelector(selector);
const { shouldFetchImages } = useAppSelector(configSelector);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation();
const toaster = useAppToaster();
const handleError = useCallback(() => {
dispatch(clearInitialImage());
if (shouldFetchImages) {
toaster({
title: 'Something went wrong, please refresh',
status: 'error',
isClosable: true,
});
} else {
toaster({
title: t('toast.parametersFailed'),
description: t('toast.parametersFailedDesc'),
status: 'error',
isClosable: true,
});
}
}, [dispatch, t, toaster, shouldFetchImages]);
const handleDrop = useCallback( const handleDrop = useCallback(
(droppedImage: ImageDTO) => { (droppedImage: ImageDTO) => {
@ -81,6 +57,7 @@ const InitialImagePreview = () => {
onDrop={handleDrop} onDrop={handleDrop}
onReset={handleReset} onReset={handleReset}
fallback={<IAIImageFallback sx={{ bg: 'none' }} />} fallback={<IAIImageFallback sx={{ bg: 'none' }} />}
postUploadAction={{ type: 'SET_INITIAL_IMAGE' }}
/> />
</Flex> </Flex>
); );

View File

@ -1,15 +1,11 @@
import { UseToastOptions } from '@chakra-ui/react'; import { UseToastOptions } from '@chakra-ui/react';
import { PayloadAction, isAnyOf } from '@reduxjs/toolkit'; import { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import * as InvokeAI from 'app/types/invokeai'; import * as InvokeAI from 'app/types/invokeai';
import { ProgressImage } from 'services/events/types'; import { ProgressImage } from 'services/events/types';
import { makeToast } from '../../../app/components/Toaster'; import { makeToast } from '../../../app/components/Toaster';
import { import { isAnySessionRejected, sessionCanceled } from 'services/thunks/session';
sessionCanceled,
sessionCreated,
sessionInvoked,
} from 'services/thunks/session';
import { receivedModels } from 'services/thunks/model'; import { receivedModels } from 'services/thunks/model';
import { parsedOpenAPISchema } from 'features/nodes/store/nodesSlice'; import { parsedOpenAPISchema } from 'features/nodes/store/nodesSlice';
import { LogLevelName } from 'roarr'; import { LogLevelName } from 'roarr';
@ -462,8 +458,3 @@ export const {
} = systemSlice.actions; } = systemSlice.actions;
export default systemSlice.reducer; export default systemSlice.reducer;
const isAnySessionRejected = isAnyOf(
sessionCreated.rejected,
sessionInvoked.rejected
);

View File

@ -32,7 +32,49 @@ export const imageMetadataReceived = createAppAsyncThunk(
} }
); );
type ImageUploadedArg = Parameters<(typeof ImagesService)['uploadImage']>[0]; type ControlNetAction = {
type: 'SET_CONTROLNET_IMAGE';
controlNetId: string;
};
type InitialImageAction = {
type: 'SET_INITIAL_IMAGE';
};
type NodesAction = {
type: 'SET_NODES_IMAGE';
nodeId: string;
fieldName: string;
};
type CanvasInitialImageAction = {
type: 'SET_CANVAS_INITIAL_IMAGE';
};
type CanvasMergedAction = {
type: 'TOAST_CANVAS_MERGED';
};
type CanvasSavedToGalleryAction = {
type: 'TOAST_CANVAS_SAVED_TO_GALLERY';
};
type UploadedToastAction = {
type: 'TOAST_UPLOADED';
};
export type PostUploadAction =
| ControlNetAction
| InitialImageAction
| NodesAction
| CanvasInitialImageAction
| CanvasMergedAction
| CanvasSavedToGalleryAction
| UploadedToastAction;
type ImageUploadedArg = Parameters<(typeof ImagesService)['uploadImage']>[0] & {
postUploadAction?: PostUploadAction;
};
/** /**
* `ImagesService.uploadImage()` thunk * `ImagesService.uploadImage()` thunk
@ -40,8 +82,9 @@ type ImageUploadedArg = Parameters<(typeof ImagesService)['uploadImage']>[0];
export const imageUploaded = createAppAsyncThunk( export const imageUploaded = createAppAsyncThunk(
'api/imageUploaded', 'api/imageUploaded',
async (arg: ImageUploadedArg) => { async (arg: ImageUploadedArg) => {
// strip out `activeTabName` from arg - the route does not need it // `postUploadAction` is only used by the listener middleware - destructure it out
const response = await ImagesService.uploadImage(arg); const { postUploadAction, ...rest } = arg;
const response = await ImagesService.uploadImage(rest);
return response; return response;
} }
); );

View File

@ -2,6 +2,7 @@ import { createAppAsyncThunk } from 'app/store/storeUtils';
import { GraphExecutionState, SessionsService } from 'services/api'; import { GraphExecutionState, SessionsService } from 'services/api';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { isObject } from 'lodash-es'; import { isObject } from 'lodash-es';
import { isAnyOf } from '@reduxjs/toolkit';
const sessionLog = log.child({ namespace: 'session' }); const sessionLog = log.child({ namespace: 'session' });
@ -115,3 +116,8 @@ export const listedSessions = createAppAsyncThunk(
return response; return response;
} }
); );
export const isAnySessionRejected = isAnyOf(
sessionCreated.rejected,
sessionInvoked.rejected
);

View File

@ -35,6 +35,6 @@ export const getInputOutlineStyles = (_props?: StyleFunctionProps) => ({
}, },
}, },
_placeholder: { _placeholder: {
color: 'base.400', color: 'base.500',
}, },
}); });