mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-02-13 00:45:01 -05:00
1001 lines
31 KiB
TypeScript
1001 lines
31 KiB
TypeScript
/* eslint-disable i18next/no-literal-string */
|
|
import type { ButtonGroupProps, SystemStyleObject, TextProps } from '@invoke-ai/ui-library';
|
|
import {
|
|
Box,
|
|
Button,
|
|
ButtonGroup,
|
|
CircularProgress,
|
|
ContextMenu,
|
|
Flex,
|
|
FormControl,
|
|
FormLabel,
|
|
Heading,
|
|
IconButton,
|
|
Image,
|
|
Menu,
|
|
MenuButton,
|
|
MenuList,
|
|
Spacer,
|
|
Switch,
|
|
Text,
|
|
Tooltip,
|
|
} from '@invoke-ai/ui-library';
|
|
import { useStore } from '@nanostores/react';
|
|
import { skipToken } from '@reduxjs/toolkit/query';
|
|
import { EMPTY_ARRAY } from 'app/store/constants';
|
|
import { useAppStore } from 'app/store/nanostores/store';
|
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
|
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
|
|
import { IAINoContentFallbackWithSpinner } from 'common/components/IAIImageFallback';
|
|
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';
|
|
import { CanvasBusySpinner } from 'features/controlLayers/components/CanvasBusySpinner';
|
|
import { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems';
|
|
import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems';
|
|
import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
|
|
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 { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
|
|
import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
|
|
import { Transform } from 'features/controlLayers/components/Transform/Transform';
|
|
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
|
import { loadImage } from 'features/controlLayers/konva/util';
|
|
import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice';
|
|
import { canvasSessionStarted, selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
|
import { newCanvasFromImageDndTarget } from 'features/dnd/dnd';
|
|
import { DndDropTarget } from 'features/dnd/DndDropTarget';
|
|
import { DndImage } from 'features/dnd/DndImage';
|
|
import { newCanvasFromImage } from 'features/imageActions/actions';
|
|
import { isImageField } from 'features/nodes/types/common';
|
|
import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils';
|
|
import { round } from 'lodash-es';
|
|
import { atom, type WritableAtom } from 'nanostores';
|
|
import type { ChangeEvent } from 'react';
|
|
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useHotkeys } from 'react-hotkeys-hook';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
import { PiDotsThreeOutlineVerticalFill, PiUploadBold } from 'react-icons/pi';
|
|
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
|
import { useListAllQueueItemsQuery } from 'services/api/endpoints/queue';
|
|
import type { ImageDTO, S } from 'services/api/types';
|
|
import type { ProgressData } from 'services/events/stores';
|
|
import { $socket, clearProgressImage, setProgress, useHasProgressImage, useProgressData } from 'services/events/stores';
|
|
import type { Equals, Param0 } from 'tsafe';
|
|
import { assert, objectEntries } from 'tsafe';
|
|
|
|
import { CanvasAlertsInvocationProgress } from './CanvasAlerts/CanvasAlertsInvocationProgress';
|
|
|
|
const FOCUS_REGION_STYLES: SystemStyleObject = {
|
|
width: 'full',
|
|
height: 'full',
|
|
};
|
|
|
|
const MenuContent = memo(() => {
|
|
return (
|
|
<CanvasManagerProviderGate>
|
|
<MenuList>
|
|
<CanvasContextMenuSelectedEntityMenuItems />
|
|
<CanvasContextMenuGlobalMenuItems />
|
|
</MenuList>
|
|
</CanvasManagerProviderGate>
|
|
);
|
|
});
|
|
MenuContent.displayName = 'MenuContent';
|
|
|
|
export const CanvasMainPanelContent = memo(() => {
|
|
const session = useAppSelector(selectCanvasSession);
|
|
|
|
if (session === null) {
|
|
return <NoActiveSession />;
|
|
}
|
|
|
|
if (session.type === 'simple') {
|
|
return <StagingAreaWrapper id={session.id} />;
|
|
}
|
|
|
|
if (session.type === 'advanced') {
|
|
return <CanvasActiveSession />;
|
|
}
|
|
|
|
assert<Equals<never, typeof session>>(false, 'Unexpected session');
|
|
});
|
|
CanvasMainPanelContent.displayName = 'CanvasMainPanelContent';
|
|
|
|
const StagingAreaWrapper = memo(({ id }: { id: string }) => {
|
|
const ctx = useMemo(
|
|
() =>
|
|
({
|
|
session: {
|
|
type: 'simple',
|
|
id,
|
|
},
|
|
$progressData: atom<Record<string, ProgressData>>({}),
|
|
}) as const,
|
|
[id]
|
|
);
|
|
|
|
return (
|
|
<StagingContext.Provider value={ctx}>
|
|
<StagingArea />
|
|
</StagingContext.Provider>
|
|
);
|
|
});
|
|
StagingAreaWrapper.displayName = 'StagingAreaWrapper';
|
|
|
|
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 DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';
|
|
|
|
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">
|
|
<Heading>Get Started with Invoke</Heading>
|
|
<Button variant="ghost" onClick={newSesh}>
|
|
Start a new Canvas Session
|
|
</Button>
|
|
<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';
|
|
|
|
const scrollIndicatorBaseSx = {
|
|
opacity: 0,
|
|
position: 'absolute',
|
|
w: 16,
|
|
h: 'full',
|
|
transitionProperty: 'opacity',
|
|
transitionDuration: '0.3s',
|
|
pointerEvents: 'none',
|
|
'&[data-visible="true"]': {
|
|
opacity: 1,
|
|
},
|
|
} satisfies SystemStyleObject;
|
|
|
|
const scrollIndicatorLeftSx = {
|
|
...scrollIndicatorBaseSx,
|
|
left: 0,
|
|
bg: 'linear-gradient(to right, var(--invoke-colors-base-900), transparent)',
|
|
} satisfies SystemStyleObject;
|
|
|
|
const scrollIndicatorRightSx = {
|
|
...scrollIndicatorBaseSx,
|
|
right: 0,
|
|
bg: 'linear-gradient(to left, var(--invoke-colors-base-900), transparent)',
|
|
} satisfies SystemStyleObject;
|
|
|
|
type StagingContextValue = {
|
|
session:
|
|
| {
|
|
type: 'simple';
|
|
id: string;
|
|
}
|
|
| {
|
|
type: 'advanced';
|
|
id: string;
|
|
};
|
|
$progressData: WritableAtom<Record<string, ProgressData>>;
|
|
};
|
|
|
|
const StagingContext = createContext<StagingContextValue | null>(null);
|
|
|
|
const useStagingContext = () => {
|
|
const ctx = useContext(StagingContext);
|
|
assert(ctx !== null, 'use in stg prov');
|
|
return ctx;
|
|
};
|
|
|
|
const useStagingAreaKeyboardNav = (
|
|
items: S['SessionQueueItem'][],
|
|
selectedItemId: number | null,
|
|
onSelectItemId: (item_id: number) => void
|
|
) => {
|
|
const onNext = useCallback(() => {
|
|
if (selectedItemId === null) {
|
|
return;
|
|
}
|
|
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
|
|
const nextIndex = (currentIndex + 1) % items.length;
|
|
const nextItem = items[nextIndex];
|
|
if (!nextItem) {
|
|
return;
|
|
}
|
|
onSelectItemId(nextItem.item_id);
|
|
}, [items, onSelectItemId, selectedItemId]);
|
|
const onPrev = useCallback(() => {
|
|
if (selectedItemId === null) {
|
|
return;
|
|
}
|
|
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
|
|
const prevIndex = (currentIndex - 1 + items.length) % items.length;
|
|
const prevItem = items[prevIndex];
|
|
if (!prevItem) {
|
|
return;
|
|
}
|
|
onSelectItemId(prevItem.item_id);
|
|
}, [items, onSelectItemId, selectedItemId]);
|
|
|
|
const onFirst = useCallback(() => {
|
|
const first = items.at(0);
|
|
if (!first) {
|
|
return;
|
|
}
|
|
onSelectItemId(first.item_id);
|
|
}, [items, onSelectItemId]);
|
|
const onLast = useCallback(() => {
|
|
const last = items.at(-1);
|
|
if (!last) {
|
|
return;
|
|
}
|
|
onSelectItemId(last.item_id);
|
|
}, [items, onSelectItemId]);
|
|
|
|
useHotkeys('left', onPrev, { preventDefault: true });
|
|
useHotkeys('right', onNext, { preventDefault: true });
|
|
useHotkeys('meta+left', onFirst, { preventDefault: true });
|
|
useHotkeys('meta+right', onLast, { preventDefault: true });
|
|
};
|
|
|
|
const LIST_ALL_OPTIONS = {
|
|
selectFromResult: ({ data }) => {
|
|
if (!data) {
|
|
return { items: EMPTY_ARRAY };
|
|
}
|
|
return { items: data.filter(({ status }) => status !== 'canceled') };
|
|
},
|
|
} satisfies Parameters<typeof useListAllQueueItemsQuery>[1];
|
|
|
|
const StagingArea = memo(() => {
|
|
const ctx = useStagingContext();
|
|
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
|
const [autoSwitch, setAutoSwitch] = useState(true);
|
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
|
const { items } = useListAllQueueItemsQuery({ destination: ctx.session.id }, LIST_ALL_OPTIONS);
|
|
const selectedItem = useMemo(
|
|
() =>
|
|
items.length > 0 && selectedItemId !== null ? items.find(({ item_id }) => item_id === selectedItemId) : null,
|
|
[items, selectedItemId]
|
|
);
|
|
const selectedItemIndex = useMemo(
|
|
() =>
|
|
items.length > 0 && selectedItemId !== null ? items.findIndex(({ item_id }) => item_id === selectedItemId) : null,
|
|
[items, selectedItemId]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const el = scrollableRef.current;
|
|
if (!el) {
|
|
return;
|
|
}
|
|
const onScroll = () => {
|
|
const { scrollLeft, scrollWidth, clientWidth } = el;
|
|
setCanScrollLeft(scrollLeft > 0);
|
|
setCanScrollRight(scrollLeft + clientWidth < scrollWidth);
|
|
};
|
|
el.addEventListener('scroll', onScroll);
|
|
const observer = new ResizeObserver(onScroll);
|
|
observer.observe(el);
|
|
return () => {
|
|
el.removeEventListener('scroll', onScroll);
|
|
observer.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
const onSelectItemId = useCallback((item_id: number | null) => {
|
|
setSelectedItemId(item_id);
|
|
if (item_id !== null) {
|
|
document.getElementById(getCardId(item_id))?.scrollIntoView();
|
|
}
|
|
}, []);
|
|
|
|
useStagingAreaKeyboardNav(items, selectedItemId, onSelectItemId);
|
|
|
|
const onChangeAutoSwitch = useCallback((autoSwitch: boolean) => {
|
|
setAutoSwitch(autoSwitch);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (items.length === 0) {
|
|
onSelectItemId(null);
|
|
return;
|
|
}
|
|
if (selectedItemId === null && items.length > 0) {
|
|
onSelectItemId(items[0]?.item_id ?? null);
|
|
return;
|
|
}
|
|
}, [items, onSelectItemId, selectedItem, selectedItemId]);
|
|
|
|
const socket = useStore($socket);
|
|
useEffect(() => {
|
|
if (!socket) {
|
|
return;
|
|
}
|
|
|
|
const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => {
|
|
if (data.destination !== ctx.session.id) {
|
|
return;
|
|
}
|
|
if (data.status === 'in_progress' && autoSwitch) {
|
|
onSelectItemId(data.item_id);
|
|
}
|
|
};
|
|
|
|
socket.on('queue_item_status_changed', onQueueItemStatusChanged);
|
|
|
|
return () => {
|
|
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
|
|
};
|
|
}, [autoSwitch, ctx.$progressData, ctx.session.id, onSelectItemId, socket]);
|
|
|
|
const _onChangeAutoSwitch = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
|
setAutoSwitch(e.target.checked);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!socket) {
|
|
return;
|
|
}
|
|
const onProgress = (data: S['InvocationProgressEvent']) => {
|
|
if (data.destination !== ctx.session.id) {
|
|
return;
|
|
}
|
|
setProgress(ctx.$progressData, data);
|
|
};
|
|
socket.on('invocation_progress', onProgress);
|
|
|
|
return () => {
|
|
socket.off('invocation_progress', onProgress);
|
|
};
|
|
}, [ctx.$progressData, ctx.session.id, socket]);
|
|
|
|
return (
|
|
<Flex flexDir="column" gap={2} w="full" h="full" minW={0} minH={0}>
|
|
<StagingAreaHeader />
|
|
<Flex position="relative" w="full" h="full" maxH="full" alignItems="center" justifyContent="center" minH={0}>
|
|
<Flex alignItems="center" justifyContent="center" w="full" h="full" objectFit="contain">
|
|
{selectedItem && selectedItemIndex !== null && (
|
|
<FullSizeQueueItem
|
|
key={`${selectedItem.item_id}-full`}
|
|
item={selectedItem}
|
|
number={selectedItemIndex + 1}
|
|
/>
|
|
)}
|
|
{!selectedItem && <Text>No generation selected</Text>}
|
|
</Flex>
|
|
<FormControl position="absolute" top={2} right={2} w="min-content">
|
|
<FormLabel m={0}>Auto-switch</FormLabel>
|
|
<Switch size="sm" isChecked={autoSwitch} onChange={_onChangeAutoSwitch} />
|
|
</FormControl>
|
|
</Flex>
|
|
<Flex position="relative" maxW="full" w="full" h={108}>
|
|
<Flex ref={scrollableRef} gap={2} maxW="full" overflowX="scroll">
|
|
{items.map((item, i) => (
|
|
<MiniQueueItem
|
|
key={`${item.item_id}-mini`}
|
|
item={item}
|
|
number={i + 1}
|
|
isSelected={selectedItemId === item.item_id}
|
|
onSelectItemId={onSelectItemId}
|
|
onChangeAutoSwitch={onChangeAutoSwitch}
|
|
/>
|
|
))}
|
|
</Flex>
|
|
<Box sx={scrollIndicatorLeftSx} data-visible={canScrollLeft} />
|
|
<Box sx={scrollIndicatorRightSx} data-visible={canScrollRight} />
|
|
</Flex>
|
|
</Flex>
|
|
);
|
|
});
|
|
StagingArea.displayName = 'StagingArea';
|
|
|
|
const StagingAreaHeader = memo(() => {
|
|
const dispatch = useAppDispatch();
|
|
|
|
const startOver = useCallback(() => {
|
|
dispatch(canvasSessionStarted({ sessionType: 'simple' }));
|
|
}, [dispatch]);
|
|
|
|
return (
|
|
<Flex w="full" alignItems="center">
|
|
<Text fontSize="lg" fontWeight="bold">
|
|
Generations
|
|
</Text>
|
|
<Spacer />
|
|
<Button size="sm" variant="ghost" onClick={startOver}>
|
|
Start Over
|
|
</Button>
|
|
</Flex>
|
|
);
|
|
});
|
|
StagingAreaHeader.displayName = 'StagingAreaHeader';
|
|
|
|
const miniQueueItemSx = {
|
|
cursor: 'pointer',
|
|
pos: 'relative',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
h: 'full',
|
|
maxH: 'full',
|
|
maxW: 'full',
|
|
minW: 0,
|
|
minH: 0,
|
|
borderWidth: 1,
|
|
borderRadius: 'base',
|
|
'&[data-selected="true"]': {
|
|
borderColor: 'invokeBlue.300',
|
|
},
|
|
aspectRatio: '1/1',
|
|
flexShrink: 0,
|
|
};
|
|
|
|
const getCardId = (item_id: number) => `queue-item-status-card-${item_id}`;
|
|
|
|
const useOutputImageDTO = (item: S['SessionQueueItem']) => {
|
|
const ctx = useStagingContext();
|
|
|
|
const outputImageName = useMemo(() => {
|
|
const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) =>
|
|
isCanvasOutputNodeId(nodeId)
|
|
)?.[1][0];
|
|
const output = nodeId ? item.session.results[nodeId] : undefined;
|
|
|
|
if (!output) {
|
|
return null;
|
|
}
|
|
|
|
for (const [_name, value] of objectEntries(output)) {
|
|
if (isImageField(value)) {
|
|
return value.image_name;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}, [item.session.results, item.session.source_prepared_mapping]);
|
|
|
|
const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken);
|
|
|
|
const preloadOutputImageAndClearProgress = useCallback(
|
|
async (imageDTO: ImageDTO) => {
|
|
try {
|
|
await loadImage(imageDTO.image_url, true);
|
|
clearProgressImage(ctx.$progressData, item.session_id);
|
|
return;
|
|
} catch {
|
|
// noop - but should we do something? means image failed to load...
|
|
}
|
|
},
|
|
[ctx.$progressData, item.session_id]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!imageDTO) {
|
|
return;
|
|
}
|
|
if (!ctx.$progressData.get()[item.session_id]?.progressImage) {
|
|
return;
|
|
}
|
|
preloadOutputImageAndClearProgress(imageDTO);
|
|
}, [ctx.$progressData, imageDTO, item.session_id, preloadOutputImageAndClearProgress]);
|
|
|
|
return imageDTO;
|
|
};
|
|
|
|
type MiniQueueItemProps = {
|
|
item: S['SessionQueueItem'];
|
|
number: number;
|
|
isSelected: boolean;
|
|
onSelectItemId: (item_id: number) => void;
|
|
onChangeAutoSwitch: (autoSwitch: boolean) => void;
|
|
};
|
|
|
|
const MiniQueueItem = memo(({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch }: MiniQueueItemProps) => {
|
|
const ctx = useStagingContext();
|
|
const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id);
|
|
const imageDTO = useOutputImageDTO(item);
|
|
|
|
const onClick = useCallback(() => {
|
|
onSelectItemId(item.item_id);
|
|
}, [item.item_id, onSelectItemId]);
|
|
|
|
const onDoubleClick = useCallback(() => {
|
|
onChangeAutoSwitch(item.status === 'in_progress');
|
|
}, [item.status, onChangeAutoSwitch]);
|
|
|
|
if (imageDTO && !hasProgressImage) {
|
|
return (
|
|
<Flex id={getCardId(item.item_id)} sx={miniQueueItemSx} data-selected={isSelected}>
|
|
<DndImage imageDTO={imageDTO} onClick={onClick} onDoubleClick={onDoubleClick} asThumbnail />
|
|
<ItemNumber number={number} position="absolute" top={0} left={1} />
|
|
</Flex>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Flex
|
|
id={getCardId(item.item_id)}
|
|
sx={miniQueueItemSx}
|
|
data-selected={isSelected}
|
|
onClick={onClick}
|
|
onDoubleClick={onDoubleClick}
|
|
>
|
|
<InProgressContent item={item} />
|
|
<ItemNumber number={number} position="absolute" top={0} left={1} />
|
|
</Flex>
|
|
);
|
|
});
|
|
MiniQueueItem.displayName = 'MiniQueueItem';
|
|
|
|
const fullSizeQueueItemSx = {
|
|
cursor: 'pointer',
|
|
pos: 'relative',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
h: 'full',
|
|
maxH: 'full',
|
|
maxW: 'full',
|
|
minW: 0,
|
|
minH: 0,
|
|
};
|
|
|
|
type FullSizeQueueItemProps = {
|
|
item: S['SessionQueueItem'];
|
|
number: number;
|
|
};
|
|
|
|
const FullSizeQueueItem = memo(({ item, number }: FullSizeQueueItemProps) => {
|
|
const ctx = useStagingContext();
|
|
const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id);
|
|
const imageDTO = useOutputImageDTO(item);
|
|
|
|
if (imageDTO && !hasProgressImage) {
|
|
return (
|
|
<Flex id={getCardId(item.item_id)} sx={fullSizeQueueItemSx}>
|
|
<DndImage imageDTO={imageDTO} />
|
|
<ItemNumber number={number} position="absolute" top={1} left={2} />
|
|
<ImageActions imageDTO={imageDTO} position="absolute" top={2} right={2} />
|
|
</Flex>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Flex id={getCardId(item.item_id)} sx={fullSizeQueueItemSx}>
|
|
<InProgressContent item={item} />
|
|
<ItemNumber number={number} position="absolute" top={1} left={2} />
|
|
<ProgressMessage session_id={item.session_id} position="absolute" bottom={1} left={2} />
|
|
</Flex>
|
|
);
|
|
});
|
|
FullSizeQueueItem.displayName = 'FullSizeQueueItem';
|
|
|
|
const getMessage = (data: S['InvocationProgressEvent']) => {
|
|
let message = data.message;
|
|
if (data.percentage) {
|
|
message += ` (${round(data.percentage * 100)}%)`;
|
|
}
|
|
return message;
|
|
};
|
|
|
|
const ItemNumber = memo(({ number, ...rest }: { number: number } & TextProps) => {
|
|
return <Text pointerEvents="none" userSelect="none" filter={DROP_SHADOW} {...rest}>{`#${number}`}</Text>;
|
|
});
|
|
ItemNumber.displayName = 'ItemNumber';
|
|
|
|
const ProgressMessage = memo(({ session_id, ...rest }: { session_id: string } & TextProps) => {
|
|
const { $progressData } = useStagingContext();
|
|
const { progressEvent } = useProgressData($progressData, session_id);
|
|
if (!progressEvent) {
|
|
return null;
|
|
}
|
|
return (
|
|
<Text pointerEvents="none" userSelect="none" filter={DROP_SHADOW} {...rest}>
|
|
{getMessage(progressEvent)}
|
|
</Text>
|
|
);
|
|
});
|
|
ProgressMessage.displayName = 'ProgressMessage';
|
|
|
|
const InProgressContent = memo(({ item }: { item: S['SessionQueueItem'] }) => {
|
|
const { $progressData } = useStagingContext();
|
|
const { progressEvent, progressImage } = useProgressData($progressData, item.session_id);
|
|
|
|
if (item.status === 'pending') {
|
|
return (
|
|
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="base.300">
|
|
Pending
|
|
</Text>
|
|
);
|
|
}
|
|
if (item.status === 'canceled') {
|
|
return (
|
|
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="warning.300">
|
|
Canceled
|
|
</Text>
|
|
);
|
|
}
|
|
if (item.status === 'failed') {
|
|
return (
|
|
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="error.300">
|
|
Failed
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (progressImage) {
|
|
return (
|
|
<>
|
|
<Image
|
|
objectFit="contain"
|
|
maxH="full"
|
|
maxW="full"
|
|
src={progressImage.dataURL}
|
|
width={progressImage.width}
|
|
height={progressImage.height}
|
|
/>
|
|
<ProgressCircle data={progressEvent} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (item.status === 'in_progress') {
|
|
return (
|
|
<>
|
|
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeBlue.300">
|
|
In Progress
|
|
</Text>
|
|
<ProgressCircle data={progressEvent} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (item.status === 'completed') {
|
|
return <IAINoContentFallbackWithSpinner />;
|
|
}
|
|
assert<Equals<never, typeof item.status>>(false);
|
|
});
|
|
InProgressContent.displayName = 'InProgressContent';
|
|
|
|
const circleStyles: SystemStyleObject = {
|
|
circle: {
|
|
transitionProperty: 'none',
|
|
transitionDuration: '0s',
|
|
},
|
|
position: 'absolute',
|
|
top: 2,
|
|
right: 2,
|
|
};
|
|
|
|
const ProgressCircle = memo(({ data }: { data?: S['InvocationProgressEvent'] | null }) => {
|
|
return (
|
|
<Tooltip label={data?.message ?? 'Generating'}>
|
|
<CircularProgress
|
|
size="14px"
|
|
color="invokeBlue.500"
|
|
thickness={14}
|
|
isIndeterminate={!data || data.percentage === null}
|
|
value={data?.percentage ? data.percentage * 100 : undefined}
|
|
sx={circleStyles}
|
|
/>
|
|
</Tooltip>
|
|
);
|
|
});
|
|
ProgressCircle.displayName = 'ProgressCircle';
|
|
|
|
const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } & ButtonGroupProps) => {
|
|
const { getState, dispatch } = useAppStore();
|
|
|
|
const vary = useCallback(() => {
|
|
newCanvasFromImage({
|
|
imageDTO,
|
|
type: 'raster_layer',
|
|
withResize: true,
|
|
getState,
|
|
dispatch,
|
|
});
|
|
}, [dispatch, getState, imageDTO]);
|
|
|
|
const useAsControl = useCallback(() => {
|
|
newCanvasFromImage({
|
|
imageDTO,
|
|
type: 'control_layer',
|
|
withResize: true,
|
|
getState,
|
|
dispatch,
|
|
});
|
|
}, [dispatch, getState, imageDTO]);
|
|
|
|
const edit = useCallback(() => {
|
|
newCanvasFromImage({
|
|
imageDTO,
|
|
type: 'raster_layer',
|
|
withInpaintMask: true,
|
|
getState,
|
|
dispatch,
|
|
});
|
|
}, [dispatch, getState, imageDTO]);
|
|
return (
|
|
<ButtonGroup isAttached={false} size="sm" {...rest}>
|
|
<Button onClick={vary} tooltip="Vary the image using Image to Image">
|
|
Vary
|
|
</Button>
|
|
<Button onClick={useAsControl} tooltip="Use this image to control a new Text to Image generation">
|
|
Use as Control
|
|
</Button>
|
|
<Button onClick={edit} tooltip="Edit parts of this image with Inpainting">
|
|
Edit
|
|
</Button>
|
|
</ButtonGroup>
|
|
);
|
|
});
|
|
ImageActions.displayName = 'ImageActions';
|
|
|
|
const canvasBgSx = {
|
|
position: 'relative',
|
|
w: 'full',
|
|
h: 'full',
|
|
borderRadius: 'base',
|
|
overflow: 'hidden',
|
|
bg: 'base.900',
|
|
'&[data-dynamic-grid="true"]': {
|
|
bg: 'base.850',
|
|
},
|
|
};
|
|
|
|
const CanvasActiveSession = memo(() => {
|
|
const dynamicGrid = useAppSelector(selectDynamicGrid);
|
|
const showHUD = useAppSelector(selectShowHUD);
|
|
|
|
const renderMenu = useCallback(() => {
|
|
return <MenuContent />;
|
|
}, []);
|
|
|
|
return (
|
|
<FocusRegionWrapper region="canvas" sx={FOCUS_REGION_STYLES}>
|
|
<Flex
|
|
tabIndex={-1}
|
|
borderRadius="base"
|
|
position="relative"
|
|
flexDirection="column"
|
|
height="full"
|
|
width="full"
|
|
gap={2}
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
overflow="hidden"
|
|
>
|
|
<CanvasManagerProviderGate>
|
|
<CanvasToolbar />
|
|
</CanvasManagerProviderGate>
|
|
<ContextMenu<HTMLDivElement> renderMenu={renderMenu} withLongPress={false}>
|
|
{(ref) => (
|
|
<Flex ref={ref} sx={canvasBgSx} data-dynamic-grid={dynamicGrid}>
|
|
<InvokeCanvasComponent />
|
|
<CanvasManagerProviderGate>
|
|
<Flex
|
|
position="absolute"
|
|
flexDir="column"
|
|
top={1}
|
|
insetInlineStart={1}
|
|
pointerEvents="none"
|
|
gap={2}
|
|
alignItems="flex-start"
|
|
>
|
|
{showHUD && <CanvasHUD />}
|
|
<CanvasAlertsSelectedEntityStatus />
|
|
<CanvasAlertsPreserveMask />
|
|
<CanvasAlertsSendingToGallery />
|
|
<CanvasAlertsInvocationProgress />
|
|
</Flex>
|
|
<Flex position="absolute" top={1} insetInlineEnd={1}>
|
|
<Menu>
|
|
<MenuButton as={IconButton} icon={<PiDotsThreeOutlineVerticalFill />} colorScheme="base" />
|
|
<MenuContent />
|
|
</Menu>
|
|
</Flex>
|
|
<Flex position="absolute" bottom={4} insetInlineEnd={4}>
|
|
<CanvasBusySpinner />
|
|
</Flex>
|
|
</CanvasManagerProviderGate>
|
|
</Flex>
|
|
)}
|
|
</ContextMenu>
|
|
<Flex position="absolute" bottom={4} gap={2} align="center" justify="center">
|
|
<CanvasManagerProviderGate>
|
|
<StagingAreaIsStagingGate>
|
|
<StagingAreaToolbar />
|
|
</StagingAreaIsStagingGate>
|
|
</CanvasManagerProviderGate>
|
|
</Flex>
|
|
<Flex position="absolute" bottom={4}>
|
|
<CanvasManagerProviderGate>
|
|
<Filter />
|
|
<Transform />
|
|
<SelectObject />
|
|
</CanvasManagerProviderGate>
|
|
</Flex>
|
|
<CanvasManagerProviderGate>
|
|
<CanvasDropArea />
|
|
</CanvasManagerProviderGate>
|
|
</Flex>
|
|
</FocusRegionWrapper>
|
|
);
|
|
});
|
|
CanvasActiveSession.displayName = 'ActiveCanvasContent';
|