feat(ui): rough out canvas staging area

This commit is contained in:
psychedelicious
2025-06-05 17:45:56 +10:00
parent 088eea9a0e
commit b038c79451
30 changed files with 622 additions and 331 deletions

View File

@@ -17,7 +17,7 @@ export const addStagingListeners = (startAppListening: AppStartListening) => {
effect: async (_, { dispatch }) => {
try {
const req = dispatch(
queueApi.endpoints.cancelByBatchDestination.initiate(
queueApi.endpoints.cancelByDestination.initiate(
{ destination: 'canvas' },
{ fixedCacheKey: 'cancelByBatchOrigin' }
)

View File

@@ -58,7 +58,7 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image
selectCanvasSlice(state).controlLayers.entities.forEach(({ id, objects }) => {
let shouldDelete = false;
for (const obj of objects) {
if (obj.type === 'image' && obj.image.image_name === imageDTO.image_name) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) {
shouldDelete = true;
break;
}

View File

@@ -13,7 +13,8 @@ import { Filter } from 'features/controlLayers/components/Filters/Filter';
import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD';
import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent';
import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject';
import { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate';
import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context';
import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList';
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
import { Transform } from 'features/controlLayers/components/Transform/Transform';
@@ -52,7 +53,7 @@ const canvasBgSx = {
},
};
export const AdvancedSession = memo((_props: { session: AdvancedSessionIdentifier }) => {
export const AdvancedSession = memo(({ session }: { session: AdvancedSessionIdentifier }) => {
const dynamicGrid = useAppSelector(selectDynamicGrid);
const showHUD = useAppSelector(selectShowHUD);
@@ -106,13 +107,27 @@ export const AdvancedSession = memo((_props: { session: AdvancedSessionIdentifie
</Flex>
)}
</ContextMenu>
<Flex position="absolute" bottom={4} gap={2} align="center" justify="center">
<CanvasManagerProviderGate>
<StagingAreaIsStagingGate>
<StagingAreaToolbar />
</StagingAreaIsStagingGate>
</CanvasManagerProviderGate>
</Flex>
<CanvasManagerProviderGate>
<CanvasSessionContextProvider session={session}>
<Flex
position="absolute"
flexDir="column"
bottom={4}
gap={2}
align="center"
justify="center"
left={4}
right={4}
>
<Flex position="relative" maxW="full" w="full" h={108}>
<StagingAreaItemsList />
</Flex>
<Flex gap={2}>
<StagingAreaToolbar />
</Flex>
</Flex>
</CanvasSessionContextProvider>
</CanvasManagerProviderGate>
<Flex position="absolute" bottom={4}>
<CanvasManagerProviderGate>
<Filter />

View File

@@ -11,24 +11,18 @@ import { memo, useCallback, useState } from 'react';
import type { S } from 'services/api/types';
const sx = {
cursor: 'pointer',
userSelect: 'none',
pos: 'relative',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
h: 'full',
maxH: 'full',
maxW: 'full',
minW: 0,
minH: 0,
h: 108,
w: 108,
flexShrink: 0,
borderWidth: 1,
borderRadius: 'base',
'&[data-selected="true"]': {
borderColor: 'invokeBlue.300',
},
aspectRatio: '1/1',
flexShrink: 0,
} satisfies SystemStyleObject;
type Props = {
@@ -64,7 +58,7 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) =
onDoubleClick={onDoubleClick}
>
<QueueItemStatusLabel status={item.status} position="absolute" margin="auto" />
{imageDTO && <DndImage imageDTO={imageDTO} onLoad={onLoad} />}
{imageDTO && <DndImage imageDTO={imageDTO} onLoad={onLoad} asThumbnail />}
{!imageLoaded && <QueueItemProgressImage itemId={item.item_id} position="absolute" />}
<QueueItemNumber number={number} position="absolute" top={0} left={1} />
<QueueItemCircularProgress itemId={item.item_id} status={item.status} position="absolute" top={1} right={2} />

View File

@@ -1,7 +1,8 @@
import type { ImageProps } from '@invoke-ai/ui-library';
import { Image } from '@invoke-ai/ui-library';
import { Flex, Icon, Image } from '@invoke-ai/ui-library';
import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context';
import { memo } from 'react';
import { PiImageBold } from 'react-icons/pi';
type Props = { itemId: number } & ImageProps;
@@ -10,7 +11,11 @@ export const QueueItemProgressImage = memo(({ itemId, ...rest }: Props) => {
const { progressImage } = useProgressData(ctx.$progressData, itemId);
if (!progressImage) {
return null;
return (
<Flex w="full" h="full" bg="base.700" alignItems="center" justifyContent="center">
<Icon as={PiImageBold} boxSize={16} opacity={0.2} />
</Flex>
);
}
return (

View File

@@ -4,16 +4,49 @@ import { useStore } from '@nanostores/react';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini';
import { memo } from 'react';
import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared';
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { effect } from 'nanostores';
import { memo, useEffect } from 'react';
export const StagingAreaItemsList = memo(() => {
const canvasManager = useCanvasManagerSafe();
const ctx = useCanvasSessionContext();
const items = useStore(ctx.$items);
const selectedItemId = useStore(ctx.$selectedItemId);
useEffect(() => {
if (!canvasManager) {
return;
}
return effect([ctx.$selectedItem, ctx.$progressData], (selectedItem, progressData) => {
if (!selectedItem) {
canvasManager.stagingArea.render();
return;
}
const outputImageName = getOutputImageName(selectedItem);
if (outputImageName) {
canvasManager.stagingArea.render({ type: 'imageName', data: outputImageName });
return;
}
const data = progressData[selectedItem.item_id];
if (data?.progressImage) {
canvasManager.stagingArea.render({ type: 'dataURL', data: data.progressImage.dataURL });
return;
}
canvasManager.stagingArea.render();
});
}, [canvasManager, ctx.$progressData, ctx.$selectedItem]);
return (
<ScrollableContent overflowX="scroll" overflowY="hidden">
<Flex gap={2} w="full" h="full">
<Flex gap={2} w="full" h="full" justifyContent="safe center">
{items.map((item, i) => (
<QueueItemPreviewMini
key={`${item.item_id}-mini`}

View File

@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppStore } from 'app/store/nanostores/store';
import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared';
import type {
AdvancedSessionIdentifier,
SimpleSessionIdentifier,
@@ -10,7 +11,7 @@ import type { ProgressImage } from 'features/nodes/types/common';
import type { Atom, WritableAtom } from 'nanostores';
import { atom, computed, effect } from 'nanostores';
import type { PropsWithChildren } from 'react';
import { createContext, memo, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { queueApi } from 'services/api/endpoints/queue';
import type { S } from 'services/api/types';
import { $socket } from 'services/events/stores';
@@ -122,13 +123,19 @@ export const clearProgressImage = ($progressData: WritableAtom<Record<number, Pr
export type CanvasSessionContextValue = {
session: SimpleSessionIdentifier | AdvancedSessionIdentifier;
$items: Atom<S['SessionQueueItem'][]>;
$itemCount: Atom<number>;
$hasItems: Atom<boolean>;
$progressData: WritableAtom<Record<string, ProgressData>>;
$selectedItemId: WritableAtom<number | null>;
$selectedItem: Atom<S['SessionQueueItem'] | null>;
$selectedItemIndex: Atom<number | null>;
$selectedItemOutputImageName: Atom<string | null>;
$autoSwitch: WritableAtom<boolean>;
$lastLoadedItemId: WritableAtom<number | null>;
selectNext: () => void;
selectPrev: () => void;
selectFirst: () => void;
selectLast: () => void;
};
const CanvasSessionContext = createContext<CanvasSessionContextValue | null>(null);
@@ -153,6 +160,11 @@ export const CanvasSessionContextProvider = memo(
*/
const $items = useState(() => atom<S['SessionQueueItem'][]>([]))[0];
/**
* Manually-synced atom containing the queue items for the current session.
*/
const $prevItems = useState(() => atom<S['SessionQueueItem'][]>([]))[0];
/**
* Whether auto-switch is enabled.
*/
@@ -174,6 +186,11 @@ export const CanvasSessionContextProvider = memo(
*/
const $selectedItemId = useState(() => atom<number | null>(null))[0];
/**
* The number of items. Computed from the queue items array.
*/
const $itemCount = useState(() => computed([$items], (items) => items.length))[0];
/**
* Whether there are any items. Computed from the queue items array.
*/
@@ -209,6 +226,23 @@ export const CanvasSessionContextProvider = memo(
})
)[0];
/**
* The currently selected queue item's output image name, or null if one is not selected or there is no output
* image recorded.
*/
const $selectedItemOutputImageName = useState(() =>
computed([$selectedItem], (selectedItem) => {
if (selectedItem === null) {
return null;
}
const outputImageName = getOutputImageName(selectedItem);
if (outputImageName === null) {
return null;
}
return outputImageName;
})
)[0];
/**
* A redux selector to select all queue items from the RTK Query cache. It's important that this returns stable
* references if possible to reduce re-renders. All derivations of the queue items (e.g. filtering out canceled
@@ -223,6 +257,54 @@ export const CanvasSessionContextProvider = memo(
[session.id]
);
const selectNext = useCallback(() => {
const selectedItemId = $selectedItemId.get();
if (selectedItemId === null) {
return;
}
const items = $items.get();
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
const nextIndex = (currentIndex + 1) % items.length;
const nextItem = items[nextIndex];
if (!nextItem) {
return;
}
$selectedItemId.set(nextItem.item_id);
}, [$items, $selectedItemId]);
const selectPrev = useCallback(() => {
const selectedItemId = $selectedItemId.get();
if (selectedItemId === null) {
return;
}
const items = $items.get();
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
const prevIndex = (currentIndex - 1 + items.length) % items.length;
const prevItem = items[prevIndex];
if (!prevItem) {
return;
}
$selectedItemId.set(prevItem.item_id);
}, [$items, $selectedItemId]);
const selectFirst = useCallback(() => {
const items = $items.get();
const first = items.at(0);
if (!first) {
return;
}
$selectedItemId.set(first.item_id);
}, [$items, $selectedItemId]);
const selectLast = useCallback(() => {
const items = $items.get();
const last = items.at(-1);
if (!last) {
return;
}
$selectedItemId.set(last.item_id);
}, [$items, $selectedItemId]);
// Set up socket listeners
useEffect(() => {
if (!socket) {
@@ -236,10 +318,23 @@ export const CanvasSessionContextProvider = memo(
setProgress($progressData, data);
};
const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => {
if (data.destination !== session.id) {
return;
}
if (data.status === 'canceled' || data.status === 'failed') {
clearProgressEvent($progressData, data.item_id);
clearProgressImage($progressData, data.item_id);
}
};
socket.on('invocation_progress', onProgress);
socket.on('queue_item_status_changed', onQueueItemStatusChanged);
return () => {
socket.off('invocation_progress', onProgress);
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
};
}, [$autoSwitch, $progressData, $selectedItemId, session.id, socket]);
@@ -253,6 +348,7 @@ export const CanvasSessionContextProvider = memo(
const prevItems = $items.get();
const items = selectQueueItems(store.getState());
if (items !== prevItems) {
$prevItems.set(prevItems);
$items.set(items);
}
});
@@ -272,13 +368,16 @@ export const CanvasSessionContextProvider = memo(
// If an item is selected and it is not in the list of items, un-set it. This effect will run again and we'll
// the above case, selecting the first item if there are any.
if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) {
$selectedItemId.set(null);
const prevIndex = $prevItems.get().findIndex(({ item_id }) => item_id === selectedItemId);
const nextItem = items[prevIndex];
$selectedItemId.set(nextItem?.item_id ?? null);
return;
}
});
// Clean up the progress data when a queue item is discarded.
const unsubCleanUpProgressData = effect([$items, $progressData], (items, progressData) => {
const unsubCleanUpProgressData = $items.listen((items) => {
const progressData = $progressData.get();
const toDelete: number[] = [];
for (const datum of Object.values(progressData)) {
if (items.findIndex(({ item_id }) => item_id === datum.itemId) === -1) {
@@ -292,7 +391,6 @@ export const CanvasSessionContextProvider = memo(
for (const itemId of toDelete) {
delete newProgressData[itemId];
}
// This will re-trigger the effect - maybe this could just be a listener on $items? Brain hurt
$progressData.set(newProgressData);
});
@@ -331,7 +429,17 @@ export const CanvasSessionContextProvider = memo(
$progressData.set({});
$selectedItemId.set(null);
};
}, [$autoSwitch, $items, $lastLoadedItemId, $progressData, $selectedItemId, selectQueueItems, session.id, store]);
}, [
$autoSwitch,
$items,
$lastLoadedItemId,
$prevItems,
$progressData,
$selectedItemId,
selectQueueItems,
session.id,
store,
]);
const value = useMemo<CanvasSessionContextValue>(
() => ({
@@ -344,17 +452,29 @@ export const CanvasSessionContextProvider = memo(
$selectedItem,
$selectedItemIndex,
$lastLoadedItemId,
$selectedItemOutputImageName,
$itemCount,
selectNext,
selectPrev,
selectFirst,
selectLast,
}),
[
$autoSwitch,
$hasItems,
$items,
$hasItems,
$lastLoadedItemId,
$progressData,
$selectedItem,
$selectedItemId,
$selectedItemIndex,
session,
$selectedItemOutputImageName,
$itemCount,
selectNext,
selectPrev,
selectFirst,
selectLast,
]
);

View File

@@ -23,7 +23,7 @@ export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0p
export const getQueueItemElementId = (itemId: number) => `queue-item-status-card-${itemId}`;
const getOutputImageName = (item: S['SessionQueueItem']) => {
export const getOutputImageName = (item: S['SessionQueueItem']) => {
const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) =>
isCanvasOutputNodeId(nodeId)
)?.[1][0];

View File

@@ -1,57 +1,11 @@
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
export const useStagingAreaKeyboardNav = () => {
const ctx = useCanvasSessionContext();
const onNext = useCallback(() => {
const selectedItemId = ctx.$selectedItemId.get();
if (selectedItemId === null) {
return;
}
const items = ctx.$items.get();
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
const nextIndex = (currentIndex + 1) % items.length;
const nextItem = items[nextIndex];
if (!nextItem) {
return;
}
ctx.$selectedItemId.set(nextItem.item_id);
}, [ctx.$items, ctx.$selectedItemId]);
const onPrev = useCallback(() => {
const selectedItemId = ctx.$selectedItemId.get();
if (selectedItemId === null) {
return;
}
const items = ctx.$items.get();
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
const prevIndex = (currentIndex - 1 + items.length) % items.length;
const prevItem = items[prevIndex];
if (!prevItem) {
return;
}
ctx.$selectedItemId.set(prevItem.item_id);
}, [ctx.$items, ctx.$selectedItemId]);
const onFirst = useCallback(() => {
const items = ctx.$items.get();
const first = items.at(0);
if (!first) {
return;
}
ctx.$selectedItemId.set(first.item_id);
}, [ctx.$items, ctx.$selectedItemId]);
const onLast = useCallback(() => {
const items = ctx.$items.get();
const last = items.at(-1);
if (!last) {
return;
}
ctx.$selectedItemId.set(last.item_id);
}, [ctx.$items, ctx.$selectedItemId]);
useHotkeys('left', onPrev, { preventDefault: true });
useHotkeys('right', onNext, { preventDefault: true });
useHotkeys('meta+left', onFirst, { preventDefault: true });
useHotkeys('meta+right', onLast, { preventDefault: true });
useHotkeys('left', ctx.selectPrev, { preventDefault: true });
useHotkeys('right', ctx.selectNext, { preventDefault: true });
useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true });
useHotkeys('meta+right', ctx.selectLast, { preventDefault: true });
};

View File

@@ -1,4 +1,6 @@
import { ButtonGroup } from '@invoke-ai/ui-library';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton';
import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton';
import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton';
@@ -8,9 +10,22 @@ import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/
import { StagingAreaToolbarSaveAsMenu } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveAsMenu';
import { StagingAreaToolbarSaveSelectedToGalleryButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton';
import { StagingAreaToolbarToggleShowResultsButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton';
import { memo } from 'react';
import { memo, useEffect } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
export const StagingAreaToolbar = memo(() => {
const ctx = useCanvasSessionContext();
useEffect(() => {
return ctx.$selectedItemId.listen((id) => {
if (id !== null) {
document.getElementById(getQueueItemElementId(id))?.scrollIntoView();
}
});
}, [ctx.$selectedItemId]);
useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true });
useHotkeys('meta+right', ctx.selectLast, { preventDefault: true });
return (
<>
<ButtonGroup borderRadius="base" shadow="dark-lg">

View File

@@ -2,48 +2,48 @@ import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/focus';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice';
import {
selectImageCount,
selectSelectedImage,
stagingAreaReset,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectBboxRect, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import type { CanvasRasterLayerState } from 'features/controlLayers/store/types';
import { imageDTOToImageObject } from 'features/controlLayers/store/util';
import { imageNameToImageObject } from 'features/controlLayers/store/util';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiCheckBold } from 'react-icons/pi';
export const StagingAreaToolbarAcceptButton = memo(() => {
const ctx = useCanvasSessionContext();
const dispatch = useAppDispatch();
const canvasManager = useCanvasManager();
const bboxRect = useAppSelector(selectBboxRect);
const selectedImage = useAppSelector(selectSelectedImage);
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const imageCount = useAppSelector(selectImageCount);
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const isCanvasFocused = useIsRegionFocused('canvas');
const selectedItemImageName = useStore(ctx.$selectedItemOutputImageName);
const { t } = useTranslation();
const acceptSelected = useCallback(() => {
if (!selectedImage) {
if (!selectedItemImageName) {
return;
}
const { x, y } = bboxRect;
const { imageDTO, offsetX, offsetY } = selectedImage;
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y, width, height } = bboxRect;
const imageObject = imageNameToImageObject(selectedItemImageName, { width, height });
const overrides: Partial<CanvasRasterLayerState> = {
position: { x: x + offsetX, y: y + offsetY },
position: { x, y },
objects: [imageObject],
};
dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' }));
dispatch(stagingAreaReset());
}, [bboxRect, dispatch, selectedEntityIdentifier?.type, selectedImage]);
}, [bboxRect, selectedItemImageName, dispatch, selectedEntityIdentifier?.type]);
useHotkeys(
['enter'],
@@ -62,7 +62,7 @@ export const StagingAreaToolbarAcceptButton = memo(() => {
icon={<PiCheckBold />}
onClick={acceptSelected}
colorScheme="invokeBlue"
isDisabled={!selectedImage}
isDisabled={!selectedItemImageName}
/>
);
});

View File

@@ -1,17 +1,18 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { stagingAreaReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
import { useDeleteQueueItemsByDestinationMutation } from 'services/api/endpoints/queue';
export const StagingAreaToolbarDiscardAllButton = memo(() => {
const dispatch = useAppDispatch();
const ctx = useCanvasSessionContext();
const { t } = useTranslation();
const [deleteByDestination] = useDeleteQueueItemsByDestinationMutation();
const discardAll = useCallback(() => {
dispatch(stagingAreaReset());
}, [dispatch]);
deleteByDestination({ destination: ctx.session.id });
}, [deleteByDestination, ctx.session.id]);
return (
<IconButton

View File

@@ -1,18 +1,22 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import {
selectImageCount,
selectSelectedImage,
selectStagedImageIndex,
stagingAreaReset,
stagingAreaStagedImageDiscarded,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
import { useDeleteQueueItemMutation } from 'services/api/endpoints/queue';
export const StagingAreaToolbarDiscardSelectedButton = memo(() => {
const ctx = useCanvasSessionContext();
const dispatch = useAppDispatch();
const [deleteQueueItem] = useDeleteQueueItemMutation();
const selectedItemId = useStore(ctx.$selectedItemId);
const index = useAppSelector(selectStagedImageIndex);
const selectedImage = useAppSelector(selectSelectedImage);
const imageCount = useAppSelector(selectImageCount);
@@ -20,15 +24,16 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(() => {
const { t } = useTranslation();
const discardSelected = useCallback(() => {
if (!selectedImage) {
if (selectedItemId === null) {
return;
}
if (imageCount === 1) {
dispatch(stagingAreaReset());
} else {
dispatch(stagingAreaStagedImageDiscarded({ index }));
}
}, [selectedImage, imageCount, dispatch, index]);
deleteQueueItem({ item_id: selectedItemId });
// if (imageCount === 1) {
// dispatch(stagingAreaReset());
// } else {
// dispatch(stagingAreaStagedImageDiscarded({ index }));
// }
}, [selectedItemId, deleteQueueItem]);
return (
<IconButton
@@ -38,7 +43,7 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(() => {
onClick={discardSelected}
colorScheme="invokeBlue"
fontSize={16}
isDisabled={!selectedImage}
isDisabled={selectedItemId === null}
/>
);
});

View File

@@ -1,19 +1,24 @@
import { Button } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { selectImageCount, selectStagedImageIndex } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useMemo } from 'react';
export const StagingAreaToolbarImageCountButton = memo(() => {
const ctx = useCanvasSessionContext();
const selectItemIndex = useStore(ctx.$selectedItemIndex);
const itemCount = useStore(ctx.$itemCount);
const index = useAppSelector(selectStagedImageIndex);
const imageCount = useAppSelector(selectImageCount);
const counterText = useMemo(() => {
if (imageCount > 0) {
return `${(index ?? 0) + 1} of ${imageCount}`;
if (itemCount > 0 && selectItemIndex !== null) {
return `${selectItemIndex + 1} of ${itemCount}`;
} else {
return `0 of 0`;
}
}, [imageCount, index]);
}, [itemCount, selectItemIndex]);
return (
<Button colorScheme="base" pointerEvents="none" minW={28}>

View File

@@ -2,17 +2,17 @@ import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/focus';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import {
selectImageCount,
stagingAreaNextStagedImageSelected,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectImageCount } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowRightBold } from 'react-icons/pi';
export const StagingAreaToolbarNextButton = memo(() => {
const ctx = useCanvasSessionContext();
const itemCount = useStore(ctx.$itemCount);
const dispatch = useAppDispatch();
const canvasManager = useCanvasManager();
const imageCount = useAppSelector(selectImageCount);
@@ -22,17 +22,17 @@ export const StagingAreaToolbarNextButton = memo(() => {
const { t } = useTranslation();
const selectNext = useCallback(() => {
dispatch(stagingAreaNextStagedImageSelected());
}, [dispatch]);
ctx.selectNext();
}, [ctx]);
useHotkeys(
['right'],
selectNext,
ctx.selectNext,
{
preventDefault: true,
enabled: isCanvasFocused && shouldShowStagedImage && imageCount > 1,
enabled: isCanvasFocused && shouldShowStagedImage && itemCount > 1,
},
[isCanvasFocused, shouldShowStagedImage, imageCount]
[isCanvasFocused, shouldShowStagedImage, itemCount, ctx.selectNext]
);
return (
@@ -42,7 +42,7 @@ export const StagingAreaToolbarNextButton = memo(() => {
icon={<PiArrowRightBold />}
onClick={selectNext}
colorScheme="invokeBlue"
isDisabled={imageCount <= 1 || !shouldShowStagedImage}
isDisabled={itemCount <= 1 || !shouldShowStagedImage}
/>
);
});

View File

@@ -2,17 +2,17 @@ import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/focus';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import {
selectImageCount,
stagingAreaPrevStagedImageSelected,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectImageCount } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowLeftBold } from 'react-icons/pi';
export const StagingAreaToolbarPrevButton = memo(() => {
const ctx = useCanvasSessionContext();
const itemCount = useStore(ctx.$itemCount);
const dispatch = useAppDispatch();
const canvasManager = useCanvasManager();
const imageCount = useAppSelector(selectImageCount);
@@ -22,17 +22,17 @@ export const StagingAreaToolbarPrevButton = memo(() => {
const { t } = useTranslation();
const selectPrev = useCallback(() => {
dispatch(stagingAreaPrevStagedImageSelected());
}, [dispatch]);
ctx.selectPrev();
}, [ctx]);
useHotkeys(
['left'],
selectPrev,
ctx.selectPrev,
{
preventDefault: true,
enabled: isCanvasFocused && shouldShowStagedImage && imageCount > 1,
enabled: isCanvasFocused && shouldShowStagedImage && itemCount > 1,
},
[isCanvasFocused, shouldShowStagedImage, imageCount]
[isCanvasFocused, shouldShowStagedImage, itemCount, ctx.selectPrev]
);
return (
@@ -42,7 +42,7 @@ export const StagingAreaToolbarPrevButton = memo(() => {
icon={<PiArrowLeftBold />}
onClick={selectPrev}
colorScheme="invokeBlue"
isDisabled={imageCount <= 1 || !shouldShowStagedImage}
isDisabled={itemCount <= 1 || !shouldShowStagedImage}
/>
);
});

View File

@@ -1,29 +1,37 @@
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { NewLayerIcon } from 'features/controlLayers/components/common/icons';
import { selectSelectedImage } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { createNewCanvasEntityFromImage } from 'features/imageActions/actions';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDotsThreeBold } from 'react-icons/pi';
import { imageDTOToFile, uploadImage } from 'services/api/endpoints/images';
import { copyImage } from 'services/api/endpoints/images';
const uploadImageArg = { image_category: 'general', is_intermediate: true, silent: true } as const;
export const StagingAreaToolbarSaveAsMenu = memo(() => {
const { t } = useTranslation();
const selectedImage = useAppSelector(selectSelectedImage);
const ctx = useCanvasSessionContext();
const imageName = useStore(ctx.$selectedItemOutputImageName);
const store = useAppStore();
const toastSentToCanvas = useCallback(() => {
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [t]);
const onClickNewRasterLayerFromImage = useCallback(async () => {
if (!selectedImage) {
if (!imageName) {
return;
}
const { dispatch, getState } = store;
const file = await imageDTOToFile(selectedImage.imageDTO);
const imageDTO = await uploadImage({ file, ...uploadImageArg });
const imageDTO = await copyImage(imageName, uploadImageArg);
createNewCanvasEntityFromImage({
imageDTO,
type: 'raster_layer',
@@ -31,20 +39,16 @@ export const StagingAreaToolbarSaveAsMenu = memo(() => {
getState,
overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default
});
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [selectedImage, store, t]);
toastSentToCanvas();
}, [imageName, store, toastSentToCanvas]);
const onClickNewControlLayerFromImage = useCallback(async () => {
if (!selectedImage) {
if (!imageName) {
return;
}
const { dispatch, getState } = store;
const file = await imageDTOToFile(selectedImage.imageDTO);
const imageDTO = await uploadImage({ file, ...uploadImageArg });
const imageDTO = await copyImage(imageName, uploadImageArg);
createNewCanvasEntityFromImage({
imageDTO,
type: 'control_layer',
@@ -52,20 +56,16 @@ export const StagingAreaToolbarSaveAsMenu = memo(() => {
getState,
overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default
});
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [selectedImage, store, t]);
toastSentToCanvas();
}, [imageName, store, toastSentToCanvas]);
const onClickNewInpaintMaskFromImage = useCallback(async () => {
if (!selectedImage) {
if (!imageName) {
return;
}
const { dispatch, getState } = store;
const file = await imageDTOToFile(selectedImage.imageDTO);
const imageDTO = await uploadImage({ file, ...uploadImageArg });
const imageDTO = await copyImage(imageName, uploadImageArg);
createNewCanvasEntityFromImage({
imageDTO,
type: 'inpaint_mask',
@@ -73,20 +73,16 @@ export const StagingAreaToolbarSaveAsMenu = memo(() => {
getState,
overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default
});
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [selectedImage, store, t]);
toastSentToCanvas();
}, [imageName, store, toastSentToCanvas]);
const onClickNewRegionalGuidanceFromImage = useCallback(async () => {
if (!selectedImage) {
if (!imageName) {
return;
}
const { dispatch, getState } = store;
const file = await imageDTOToFile(selectedImage.imageDTO);
const imageDTO = await uploadImage({ file, ...uploadImageArg });
const imageDTO = await copyImage(imageName, uploadImageArg);
createNewCanvasEntityFromImage({
imageDTO,
type: 'regional_guidance',
@@ -94,12 +90,8 @@ export const StagingAreaToolbarSaveAsMenu = memo(() => {
getState,
overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default
});
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [selectedImage, store, t]);
toastSentToCanvas();
}, [imageName, store, toastSentToCanvas]);
return (
<Menu>
@@ -109,23 +101,19 @@ export const StagingAreaToolbarSaveAsMenu = memo(() => {
tooltip={t('controlLayers.newLayerFromImage')}
icon={<PiDotsThreeBold />}
colorScheme="invokeBlue"
isDisabled={!selectedImage}
isDisabled={!imageName}
/>
<MenuList>
<MenuItem icon={<NewLayerIcon />} onClickCapture={onClickNewInpaintMaskFromImage} isDisabled={!selectedImage}>
<MenuItem icon={<NewLayerIcon />} onClickCapture={onClickNewInpaintMaskFromImage} isDisabled={!imageName}>
{t('controlLayers.inpaintMask')}
</MenuItem>
<MenuItem
icon={<NewLayerIcon />}
onClickCapture={onClickNewRegionalGuidanceFromImage}
isDisabled={!selectedImage}
>
<MenuItem icon={<NewLayerIcon />} onClickCapture={onClickNewRegionalGuidanceFromImage} isDisabled={!imageName}>
{t('controlLayers.regionalGuidance')}
</MenuItem>
<MenuItem icon={<NewLayerIcon />} onClickCapture={onClickNewControlLayerFromImage} isDisabled={!selectedImage}>
<MenuItem icon={<NewLayerIcon />} onClickCapture={onClickNewControlLayerFromImage} isDisabled={!imageName}>
{t('controlLayers.controlLayer')}
</MenuItem>
<MenuItem icon={<NewLayerIcon />} onClickCapture={onClickNewRasterLayerFromImage} isDisabled={!selectedImage}>
<MenuItem icon={<NewLayerIcon />} onClickCapture={onClickNewRasterLayerFromImage} isDisabled={!imageName}>
{t('controlLayers.rasterLayer')}
</MenuItem>
</MenuList>

View File

@@ -1,24 +1,28 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { withResultAsync } from 'common/util/result';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { selectSelectedImage } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFloppyDiskBold } from 'react-icons/pi';
import { imageDTOToFile, uploadImage } from 'services/api/endpoints/images';
import { copyImage } from 'services/api/endpoints/images';
const TOAST_ID = 'SAVE_STAGING_AREA_IMAGE_TO_GALLERY';
export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const selectedImage = useAppSelector(selectSelectedImage);
const ctx = useCanvasSessionContext();
const imageName = useStore(ctx.$selectedItemOutputImageName);
const { t } = useTranslation();
const saveSelectedImageToGallery = useCallback(async () => {
if (!selectedImage) {
if (!imageName) {
return;
}
@@ -26,10 +30,7 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
// the gallery without borking the canvas, which may need this image to exist.
const result = await withResultAsync(async () => {
// Create a new file with the same name, which we will upload
const file = await imageDTOToFile(selectedImage.imageDTO);
await uploadImage({
file,
await copyImage(imageName, {
// Image should show up in the Images tab
image_category: 'general',
is_intermediate: false,
@@ -53,7 +54,7 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
status: 'error',
});
}
}, [autoAddBoardId, selectedImage, t]);
}, [autoAddBoardId, imageName, t]);
return (
<IconButton

View File

@@ -52,6 +52,11 @@ export const useInvokeCanvas = (): ((el: HTMLDivElement | null) => void) => {
const manager = new CanvasManager(container, store, socket);
manager.initialize();
return () => {
manager.destroy();
$canvasManager.set(null);
};
}, [container, socket, store]);
return containerRef;

View File

@@ -10,7 +10,6 @@ import { CanvasEntityAdapterRegionalGuidance } from 'features/controlLayers/konv
import type { CanvasEntityAdapter, CanvasEntityAdapterFromType } from 'features/controlLayers/konva/CanvasEntity/types';
import { CanvasEntityRendererModule } from 'features/controlLayers/konva/CanvasEntityRendererModule';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasProgressImageModule } from 'features/controlLayers/konva/CanvasProgressImageModule';
import { CanvasStageModule } from 'features/controlLayers/konva/CanvasStageModule';
import { CanvasStagingAreaModule } from 'features/controlLayers/konva/CanvasStagingAreaModule';
import { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule';
@@ -66,7 +65,6 @@ export class CanvasManager extends CanvasModuleBase {
compositor: CanvasCompositorModule;
tool: CanvasToolModule;
stagingArea: CanvasStagingAreaModule;
progressImage: CanvasProgressImageModule;
konva: {
previewLayer: Konva.Layer;
@@ -131,11 +129,9 @@ export class CanvasManager extends CanvasModuleBase {
this.stage.addLayer(this.konva.previewLayer);
this.tool = new CanvasToolModule(this);
this.progressImage = new CanvasProgressImageModule(this);
// Must add in this order for correct z-index
this.konva.previewLayer.add(this.stagingArea.konva.group);
this.konva.previewLayer.add(this.progressImage.konva.group);
this.konva.previewLayer.add(this.tool.konva.group);
}
@@ -238,7 +234,6 @@ export class CanvasManager extends CanvasModuleBase {
return [
this.stagingArea,
this.tool,
this.progressImage,
this.stateApi,
this.background,
this.worker,
@@ -285,7 +280,6 @@ export class CanvasManager extends CanvasModuleBase {
stateApi: this.stateApi.repr(),
stagingArea: this.stagingArea.repr(),
tool: this.tool.repr(),
progressImage: this.progressImage.repr(),
background: this.background.repr(),
worker: this.worker.repr(),
entityRenderer: this.entityRenderer.repr(),

View File

@@ -12,6 +12,7 @@ import { getKonvaNodeDebugAttrs, loadImage } from 'features/controlLayers/konva/
import type { CanvasImageState } from 'features/controlLayers/store/types';
import { t } from 'i18next';
import Konva from 'konva';
import { isEqual } from 'lodash-es';
import type { Logger } from 'roarr';
import { getImageDTOSafe } from 'services/api/endpoints/images';
@@ -94,7 +95,7 @@ export class CanvasObjectImage extends CanvasModuleBase {
this.state = state;
}
updateImageSource = async (imageName: string) => {
updateImageSourceByImageName = async (imageName: string) => {
this.log.trace({ imageName }, 'Updating image source');
this.isLoading = true;
@@ -121,7 +122,30 @@ export class CanvasObjectImage extends CanvasModuleBase {
this.imageElement = imageElementResult.value;
await this.updateImageElement();
this.updateImageElement();
};
updateImageSourceByDataURL = async (dataURL: string) => {
this.log.trace({ dataURL: `${dataURL.substring(0, 16)}...` }, 'Updating image source');
this.isLoading = true;
this.konva.group.visible(true);
if (!this.konva.image) {
this.konva.placeholder.group.visible(false);
this.konva.placeholder.text.text(t('common.loadingImage', 'Loading Image'));
}
const imageElementResult = await withResultAsync(() => loadImage(dataURL, false));
if (imageElementResult.isErr()) {
// Image loading failed (e.g. the URL to the "physical" image is invalid)
this.onFailedToLoadImage(t('controlLayers.unableToLoadImage', 'Unable to load image'));
return;
}
this.imageElement = imageElementResult.value;
this.updateImageElement();
};
onFailedToLoadImage = (message: string) => {
@@ -133,43 +157,37 @@ export class CanvasObjectImage extends CanvasModuleBase {
this.konva.placeholder.group.visible(true);
};
updateImageElement = async () => {
const release = await this.mutex.acquire();
updateImageElement = () => {
if (this.imageElement) {
const { width, height } = this.state.image;
try {
if (this.imageElement) {
const { width, height } = this.state.image;
if (this.konva.image) {
this.log.trace('Updating Konva image attrs');
this.konva.image.setAttrs({
image: this.imageElement,
width,
height,
visible: true,
});
} else {
this.log.trace('Creating new Konva image');
this.konva.image = new Konva.Image({
name: `${this.type}:image`,
listening: false,
image: this.imageElement,
width,
height,
perfectDrawEnabled: false,
});
this.konva.group.add(this.konva.image);
}
this.konva.placeholder.rect.setAttrs({ width, height });
this.konva.placeholder.text.setAttrs({ width, height, fontSize: width / 16 });
this.isLoading = false;
this.isError = false;
this.konva.placeholder.group.visible(false);
if (this.konva.image) {
this.log.trace('Updating Konva image attrs');
this.konva.image.setAttrs({
image: this.imageElement,
width,
height,
visible: true,
});
} else {
this.log.trace('Creating new Konva image');
this.konva.image = new Konva.Image({
name: `${this.type}:image`,
listening: false,
image: this.imageElement,
width,
height,
perfectDrawEnabled: false,
});
this.konva.group.add(this.konva.image);
}
} finally {
release();
this.konva.placeholder.rect.setAttrs({ width, height });
this.konva.placeholder.text.setAttrs({ width, height, fontSize: width / 16 });
this.isLoading = false;
this.isError = false;
this.konva.placeholder.group.visible(false);
}
};
@@ -178,10 +196,22 @@ export class CanvasObjectImage extends CanvasModuleBase {
this.log.trace({ state }, 'Updating image');
const { image } = state;
const { width, height, image_name } = image;
if (force || (this.state.image.image_name !== image_name && !this.isLoading)) {
await this.updateImageSource(image_name);
const { width, height } = image;
if (force || (!isEqual(this.state, state) && !this.isLoading)) {
const release = await this.mutex.acquire();
try {
if ('image_name' in image) {
await this.updateImageSourceByImageName(image.image_name);
} else {
await this.updateImageSourceByDataURL(image.dataURL);
}
} finally {
release();
}
}
this.konva.image?.setAttrs({ width, height });
this.state = state;
return true;

View File

@@ -1,10 +1,9 @@
import { Mutex } from 'async-mutex';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectCanvasStagingAreaSlice, selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import type { StagingAreaImage } from 'features/controlLayers/store/types';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import type { CanvasImageState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
@@ -20,10 +19,10 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
subscriptions: Set<() => void> = new Set();
konva: { group: Konva.Group };
image: CanvasObjectImage | null;
selectedImage: StagingAreaImage | null;
mutex = new Mutex();
$shouldShowStagedImage = atom<boolean>(true);
$isStaging = atom<boolean>(false);
$isStaging = atom(true); //TODO: wire up to queue?
constructor(manager: CanvasManager) {
super();
@@ -37,30 +36,13 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
this.konva = { group: new Konva.Group({ name: `${this.type}:group`, listening: false }) };
this.image = null;
this.selectedImage = null;
/**
* When we change this flag, we need to re-render the staging area, which hides or shows the staged image.
*/
this.subscriptions.add(this.$shouldShowStagedImage.listen(this.render));
/**
* When the staging redux state changes (i.e. when the selected staged image is changed, or we add/discard a staged
* image), we need to re-render the staging area.
*/
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasStagingAreaSlice, this.render));
/**
* Sync the $isStaging flag with the redux state. $isStaging is used by the manager to determine the global busy
* state of the canvas.
*
* We also set the $shouldShowStagedImage flag when we enter staging mode, so that the staged images are shown,
* even if the user disabled this in the last staging session.
*/
this.subscriptions.add(
this.manager.stateApi.createStoreSubscription(selectIsStaging, (isStaging, oldIsStaging) => {
this.$isStaging.set(isStaging);
if (isStaging && !oldIsStaging) {
this.$shouldShowStagedImage.set(true);
}
this.$shouldShowStagedImage.listen(() => {
this.render();
})
);
}
@@ -68,71 +50,56 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
initialize = () => {
this.log.debug('Initializing module');
this.render();
this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging));
};
render = async () => {
this.log.trace('Rendering staging area');
const stagingArea = this.manager.stateApi.runSelector(selectCanvasStagingAreaSlice);
const { x, y } = this.manager.stateApi.getBbox().rect;
const shouldShowStagedImage = this.$shouldShowStagedImage.get();
this.selectedImage = stagingArea.images[stagingArea.selectedImageIndex] ?? null;
this.konva.group.position({ x, y });
if (this.selectedImage) {
const { imageDTO } = this.selectedImage;
const image = imageDTOToImageWithDims(imageDTO);
/**
* When the final output image of a generation is received, we should clear that generation's last progress image.
*
* It's possible that we have already rendered the progress image from the next generation before the output image
* from the previous is fully loaded/rendered. This race condition results in a flicker:
* - LAST GENERATION: Render the final progress image
* - LAST GENERATION: Start loading the final output image...
* - NEXT GENERATION: Render the first progress image
* - LAST GENERATION: ...Finish loading the final output image & render it, clearing the progress image <-- Flicker!
* - NEXT GENERATION: Render the next progress image
*
* We can detect the race condition by stashing the session ID of the last progress image when we begin loading
* that session's output image. After we render it, if the progress image's session ID is the same as the one we
* stashed, we know that we have not yet gotten that next generation's first progress image. We can clear the
* progress image without causing a flicker.
*/
const lastProgressEventSessionId = this.manager.progressImage.$lastProgressEvent.get()?.session_id;
const hideProgressIfSameSession = () => {
const currentProgressEventSessionId = this.manager.progressImage.$lastProgressEvent.get()?.session_id;
if (lastProgressEventSessionId === currentProgressEventSessionId) {
this.manager.progressImage.$lastProgressEvent.set(null);
}
getImageFromSrc = (
{ type, data }: { type: 'imageName'; data: string } | { type: 'dataURL'; data: string },
width: number,
height: number
): CanvasImageState['image'] => {
if (type === 'imageName') {
return {
image_name: data,
width,
height,
};
if (!this.image) {
this.image = new CanvasObjectImage(
{
id: 'staging-area-image',
type: 'image',
image,
},
this
);
await this.image.update(this.image.state, true);
this.konva.group.add(this.image.konva.group);
hideProgressIfSameSession();
} else if (this.image.isLoading) {
// noop - just wait for the image to load
} else if (this.image.state.image.image_name !== image.image_name) {
await this.image.update({ ...this.image.state, image }, true);
hideProgressIfSameSession();
} else if (this.image.isError) {
hideProgressIfSameSession();
}
this.image.konva.group.visible(shouldShowStagedImage);
} else {
this.image?.destroy();
this.image = null;
return {
dataURL: data,
width,
height,
};
}
};
render = async (imageSrc?: { type: 'imageName'; data: string } | { type: 'dataURL'; data: string }) => {
const release = await this.mutex.acquire();
try {
this.log.trace('Rendering staging area');
const { x, y, width, height } = this.manager.stateApi.getBbox().rect;
const shouldShowStagedImage = this.$shouldShowStagedImage.get();
this.konva.group.position({ x, y });
if (imageSrc) {
const image = this.getImageFromSrc(imageSrc, width, height);
if (!this.image) {
this.image = new CanvasObjectImage({ id: 'staging-area-image', type: 'image', image }, this);
await this.image.update(this.image.state, true);
this.konva.group.add(this.image.konva.group);
} else if (this.image.isLoading || this.image.isError) {
// noop
} else {
await this.image.update({ ...this.image.state, image }, true);
}
this.image.konva.group.visible(shouldShowStagedImage);
} else {
this.image?.destroy();
this.image = null;
}
} finally {
release();
}
};
@@ -157,7 +124,6 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
id: this.id,
type: this.type,
path: this.path,
selectedImage: this.selectedImage,
$shouldShowStagedImage: this.$shouldShowStagedImage.get(),
$isStaging: this.$isStaging.get(),
image: this.image?.repr() ?? null,

View File

@@ -59,6 +59,13 @@ const zImageWithDims = z
});
export type ImageWithDims = z.infer<typeof zImageWithDims>;
const zImageWithDimsDataURL = z.object({
dataURL: z.string(),
width: z.number().int().positive(),
height: z.number().int().positive(),
});
export type ImageWithDimsDataURL = z.infer<typeof zImageWithDimsDataURL>;
const zBeginEndStepPct = z
.tuple([z.number().gte(0).lte(1), z.number().gte(0).lte(1)])
.refine(([begin, end]) => begin < end, {
@@ -231,7 +238,7 @@ export type CanvasRectState = z.infer<typeof zCanvasRectState>;
const zCanvasImageState = z.object({
id: zId,
type: z.literal('image'),
image: zImageWithDims,
image: z.union([zImageWithDims, zImageWithDimsDataURL]),
});
export type CanvasImageState = z.infer<typeof zCanvasImageState>;

View File

@@ -10,6 +10,7 @@ import type {
ChatGPT4oReferenceImageConfig,
ControlLoRAConfig,
ControlNetConfig,
Dimensions,
FLUXReduxConfig,
ImageWithDims,
IPAdapterConfig,
@@ -34,6 +35,22 @@ export const imageDTOToImageObject = (imageDTO: ImageDTO, overrides?: Partial<Ca
};
};
export const imageNameToImageObject = (
imageName: string,
dimensions: Dimensions,
overrides?: Partial<CanvasImageState>
): CanvasImageState => {
return {
id: getPrefixedId('image'),
type: 'image',
image: {
image_name: imageName,
...dimensions,
},
...overrides,
};
};
export const imageDTOToImageWithDims = ({ image_name, width, height }: ImageDTO): ImageWithDims => ({
image_name,
width,

View File

@@ -38,15 +38,15 @@ export const getImageUsage = (nodes: NodesState, canvas: CanvasState, upscale: U
);
const isRasterLayerImage = canvas.rasterLayers.entities.some(({ objects }) =>
objects.some((obj) => obj.type === 'image' && obj.image.image_name === image_name)
objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name)
);
const isControlLayerImage = canvas.controlLayers.entities.some(({ objects }) =>
objects.some((obj) => obj.type === 'image' && obj.image.image_name === image_name)
objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name)
);
const isInpaintMaskImage = canvas.inpaintMasks.entities.some(({ objects }) =>
objects.some((obj) => obj.type === 'image' && obj.image.image_name === image_name)
objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name)
);
const isRegionalGuidanceImage = canvas.regionalGuidance.entities.some(({ referenceImages }) =>

View File

@@ -13,11 +13,11 @@ export const useCancelCurrentQueueItem = () => {
const { t } = useTranslation();
const currentQueueItemId = useMemo(() => queueStatus?.queue.item_id, [queueStatus?.queue.item_id]);
const cancelQueueItem = useCallback(async () => {
if (!currentQueueItemId) {
if (currentQueueItemId !== null || currentQueueItemId !== undefined) {
return;
}
try {
await trigger(currentQueueItemId).unwrap();
await trigger({ item_id: currentQueueItemId }).unwrap();
toast({
id: 'QUEUE_CANCEL_SUCCEEDED',
title: t('queue.cancelSucceeded'),

View File

@@ -11,7 +11,7 @@ export const useCancelQueueItem = (item_id: number) => {
const { t } = useTranslation();
const cancelQueueItem = useCallback(async () => {
try {
await trigger(item_id).unwrap();
await trigger({ item_id }).unwrap();
toast({
id: 'QUEUE_CANCEL_SUCCEEDED',
title: t('queue.cancelSucceeded'),

View File

@@ -674,6 +674,13 @@ export const uploadImage = (arg: UploadImageArg): Promise<ImageDTO> => {
return req.unwrap();
};
export const copyImage = async (imageName: string, uploadImageArg: Omit<UploadImageArg, 'file'>): Promise<ImageDTO> => {
const originalImageDTO = await getImageDTO(imageName);
const file = await imageDTOToFile(originalImageDTO);
const imageDTO = await uploadImage({ file, ...uploadImageArg });
return imageDTO;
};
export const uploadImages = async (args: UploadImageArg[]): Promise<ImageDTO[]> => {
const { dispatch } = getStore();
const results = await Promise.allSettled(

View File

@@ -79,7 +79,12 @@ export const queueApi = api.injectEndpoints({
url: buildQueueUrl('prune'),
method: 'PUT',
}),
invalidatesTags: ['SessionQueueStatus', 'BatchStatus', { type: 'SessionQueueItem', id: LIST_TAG }],
invalidatesTags: [
'SessionQueueStatus',
'BatchStatus',
{ type: 'SessionQueueItem', id: LIST_TAG },
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
],
}),
clearQueue: build.mutation<
paths['/api/v1/queue/{queue_id}/clear']['put']['responses']['200']['content']['application/json'],
@@ -176,9 +181,9 @@ export const queueApi = api.injectEndpoints({
}),
cancelQueueItem: build.mutation<
paths['/api/v1/queue/{queue_id}/i/{item_id}/cancel']['put']['responses']['200']['content']['application/json'],
number
{ item_id: number }
>({
query: (item_id) => ({
query: ({ item_id }) => ({
url: buildQueueUrl(`i/${item_id}/cancel`),
method: 'PUT',
}),
@@ -219,7 +224,7 @@ export const queueApi = api.injectEndpoints({
];
},
}),
cancelByBatchDestination: build.mutation<
cancelByDestination: build.mutation<
paths['/api/v1/queue/{queue_id}/cancel_by_destination']['put']['responses']['200']['content']['application/json'],
paths['/api/v1/queue/{queue_id}/cancel_by_destination']['put']['parameters']['query']
>({
@@ -319,6 +324,24 @@ export const queueApi = api.injectEndpoints({
return tags;
},
}),
deleteQueueItem: build.mutation<void, { item_id: number }>({
query: ({ item_id }) => ({
url: buildQueueUrl(`i/${item_id}`),
method: 'DELETE',
}),
invalidatesTags: (result, error, { item_id }) => [{ type: 'SessionQueueItem', id: item_id }],
}),
deleteQueueItemsByDestination: build.mutation<void, { destination: string }>({
query: ({ destination }) => ({
url: buildQueueUrl(`d/${destination}`),
method: 'DELETE',
}),
invalidatesTags: (result, error, { destination }) => [
{ type: 'QueueCountsByDestination', id: destination },
{ type: 'SessionQueueItem', id: LIST_TAG },
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
],
}),
getQueueCountsByDestination: build.query<
paths['/api/v1/queue/{queue_id}/counts_by_destination']['get']['responses']['200']['content']['application/json'],
paths['/api/v1/queue/{queue_id}/counts_by_destination']['get']['parameters']['query']
@@ -345,6 +368,9 @@ export const {
useListQueueItemsQuery,
useListAllQueueItemsQuery,
useCancelQueueItemMutation,
useCancelByDestinationMutation,
useDeleteQueueItemMutation,
useDeleteQueueItemsByDestinationMutation,
useGetBatchStatusQuery,
useGetCurrentQueueItemQuery,
useGetQueueCountsByDestinationQuery,

View File

@@ -1438,7 +1438,11 @@ export type paths = {
get: operations["get_queue_item"];
put?: never;
post?: never;
delete?: never;
/**
* Delete Queue Item
* @description Deletes a queue item
*/
delete: operations["delete_queue_item"];
options?: never;
head?: never;
patch?: never;
@@ -1484,6 +1488,26 @@ export type paths = {
patch?: never;
trace?: never;
};
"/api/v1/queue/{queue_id}/d/{destination}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/**
* Delete By Destination
* @description Deletes all items with the given destination
*/
delete: operations["delete_by_destination"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/workflows/i/{workflow_id}": {
parameters: {
query?: never;
@@ -5879,6 +5903,17 @@ export type components = {
*/
deleted_images: string[];
};
/**
* DeleteByDestinationResult
* @description Result of deleting by a destination
*/
DeleteByDestinationResult: {
/**
* Deleted
* @description Number of queue items deleted
*/
deleted: number;
};
/** DeleteImagesFromListResult */
DeleteImagesFromListResult: {
/** Deleted Images */
@@ -24784,6 +24819,40 @@ export interface operations {
};
};
};
delete_queue_item: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The queue id to perform this operation on */
queue_id: string;
/** @description The queue item to delete */
item_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
cancel_queue_item: {
parameters: {
query?: never;
@@ -24853,6 +24922,40 @@ export interface operations {
};
};
};
delete_by_destination: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The queue id to query */
queue_id: string;
/** @description The destination to query */
destination: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeleteByDestinationResult"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_workflow: {
parameters: {
query?: never;