refactor(ui): canvas flow (wip)

This commit is contained in:
psychedelicious
2025-05-29 16:36:36 +10:00
parent d985dfe821
commit faeb5f0c3b
21 changed files with 658 additions and 109 deletions

View File

@@ -2018,6 +2018,7 @@
"replaceCurrent": "Replace Current",
"controlLayerEmptyState": "<UploadButton>Upload an image</UploadButton>, drag an image from the <GalleryButton>gallery</GalleryButton> onto this layer, <PullBboxButton>pull the bounding box into this layer</PullBboxButton>, or draw on the canvas to get started.",
"referenceImageEmptyState": "<UploadButton>Upload an image</UploadButton>, drag an image from the <GalleryButton>gallery</GalleryButton> onto this layer, or <PullBboxButton>pull the bounding box into this layer</PullBboxButton> to get started.",
"uploadOrDragAnImage": "Drag an image from the gallery or <UploadButton>upload an image</UploadButton>.",
"imageNoise": "Image Noise",
"denoiseLimit": "Denoise Limit",
"warnings": {

View File

@@ -8,8 +8,8 @@ import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice';
import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
import { canvasPersistConfig, canvasSlice, canvasUndoableConfig } from 'features/controlLayers/store/canvasSlice';
import {
canvasStagingAreaPersistConfig,
canvasSessionSlice,
canvasStagingAreaPersistConfig,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { lorasPersistConfig, lorasSlice } from 'features/controlLayers/store/lorasSlice';
import { paramsPersistConfig, paramsSlice } from 'features/controlLayers/store/paramsSlice';

View File

@@ -1,11 +1,11 @@
import type { IconButtonProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import type { ButtonProps, IconButtonProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Button, IconButton } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { useAppSelector } from 'app/store/storeHooks';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { selectIsClientSideUploadEnabled } from 'features/system/store/configSlice';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import { memo, useCallback } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
@@ -163,32 +163,63 @@ const sx = {
},
} satisfies SystemStyleObject;
export const UploadImageButton = ({
isDisabled = false,
onUpload,
isError = false,
...rest
}: {
export const UploadImageIconButton = memo(
({
isDisabled = false,
onUpload,
isError = false,
...rest
}: {
onUpload?: (imageDTO: ImageDTO) => void;
isError?: boolean;
} & SetOptional<IconButtonProps, 'aria-label'>) => {
const uploadApi = useImageUploadButton({ isDisabled, allowMultiple: false, onUpload });
return (
<>
<IconButton
aria-label="Upload image"
variant="outline"
sx={sx}
data-error={isError}
icon={<PiUploadBold />}
isLoading={uploadApi.request.isLoading}
{...rest}
{...uploadApi.getUploadButtonProps()}
/>
<input {...uploadApi.getUploadInputProps()} />
</>
);
}
);
UploadImageIconButton.displayName = 'UploadImageIconButton';
type UploadImageButtonProps = {
onUpload?: (imageDTO: ImageDTO) => void;
isError?: boolean;
} & SetOptional<IconButtonProps, 'aria-label'>) => {
} & ButtonProps;
export const UploadImageButton = memo((props: UploadImageButtonProps) => {
const { children, isDisabled = false, onUpload, isError = false, ...rest } = props;
const uploadApi = useImageUploadButton({ isDisabled, allowMultiple: false, onUpload });
return (
<>
<IconButton
<Button
aria-label="Upload image"
variant="outline"
sx={sx}
data-error={isError}
icon={<PiUploadBold />}
rightIcon={<PiUploadBold />}
isLoading={uploadApi.request.isLoading}
{...rest}
{...uploadApi.getUploadButtonProps()}
/>
>
{children ?? 'Upload'}
</Button>
<input {...uploadApi.getUploadInputProps()} />
</>
);
};
});
UploadImageButton.displayName = 'UploadImageButton';
export const UploadMultipleImageButton = ({
isDisabled = false,

View File

@@ -6,11 +6,11 @@ import { selectIsLocal } from 'features/system/store/configSlice';
import { selectSystemShouldShowInvocationProgressDetail } from 'features/system/store/systemSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { $invocationProgressMessage } from 'services/events/stores';
import { $lastProgressMessage } from 'services/events/stores';
const CanvasAlertsInvocationProgressContentLocal = memo(() => {
const { t } = useTranslation();
const invocationProgressMessage = useStore($invocationProgressMessage);
const invocationProgressMessage = useStore($lastProgressMessage);
if (!invocationProgressMessage) {
return null;

View File

@@ -1,8 +1,21 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Button, ContextMenu, Flex, IconButton, Image, Menu, MenuButton, MenuList, Text } from '@invoke-ai/ui-library';
import {
Button,
ContextMenu,
Flex,
Heading,
IconButton,
Image,
Menu,
MenuButton,
MenuList,
Text,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppStore } from 'app/store/nanostores/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask';
import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus';
import { CanvasAlertsSendingToGallery } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo';
@@ -32,13 +45,17 @@ import {
stagingAreaNextStagedImageSelected,
stagingAreaPrevStagedImageSelected,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { newCanvasFromImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { newCanvasFromImage } from 'features/imageActions/actions';
import type { ProgressImage } from 'features/nodes/types/common';
import { memo, useCallback, useEffect } from 'react';
import { memo, useCallback, useEffect, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi';
import { Trans, useTranslation } from 'react-i18next';
import { PiDotsThreeOutlineVerticalFill, PiUploadBold } from 'react-icons/pi';
import type { ImageDTO, S } from 'services/api/types';
import { $lastCanvasProgressImage, $socket } from 'services/events/stores';
import type { Equals } from 'tsafe';
import type { Equals, Param0 } from 'tsafe';
import { assert } from 'tsafe';
import { CanvasAlertsInvocationProgress } from './CanvasAlerts/CanvasAlertsInvocationProgress';
@@ -80,45 +97,190 @@ export const CanvasMainPanelContent = memo(() => {
CanvasMainPanelContent.displayName = 'CanvasMainPanelContent';
const generateWithStartingImageDndTargetData = newCanvasFromImageDndTarget.getData({
type: 'raster_layer',
withResize: true,
});
const generateWithStartingImageAndInpaintMaskDndTargetData = newCanvasFromImageDndTarget.getData({
type: 'raster_layer',
withInpaintMask: true,
});
const generateWithControlImageDndTargetData = newCanvasFromImageDndTarget.getData({
type: 'control_layer',
withResize: true,
});
const NoActiveSession = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const newSesh = useCallback(() => {
dispatch(canvasSessionStarted({ sessionType: 'advanced' }));
}, [dispatch]);
return (
<Flex flexDir="column" w="full" h="full" alignItems="center" justifyContent="center">
<Text fontSize="lg" fontWeight="bold">
No Active Session
</Text>
<Button display="flex" flexDir="column" gap={2} p={8} minH={0} minW={0} onClick={newSesh}>
<Text>New Canvas Session</Text>
<Text>- New Canvas Session</Text>
<Text>- 1 Inpaint mask layer added</Text>
<Heading>Get Started with Invoke</Heading>
<Button variant="ghost" onClick={newSesh}>
Start a new Canvas Session
</Button>
<Flex flexDir="column" gap={2} p={8} border="dashed yellow 2px">
<Text>Generate with Starting Image</Text>
<Text>- New Canvas Session</Text>
<Text>- Dropped image as raster layer</Text>
<Text>- Bbox resized</Text>
</Flex>
<Flex flexDir="column" gap={2} p={8} border="dashed yellow 2px">
<Text>Generate with Control Image</Text>
<Text>- New Canvas Session</Text>
<Text>- Dropped image as control layer</Text>
<Text>- Bbox resized</Text>
</Flex>
<Flex flexDir="column" gap={2} p={8} border="dashed yellow 2px">
<Text>Edit Image</Text>
<Text>- New Canvas Session</Text>
<Text>- Dropped image as raster layer</Text>
<Text>- Bbox resized</Text>
<Text>- 1 Inpaint mask layer added</Text>
<Text>or</Text>
<Flex flexDir="column" maxW={512}>
<GenerateWithStartingImage />
<GenerateWithControlImage />
<GenerateWithStartingImageAndInpaintMask />
</Flex>
</Flex>
);
});
NoActiveSession.displayName = 'NoActiveSession';
const GenerateWithStartingImage = memo(() => {
const { t } = useTranslation();
const { getState, dispatch } = useAppStore();
const useImageUploadButtonOptions = useMemo<Param0<typeof useImageUploadButton>>(
() => ({
onUpload: (imageDTO: ImageDTO) => {
newCanvasFromImage({ imageDTO, type: 'raster_layer', withResize: true, getState, dispatch });
},
allowMultiple: false,
}),
[dispatch, getState]
);
const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
const components = useMemo(
() => ({
UploadButton: (
<Button
size="sm"
variant="link"
color="base.300"
{...uploadApi.getUploadButtonProps()}
rightIcon={<PiUploadBold />}
/>
),
}),
[uploadApi]
);
return (
<Flex position="relative" flexDir="column">
<Text fontSize="lg" fontWeight="semibold">
Generate with a Starting Image
</Text>
<Text color="base.300">Regenerate the starting image using the model (Image to Image).</Text>
<Text color="base.300">
<Trans i18nKey="controlLayers.uploadOrDragAnImage" components={components} />
<input {...uploadApi.getUploadInputProps()} />
</Text>
<DndDropTarget
dndTarget={newCanvasFromImageDndTarget}
dndTargetData={generateWithStartingImageDndTargetData}
label="Drop"
/>
</Flex>
);
});
GenerateWithStartingImage.displayName = 'GenerateWithStartingImage';
const GenerateWithControlImage = memo(() => {
const { t } = useTranslation();
const { getState, dispatch } = useAppStore();
const useImageUploadButtonOptions = useMemo<Param0<typeof useImageUploadButton>>(
() => ({
onUpload: (imageDTO: ImageDTO) => {
newCanvasFromImage({ imageDTO, type: 'control_layer', withResize: true, getState, dispatch });
},
allowMultiple: false,
}),
[dispatch, getState]
);
const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
const components = useMemo(
() => ({
UploadButton: (
<Button
size="sm"
variant="link"
color="base.300"
{...uploadApi.getUploadButtonProps()}
rightIcon={<PiUploadBold />}
/>
),
}),
[uploadApi]
);
return (
<Flex position="relative" flexDir="column">
<Text fontSize="lg" fontWeight="semibold">
Generate with a Control Image
</Text>
<Text color="base.300">
Generate a new image using the control image to guide the structure and composition (Text to Image with
Control).
</Text>
<Text color="base.300">
<Trans i18nKey="controlLayers.uploadOrDragAnImage" components={components} />
<input {...uploadApi.getUploadInputProps()} />
</Text>
<DndDropTarget
dndTarget={newCanvasFromImageDndTarget}
dndTargetData={generateWithControlImageDndTargetData}
label="Drop"
/>
</Flex>
);
});
GenerateWithControlImage.displayName = 'GenerateWithControlImage';
const GenerateWithStartingImageAndInpaintMask = memo(() => {
const { t } = useTranslation();
const { getState, dispatch } = useAppStore();
const useImageUploadButtonOptions = useMemo<Param0<typeof useImageUploadButton>>(
() => ({
onUpload: (imageDTO: ImageDTO) => {
newCanvasFromImage({ imageDTO, type: 'raster_layer', withInpaintMask: true, getState, dispatch });
},
allowMultiple: false,
}),
[dispatch, getState]
);
const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
const components = useMemo(
() => ({
UploadButton: (
<Button
size="sm"
variant="link"
color="base.300"
{...uploadApi.getUploadButtonProps()}
rightIcon={<PiUploadBold />}
/>
),
}),
[uploadApi]
);
return (
<Flex position="relative" flexDir="column">
<Text fontSize="lg" fontWeight="semibold">
Edit Image
</Text>
<Text color="base.300">Edit the image by regenerating parts of it (Inpaint).</Text>
<Text color="base.300">
<Trans i18nKey="controlLayers.uploadOrDragAnImage" components={components} />
<input {...uploadApi.getUploadInputProps()} />
</Text>
<DndDropTarget
dndTarget={newCanvasFromImageDndTarget}
dndTargetData={generateWithStartingImageAndInpaintMaskDndTargetData}
label="Drop"
/>
</Flex>
);
});
GenerateWithStartingImageAndInpaintMask.displayName = 'GenerateWithStartingImageAndInpaintMask';
type EphemeralProgressImage = { sessionId: string; image: ProgressImage };
const SimpleActiveSession = memo(() => {
@@ -213,6 +375,9 @@ const SelectedImage = memo(() => {
src={selectedImage.imageDTO.image_url}
width={selectedImage.imageDTO.width}
height={selectedImage.imageDTO.height}
onLoad={() => {
console.log('onload');
}}
/>
</Flex>
);

View File

@@ -0,0 +1,254 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { dropTargetForExternal, monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
import { Box, Tab } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectEntityCountActive } from 'features/controlLayers/store/selectors';
import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd';
import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
import type { DndTargetState } from 'features/dnd/types';
import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
export const CanvasRightPanelStacked = memo(() => {
const { t } = useTranslation();
const activeTab = useAppSelector(selectActiveTabCanvasRightPanel);
const imageViewer = useImageViewer();
const dispatch = useAppDispatch();
const tabIndex = useMemo(() => {
if (activeTab === 'gallery') {
return 1;
} else {
return 0;
}
}, [activeTab]);
const onClickViewerToggleButton = useCallback(() => {
imageViewer.open();
}, [imageViewer]);
const onChangeTab = useCallback(
(index: number) => {
if (index === 0) {
dispatch(activeTabCanvasRightPanelChanged('layers'));
} else {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}
},
[dispatch]
);
return (
<PanelGroup direction="vertical">
<Panel>
<GalleryPanelContent />
</Panel>
<PanelResizeHandle />
<Panel>
<CanvasManagerProviderGate>
<CanvasLayersPanelContent />
</CanvasManagerProviderGate>
</Panel>
</PanelGroup>
);
});
CanvasRightPanelStacked.displayName = 'CanvasRightPanelStacked';
const PanelTabs = memo(() => {
const { t } = useTranslation();
const store = useAppStore();
const activeEntityCount = useAppSelector(selectEntityCountActive);
const [layersTabDndState, setLayersTabDndState] = useState<DndTargetState>('idle');
const [galleryTabDndState, setGalleryTabDndState] = useState<DndTargetState>('idle');
const layersTabRef = useRef<HTMLDivElement>(null);
const galleryTabRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<number | null>(null);
const layersTabLabel = useMemo(() => {
if (activeEntityCount === 0) {
return t('controlLayers.layer_other');
}
return `${t('controlLayers.layer_other')} (${activeEntityCount})`;
}, [activeEntityCount, t]);
useEffect(() => {
if (!layersTabRef.current) {
return;
}
const getIsOnLayersTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'layers';
const onDragEnter = () => {
// If we are already on the layers tab, do nothing
if (getIsOnLayersTab()) {
return;
}
// Else set the state to active and switch to the layers tab after a timeout
setLayersTabDndState('over');
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null;
store.dispatch(activeTabCanvasRightPanelChanged('layers'));
// When we switch tabs, the other tab should be pending
setLayersTabDndState('idle');
setGalleryTabDndState('potential');
}, 300);
};
const onDragLeave = () => {
// Set the state to idle or pending depending on the current tab
if (getIsOnLayersTab()) {
setLayersTabDndState('idle');
} else {
setLayersTabDndState('potential');
}
// Abort the tab switch if it hasn't happened yet
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
const onDragStart = () => {
// Set the state to pending when a drag starts
setLayersTabDndState('potential');
};
return combine(
dropTargetForElements({
element: layersTabRef.current,
onDragEnter,
onDragLeave,
}),
monitorForElements({
canMonitor: ({ source }) => {
if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) {
return false;
}
// Only monitor if we are not already on the gallery tab
return !getIsOnLayersTab();
},
onDragStart,
}),
dropTargetForExternal({
element: layersTabRef.current,
onDragEnter,
onDragLeave,
}),
monitorForExternal({
canMonitor: () => !getIsOnLayersTab(),
onDragStart,
})
);
}, [store]);
useEffect(() => {
if (!galleryTabRef.current) {
return;
}
const getIsOnGalleryTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'gallery';
const onDragEnter = () => {
// If we are already on the gallery tab, do nothing
if (getIsOnGalleryTab()) {
return;
}
// Else set the state to active and switch to the gallery tab after a timeout
setGalleryTabDndState('over');
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null;
store.dispatch(activeTabCanvasRightPanelChanged('gallery'));
// When we switch tabs, the other tab should be pending
setGalleryTabDndState('idle');
setLayersTabDndState('potential');
}, 300);
};
const onDragLeave = () => {
// Set the state to idle or pending depending on the current tab
if (getIsOnGalleryTab()) {
setGalleryTabDndState('idle');
} else {
setGalleryTabDndState('potential');
}
// Abort the tab switch if it hasn't happened yet
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
const onDragStart = () => {
// Set the state to pending when a drag starts
setGalleryTabDndState('potential');
};
return combine(
dropTargetForElements({
element: galleryTabRef.current,
onDragEnter,
onDragLeave,
}),
monitorForElements({
canMonitor: ({ source }) => {
if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) {
return false;
}
// Only monitor if we are not already on the gallery tab
return !getIsOnGalleryTab();
},
onDragStart,
}),
dropTargetForExternal({
element: galleryTabRef.current,
onDragEnter,
onDragLeave,
}),
monitorForExternal({
canMonitor: () => !getIsOnGalleryTab(),
onDragStart,
})
);
}, [store]);
useEffect(() => {
const onDrop = () => {
// Reset the dnd state when a drop happens
setGalleryTabDndState('idle');
setLayersTabDndState('idle');
};
const cleanup = combine(monitorForElements({ onDrop }), monitorForExternal({ onDrop }));
return () => {
cleanup();
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return (
<>
<Tab ref={layersTabRef} position="relative" w={32}>
<Box as="span" w="full">
{layersTabLabel}
</Box>
<DndDropOverlay dndState={layersTabDndState} withBackdrop={false} />
</Tab>
<Tab ref={galleryTabRef} position="relative" w={32}>
<Box as="span" w="full">
{t('gallery.gallery')}
</Box>
<DndDropOverlay dndState={galleryTabDndState} withBackdrop={false} />
</Tab>
</>
);
});
PanelTabs.displayName = 'PanelTabs';

View File

@@ -1,7 +1,7 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { UploadImageButton } from 'common/hooks/useImageUploadButton';
import { UploadImageIconButton } from 'common/hooks/useImageUploadButton';
import type { ImageWithDims } from 'features/controlLayers/store/types';
import type { setGlobalReferenceImageDndTarget, setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
@@ -51,7 +51,7 @@ export const IPAdapterImagePreview = memo(
return (
<Flex position="relative" w="full" h="full" alignItems="center" data-error={!imageDTO && !image?.image_name}>
{!imageDTO && (
<UploadImageButton
<UploadImageIconButton
w="full"
h="full"
isError={!imageDTO && !image?.image_name}

View File

@@ -20,7 +20,7 @@ export const useNewGallerySession = () => {
const newSessionDialog = useNewGallerySessionDialog();
const newGallerySessionImmediate = useCallback(() => {
dispatch(canvasSessionStarted({ sessionType: 'simple' }));
dispatch(canvasSessionStarted({ sessionType: null }));
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch]);

View File

@@ -1,11 +1,11 @@
import { useStore } from '@nanostores/react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { $invocationProgressMessage } from 'services/events/stores';
import { $lastProgressMessage } from 'services/events/stores';
export const useDeferredModelLoadingInvocationProgressMessage = () => {
const { t } = useTranslation();
const invocationProgressMessage = useStore($invocationProgressMessage);
const invocationProgressMessage = useStore($lastProgressMessage);
const [delayedMessage, setDelayedMessage] = useState<string | null>(null);
useEffect(() => {

View File

@@ -1,6 +1,7 @@
import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types';
import { fetchModelConfigByIdentifier } from 'features/metadata/util/modelFetchingHelpers';
import type { ProgressImage} from 'features/nodes/types/common';
import { zMainModelBase, zModelIdentifierField } from 'features/nodes/types/common';
import type { ParameterLoRAModel } from 'features/parameters/types/parameterSchemas';
import {
@@ -437,10 +438,12 @@ export type LoRA = {
};
export type StagingAreaImage = {
sessionId: string;
imageDTO: ImageDTO;
offsetX: number;
offsetY: number;
};
export type EphemeralProgressImage = { sessionId: string; image: ProgressImage };
export const zAspectRatioID = z.enum(['Free', '21:9', '9:21', '16:9', '3:2', '4:3', '1:1', '3:4', '2:3', '9:16']);

View File

@@ -11,6 +11,7 @@ import type { BoardId } from 'features/gallery/store/types';
import {
addImagesToBoard,
createNewCanvasEntityFromImage,
newCanvasFromImage,
removeImagesFromBoard,
replaceCanvasEntityObjectsWithImage,
setComparisonImage,
@@ -343,7 +344,35 @@ export const newCanvasEntityFromImageDndTarget: DndTarget<
createNewCanvasEntityFromImage({ type, imageDTO, dispatch, getState });
},
};
//#endregion
//#region New Canvas from Image
const _newCanvas = buildTypeAndKey('new-canvas-entity-from-image');
type NewCanvasFromImageDndTargetData = DndData<
typeof _newCanvas.type,
typeof _newCanvas.key,
{
type: CanvasEntityType | 'regional_guidance_with_reference_image';
withResize?: boolean;
withInpaintMask?: boolean;
}
>;
export const newCanvasFromImageDndTarget: DndTarget<NewCanvasFromImageDndTargetData, SingleImageDndSourceData> = {
..._newCanvas,
typeGuard: buildTypeGuard(_newCanvas.key),
getData: buildGetData(_newCanvas.key, _newCanvas.type),
isValid: ({ sourceData }) => {
if (!singleImageDndSource.typeGuard(sourceData)) {
return false;
}
return true;
},
handler: ({ sourceData, targetData, dispatch, getState }) => {
const { type, withResize } = targetData.payload;
const { imageDTO } = sourceData.payload;
newCanvasFromImage({ type, imageDTO, dispatch, getState, withResize });
},
};
//#endregion
//#region Replace Canvas Entity Objects With Image
@@ -471,6 +500,7 @@ export const dndTargets = [
replaceCanvasEntityObjectsWithImageDndTarget,
addImageToBoardDndTarget,
removeImageFromBoardDndTarget,
newCanvasFromImageDndTarget,
// Single or Multiple Image
addImageToBoardDndTarget,
removeImageFromBoardDndTarget,

View File

@@ -10,6 +10,9 @@ import {
} from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { GalleryHeader } from 'features/gallery/components/GalleryHeader';
import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors';
import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
@@ -43,6 +46,7 @@ const GalleryPanelContent = () => {
const dispatch = useAppDispatch();
const boardSearchDisclosure = useDisclosure({ defaultIsOpen: !!boardSearchText.length });
const imperativePanelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const sessionType = useAppSelector(selectCanvasSessionType);
const boardsListPanelOptions = useMemo<UsePanelOptions>(
() => ({
@@ -56,6 +60,30 @@ const GalleryPanelContent = () => {
);
const boardsListPanel = usePanel(boardsListPanelOptions);
const galleryPanelOptions = useMemo<UsePanelOptions>(
() => ({
id: 'gallery-panel',
minSizePx: 128,
defaultSizePx: 256,
imperativePanelGroupRef,
panelGroupDirection: 'vertical',
}),
[]
);
const galleryPanel = usePanel(galleryPanelOptions);
const canvasLayersPanelOptions = useMemo<UsePanelOptions>(
() => ({
id: 'canvas-layers-panel',
minSizePx: 128,
defaultSizePx: 256,
imperativePanelGroupRef,
panelGroupDirection: 'vertical',
}),
[]
);
const canvasLayersPanel = usePanel(canvasLayersPanelOptions);
const handleClickBoardSearch = useCallback(() => {
if (boardSearchText.length) {
dispatch(boardSearchTextChanged(''));
@@ -98,7 +126,7 @@ const GalleryPanelContent = () => {
</Flex>
<PanelGroup ref={imperativePanelGroupRef} direction="vertical" autoSaveId="boards-list-panel">
<Panel collapsible {...boardsListPanel.panelProps}>
<Panel order={0} id="boards-panel" collapsible {...boardsListPanel.panelProps}>
<Flex flexDir="column" w="full" h="full">
<Collapse in={boardSearchDisclosure.isOpen} style={COLLAPSE_STYLES}>
<Box w="full" pt={2}>
@@ -109,10 +137,20 @@ const GalleryPanelContent = () => {
<BoardsListWrapper />
</Flex>
</Panel>
<HorizontalResizeHandle id="gallery-panel-handle" {...boardsListPanel.resizeHandleProps} />
<Panel id="gallery-wrapper-panel" minSize={20}>
<HorizontalResizeHandle id="boards-list-to-gallery-panel-handle" {...boardsListPanel.resizeHandleProps} />
<Panel order={1} id="gallery-wrapper-panel" collapsible {...galleryPanel.panelProps}>
<Gallery />
</Panel>
{sessionType === 'advanced' && (
<>
<HorizontalResizeHandle id="gallery-panel-to-layers-handle" {...galleryPanel.resizeHandleProps} />
<Panel order={2} id="canvas-layers-panel" collapsible {...canvasLayersPanel.panelProps}>
<CanvasManagerProviderGate>
<CanvasLayersPanelContent />
</CanvasManagerProviderGate>
</Panel>
</>
)}
</PanelGroup>
</FocusRegionWrapper>
);

View File

@@ -11,7 +11,7 @@ import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useCallback, useRef, useState } from 'react';
import type { ImageDTO } from 'services/api/types';
import { $hasProgressImage } from 'services/events/stores';
import { $hasLastProgressImage } from 'services/events/stores';
import { NoContentForViewer } from './NoContentForViewer';
import ProgressImage from './ProgressImage';
@@ -86,7 +86,7 @@ const CurrentImagePreview = ({ imageDTO }: { imageDTO?: ImageDTO }) => {
export default memo(CurrentImagePreview);
const ImageContent = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => {
const hasProgressImage = useStore($hasProgressImage);
const hasProgressImage = useStore($hasLastProgressImage);
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
if (hasProgressImage && shouldShowProgressInViewer) {

View File

@@ -5,7 +5,7 @@ import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectSystemSlice } from 'features/system/store/systemSlice';
import { memo, useMemo } from 'react';
import { $progressImage } from 'services/events/stores';
import { $lastProgressImage } from 'services/events/stores';
const selectShouldAntialiasProgressImage = createSelector(
selectSystemSlice,
@@ -13,7 +13,7 @@ const selectShouldAntialiasProgressImage = createSelector(
);
const CurrentImagePreview = () => {
const progressImage = useStore($progressImage);
const progressImage = useStore($lastProgressImage);
const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage);
const sx = useMemo<SystemStyleObject>(

View File

@@ -3,9 +3,9 @@ import { deepClone } from 'common/util/deepClone';
import { selectDefaultIPAdapter, selectDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityTransformer';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { canvasReset } from 'features/controlLayers/store/actions';
import {
bboxChangedFromCanvas,
canvasClearHistory,
controlLayerAdded,
entityRasterized,
inpaintMaskAdded,
@@ -15,6 +15,7 @@ import {
rgAdded,
rgIPAdapterImageChanged,
} from 'features/controlLayers/store/canvasSlice';
import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectBboxModelBase, selectBboxRect } from 'features/controlLayers/store/selectors';
import type {
CanvasControlLayerState,
@@ -147,10 +148,11 @@ export const newCanvasFromImage = async (arg: {
imageDTO: ImageDTO;
type: CanvasEntityType | 'regional_guidance_with_reference_image';
withResize?: boolean;
withInpaintMask?: boolean;
dispatch: AppDispatch;
getState: () => RootState;
}) => {
const { type, imageDTO, withResize = false, dispatch, getState } = arg;
const { type, imageDTO, withResize = false, withInpaintMask = false, dispatch, getState } = arg;
const state = getState();
const base = selectBboxModelBase(state);
@@ -192,10 +194,14 @@ export const newCanvasFromImage = async (arg: {
objects: [imageObject],
} satisfies Partial<CanvasRasterLayerState>;
addFitOnLayerInitCallback(overrides.id);
dispatch(canvasReset());
dispatch(canvasSessionStarted({ sessionType: 'advanced' }));
// The `bboxChangedFromCanvas` reducer does no validation! Careful!
dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height }));
dispatch(rasterLayerAdded({ overrides, isSelected: true }));
if (withInpaintMask) {
dispatch(inpaintMaskAdded({ isSelected: true }));
}
dispatch(canvasClearHistory());
break;
}
case 'control_layer': {
@@ -205,10 +211,14 @@ export const newCanvasFromImage = async (arg: {
controlAdapter: deepClone(initialControlNet),
} satisfies Partial<CanvasControlLayerState>;
addFitOnLayerInitCallback(overrides.id);
dispatch(canvasReset());
dispatch(canvasSessionStarted({ sessionType: 'advanced' }));
// The `bboxChangedFromCanvas` reducer does no validation! Careful!
dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height }));
dispatch(controlLayerAdded({ overrides, isSelected: true }));
if (withInpaintMask) {
dispatch(inpaintMaskAdded({ isSelected: true }));
}
dispatch(canvasClearHistory());
break;
}
case 'inpaint_mask': {
@@ -217,10 +227,14 @@ export const newCanvasFromImage = async (arg: {
objects: [imageObject],
} satisfies Partial<CanvasInpaintMaskState>;
addFitOnLayerInitCallback(overrides.id);
dispatch(canvasReset());
dispatch(canvasSessionStarted({ sessionType: 'advanced' }));
// The `bboxChangedFromCanvas` reducer does no validation! Careful!
dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height }));
dispatch(inpaintMaskAdded({ overrides, isSelected: true }));
if (withInpaintMask) {
dispatch(inpaintMaskAdded({ isSelected: true }));
}
dispatch(canvasClearHistory());
break;
}
case 'regional_guidance': {
@@ -229,25 +243,37 @@ export const newCanvasFromImage = async (arg: {
objects: [imageObject],
} satisfies Partial<CanvasRegionalGuidanceState>;
addFitOnLayerInitCallback(overrides.id);
dispatch(canvasReset());
dispatch(canvasSessionStarted({ sessionType: 'advanced' }));
// The `bboxChangedFromCanvas` reducer does no validation! Careful!
dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height }));
dispatch(rgAdded({ overrides, isSelected: true }));
if (withInpaintMask) {
dispatch(inpaintMaskAdded({ isSelected: true }));
}
dispatch(canvasClearHistory());
break;
}
case 'reference_image': {
const ipAdapter = deepClone(selectDefaultRefImageConfig(getState()));
ipAdapter.image = imageDTOToImageWithDims(imageDTO);
dispatch(canvasReset());
dispatch(canvasSessionStarted({ sessionType: 'advanced' }));
dispatch(referenceImageAdded({ overrides: { ipAdapter }, isSelected: true }));
if (withInpaintMask) {
dispatch(inpaintMaskAdded({ isSelected: true }));
}
dispatch(canvasClearHistory());
break;
}
case 'regional_guidance_with_reference_image': {
const ipAdapter = deepClone(selectDefaultIPAdapter(getState()));
ipAdapter.image = imageDTOToImageWithDims(imageDTO);
const referenceImages = [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }];
dispatch(canvasReset());
dispatch(canvasSessionStarted({ sessionType: 'advanced' }));
dispatch(rgAdded({ overrides: { referenceImages }, isSelected: true }));
if (withInpaintMask) {
dispatch(inpaintMaskAdded({ isSelected: true }));
}
dispatch(canvasClearHistory());
break;
}
default:

View File

@@ -2,7 +2,7 @@ import { Flex, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch } from 'app/store/storeHooks';
import { UploadImageButton } from 'common/hooks/useImageUploadButton';
import { UploadImageIconButton } from 'common/hooks/useImageUploadButton';
import type { SetNodeImageFieldImageDndTargetData } from 'features/dnd/dnd';
import { setNodeImageFieldImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
@@ -66,7 +66,7 @@ const ImageFieldInputComponent = (props: FieldComponentProps<ImageFieldInputInst
return (
<Flex position="relative" className={NO_DRAG_CLASS} w="full" h={32} alignItems="stretch">
{!imageDTO && (
<UploadImageButton
<UploadImageIconButton
w="full"
h="auto"
isError={fieldTemplate.required && !field.value}

View File

@@ -1,6 +1,6 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { UploadImageButton } from 'common/hooks/useImageUploadButton';
import { UploadImageIconButton } from 'common/hooks/useImageUploadButton';
import type { SetUpscaleInitialImageDndTargetData } from 'features/dnd/dnd';
import { setUpscaleInitialImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
@@ -34,7 +34,7 @@ export const UpscaleInitialImage = () => {
return (
<Flex justifyContent="flex-start">
<Flex position="relative" w={36} h={36} alignItems="center" justifyContent="center">
{!imageDTO && <UploadImageButton w="full" h="full" isError={!imageDTO} onUpload={onUpload} fontSize={36} />}
{!imageDTO && <UploadImageIconButton w="full" h="full" isError={!imageDTO} onUpload={onUpload} fontSize={36} />}
{imageDTO && (
<>
<DndImage imageDTO={imageDTO} />

View File

@@ -1,7 +1,7 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasMainPanelContent } from 'features/controlLayers/components/CanvasMainPanelContent';
import { CanvasRightPanel } from 'features/controlLayers/components/CanvasRightPanel';
import { selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useDndMonitor } from 'features/dnd/useDndMonitor';
import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent';
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
@@ -161,11 +161,9 @@ AppContent.displayName = 'AppContent';
const RightPanelContent = memo(() => {
const tab = useAppSelector(selectActiveTab);
const sessionType = useAppSelector(selectCanvasSessionType);
if (tab === 'canvas') {
return <CanvasRightPanel />;
}
if (tab === 'upscaling' || tab === 'workflows') {
if (tab === 'upscaling' || tab === 'workflows' || tab === 'canvas') {
return <GalleryPanelContent />;
}

View File

@@ -2,6 +2,7 @@ import { ButtonGroup, Flex, Icon, IconButton, spinAnimation, Tooltip, useShiftMo
import { useAppSelector } from 'app/store/storeHooks';
import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
import { useClearQueueDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { InvokeButtonTooltip } from 'features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip';
@@ -30,10 +31,11 @@ const FloatingSidePanelButtons = ({ togglePanel }: Props) => {
const { t } = useTranslation();
const tab = useAppSelector(selectActiveTab);
const isCancelAndClearAllEnabled = useFeatureStatus('cancelAndClearAll');
const sessionType = useAppSelector(selectCanvasSessionType);
return (
<Flex pos="absolute" transform="translate(0, -50%)" top="50%" insetInlineStart={2} direction="column" gap={2}>
{tab === 'canvas' && (
{tab === 'canvas' && sessionType === 'advanced' && (
<CanvasManagerProviderGate>
<ToolChooser />
</CanvasManagerProviderGate>

View File

@@ -30,7 +30,15 @@ import type { ClientToServerEvents, ServerToClientEvents } from 'services/events
import type { Socket } from 'socket.io-client';
import type { JsonObject } from 'type-fest';
import { $lastCanvasProgressEvent, $lastProgressEvent } from './stores';
import {
$lastCanvasProgressEvent,
$lastCanvasProgressImage,
$lastProgressEvent,
$lastUpscalingProgressEvent,
$lastUpscalingProgressImage,
$lastWorkflowsProgressEvent,
$lastWorkflowsProgressImage,
} from './stores';
const log = logger('events');
@@ -92,7 +100,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
});
socket.on('invocation_progress', (data) => {
const { invocation_source_id, invocation, image, origin, percentage, message } = data;
const { invocation_source_id, invocation, session_id, image, origin, percentage, message } = data;
let _message = 'Invocation progress';
if (message) {
@@ -107,7 +115,27 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
$lastProgressEvent.set(data);
if (origin === 'canvas') {
$lastCanvasProgressEvent.set(data);
if (image) {
$lastCanvasProgressImage.set({ sessionId: session_id, image });
}
}
if (origin === 'upscaling') {
$lastUpscalingProgressEvent.set(data);
if (image) {
$lastUpscalingProgressImage.set({ sessionId: session_id, image });
}
}
if (origin === 'workflows') {
$lastWorkflowsProgressEvent.set(data);
if (image) {
$lastWorkflowsProgressImage.set({ sessionId: session_id, image });
}
const nes = deepClone($nodeExecutionStates.get()[invocation_source_id]);
if (nes) {
nes.status = zNodeStatus.enum.IN_PROGRESS;

View File

@@ -1,4 +1,4 @@
import type { ProgressImage } from 'features/nodes/types/common';
import type { EphemeralProgressImage } from 'features/controlLayers/store/types';
import { round } from 'lodash-es';
import { atom, computed, map } from 'nanostores';
import type { S } from 'services/api/types';
@@ -9,33 +9,6 @@ export const $socket = atom<AppSocket | null>(null);
export const $socketOptions = map<Partial<ManagerOptions & SocketOptions>>({});
export const $isConnected = atom<boolean>(false);
export const $lastProgressEvent = atom<S['InvocationProgressEvent'] | null>(null);
$lastProgressEvent.subscribe((event) => {
if (!event) {
return;
}
switch (event.destination) {
case 'workflows':
$lastWorkflowsProgressEvent.set(event);
if (event.image) {
$lastWorkflowsProgressImage.set({ sessionId: event.session_id, image: event.image });
}
break;
case 'upscaling':
$lastUpscalingProgressEvent.set(event);
if (event.image) {
$lastUpscalingProgressImage.set({ sessionId: event.session_id, image: event.image });
}
break;
case 'canvas':
$lastCanvasProgressEvent.set(event);
if (event.image) {
$lastCanvasProgressImage.set({ sessionId: event.session_id, image: event.image });
}
break;
}
});
type EphemeralProgressImage = { sessionId: string; image: ProgressImage };
export const $lastCanvasProgressEvent = atom<S['InvocationProgressEvent'] | null>(null);
export const $lastCanvasProgressImage = atom<EphemeralProgressImage | null>(null);
@@ -44,9 +17,9 @@ export const $lastWorkflowsProgressImage = atom<EphemeralProgressImage | null>(n
export const $lastUpscalingProgressEvent = atom<S['InvocationProgressEvent'] | null>(null);
export const $lastUpscalingProgressImage = atom<EphemeralProgressImage | null>(null);
export const $progressImage = computed($lastProgressEvent, (val) => val?.image ?? null);
export const $hasProgressImage = computed($lastProgressEvent, (val) => Boolean(val?.image));
export const $invocationProgressMessage = computed($lastProgressEvent, (val) => {
export const $lastProgressImage = computed($lastProgressEvent, (val) => val?.image ?? null);
export const $hasLastProgressImage = computed($lastProgressEvent, (val) => Boolean(val?.image));
export const $lastProgressMessage = computed($lastProgressEvent, (val) => {
if (!val) {
return null;
}