diff --git a/invokeai/frontend/web/.eslintrc.js b/invokeai/frontend/web/.eslintrc.js index 6c409cb164a..434b35edb9d 100644 --- a/invokeai/frontend/web/.eslintrc.js +++ b/invokeai/frontend/web/.eslintrc.js @@ -16,6 +16,14 @@ module.exports = { 'no-promise-executor-return': 'error', // https://eslint.org/docs/latest/rules/require-await 'require-await': 'error', + 'no-restricted-properties': [ + 'error', + { + object: 'crypto', + property: 'randomUUID', + message: 'Use of crypto.randomUUID is not allowed as it is not available in all browsers.', + }, + ], }, overrides: [ /** diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index eb99ef4f6f6..a1ffd85cae3 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1656,6 +1656,7 @@ "storeNotInitialized": "Store is not initialized" }, "controlLayers": { + "autoPreviewFilter": "Auto Preview", "bookmark": "Bookmark for Quick Switch", "fitBboxToLayers": "Fit Bbox To Layers", "removeBookmark": "Remove Bookmark", diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 2e12f195415..54282f9966f 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -9,6 +9,7 @@ import { addBatchEnqueuedListener } from 'app/store/middleware/listenerMiddlewar import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted'; import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected'; import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload'; +import { addCancellationsListeners } from 'app/store/middleware/listenerMiddleware/listeners/cancellationsListeners'; import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear'; import { addEnqueueRequestedNodes } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes'; import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; @@ -119,3 +120,5 @@ addDynamicPromptsListener(startAppListening); addSetDefaultSettingsListener(startAppListening); // addControlAdapterPreprocessor(startAppListening); + +addCancellationsListeners(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts index 178d8df0c72..8ebbea1166a 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts @@ -12,7 +12,6 @@ import { imageDTOToImageObject } from 'features/controlLayers/store/types'; import { toast } from 'features/toast/toast'; import { t } from 'i18next'; import { queueApi } from 'services/api/endpoints/queue'; -import { $lastCanvasProgressEvent } from 'services/events/setEventListeners'; import { assert } from 'tsafe'; const log = logger('canvas'); @@ -33,8 +32,6 @@ export const addStagingListeners = (startAppListening: AppStartListening) => { const { canceled } = await req.unwrap(); req.reset(); - $lastCanvasProgressEvent.set(null); - if (canceled > 0) { log.debug(`Canceled ${canceled} canvas batches`); toast({ diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/cancellationsListeners.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/cancellationsListeners.ts new file mode 100644 index 00000000000..d5021fe7638 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/cancellationsListeners.ts @@ -0,0 +1,137 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { $lastCanvasProgressEvent } from 'features/controlLayers/store/canvasSlice'; +import { queueApi } from 'services/api/endpoints/queue'; + +/** + * To prevent a race condition where a progress event arrives after a successful cancellation, we need to keep track of + * cancellations: + * - In the route handlers above, we track and update the cancellations object + * - When the user queues a, we should reset the cancellations, also handled int he route handlers above + * - When we get a progress event, we should check if the event is cancelled before setting the event + * + * We have a few ways that cancellations are effected, so we need to track them all: + * - by queue item id (in this case, we will compare the session_id and not the item_id) + * - by batch id + * - by destination + * - by clearing the queue + */ +type Cancellations = { + sessionIds: Set; + batchIds: Set; + destinations: Set; + clearQueue: boolean; +}; + +const resetCancellations = (): void => { + cancellations.clearQueue = false; + cancellations.sessionIds.clear(); + cancellations.batchIds.clear(); + cancellations.destinations.clear(); +}; + +const cancellations: Cancellations = { + sessionIds: new Set(), + batchIds: new Set(), + destinations: new Set(), + clearQueue: false, +} as Readonly; + +/** + * Checks if an item is cancelled, used to prevent race conditions with event handling. + * + * To use this, provide the session_id, batch_id and destination from the event payload. + */ +export const getIsCancelled = (item: { + session_id: string; + batch_id: string; + destination?: string | null; +}): boolean => { + if (cancellations.clearQueue) { + return true; + } + if (cancellations.sessionIds.has(item.session_id)) { + return true; + } + if (cancellations.batchIds.has(item.batch_id)) { + return true; + } + if (item.destination && cancellations.destinations.has(item.destination)) { + return true; + } + return false; +}; + +export const addCancellationsListeners = (startAppListening: AppStartListening) => { + // When we get a cancellation, we may need to clear the last progress event - next few listeners handle those cases. + // Maybe we could use the `getIsCancelled` util here, but I think that could introduce _another_ race condition... + startAppListening({ + matcher: queueApi.endpoints.enqueueBatch.matchFulfilled, + effect: () => { + resetCancellations(); + }, + }); + + startAppListening({ + matcher: queueApi.endpoints.cancelByBatchDestination.matchFulfilled, + effect: (action) => { + cancellations.destinations.add(action.meta.arg.originalArgs.destination); + + const event = $lastCanvasProgressEvent.get(); + if (!event) { + return; + } + const { session_id, batch_id, destination } = event; + if (getIsCancelled({ session_id, batch_id, destination })) { + $lastCanvasProgressEvent.set(null); + } + }, + }); + + startAppListening({ + matcher: queueApi.endpoints.cancelQueueItem.matchFulfilled, + effect: (action) => { + cancellations.sessionIds.add(action.payload.session_id); + + const event = $lastCanvasProgressEvent.get(); + if (!event) { + return; + } + const { session_id, batch_id, destination } = event; + if (getIsCancelled({ session_id, batch_id, destination })) { + $lastCanvasProgressEvent.set(null); + } + }, + }); + + startAppListening({ + matcher: queueApi.endpoints.cancelByBatchIds.matchFulfilled, + effect: (action) => { + for (const batch_id of action.meta.arg.originalArgs.batch_ids) { + cancellations.batchIds.add(batch_id); + } + const event = $lastCanvasProgressEvent.get(); + if (!event) { + return; + } + const { session_id, batch_id, destination } = event; + if (getIsCancelled({ session_id, batch_id, destination })) { + $lastCanvasProgressEvent.set(null); + } + }, + }); + + startAppListening({ + matcher: queueApi.endpoints.clearQueue.matchFulfilled, + effect: () => { + cancellations.clearQueue = true; + const event = $lastCanvasProgressEvent.get(); + if (!event) { + return; + } + const { session_id, batch_id, destination } = event; + if (getIsCancelled({ session_id, batch_id, destination })) { + $lastCanvasProgressEvent.set(null); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts index 996050c3e24..e09cd9589e4 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts @@ -1,6 +1,7 @@ import { createAction } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { selectDefaultControlAdapter } from 'features/controlLayers/hooks/addLayerHooks'; import { controlLayerAdded, ipaImageChanged, @@ -103,11 +104,14 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) => activeData.payloadType === 'IMAGE_DTO' && activeData.payload.imageDTO ) { + const state = getState(); const imageObject = imageDTOToImageObject(activeData.payload.imageDTO); - const { x, y } = selectCanvasSlice(getState()).bbox.rect; + const { x, y } = selectCanvasSlice(state).bbox.rect; + const defaultControlAdapter = selectDefaultControlAdapter(state); const overrides: Partial = { objects: [imageObject], position: { x, y }, + controlAdapter: defaultControlAdapter, }; dispatch(controlLayerAdded({ overrides, isSelected: true })); return; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts index 0a5e18a160f..3f81400fa79 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts @@ -2,6 +2,7 @@ import { logger } from 'app/logging/logger'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import { ipaImageChanged, rgIPAdapterImageChanged } from 'features/controlLayers/store/canvasSlice'; import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { boardIdSelected, galleryViewChanged } from 'features/gallery/store/gallerySlice'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import { toast } from 'features/toast/toast'; @@ -44,6 +45,8 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis if (!autoAddBoardId || autoAddBoardId === 'none') { const title = postUploadAction.title || DEFAULT_UPLOADED_TOAST.title; toast({ ...DEFAULT_UPLOADED_TOAST, title }); + dispatch(boardIdSelected({ boardId: 'none' })); + dispatch(galleryViewChanged('assets')); } else { // Add this image to the board dispatch( @@ -67,6 +70,8 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis ...DEFAULT_UPLOADED_TOAST, description, }); + dispatch(boardIdSelected({ boardId: autoAddBoardId })); + dispatch(galleryViewChanged('assets')); } return; } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx index 47e58668f47..33357e572cb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx @@ -1,34 +1,22 @@ import { Button, ButtonGroup, Flex } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; import { - controlLayerAdded, - inpaintMaskAdded, - ipaAdded, - rasterLayerAdded, - rgAdded, -} from 'features/controlLayers/store/canvasSlice'; -import { memo, useCallback } from 'react'; + useAddControlLayer, + useAddInpaintMask, + useAddIPAdapter, + useAddRasterLayer, + useAddRegionalGuidance, +} from 'features/controlLayers/hooks/addLayerHooks'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiPlusBold } from 'react-icons/pi'; export const CanvasAddEntityButtons = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const addInpaintMask = useCallback(() => { - dispatch(inpaintMaskAdded({ isSelected: true })); - }, [dispatch]); - const addRegionalGuidance = useCallback(() => { - dispatch(rgAdded({ isSelected: true })); - }, [dispatch]); - const addRasterLayer = useCallback(() => { - dispatch(rasterLayerAdded({ isSelected: true })); - }, [dispatch]); - const addControlLayer = useCallback(() => { - dispatch(controlLayerAdded({ isSelected: true })); - }, [dispatch]); - const addIPAdapter = useCallback(() => { - dispatch(ipaAdded({ isSelected: true })); - }, [dispatch]); + const addInpaintMask = useAddInpaintMask(); + const addRegionalGuidance = useAddRegionalGuidance(); + const addRasterLayer = useAddRasterLayer(); + const addControlLayer = useAddControlLayer(); + const addIPAdapter = useAddIPAdapter(); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx index b68b6215f19..694d50aab6f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx @@ -1,37 +1,22 @@ import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { useDefaultIPAdapter } from 'features/controlLayers/hooks/useLayerControlAdapter'; import { - controlLayerAdded, - inpaintMaskAdded, - ipaAdded, - rasterLayerAdded, - rgAdded, -} from 'features/controlLayers/store/canvasSlice'; -import { memo, useCallback } from 'react'; + useAddControlLayer, + useAddInpaintMask, + useAddIPAdapter, + useAddRasterLayer, + useAddRegionalGuidance, +} from 'features/controlLayers/hooks/addLayerHooks'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiPlusBold } from 'react-icons/pi'; export const EntityListGlobalActionBarAddLayerMenu = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const defaultIPAdapter = useDefaultIPAdapter(); - const addInpaintMask = useCallback(() => { - dispatch(inpaintMaskAdded({ isSelected: true })); - }, [dispatch]); - const addRegionalGuidance = useCallback(() => { - dispatch(rgAdded({ isSelected: true })); - }, [dispatch]); - const addRasterLayer = useCallback(() => { - dispatch(rasterLayerAdded({ isSelected: true })); - }, [dispatch]); - const addControlLayer = useCallback(() => { - dispatch(controlLayerAdded({ isSelected: true })); - }, [dispatch]); - const addIPAdapter = useCallback(() => { - const overrides = { ipAdapter: defaultIPAdapter }; - dispatch(ipaAdded({ isSelected: true, overrides })); - }, [defaultIPAdapter, dispatch]); + const addInpaintMask = useAddInpaintMask(); + const addRegionalGuidance = useAddRegionalGuidance(); + const addRasterLayer = useAddRasterLayer(); + const addControlLayer = useAddControlLayer(); + const addIPAdapter = useAddIPAdapter(); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton.tsx index 11ba3cd16df..5daf6529e5a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton.tsx @@ -1,31 +1,16 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; -import { selectIsStaging } from 'features/controlLayers/store/canvasSessionSlice'; +import { useEntityFilter } from 'features/controlLayers/hooks/useEntityFilter'; import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import { isFilterableEntityIdentifier } from 'features/controlLayers/store/types'; -import { memo, useCallback } from 'react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiShootingStarBold } from 'react-icons/pi'; export const EntityListSelectedEntityActionBarFilterButton = memo(() => { const { t } = useTranslation(); const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); - const canvasManager = useCanvasManager(); - const isStaging = useAppSelector(selectIsStaging); - const isBusy = useCanvasIsBusy(); - - const onClick = useCallback(() => { - if (!selectedEntityIdentifier) { - return; - } - if (!isFilterableEntityIdentifier(selectedEntityIdentifier)) { - return; - } - - canvasManager.filter.startFilter(selectedEntityIdentifier); - }, [canvasManager, selectedEntityIdentifier]); + const filter = useEntityFilter(selectedEntityIdentifier); if (!selectedEntityIdentifier) { return null; @@ -37,8 +22,8 @@ export const EntityListSelectedEntityActionBarFilterButton = memo(() => { return ( { const { t } = useTranslation(); const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); - const canvasManager = useCanvasManager(); - const isStaging = useAppSelector(selectIsStaging); - const isBusy = useCanvasIsBusy(); - - const onClick = useCallback(() => { - if (!selectedEntityIdentifier) { - return; - } - if (!isTransformableEntityIdentifier(selectedEntityIdentifier)) { - return; - } - const adapter = canvasManager.getAdapter(selectedEntityIdentifier); - if (!adapter) { - return; - } - adapter.transformer.startTransform(); - }, [canvasManager, selectedEntityIdentifier]); + const transform = useEntityTransform(selectedEntityIdentifier); if (!selectedEntityIdentifier) { return null; @@ -40,8 +22,8 @@ export const EntityListSelectedEntityActionBarTransformButton = memo(() => { return ( ) => { + const selectControlAdapter = useMemo( + () => + createMemoizedAppSelector(selectCanvasSlice, (canvas) => { + const layer = selectEntityOrThrow(canvas, entityIdentifier); + return layer.controlAdapter; + }), + [entityIdentifier] + ); + const controlAdapter = useAppSelector(selectControlAdapter); + return controlAdapter; +}; + export const ControlLayerControlAdapter = memo(() => { const dispatch = useAppDispatch(); const entityIdentifier = useEntityIdentifierContext('control_layer'); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapterModel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapterModel.tsx index 83ce7cf28db..1bf634eb369 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapterModel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapterModel.tsx @@ -1,15 +1,7 @@ import { Combobox, FormControl, Tooltip } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox'; -import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { selectBase } from 'features/controlLayers/store/paramsSlice'; -import { - IMAGE_FILTERS, - isControlLayerEntityIdentifier, - isFilterType, - isRasterLayerEntityIdentifier, -} from 'features/controlLayers/store/types'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useControlNetAndT2IAdapterModels } from 'services/api/hooks/modelsByType'; @@ -22,8 +14,6 @@ type Props = { export const ControlLayerControlAdapterModel = memo(({ modelKey, onChange: onChangeModel }: Props) => { const { t } = useTranslation(); - const entityIdentifier = useEntityIdentifierContext(); - const canvasManager = useCanvasManager(); const currentBaseModel = useAppSelector(selectBase); const [modelConfigs, { isLoading }] = useControlNetAndT2IAdapterModels(); const selectedModel = useMemo(() => modelConfigs.find((m) => m.key === modelKey), [modelConfigs, modelKey]); @@ -34,35 +24,8 @@ export const ControlLayerControlAdapterModel = memo(({ modelKey, onChange: onCha return; } onChangeModel(modelConfig); - - // When we set the model for the first time, we'll set the default filter settings and open the filter popup - - if (modelKey) { - // If there is already a model key, this is not the first time we're setting the model - return; - } - - // Open the filter popup by setting this entity as the filtering entity - if (!canvasManager.filter.$adapter.get()) { - // Can only filter raster and control layers - if (!isRasterLayerEntityIdentifier(entityIdentifier) && !isControlLayerEntityIdentifier(entityIdentifier)) { - return; - } - - // Update the filter, preferring the model's default - if (isFilterType(modelConfig.default_settings?.preprocessor)) { - canvasManager.filter.$config.set( - IMAGE_FILTERS[modelConfig.default_settings.preprocessor].buildDefaults(modelConfig.base) - ); - } else { - canvasManager.filter.$config.set(IMAGE_FILTERS.canny_image_processor.buildDefaults(modelConfig.base)); - } - - canvasManager.filter.startFilter(entityIdentifier); - canvasManager.filter.previewFilter(); - } }, - [canvasManager.filter, entityIdentifier, modelKey, onChangeModel] + [onChangeModel] ); const getIsDisabled = useCallback( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/Filter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/Filter.tsx index cc19d476916..531dab39361 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Filters/Filter.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/Filter.tsx @@ -1,37 +1,44 @@ -import { Button, ButtonGroup, Flex, Heading, Spacer } from '@invoke-ai/ui-library'; +import { Button, ButtonGroup, Flex, FormControl, FormLabel, Heading, Spacer, Switch } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { FilterSettings } from 'features/controlLayers/components/Filters/FilterSettings'; import { FilterTypeSelect } from 'features/controlLayers/components/Filters/FilterTypeSelect'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import type { CanvasEntityAdapterControlLayer } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterControlLayer'; +import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRasterLayer'; +import { + selectAutoPreviewFilter, + settingsAutoPreviewFilterToggled, +} from 'features/controlLayers/store/canvasSettingsSlice'; import { type FilterConfig, IMAGE_FILTERS } from 'features/controlLayers/store/types'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCheckBold, PiShootingStarBold, PiXBold } from 'react-icons/pi'; -export const Filter = memo(() => { +const FilterBox = memo(({ adapter }: { adapter: CanvasEntityAdapterRasterLayer | CanvasEntityAdapterControlLayer }) => { const { t } = useTranslation(); - const canvasManager = useCanvasManager(); - const config = useStore(canvasManager.filter.$config); - const isFiltering = useStore(canvasManager.filter.$isFiltering); - const isProcessing = useStore(canvasManager.filter.$isProcessing); + const dispatch = useAppDispatch(); + const config = useStore(adapter.filterer.$filterConfig); + const isProcessing = useStore(adapter.filterer.$isProcessing); + const autoPreviewFilter = useAppSelector(selectAutoPreviewFilter); const onChangeFilterConfig = useCallback( (filterConfig: FilterConfig) => { - canvasManager.filter.$config.set(filterConfig); + adapter.filterer.$filterConfig.set(filterConfig); }, - [canvasManager.filter.$config] + [adapter.filterer.$filterConfig] ); const onChangeFilterType = useCallback( (filterType: FilterConfig['type']) => { - canvasManager.filter.$config.set(IMAGE_FILTERS[filterType].buildDefaults()); + adapter.filterer.$filterConfig.set(IMAGE_FILTERS[filterType].buildDefaults()); }, - [canvasManager.filter.$config] + [adapter.filterer.$filterConfig] ); - if (!isFiltering) { - return null; - } + const onChangeAutoPreviewFilter = useCallback(() => { + dispatch(settingsAutoPreviewFilterToggled()); + }, [dispatch]); return ( { transitionProperty="height" transitionDuration="normal" > - - {t('controlLayers.filter.filter')} - + + + {t('controlLayers.filter.filter')} + + + + {t('controlLayers.autoPreviewFilter')} + + + - diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.tsx index a7832e26ac3..bb9668f8d40 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.tsx @@ -1,53 +1,38 @@ import { MenuItem } from '@invoke-ai/ui-library'; -import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppSelector } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; -import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { - rgIPAdapterAdded, - rgNegativePromptChanged, - rgPositivePromptChanged, -} from 'features/controlLayers/store/canvasSlice'; -import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; -import { memo, useCallback, useMemo } from 'react'; + buildSelectValidRegionalGuidanceActions, + useAddRegionalGuidanceIPAdapter, + useAddRegionalGuidanceNegativePrompt, + useAddRegionalGuidancePositivePrompt, +} from 'features/controlLayers/hooks/addLayerHooks'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; export const RegionalGuidanceMenuItemsAddPromptsAndIPAdapter = memo(() => { const entityIdentifier = useEntityIdentifierContext('regional_guidance'); const { t } = useTranslation(); - const dispatch = useAppDispatch(); const isBusy = useCanvasIsBusy(); + const addRegionalGuidanceIPAdapter = useAddRegionalGuidanceIPAdapter(entityIdentifier); + const addRegionalGuidancePositivePrompt = useAddRegionalGuidancePositivePrompt(entityIdentifier); + const addRegionalGuidanceNegativePrompt = useAddRegionalGuidanceNegativePrompt(entityIdentifier); const selectValidActions = useMemo( - () => - createMemoizedSelector(selectCanvasSlice, (canvas) => { - const entity = selectEntity(canvas, entityIdentifier); - return { - canAddPositivePrompt: entity?.positivePrompt === null, - canAddNegativePrompt: entity?.negativePrompt === null, - }; - }), + () => buildSelectValidRegionalGuidanceActions(entityIdentifier), [entityIdentifier] ); const validActions = useAppSelector(selectValidActions); - const addPositivePrompt = useCallback(() => { - dispatch(rgPositivePromptChanged({ entityIdentifier, prompt: '' })); - }, [dispatch, entityIdentifier]); - const addNegativePrompt = useCallback(() => { - dispatch(rgNegativePromptChanged({ entityIdentifier, prompt: '' })); - }, [dispatch, entityIdentifier]); - const addIPAdapter = useCallback(() => { - dispatch(rgIPAdapterAdded({ entityIdentifier })); - }, [dispatch, entityIdentifier]); return ( <> - + {t('controlLayers.addPositivePrompt')} - + {t('controlLayers.addNegativePrompt')} - + {t('controlLayers.addIPAdapter')} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityAddOfTypeButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityAddOfTypeButton.tsx index f3dd8a2c1ab..ac7b7d67f37 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityAddOfTypeButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityAddOfTypeButton.tsx @@ -1,12 +1,11 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; import { - controlLayerAdded, - inpaintMaskAdded, - ipaAdded, - rasterLayerAdded, - rgAdded, -} from 'features/controlLayers/store/canvasSlice'; + useAddControlLayer, + useAddInpaintMask, + useAddIPAdapter, + useAddRasterLayer, + useAddRegionalGuidance, +} from 'features/controlLayers/hooks/addLayerHooks'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -18,26 +17,31 @@ type Props = { export const CanvasEntityAddOfTypeButton = memo(({ type }: Props) => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); + const addInpaintMask = useAddInpaintMask(); + const addRegionalGuidance = useAddRegionalGuidance(); + const addRasterLayer = useAddRasterLayer(); + const addControlLayer = useAddControlLayer(); + const addIPAdapter = useAddIPAdapter(); + const onClick = useCallback(() => { switch (type) { case 'inpaint_mask': - dispatch(inpaintMaskAdded({ isSelected: true })); + addInpaintMask(); break; case 'regional_guidance': - dispatch(rgAdded({ isSelected: true })); + addRegionalGuidance(); break; case 'raster_layer': - dispatch(rasterLayerAdded({ isSelected: true })); + addRasterLayer(); break; case 'control_layer': - dispatch(controlLayerAdded({ isSelected: true })); + addControlLayer(); break; case 'ip_adapter': - dispatch(ipaAdded({ isSelected: true })); + addIPAdapter(); break; } - }, [dispatch, type]); + }, [addControlLayer, addIPAdapter, addInpaintMask, addRasterLayer, addRegionalGuidance, type]); const label = useMemo(() => { switch (type) { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsFilter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsFilter.tsx index 64ccddc1d5a..9e361c0911b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsFilter.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsFilter.tsx @@ -1,33 +1,17 @@ import { MenuItem } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; -import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; -import { selectIsStaging } from 'features/controlLayers/store/canvasSessionSlice'; -import { isFilterableEntityIdentifier } from 'features/controlLayers/store/types'; -import { memo, useCallback } from 'react'; +import { useEntityFilter } from 'features/controlLayers/hooks/useEntityFilter'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiShootingStarBold } from 'react-icons/pi'; export const CanvasEntityMenuItemsFilter = memo(() => { const { t } = useTranslation(); - const canvasManager = useCanvasManager(); const entityIdentifier = useEntityIdentifierContext(); - const isStaging = useAppSelector(selectIsStaging); - const isBusy = useCanvasIsBusy(); - - const onClick = useCallback(() => { - if (!entityIdentifier) { - return; - } - if (!isFilterableEntityIdentifier(entityIdentifier)) { - return; - } - canvasManager.filter.startFilter(entityIdentifier); - }, [canvasManager.filter, entityIdentifier]); + const filter = useEntityFilter(entityIdentifier); return ( - } isDisabled={isBusy || isStaging}> + } isDisabled={filter.isDisabled}> {t('controlLayers.filter.filter')} ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsTransform.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsTransform.tsx index 261b00890d1..187099a4ebf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsTransform.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsTransform.tsx @@ -1,30 +1,17 @@ import { MenuItem } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; -import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; -import { useEntityAdapter } from 'features/controlLayers/hooks/useEntityAdapter'; -import { selectIsStaging } from 'features/controlLayers/store/canvasSessionSlice'; -import { isTransformableEntityIdentifier } from 'features/controlLayers/store/types'; -import { memo, useCallback } from 'react'; +import { useEntityTransform } from 'features/controlLayers/hooks/useEntityTransform'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFrameCornersBold } from 'react-icons/pi'; export const CanvasEntityMenuItemsTransform = memo(() => { const { t } = useTranslation(); const entityIdentifier = useEntityIdentifierContext(); - const adapter = useEntityAdapter(entityIdentifier); - const isStaging = useAppSelector(selectIsStaging); - const isBusy = useCanvasIsBusy(); - - const onClick = useCallback(() => { - if (!isTransformableEntityIdentifier(entityIdentifier)) { - return; - } - adapter.transformer.startTransform(); - }, [adapter.transformer, entityIdentifier]); + const transform = useEntityTransform(entityIdentifier); return ( - } isDisabled={isBusy || isStaging}> + } isDisabled={transform.isDisabled}> {t('controlLayers.transform.transform')} ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx index 636996efa02..25c28877103 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx @@ -43,7 +43,7 @@ export const CanvasEntityMergeVisibleButton = memo(({ type }: Props) => { objects: [imageDTOToImageObject(result.value)], position: { x: Math.floor(rect.x), y: Math.floor(rect.y) }, }, - deleteOthers: true, + isMergingVisible: true, }) ); toast({ title: t('controlLayers.mergeVisibleOk') }); @@ -65,7 +65,7 @@ export const CanvasEntityMergeVisibleButton = memo(({ type }: Props) => { objects: [imageDTOToImageObject(result.value)], position: { x: Math.floor(rect.x), y: Math.floor(rect.y) }, }, - deleteOthers: true, + isMergingVisible: true, }) ); toast({ title: t('controlLayers.mergeVisibleOk') }); diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts new file mode 100644 index 00000000000..09929f0c358 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts @@ -0,0 +1,154 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { deepClone } from 'common/util/deepClone'; +import { + controlLayerAdded, + inpaintMaskAdded, + ipaAdded, + rasterLayerAdded, + rgAdded, + rgIPAdapterAdded, + rgNegativePromptChanged, + rgPositivePromptChanged, +} from 'features/controlLayers/store/canvasSlice'; +import { selectBase } from 'features/controlLayers/store/paramsSlice'; +import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; +import type { + CanvasEntityIdentifier, + ControlNetConfig, + IPAdapterConfig, + T2IAdapterConfig, +} from 'features/controlLayers/store/types'; +import { initialControlNet, initialIPAdapter, initialT2IAdapter } from 'features/controlLayers/store/types'; +import { zModelIdentifierField } from 'features/nodes/types/common'; +import { useCallback } from 'react'; +import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models'; +import type { ControlNetModelConfig, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types'; +import { isControlNetOrT2IAdapterModelConfig, isIPAdapterModelConfig } from 'services/api/types'; + +export const selectDefaultControlAdapter = createSelector( + selectModelConfigsQuery, + selectBase, + (query, base): ControlNetConfig | T2IAdapterConfig => { + const { data } = query; + let model: ControlNetModelConfig | T2IAdapterModelConfig | null = null; + if (data) { + const modelConfigs = modelConfigsAdapterSelectors + .selectAll(data) + .filter(isControlNetOrT2IAdapterModelConfig) + .sort((a) => (a.type === 'controlnet' ? -1 : 1)); // Prefer ControlNet models + const compatibleModels = modelConfigs.filter((m) => (base ? m.base === base : true)); + model = compatibleModels[0] ?? modelConfigs[0] ?? null; + } + const controlAdapter = model?.type === 't2i_adapter' ? deepClone(initialT2IAdapter) : deepClone(initialControlNet); + if (model) { + controlAdapter.model = zModelIdentifierField.parse(model); + } + return controlAdapter; + } +); + +const selectDefaultIPAdapter = createSelector(selectModelConfigsQuery, selectBase, (query, base): IPAdapterConfig => { + const { data } = query; + let model: IPAdapterModelConfig | null = null; + if (data) { + const modelConfigs = modelConfigsAdapterSelectors.selectAll(data).filter(isIPAdapterModelConfig); + const compatibleModels = modelConfigs.filter((m) => (base ? m.base === base : true)); + model = compatibleModels[0] ?? modelConfigs[0] ?? null; + } + const ipAdapter = deepClone(initialIPAdapter); + if (model) { + ipAdapter.model = zModelIdentifierField.parse(model); + } + return ipAdapter; +}); + +export const useAddControlLayer = () => { + const dispatch = useAppDispatch(); + const defaultControlAdapter = useAppSelector(selectDefaultControlAdapter); + const addControlLayer = useCallback(() => { + const overrides = { controlAdapter: defaultControlAdapter }; + dispatch(controlLayerAdded({ isSelected: true, overrides })); + }, [defaultControlAdapter, dispatch]); + + return addControlLayer; +}; + +export const useAddRasterLayer = () => { + const dispatch = useAppDispatch(); + const addRasterLayer = useCallback(() => { + dispatch(rasterLayerAdded({ isSelected: true })); + }, [dispatch]); + + return addRasterLayer; +}; + +export const useAddInpaintMask = () => { + const dispatch = useAppDispatch(); + const addInpaintMask = useCallback(() => { + dispatch(inpaintMaskAdded({ isSelected: true })); + }, [dispatch]); + + return addInpaintMask; +}; + +export const useAddRegionalGuidance = () => { + const dispatch = useAppDispatch(); + const addRegionalGuidance = useCallback(() => { + dispatch(rgAdded({ isSelected: true })); + }, [dispatch]); + + return addRegionalGuidance; +}; + +export const useAddIPAdapter = () => { + const dispatch = useAppDispatch(); + const defaultIPAdapter = useAppSelector(selectDefaultIPAdapter); + const addControlLayer = useCallback(() => { + const overrides = { ipAdapter: defaultIPAdapter }; + dispatch(ipaAdded({ isSelected: true, overrides })); + }, [defaultIPAdapter, dispatch]); + + return addControlLayer; +}; + +export const useAddRegionalGuidanceIPAdapter = (entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>) => { + const dispatch = useAppDispatch(); + const defaultIPAdapter = useAppSelector(selectDefaultIPAdapter); + const addRegionalGuidanceIPAdapter = useCallback(() => { + dispatch(rgIPAdapterAdded({ entityIdentifier, overrides: defaultIPAdapter })); + }, [defaultIPAdapter, dispatch, entityIdentifier]); + + return addRegionalGuidanceIPAdapter; +}; + +export const useAddRegionalGuidancePositivePrompt = (entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>) => { + const dispatch = useAppDispatch(); + const addRegionalGuidancePositivePrompt = useCallback(() => { + dispatch(rgPositivePromptChanged({ entityIdentifier, prompt: '' })); + }, [dispatch, entityIdentifier]); + + return addRegionalGuidancePositivePrompt; +}; + +export const useAddRegionalGuidanceNegativePrompt = (entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>) => { + const dispatch = useAppDispatch(); + const addRegionalGuidanceNegativePrompt = useCallback(() => { + dispatch(rgNegativePromptChanged({ entityIdentifier, prompt: '' })); + }, [dispatch, entityIdentifier]); + + return addRegionalGuidanceNegativePrompt; +}; + +export const buildSelectValidRegionalGuidanceActions = ( + entityIdentifier: CanvasEntityIdentifier<'regional_guidance'> +) => { + return createMemoizedSelector(selectCanvasSlice, (canvas) => { + const entity = selectEntityOrThrow(canvas, entityIdentifier); + return { + canAddPositivePrompt: entity?.positivePrompt === null, + canAddNegativePrompt: entity?.negativePrompt === null, + }; + }); +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityAdapter.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityAdapter.ts index 6d4b6b8e95d..f435e6399b0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityAdapter.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityAdapter.ts @@ -1,19 +1,11 @@ import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import type { CanvasEntityAdapterControlLayer } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterControlLayer'; -import type { CanvasEntityAdapterInpaintMask } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterInpaintMask'; -import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRasterLayer'; -import type { CanvasEntityAdapterRegionalGuidance } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRegionalGuidance'; +import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntityAdapter/types'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { useMemo } from 'react'; import { assert } from 'tsafe'; -export const useEntityAdapter = ( - entityIdentifier: CanvasEntityIdentifier -): - | CanvasEntityAdapterRasterLayer - | CanvasEntityAdapterControlLayer - | CanvasEntityAdapterInpaintMask - | CanvasEntityAdapterRegionalGuidance => { +/** @knipignore */ +export const useEntityAdapter = (entityIdentifier: CanvasEntityIdentifier): CanvasEntityAdapter => { const canvasManager = useCanvasManager(); const adapter = useMemo(() => { @@ -24,3 +16,17 @@ export const useEntityAdapter = ( return adapter; }; + +export const useEntityAdapterSafe = (entityIdentifier: CanvasEntityIdentifier | null): CanvasEntityAdapter | null => { + const canvasManager = useCanvasManager(); + + const adapter = useMemo(() => { + if (!entityIdentifier) { + return null; + } + const adapter = canvasManager.getAdapter(entityIdentifier); + return adapter ?? null; + }, [canvasManager, entityIdentifier]); + + return adapter; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityFilter.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityFilter.ts new file mode 100644 index 00000000000..37785ca3dd2 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityFilter.ts @@ -0,0 +1,75 @@ +import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { SyncableMap } from 'common/util/SyncableMap/SyncableMap'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { useEntityAdapterSafe } from 'features/controlLayers/hooks/useEntityAdapter'; +import type { AnyObjectRenderer } from 'features/controlLayers/konva/CanvasEntityObjectRenderer'; +import { getEmptyRect } from 'features/controlLayers/konva/util'; +import { selectIsStaging } from 'features/controlLayers/store/canvasSessionSlice'; +import type { CanvasEntityIdentifier, Rect } from 'features/controlLayers/store/types'; +import { isFilterableEntityIdentifier } from 'features/controlLayers/store/types'; +import { atom } from 'nanostores'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; + +// When the entity is empty (the rect has no size) or there are no renderers, we have nothing to filter. Because the +// entity is dynamic, and we need reactivity on these values, we need to do a little hack. These fallback objects +// can be used to provide a default value for the useStore and useSyncExternalStore hooks, which require _some_ value +// to be used. +const $fallbackPixelRect = atom(getEmptyRect()); +const fallbackRenderersMap = new SyncableMap(); + +export const useEntityFilter = (entityIdentifier: CanvasEntityIdentifier | null) => { + const canvasManager = useCanvasManager(); + const adapter = useEntityAdapterSafe(entityIdentifier); + const isStaging = useAppSelector(selectIsStaging); + const isBusy = useCanvasIsBusy(); + // Use the fallback pixel rect if the adapter is not available + const pixelRect = useStore(adapter?.transformer.$pixelRect ?? $fallbackPixelRect); + // Use the fallback renderers map if the adapter is not available + const renderers = useSyncExternalStore( + adapter?.renderer.renderers.subscribe ?? fallbackRenderersMap.subscribe, + adapter?.renderer.renderers.getSnapshot ?? fallbackRenderersMap.getSnapshot + ); + + const isDisabled = useMemo(() => { + if (!entityIdentifier) { + return true; + } + if (!isFilterableEntityIdentifier(entityIdentifier)) { + return true; + } + if (!adapter) { + return true; + } + if (isBusy || isStaging) { + return true; + } + if (pixelRect.width === 0 || pixelRect.height === 0) { + return true; + } + if (renderers.size === 0) { + return true; + } + return false; + }, [entityIdentifier, adapter, isBusy, isStaging, pixelRect.width, pixelRect.height, renderers.size]); + + const start = useCallback(() => { + if (isDisabled) { + return; + } + if (!entityIdentifier) { + return; + } + if (!isFilterableEntityIdentifier(entityIdentifier)) { + return; + } + const adapter = canvasManager.getAdapter(entityIdentifier); + if (!adapter) { + return; + } + adapter.filterer.startFilter(); + }, [isDisabled, entityIdentifier, canvasManager]); + + return { isDisabled, start } as const; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTransform.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTransform.ts new file mode 100644 index 00000000000..9b16210be1e --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTransform.ts @@ -0,0 +1,72 @@ +import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { SyncableMap } from 'common/util/SyncableMap/SyncableMap'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { useEntityAdapterSafe } from 'features/controlLayers/hooks/useEntityAdapter'; +import type { AnyObjectRenderer } from 'features/controlLayers/konva/CanvasEntityObjectRenderer'; +import { getEmptyRect } from 'features/controlLayers/konva/util'; +import { selectIsStaging } from 'features/controlLayers/store/canvasSessionSlice'; +import type { CanvasEntityIdentifier, Rect } from 'features/controlLayers/store/types'; +import { isTransformableEntityIdentifier } from 'features/controlLayers/store/types'; +import { atom } from 'nanostores'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; + +// When the entity is empty (the rect has no size) or there are no renderers, we have nothing to transform. Because the +// entity is dynamic, and we need reactivity on these values, we need to do a little hack. These fallback objects +// can be used to provide a default value for the useStore and useSyncExternalStore hooks, which require _some_ value +// to be used. +const $fallbackPixelRect = atom(getEmptyRect()); +const fallbackRenderersMap = new SyncableMap(); + +export const useEntityTransform = (entityIdentifier: CanvasEntityIdentifier | null) => { + const canvasManager = useCanvasManager(); + const adapter = useEntityAdapterSafe(entityIdentifier); + const isStaging = useAppSelector(selectIsStaging); + const isBusy = useCanvasIsBusy(); + // Use the fallback pixel rect if the adapter is not available + const pixelRect = useStore(adapter?.transformer.$pixelRect ?? $fallbackPixelRect); + // Use the fallback renderers map if the adapter is not available + const renderers = useSyncExternalStore( + adapter?.renderer.renderers.subscribe ?? fallbackRenderersMap.subscribe, + adapter?.renderer.renderers.getSnapshot ?? fallbackRenderersMap.getSnapshot + ); + + const start = useCallback(() => { + if (!entityIdentifier) { + return; + } + if (!isTransformableEntityIdentifier(entityIdentifier)) { + return; + } + const adapter = canvasManager.getAdapter(entityIdentifier); + if (!adapter) { + return; + } + adapter.transformer.startTransform(); + }, [entityIdentifier, canvasManager]); + + const isDisabled = useMemo(() => { + if (!entityIdentifier) { + return true; + } + if (!isTransformableEntityIdentifier(entityIdentifier)) { + return true; + } + if (!adapter) { + return true; + } + if (isBusy || isStaging) { + return true; + } + if (pixelRect.width === 0 || pixelRect.height === 0) { + return true; + } + if (renderers.size === 0) { + return true; + } + return false; + }, [entityIdentifier, adapter, isBusy, isStaging, pixelRect.width, pixelRect.height, renderers.size]); + + return { isDisabled, start } as const; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useLayerControlAdapter.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useLayerControlAdapter.ts deleted file mode 100644 index f10bcba0294..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useLayerControlAdapter.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createMemoizedAppSelector } from 'app/store/createMemoizedSelector'; -import { useAppSelector } from 'app/store/storeHooks'; -import { deepClone } from 'common/util/deepClone'; -import { selectBase } from 'features/controlLayers/store/paramsSlice'; -import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; -import type { - CanvasEntityIdentifier, - ControlNetConfig, - IPAdapterConfig, - T2IAdapterConfig, -} from 'features/controlLayers/store/types'; -import { initialControlNet, initialIPAdapter, initialT2IAdapter } from 'features/controlLayers/store/types'; -import { zModelIdentifierField } from 'features/nodes/types/common'; -import { useMemo } from 'react'; -import { useControlNetAndT2IAdapterModels, useIPAdapterModels } from 'services/api/hooks/modelsByType'; - -export const useControlLayerControlAdapter = (entityIdentifier: CanvasEntityIdentifier<'control_layer'>) => { - const selectControlAdapter = useMemo( - () => - createMemoizedAppSelector(selectCanvasSlice, (canvas) => { - const layer = selectEntityOrThrow(canvas, entityIdentifier); - return layer.controlAdapter; - }), - [entityIdentifier] - ); - const controlAdapter = useAppSelector(selectControlAdapter); - return controlAdapter; -}; - -/** @knipignore */ -export const useDefaultControlAdapter = (): ControlNetConfig | T2IAdapterConfig => { - const [modelConfigs] = useControlNetAndT2IAdapterModels(); - - const baseModel = useAppSelector(selectBase); - - const defaultControlAdapter = useMemo(() => { - const compatibleModels = modelConfigs.filter((m) => (baseModel ? m.base === baseModel : true)); - const model = compatibleModels[0] ?? modelConfigs[0] ?? null; - const controlAdapter = model?.type === 't2i_adapter' ? deepClone(initialT2IAdapter) : deepClone(initialControlNet); - - if (model) { - controlAdapter.model = zModelIdentifierField.parse(model); - } - - return controlAdapter; - }, [baseModel, modelConfigs]); - - return defaultControlAdapter; -}; - -export const useDefaultIPAdapter = (): IPAdapterConfig => { - const [modelConfigs] = useIPAdapterModels(); - - const baseModel = useAppSelector(selectBase); - - const defaultControlAdapter = useMemo(() => { - const compatibleModels = modelConfigs.filter((m) => (baseModel ? m.base === baseModel : true)); - const model = compatibleModels[0] ?? modelConfigs[0] ?? null; - const ipAdapter = deepClone(initialIPAdapter); - - if (model) { - ipAdapter.model = zModelIdentifierField.parse(model); - } - - return ipAdapter; - }, [baseModel, modelConfigs]); - - return defaultControlAdapter; -}; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index 9174810927c..e12d2f75b6c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -42,17 +42,23 @@ export class CanvasCompositorModule extends CanvasModuleBase { /** * Gets the entity IDs of all raster layers that should be included in the composite raster layer. - * A raster layer is included if it is enabled and has objects. + * A raster layer is included if it is enabled and has objects. The ids are sorted by draw order. * @returns An array of raster layer entity IDs */ getCompositeRasterLayerEntityIds = (): string[] => { - const ids = []; - for (const adapter of this.manager.adapters.rasterLayers.values()) { - if (adapter.state.isEnabled && adapter.renderer.hasObjects()) { - ids.push(adapter.id); + const validSortedIds = []; + const sortedIds = this.manager.stateApi.getRasterLayersState().entities.map(({ id }) => id); + for (const id of sortedIds) { + const adapter = this.manager.adapters.rasterLayers.get(id); + if (!adapter) { + this.log.warn({ id }, 'Raster layer adapter not found'); + continue; + } + if (adapter.state.isEnabled && adapter.state.objects.length > 0) { + validSortedIds.push(adapter.id); } } - return ids; + return validSortedIds; }; /** @@ -62,17 +68,22 @@ export class CanvasCompositorModule extends CanvasModuleBase { * @returns A hash for the composite raster layer */ getCompositeRasterLayerHash = (extra: SerializableObject): string => { - const data: Record = { - extra, - }; + const adapterHashes: SerializableObject[] = []; + for (const id of this.getCompositeRasterLayerEntityIds()) { const adapter = this.manager.adapters.rasterLayers.get(id); if (!adapter) { this.log.warn({ id }, 'Raster layer adapter not found'); continue; } - data[id] = adapter.getHashableState(); + adapterHashes.push(adapter.getHashableState()); } + + const data: SerializableObject = { + extra, + adapterHashes, + }; + return stableHash(data); }; @@ -173,17 +184,23 @@ export class CanvasCompositorModule extends CanvasModuleBase { /** * Gets the entity IDs of all inpaint masks that should be included in the composite inpaint mask. - * An inpaint mask is included if it is enabled and has objects. + * An inpaint mask is included if it is enabled and has objects. The ids are sorted by draw order. * @returns An array of inpaint mask entity IDs */ getCompositeInpaintMaskEntityIds = (): string[] => { - const ids = []; - for (const adapter of this.manager.adapters.inpaintMasks.values()) { - if (adapter.state.isEnabled && adapter.renderer.hasObjects()) { - ids.push(adapter.id); + const validSortedIds = []; + const sortedIds = this.manager.stateApi.getInpaintMasksState().entities.map(({ id }) => id); + for (const id of sortedIds) { + const adapter = this.manager.adapters.inpaintMasks.get(id); + if (!adapter) { + this.log.warn({ id }, 'Inpaint mask adapter not found'); + continue; + } + if (adapter.state.isEnabled && adapter.state.objects.length > 0) { + validSortedIds.push(adapter.id); } } - return ids; + return validSortedIds; }; /** @@ -193,17 +210,22 @@ export class CanvasCompositorModule extends CanvasModuleBase { * @returns A hash for the composite inpaint mask */ getCompositeInpaintMaskHash = (extra: SerializableObject): string => { - const data: Record = { - extra, - }; + const adapterHashes: SerializableObject[] = []; + for (const id of this.getCompositeInpaintMaskEntityIds()) { const adapter = this.manager.adapters.inpaintMasks.get(id); if (!adapter) { this.log.warn({ id }, 'Inpaint mask adapter not found'); continue; } - data[id] = adapter.getHashableState(); + adapterHashes.push(adapter.getHashableState()); } + + const data: SerializableObject = { + extra, + adapterHashes, + }; + return stableHash(data); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterBase.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterBase.ts index 3c6d2f6c527..9a32cc3e4f4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterBase.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterBase.ts @@ -1,6 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import type { SerializableObject } from 'common/types'; import { deepClone } from 'common/util/deepClone'; +import type { CanvasEntityFilterer } from 'features/controlLayers/konva/CanvasEntityFilterer'; import type { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntityObjectRenderer'; import type { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntityTransformer'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; @@ -25,15 +26,23 @@ export abstract class CanvasEntityAdapterBase< readonly entityIdentifier: CanvasEntityIdentifier; /** - * The transformer for this entity adapter. + * The transformer for this entity adapter. All entities must have a transformer. */ abstract transformer: CanvasEntityTransformer; /** - * The renderer for this entity adapter. + * The renderer for this entity adapter. All entities must have a renderer. */ abstract renderer: CanvasEntityObjectRenderer; + /** + * The filterer for this entity adapter. Entities that support filtering should implement this property. + */ + // TODO(psyche): This is in the ABC and not in the concrete classes to allow all adapters to share the `destroy` + // method. If it wasn't in this ABC, we'd get a TS error in `destroy`. Maybe there's a better way to handle this + // without requiring all adapters to implement this property and their own `destroy`? + abstract filterer?: CanvasEntityFilterer; + /** * Synchronizes the entity state with the canvas. This includes rendering the entity's objects, handling visibility, * positioning, opacity, locked state, and any other properties. @@ -201,8 +210,8 @@ export abstract class CanvasEntityAdapterBase< this.transformer.stopTransform(); } this.transformer.destroy(); - if (this.manager.filter.$adapter.get()?.id === this.id) { - this.manager.filter.cancelFilter(); + if (this.filterer?.$isFiltering.get()) { + this.filterer.cancelFilter(); } this.konva.layer.destroy(); this.manager.deleteAdapter(this.entityIdentifier); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterControlLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterControlLayer.ts index ec746d9e028..b5af1d4df37 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterControlLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterControlLayer.ts @@ -1,5 +1,6 @@ import type { SerializableObject } from 'common/types'; import { CanvasEntityAdapterBase } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterBase'; +import { CanvasEntityFilterer } from 'features/controlLayers/konva/CanvasEntityFilterer'; import { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntityObjectRenderer'; import { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntityTransformer'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; @@ -12,12 +13,14 @@ export class CanvasEntityAdapterControlLayer extends CanvasEntityAdapterBase, manager: CanvasManager) { super(entityIdentifier, manager, CanvasEntityAdapterControlLayer.TYPE); this.renderer = new CanvasEntityObjectRenderer(this); this.transformer = new CanvasEntityTransformer(this); + this.filterer = new CanvasEntityFilterer(this); this.subscriptions.add(this.manager.stateApi.createStoreSubscription(this.selectState, this.sync)); } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterInpaintMask.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterInpaintMask.ts index bb9673b7fb0..5d1b57c8498 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterInpaintMask.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterInpaintMask.ts @@ -12,6 +12,7 @@ export class CanvasEntityAdapterInpaintMask extends CanvasEntityAdapterBase, manager: CanvasManager) { super(entityIdentifier, manager, CanvasEntityAdapterInpaintMask.TYPE); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRasterLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRasterLayer.ts index 34e8abe4800..2333ebc031c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRasterLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRasterLayer.ts @@ -1,5 +1,6 @@ import type { SerializableObject } from 'common/types'; import { CanvasEntityAdapterBase } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterBase'; +import { CanvasEntityFilterer } from 'features/controlLayers/konva/CanvasEntityFilterer'; import { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntityObjectRenderer'; import { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntityTransformer'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; @@ -12,12 +13,14 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase, manager: CanvasManager) { super(entityIdentifier, manager, CanvasEntityAdapterRasterLayer.TYPE); this.renderer = new CanvasEntityObjectRenderer(this); this.transformer = new CanvasEntityTransformer(this); + this.filterer = new CanvasEntityFilterer(this); this.subscriptions.add(this.manager.stateApi.createStoreSubscription(this.selectState, this.sync)); } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRegionalGuidance.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRegionalGuidance.ts index d5962a4eee7..38d80651090 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRegionalGuidance.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRegionalGuidance.ts @@ -12,6 +12,7 @@ export class CanvasEntityAdapterRegionalGuidance extends CanvasEntityAdapterBase transformer: CanvasEntityTransformer; renderer: CanvasEntityObjectRenderer; + filterer = undefined; constructor(entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>, manager: CanvasManager) { super(entityIdentifier, manager, CanvasEntityAdapterRegionalGuidance.TYPE); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityFilterer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityFilterer.ts new file mode 100644 index 00000000000..f4768428cce --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityFilterer.ts @@ -0,0 +1,174 @@ +import type { SerializableObject } from 'common/types'; +import type { CanvasEntityAdapterControlLayer } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterControlLayer'; +import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRasterLayer'; +import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { selectAutoPreviewFilter } from 'features/controlLayers/store/canvasSettingsSlice'; +import type { CanvasImageState, FilterConfig } from 'features/controlLayers/store/types'; +import { IMAGE_FILTERS, imageDTOToImageObject } from 'features/controlLayers/store/types'; +import { debounce } from 'lodash-es'; +import { atom } from 'nanostores'; +import type { Logger } from 'roarr'; +import { getImageDTO } from 'services/api/endpoints/images'; +import type { BatchConfig, ImageDTO, S } from 'services/api/types'; +import { assert } from 'tsafe'; + +export class CanvasEntityFilterer extends CanvasModuleBase { + readonly type = 'canvas_filterer'; + readonly id: string; + readonly path: string[]; + readonly parent: CanvasEntityAdapterRasterLayer | CanvasEntityAdapterControlLayer; + readonly manager: CanvasManager; + readonly log: Logger; + + imageState: CanvasImageState | null = null; + subscriptions = new Set<() => void>(); + + $isFiltering = atom(false); + $isProcessing = atom(false); + $filterConfig = atom(IMAGE_FILTERS.canny_image_processor.buildDefaults()); + + constructor(parent: CanvasEntityAdapterRasterLayer | CanvasEntityAdapterControlLayer) { + super(); + this.id = getPrefixedId(this.type); + this.parent = parent; + this.manager = this.parent.manager; + this.path = this.manager.buildPath(this); + this.log = this.manager.buildLogger(this); + + this.log.debug('Creating filter module'); + + this.subscriptions.add( + this.$filterConfig.listen(() => { + if (this.manager.stateApi.getSettings().autoPreviewFilter && this.$isFiltering.get()) { + this.previewFilter(); + } + }) + ); + this.subscriptions.add( + this.manager.stateApi.createStoreSubscription(selectAutoPreviewFilter, (autoPreviewFilter) => { + if (autoPreviewFilter && this.$isFiltering.get()) { + this.previewFilter(); + } + }) + ); + } + + startFilter = (config?: FilterConfig) => { + this.log.trace('Initializing filter'); + if (config) { + this.$filterConfig.set(config); + } + this.$isFiltering.set(true); + this.manager.stateApi.$filteringAdapter.set(this.parent); + this.previewFilter(); + }; + + previewFilter = debounce( + async () => { + const config = this.$filterConfig.get(); + this.log.trace({ config }, 'Previewing filter'); + const rect = this.parent.transformer.getRelativeRect(); + const imageDTO = await this.parent.renderer.rasterize({ rect, attrs: { filters: [] } }); + const nodeId = getPrefixedId('filter_node'); + const batch = this.buildBatchConfig(imageDTO, config, nodeId); + + // Listen for the filter processing completion event + const listener = async (event: S['InvocationCompleteEvent']) => { + if (event.origin !== this.id || event.invocation_source_id !== nodeId) { + return; + } + this.manager.socket.off('invocation_complete', listener); + + this.log.trace({ event } as SerializableObject, 'Handling filter processing completion'); + + const { result } = event; + assert(result.type === 'image_output', `Processor did not return an image output, got: ${result}`); + + const imageDTO = await getImageDTO(result.image.image_name); + assert(imageDTO, "Failed to fetch processor output's image DTO"); + + this.imageState = imageDTOToImageObject(imageDTO); + + await this.parent.renderer.setBuffer(this.imageState, true); + + this.parent.renderer.hideObjects(); + this.$isProcessing.set(false); + }; + + this.manager.socket.on('invocation_complete', listener); + + this.log.trace({ batch } as SerializableObject, 'Enqueuing filter batch'); + + this.$isProcessing.set(true); + this.manager.stateApi.enqueueBatch(batch); + }, + 1000, + { leading: true, trailing: true } + ); + + applyFilter = () => { + const imageState = this.imageState; + if (!imageState) { + this.log.warn('No image state to apply filter to'); + return; + } + this.log.trace('Applying filter'); + this.parent.renderer.commitBuffer(); + const rect = this.parent.transformer.getRelativeRect(); + this.manager.stateApi.rasterizeEntity({ + entityIdentifier: this.parent.entityIdentifier, + imageObject: imageState, + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: imageState.image.height, + height: imageState.image.width, + }, + replaceObjects: true, + }); + this.parent.renderer.showObjects(); + this.imageState = null; + this.$isFiltering.set(false); + this.manager.stateApi.$filteringAdapter.set(null); + }; + + cancelFilter = () => { + this.log.trace('Cancelling filter'); + + this.parent.renderer.clearBuffer(); + this.parent.renderer.showObjects(); + this.parent.transformer.updatePosition(); + this.parent.renderer.syncCache(true); + this.imageState = null; + this.$isProcessing.set(false); + this.$isFiltering.set(false); + this.manager.stateApi.$filteringAdapter.set(null); + }; + + buildBatchConfig = (imageDTO: ImageDTO, config: FilterConfig, id: string): BatchConfig => { + // TODO(psyche): I can't get TS to be happy, it thinkgs `config` is `never` but it should be inferred from the generic... I'll just cast it for now + const node = IMAGE_FILTERS[config.type].buildNode(imageDTO, config as never); + node.id = id; + const batch: BatchConfig = { + prepend: true, + batch: { + graph: { + nodes: { + [node.id]: { + ...node, + // filtered images are always intermediate - do not save to gallery + is_intermediate: true, + }, + }, + edges: [], + }, + origin: this.id, + runs: 1, + }, + }; + + return batch; + }; +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityObjectRenderer.ts index b30ec7089db..75e3776c4fd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityObjectRenderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityObjectRenderer.ts @@ -1,4 +1,5 @@ import { rgbColorToString } from 'common/util/colorCodeTransformers'; +import { SyncableMap } from 'common/util/SyncableMap/SyncableMap'; import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntityAdapter/types'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; @@ -45,7 +46,7 @@ function setFillPatternImage(shape: Konva.Shape, ...args: Parameters = new Map(); + renderers = new SyncableMap(); /** * A object containing singleton Konva nodes. diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityTransformer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityTransformer.ts index b008012ae23..566e8ce85a6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityTransformer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityTransformer.ts @@ -454,17 +454,18 @@ export class CanvasEntityTransformer extends CanvasModuleBase { syncInteractionState = () => { this.log.trace('Syncing interaction state'); - if (this.manager.filter.$isFiltering.get()) { + // Not all entities have a filterer - only raster layer and control layer adapters + if (this.parent.filterer?.$isFiltering.get()) { // May not interact with the entity when the filter is active this.parent.konva.layer.listening(false); - this.setInteractionMode('off'); + this._setInteractionMode('off'); return; } if (this.manager.stateApi.$isTranforming.get() && !this.$isTransforming.get()) { // If another entity is being transformed, we can't interact with this transformer this.parent.konva.layer.listening(false); - this.setInteractionMode('off'); + this._setInteractionMode('off'); return; } @@ -474,7 +475,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase { if (isPendingRectCalculation || pixelRect.width === 0 || pixelRect.height === 0) { // If the rect is being calculated, or if the rect has no width or height, we can't interact with the transformer this.parent.konva.layer.listening(false); - this.setInteractionMode('off'); + this._setInteractionMode('off'); return; } @@ -484,14 +485,14 @@ export class CanvasEntityTransformer extends CanvasModuleBase { if (!this.parent.renderer.hasObjects() || !this.parent.getIsInteractable()) { // The layer is totally empty, we can just disable the layer this.parent.konva.layer.listening(false); - this.setInteractionMode('off'); + this._setInteractionMode('off'); return; } if (isSelected && !this.$isTransforming.get() && tool === 'move') { // We are moving this layer, it must be listening this.parent.konva.layer.listening(true); - this.setInteractionMode('drag'); + this._setInteractionMode('drag'); return; } @@ -500,15 +501,15 @@ export class CanvasEntityTransformer extends CanvasModuleBase { // active, it will interrupt the stage drag events. So we should disable listening when the view tool is selected. if (tool === 'view') { this.parent.konva.layer.listening(false); - this.setInteractionMode('off'); + this._setInteractionMode('off'); } else { this.parent.konva.layer.listening(true); - this.setInteractionMode('all'); + this._setInteractionMode('all'); } } else { // The layer is not selected, or we are using a tool that doesn't need the layer to be listening - disable interaction stuff this.parent.konva.layer.listening(false); - this.setInteractionMode('off'); + this._setInteractionMode('off'); } }; @@ -535,15 +536,8 @@ export class CanvasEntityTransformer extends CanvasModuleBase { startTransform = () => { this.log.debug('Starting transform'); this.$isTransforming.set(true); - this.manager.tool.$tool.set('move'); - // When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or - // interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening - // when the view tool is selected - // TODO(psyche): We just set the tool to 'move', why would it be 'view'? Investigate and figure out if this is needed - const shouldListen = this.manager.tool.$tool.get() !== 'view'; - this.parent.konva.layer.listening(shouldListen); - this.setInteractionMode('all'); this.manager.stateApi.$transformingAdapter.set(this.parent); + this.syncInteractionState(); }; /** @@ -572,7 +566,6 @@ export class CanvasEntityTransformer extends CanvasModuleBase { this.log.debug('Stopping transform'); this.$isTransforming.set(false); - this.setInteractionMode('off'); // Reset the transform of the the entity. We've either replaced the transformed objects with a rasterized image, or // canceled a transformation. In either case, the scale should be reset. @@ -621,13 +614,15 @@ export class CanvasEntityTransformer extends CanvasModuleBase { }; /** - * Sets the transformer to a specific interaction mode. + * Sets the transformer to a specific interaction mode. This internal method shouldn't be used. Instead, use + * `syncInteractionState` to update the transformer's interaction state. + * * @param interactionMode The mode to set the transformer to. The transformer can be in one of three modes: * - 'all': The entity can be moved, resized, and rotated. * - 'drag': The entity can be moved. * - 'off': The transformer is not interactable. */ - setInteractionMode = (interactionMode: 'all' | 'drag' | 'off') => { + _setInteractionMode = (interactionMode: 'all' | 'drag' | 'off') => { this.$interactionMode.set(interactionMode); if (interactionMode === 'drag') { this._enableDrag(); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasFilterModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasFilterModule.ts deleted file mode 100644 index 18dc2341a05..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasFilterModule.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { SerializableObject } from 'common/types'; -import type { CanvasEntityAdapterControlLayer } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterControlLayer'; -import type { CanvasEntityAdapterInpaintMask } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterInpaintMask'; -import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRasterLayer'; -import type { CanvasEntityAdapterRegionalGuidance } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRegionalGuidance'; -import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; -import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; -import { getPrefixedId } from 'features/controlLayers/konva/util'; -import type { CanvasEntityIdentifier, CanvasImageState, FilterConfig } from 'features/controlLayers/store/types'; -import { IMAGE_FILTERS, imageDTOToImageObject } from 'features/controlLayers/store/types'; -import { atom, computed } from 'nanostores'; -import type { Logger } from 'roarr'; -import { getImageDTO } from 'services/api/endpoints/images'; -import type { BatchConfig, ImageDTO, S } from 'services/api/types'; -import { assert } from 'tsafe'; - -export class CanvasFilterModule extends CanvasModuleBase { - readonly type = 'canvas_filter'; - readonly id: string; - readonly path: string[]; - readonly parent: CanvasManager; - readonly manager: CanvasManager; - readonly log: Logger; - - imageState: CanvasImageState | null = null; - - $adapter = atom< - | CanvasEntityAdapterRasterLayer - | CanvasEntityAdapterControlLayer - | CanvasEntityAdapterInpaintMask - | CanvasEntityAdapterRegionalGuidance - | null - >(null); - $isFiltering = computed(this.$adapter, (adapter) => Boolean(adapter)); - $isProcessing = atom(false); - $config = atom(IMAGE_FILTERS.canny_image_processor.buildDefaults()); - - constructor(manager: CanvasManager) { - super(); - this.id = getPrefixedId(this.type); - this.parent = manager; - this.manager = manager; - this.path = this.manager.buildPath(this); - this.log = this.manager.buildLogger(this); - - this.log.debug('Creating filter module'); - } - - startFilter = (entityIdentifier: CanvasEntityIdentifier<'raster_layer' | 'control_layer'>) => { - this.log.trace('Initializing filter'); - const adapter = this.manager.getAdapter(entityIdentifier); - if (!adapter) { - this.log.warn({ entityIdentifier }, 'Unable to find entity'); - return; - } - if (adapter.entityIdentifier.type !== 'raster_layer' && adapter.entityIdentifier.type !== 'control_layer') { - this.log.warn({ entityIdentifier }, 'Unsupported entity type'); - return; - } - this.$adapter.set(adapter); - this.manager.tool.$tool.set('view'); - }; - - previewFilter = async () => { - const adapter = this.$adapter.get(); - if (!adapter) { - this.log.warn('Cannot preview filter without an adapter'); - return; - } - const config = this.$config.get(); - this.log.trace({ config }, 'Previewing filter'); - const rect = adapter.transformer.getRelativeRect(); - const imageDTO = await adapter.renderer.rasterize({ rect, attrs: { filters: [] } }); - const nodeId = getPrefixedId('filter_node'); - const batch = this.buildBatchConfig(imageDTO, config, nodeId); - - // Listen for the filter processing completion event - const listener = async (event: S['InvocationCompleteEvent']) => { - if (event.origin !== this.id || event.invocation_source_id !== nodeId) { - return; - } - this.manager.socket.off('invocation_complete', listener); - - this.log.trace({ event } as SerializableObject, 'Handling filter processing completion'); - - const { result } = event; - assert(result.type === 'image_output', `Processor did not return an image output, got: ${result}`); - - const imageDTO = await getImageDTO(result.image.image_name); - assert(imageDTO, "Failed to fetch processor output's image DTO"); - - this.imageState = imageDTOToImageObject(imageDTO); - adapter.renderer.clearBuffer(); - - await adapter.renderer.setBuffer(this.imageState, true); - - adapter.renderer.hideObjects(); - this.$isProcessing.set(false); - }; - - this.manager.socket.on('invocation_complete', listener); - - this.log.trace({ batch } as SerializableObject, 'Enqueuing filter batch'); - - this.$isProcessing.set(true); - this.manager.stateApi.enqueueBatch(batch); - }; - - applyFilter = () => { - const imageState = this.imageState; - const adapter = this.$adapter.get(); - if (!imageState) { - this.log.warn('No image state to apply filter to'); - return; - } - if (!adapter) { - this.log.warn('Cannot apply filter without an adapter'); - return; - } - this.log.trace('Applying filter'); - adapter.renderer.commitBuffer(); - const rect = adapter.transformer.getRelativeRect(); - this.manager.stateApi.rasterizeEntity({ - entityIdentifier: adapter.entityIdentifier, - imageObject: imageState, - rect: { - x: Math.round(rect.x), - y: Math.round(rect.y), - width: imageState.image.height, - height: imageState.image.width, - }, - replaceObjects: true, - }); - adapter.renderer.showObjects(); - this.imageState = null; - this.$adapter.set(null); - }; - - cancelFilter = () => { - this.log.trace('Cancelling filter'); - - const adapter = this.$adapter.get(); - - if (adapter) { - adapter.renderer.clearBuffer(); - adapter.renderer.showObjects(); - adapter.transformer.updatePosition(); - adapter.renderer.syncCache(true); - this.$adapter.set(null); - } - this.imageState = null; - this.$isProcessing.set(false); - }; - - buildBatchConfig = (imageDTO: ImageDTO, config: FilterConfig, id: string): BatchConfig => { - // TODO(psyche): I can't get TS to be happy, it thinkgs `config` is `never` but it should be inferred from the generic... I'll just cast it for now - const node = IMAGE_FILTERS[config.type].buildNode(imageDTO, config as never); - node.id = id; - const batch: BatchConfig = { - prepend: true, - batch: { - graph: { - nodes: { - [node.id]: { - ...node, - // filtered images are always intermediate - do not save to gallery - is_intermediate: true, - }, - }, - edges: [], - }, - origin: this.id, - runs: 1, - }, - }; - - return batch; - }; -} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts index 4b87f35bae6..48d65d07f57 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts @@ -12,7 +12,6 @@ import { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/Can import { CanvasEntityAdapterRegionalGuidance } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRegionalGuidance'; import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntityAdapter/types'; import { CanvasEntityRendererModule } from 'features/controlLayers/konva/CanvasEntityRendererModule'; -import { CanvasFilterModule } from 'features/controlLayers/konva/CanvasFilterModule'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; import { CanvasProgressImageModule } from 'features/controlLayers/konva/CanvasProgressImageModule'; import { CanvasStageModule } from 'features/controlLayers/konva/CanvasStageModule'; @@ -58,7 +57,6 @@ export class CanvasManager extends CanvasModuleBase { stateApi: CanvasStateApiModule; background: CanvasBackgroundModule; - filter: CanvasFilterModule; stage: CanvasStageModule; worker: CanvasWorkerModule; cache: CanvasCacheModule; @@ -105,7 +103,6 @@ export class CanvasManager extends CanvasModuleBase { this.worker = new CanvasWorkerModule(this); this.cache = new CanvasCacheModule(this); this.entityRenderer = new CanvasEntityRendererModule(this); - this.filter = new CanvasFilterModule(this); this.compositor = new CanvasCompositorModule(this); @@ -128,9 +125,12 @@ export class CanvasManager extends CanvasModuleBase { this.konva.previewLayer.add(this.bbox.konva.group); this.konva.previewLayer.add(this.tool.konva.group); - this.$isBusy = computed([this.filter.$isFiltering, this.stateApi.$isTranforming], (isFiltering, isTransforming) => { - return isFiltering || isTransforming; - }); + this.$isBusy = computed( + [this.stateApi.$isFiltering, this.stateApi.$isTranforming], + (isFiltering, isTransforming) => { + return isFiltering || isTransforming; + } + ); } getAdapter = ( @@ -233,7 +233,6 @@ export class CanvasManager extends CanvasModuleBase { this.progressImage, this.stateApi, this.background, - this.filter, this.worker, this.entityRenderer, this.compositor, @@ -280,7 +279,6 @@ export class CanvasManager extends CanvasModuleBase { tool: this.tool.repr(), progressImage: this.progressImage.repr(), background: this.background.repr(), - filter: this.filter.repr(), worker: this.worker.repr(), entityRenderer: this.entityRenderer.repr(), compositor: this.compositor.repr(), diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectImage.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectImage.ts index c60ed9cb972..4c237878e3b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectImage.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectImage.ts @@ -1,7 +1,7 @@ import { Mutex } from 'async-mutex'; import { deepClone } from 'common/util/deepClone'; +import type { CanvasEntityFilterer } from 'features/controlLayers/konva/CanvasEntityFilterer'; import type { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntityObjectRenderer'; -import type { CanvasFilterModule } from 'features/controlLayers/konva/CanvasFilterModule'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; import type { CanvasStagingAreaModule } from 'features/controlLayers/konva/CanvasStagingAreaModule'; @@ -16,7 +16,7 @@ export class CanvasObjectImage extends CanvasModuleBase { readonly type = 'object_image'; readonly id: string; readonly path: string[]; - readonly parent: CanvasEntityObjectRenderer | CanvasStagingAreaModule | CanvasFilterModule; + readonly parent: CanvasEntityObjectRenderer | CanvasStagingAreaModule | CanvasEntityFilterer; readonly manager: CanvasManager; readonly log: Logger; @@ -33,7 +33,7 @@ export class CanvasObjectImage extends CanvasModuleBase { constructor( state: CanvasImageState, - parent: CanvasEntityObjectRenderer | CanvasStagingAreaModule | CanvasFilterModule + parent: CanvasEntityObjectRenderer | CanvasStagingAreaModule | CanvasEntityFilterer ) { super(); this.id = state.id; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts index e3ae486d78c..8b5a58db0ae 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts @@ -1,6 +1,8 @@ import { $alt, $ctrl, $meta, $shift } from '@invoke-ai/ui-library'; import type { Selector } from '@reduxjs/toolkit'; import type { AppStore, RootState } from 'app/store/store'; +import type { CanvasEntityAdapterControlLayer } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterControlLayer'; +import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/CanvasEntityAdapter/CanvasEntityAdapterRasterLayer'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; import type { SubscriptionHandler } from 'features/controlLayers/konva/util'; @@ -11,6 +13,7 @@ import { settingsEraserWidthChanged, } from 'features/controlLayers/store/canvasSettingsSlice'; import { + $lastCanvasProgressEvent, bboxChanged, entityBrushLineAdded, entityEraserLineAdded, @@ -36,7 +39,6 @@ import { atom, computed } from 'nanostores'; import type { Logger } from 'roarr'; import { queueApi } from 'services/api/endpoints/queue'; import type { BatchConfig } from 'services/api/types'; -import { $lastCanvasProgressEvent } from 'services/events/setEventListeners'; import { assert } from 'tsafe'; import type { CanvasEntityAdapter } from './CanvasEntityAdapter/types'; @@ -307,6 +309,16 @@ export class CanvasStateApiModule extends CanvasModuleBase { } }; + /** + * The entity adapter being filtered, if any. + */ + $filteringAdapter = atom(null); + + /** + * Whether an entity is currently being filtered. Derived from `$filteringAdapter`. + */ + $isFiltering = computed(this.$filteringAdapter, (filteringAdapter) => Boolean(filteringAdapter)); + /** * The entity adapter being transformed, if any. */ diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts index c32bcc8e0b1..373d9feffc3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts @@ -106,6 +106,8 @@ export class CanvasToolModule extends CanvasModuleBase { this.konva.group.add(this.colorPickerToolPreview.konva.group); this.subscriptions.add(this.manager.stage.$stageAttrs.listen(this.render)); + this.subscriptions.add(this.manager.stateApi.$isTranforming.listen(this.render)); + this.subscriptions.add(this.manager.stateApi.$isFiltering.listen(this.render)); this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSettingsSlice, this.render)); this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSlice, this.syncCursorStyle)); this.subscriptions.add( @@ -142,7 +144,9 @@ export class CanvasToolModule extends CanvasModuleBase { stage.setCursor(isMouseDown ? 'grabbing' : 'grab'); } else if (this.manager.stateApi.getRenderedEntityCount() === 0) { stage.setCursor('not-allowed'); - } else if (this.manager.$isBusy.get()) { + } else if (this.manager.stateApi.$isTranforming.get()) { + stage.setCursor('default'); + } else if (this.manager.stateApi.$isFiltering.get()) { stage.setCursor('not-allowed'); } else if (!this.manager.stateApi.getSelectedEntityAdapter()?.getIsInteractable()) { stage.setCursor('not-allowed'); @@ -278,7 +282,7 @@ export class CanvasToolModule extends CanvasModuleBase { return false; } else if (this.manager.stateApi.$isTranforming.get()) { return false; - } else if (this.manager.filter.$isFiltering.get()) { + } else if (this.manager.stateApi.$isFiltering.get()) { return false; } else if (!this.manager.stateApi.getSelectedEntityAdapter()?.getIsInteractable()) { return false; @@ -652,6 +656,7 @@ export class CanvasToolModule extends CanvasModuleBase { if (e.key === 'Escape') { // Cancel shape drawing on escape + e.preventDefault(); const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); if (selectedEntity) { selectedEntity.renderer.clearBuffer(); @@ -661,6 +666,7 @@ export class CanvasToolModule extends CanvasModuleBase { if (e.key === ' ') { // Select the view tool on space key down + e.preventDefault(); this.$toolBuffer.set(this.$tool.get()); this.$tool.set('view'); this.manager.stateApi.$spaceKey.set(true); @@ -670,6 +676,7 @@ export class CanvasToolModule extends CanvasModuleBase { if (e.key === 'Alt') { // Select the color picker on alt key down + e.preventDefault(); this.$toolBuffer.set(this.$tool.get()); this.$tool.set('colorPicker'); } @@ -686,6 +693,7 @@ export class CanvasToolModule extends CanvasModuleBase { if (e.key === ' ') { // Revert the tool to the previous tool on space key up + e.preventDefault(); const toolBuffer = this.$toolBuffer.get(); this.$tool.set(toolBuffer ?? 'move'); this.$toolBuffer.set(null); @@ -695,6 +703,7 @@ export class CanvasToolModule extends CanvasModuleBase { if (e.key === 'Alt') { // Revert the tool to the previous tool on alt key up + e.preventDefault(); const toolBuffer = this.$toolBuffer.get(); this.$tool.set(toolBuffer ?? 'move'); this.$toolBuffer.set(null); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts index f88a0675ea7..f3a4d1fa5d3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts @@ -51,7 +51,10 @@ type CanvasSettingsState = { * When `sendToCanvas` is disabled, this setting is ignored, masked regions will always be composited. */ compositeMaskedRegions: boolean; - + /** + * Whether to automatically preview the filter when the filter configuration changes. + */ + autoPreviewFilter: boolean; // TODO(psyche): These are copied from old canvas state, need to be implemented // imageSmoothing: boolean; // preserveMaskedArea: boolean; @@ -69,6 +72,7 @@ const initialState: CanvasSettingsState = { color: { r: 31, g: 160, b: 224, a: 1 }, // invokeBlue.500 sendToCanvas: false, compositeMaskedRegions: false, + autoPreviewFilter: true, }; export const canvasSettingsSlice = createSlice({ @@ -105,6 +109,9 @@ export const canvasSettingsSlice = createSlice({ settingsCompositeMaskedRegionsChanged: (state, action: PayloadAction) => { state.compositeMaskedRegions = action.payload; }, + settingsAutoPreviewFilterToggled: (state) => { + state.autoPreviewFilter = !state.autoPreviewFilter; + }, }, }); @@ -119,6 +126,7 @@ export const { settingsInvertScrollForToolWidthChanged, settingsSendToCanvasChanged, settingsCompositeMaskedRegionsChanged, + settingsAutoPreviewFilterToggled, } = canvasSettingsSlice.actions; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -141,3 +149,7 @@ export const selectDynamicGrid = createSelector( ); export const selectShowHUD = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.showHUD); +export const selectAutoPreviewFilter = createSelector( + selectCanvasSettingsSlice, + (canvasSettings) => canvasSettings.autoPreviewFilter +); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 06f25878dc9..cc7a4828ddd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -27,8 +27,15 @@ import type { AspectRatioID } from 'features/parameters/components/Bbox/types'; import { getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension'; import type { IRect } from 'konva/lib/types'; import { isEqual, merge, omit } from 'lodash-es'; +import { atom } from 'nanostores'; import type { UndoableOptions } from 'redux-undo'; -import type { ControlNetModelConfig, ImageDTO, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types'; +import type { + ControlNetModelConfig, + ImageDTO, + IPAdapterModelConfig, + S, + T2IAdapterModelConfig, +} from 'services/api/types'; import { assert } from 'tsafe'; import type { @@ -127,10 +134,10 @@ export const canvasSlice = createSlice({ id: string; overrides?: Partial; isSelected?: boolean; - deleteOthers?: boolean; + isMergingVisible?: boolean; }> ) => { - const { id, overrides, isSelected, deleteOthers } = action.payload; + const { id, overrides, isSelected, isMergingVisible } = action.payload; const entity: CanvasRasterLayerState = { id, name: null, @@ -143,12 +150,13 @@ export const canvasSlice = createSlice({ }; merge(entity, overrides); - if (deleteOthers) { - state.rasterLayers.entities = [entity]; - } else { - state.rasterLayers.entities.push(entity); + if (isMergingVisible) { + // When merging visible, we delete all disabled layers + state.rasterLayers.entities = state.rasterLayers.entities.filter((layer) => !layer.isEnabled); } + state.rasterLayers.entities.push(entity); + if (isSelected) { state.selectedEntityIdentifier = getEntityIdentifier(entity); } @@ -156,10 +164,7 @@ export const canvasSlice = createSlice({ prepare: (payload: { overrides?: Partial; isSelected?: boolean; - /** - * asdf - */ - deleteOthers?: boolean; + isMergingVisible?: boolean; }) => ({ payload: { ...payload, id: getPrefixedId('raster_layer') }, }), @@ -625,10 +630,10 @@ export const canvasSlice = createSlice({ id: string; overrides?: Partial; isSelected?: boolean; - deleteOthers?: boolean; + isMergingVisible?: boolean; }> ) => { - const { id, overrides, isSelected, deleteOthers } = action.payload; + const { id, overrides, isSelected, isMergingVisible } = action.payload; const entity: CanvasInpaintMaskState = { id, name: null, @@ -645,12 +650,13 @@ export const canvasSlice = createSlice({ }; merge(entity, overrides); - if (deleteOthers) { - state.inpaintMasks.entities = [entity]; - } else { - state.inpaintMasks.entities.push(entity); + if (isMergingVisible) { + // When merging visible, we delete all disabled layers + state.inpaintMasks.entities = state.inpaintMasks.entities.filter((layer) => !layer.isEnabled); } + state.inpaintMasks.entities.push(entity); + if (isSelected) { state.selectedEntityIdentifier = getEntityIdentifier(entity); } @@ -658,7 +664,7 @@ export const canvasSlice = createSlice({ prepare: (payload?: { overrides?: Partial; isSelected?: boolean; - deleteOthers?: boolean; + isMergingVisible?: boolean; }) => ({ payload: { ...payload, id: getPrefixedId('inpaint_mask') }, }), @@ -687,6 +693,13 @@ export const canvasSlice = createSlice({ }, bboxChanged: (state, action: PayloadAction) => { state.bbox.rect = action.payload; + + if (!state.bbox.aspectRatio.isLocked) { + state.bbox.aspectRatio.value = state.bbox.rect.width / state.bbox.rect.height; + state.bbox.aspectRatio.id = 'Free'; + state.bbox.aspectRatio.isLocked = false; + } + syncScaledSize(state); }, bboxWidthChanged: ( @@ -1263,3 +1276,5 @@ function actionsThrottlingFilter(action: UnknownAction) { }, THROTTLE_MS); return true; } + +export const $lastCanvasProgressEvent = atom(null); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 496a268d41a..3a544db45a9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -553,14 +553,8 @@ export type FillStyle = z.infer; export const isFillStyle = (v: unknown): v is FillStyle => zFillStyle.safeParse(v).success; const zFill = z.object({ style: zFillStyle, color: zRgbColor }); -const zRegionalGuidanceIPAdapterConfig = z.object({ +const zRegionalGuidanceIPAdapterConfig = zIPAdapterConfig.extend({ id: zId, - image: zImageWithDims.nullable(), - model: zModelIdentifierField.nullable(), - weight: z.number().gte(-1).lte(2), - beginEndStepPct: zBeginEndStepPct, - method: zIPMethodV2, - clipVisionModel: zCLIPVisionModelV2, }); export type RegionalGuidanceIPAdapterConfig = z.infer; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts index 90a314c11be..255818a4631 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts @@ -1,4 +1,4 @@ -import type { CanvasIPAdapterState, IPAdapterConfig } from 'features/controlLayers/store/types'; +import type { CanvasIPAdapterState } from 'features/controlLayers/store/types'; import type { Graph } from 'features/nodes/util/graph/generation/Graph'; import type { BaseModelType, Invocation } from 'services/api/types'; import { assert } from 'tsafe'; @@ -13,7 +13,7 @@ export const addIPAdapters = ( collector: Invocation<'collect'>, base: BaseModelType ): AddIPAdaptersResult => { - const validIPAdapters = ipAdapters.filter((entity) => isValidIPAdapter(entity.ipAdapter, base)); + const validIPAdapters = ipAdapters.filter((entity) => isValidIPAdapter(entity, base)); const result: AddIPAdaptersResult = { addedIPAdapters: 0, @@ -50,10 +50,10 @@ const addIPAdapter = (entity: CanvasIPAdapterState, g: Graph, collector: Invocat g.addEdge(ipAdapterNode, 'ip_adapter', collector, 'item'); }; -export const isValidIPAdapter = (ipAdapter: IPAdapterConfig, base: BaseModelType): boolean => { +const isValidIPAdapter = ({ isEnabled, ipAdapter }: CanvasIPAdapterState, base: BaseModelType): boolean => { // Must be have a model that matches the current base and must have a control image const hasModel = Boolean(ipAdapter.model); const modelMatchesBase = ipAdapter.model?.base === base; const hasImage = Boolean(ipAdapter.image); - return hasModel && modelMatchesBase && hasImage; + return isEnabled && hasModel && modelMatchesBase && hasImage; }; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts index 3542356a342..fa523e419a0 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts @@ -3,10 +3,10 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import type { CanvasRegionalGuidanceState, + IPAdapterConfig, Rect, RegionalGuidanceIPAdapterConfig, } from 'features/controlLayers/store/types'; -import { isValidIPAdapter } from 'features/nodes/util/graph/generation/addIPAdapters'; import type { Graph } from 'features/nodes/util/graph/generation/Graph'; import type { BaseModelType, Invocation } from 'services/api/types'; import { assert } from 'tsafe'; @@ -234,3 +234,11 @@ export const addRegions = async ( return results; }; + +const isValidIPAdapter = (ipAdapter: IPAdapterConfig, base: BaseModelType): boolean => { + // Must be have a model that matches the current base and must have a control image + const hasModel = Boolean(ipAdapter.model); + const modelMatchesBase = ipAdapter.model?.base === base; + const hasImage = Boolean(ipAdapter.image); + return hasModel && modelMatchesBase && hasImage; +}; diff --git a/invokeai/frontend/web/src/features/toast/toast.ts b/invokeai/frontend/web/src/features/toast/toast.ts index 2bb499a854d..15db1e052ba 100644 --- a/invokeai/frontend/web/src/features/toast/toast.ts +++ b/invokeai/frontend/web/src/features/toast/toast.ts @@ -1,5 +1,6 @@ import type { UseToastOptions } from '@invoke-ai/ui-library'; import { createStandaloneToast, theme, TOAST_OPTIONS } from '@invoke-ai/ui-library'; +import { nanoid } from 'features/controlLayers/konva/util'; import { map } from 'nanostores'; const toastApi = createStandaloneToast({ @@ -67,7 +68,7 @@ const getGetState = (id: string) => () => $toastMap.get()[id] ?? null; */ export const toast = (arg: ToastArg): ToastApi => { // All toasts need an id, set a random one if not provided - const id = arg.id ?? crypto.randomUUID(); + const id = arg.id ?? nanoid(); if (!arg.id) { arg.id = id; } diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index e83985f8ef4..a32bc494fae 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -278,3 +278,5 @@ export const { usePruneCompletedModelInstallsMutation, useGetStarterModelsQuery, } = modelsApi; + +export const selectModelConfigsQuery = modelsApi.endpoints.getModelConfigs.select(); diff --git a/invokeai/frontend/web/src/services/api/endpoints/queue.ts b/invokeai/frontend/web/src/services/api/endpoints/queue.ts index a664fdd8623..2849307fc46 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/queue.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/queue.ts @@ -70,7 +70,7 @@ export const queueApi = api.injectEndpoints({ body: arg, method: 'POST', }), - invalidatesTags: ['CurrentSessionQueueItem', 'NextSessionQueueItem'], + invalidatesTags: ['SessionQueueStatus', 'CurrentSessionQueueItem', 'NextSessionQueueItem'], onQueryStarted: async (arg, api) => { const { dispatch, queryFulfilled } = api; try { diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index f0aaeb9ad6b..010d018ddc0 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -1,12 +1,14 @@ import { ExternalLink } from '@invoke-ai/ui-library'; import { createAction } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; +import { getIsCancelled } from 'app/store/middleware/listenerMiddleware/listeners/cancellationsListeners'; import { $baseUrl } from 'app/store/nanostores/baseUrl'; import { $bulkDownloadId } from 'app/store/nanostores/bulkDownloadId'; import { $queueId } from 'app/store/nanostores/queueId'; import type { AppDispatch, RootState } from 'app/store/store'; import type { SerializableObject } from 'common/types'; import { deepClone } from 'common/util/deepClone'; +import { $lastCanvasProgressEvent } from 'features/controlLayers/store/canvasSlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState'; import { zNodeStatus } from 'features/nodes/types/invocation'; import ErrorToastDescription, { getTitleFromErrorType } from 'features/toast/ErrorToastDescription'; @@ -36,10 +38,8 @@ type SetEventListenersArg = { const selectModelInstalls = modelsApi.endpoints.listModelInstalls.select(); const nodeTypeDenylist = ['load_image', 'image']; export const $lastProgressEvent = atom(null); -export const $lastCanvasProgressEvent = atom(null); export const $hasProgress = computed($lastProgressEvent, (val) => Boolean(val)); export const $progressImage = computed($lastProgressEvent, (val) => val?.progress_image ?? null); -const cancellations = new Set(); export const setEventListeners = ({ socket, dispatch, getState, setIsConnected }: SetEventListenersArg) => { socket.on('connect', () => { @@ -54,7 +54,6 @@ export const setEventListeners = ({ socket, dispatch, getState, setIsConnected } } $lastProgressEvent.set(null); $lastCanvasProgressEvent.set(null); - cancellations.clear(); }); socket.on('connect_error', (error) => { @@ -73,7 +72,6 @@ export const setEventListeners = ({ socket, dispatch, getState, setIsConnected } }); } } - cancellations.clear(); }); socket.on('disconnect', () => { @@ -81,7 +79,6 @@ export const setEventListeners = ({ socket, dispatch, getState, setIsConnected } $lastProgressEvent.set(null); $lastCanvasProgressEvent.set(null); setIsConnected(false); - cancellations.clear(); }); socket.on('invocation_started', (data) => { @@ -92,7 +89,6 @@ export const setEventListeners = ({ socket, dispatch, getState, setIsConnected } nes.status = zNodeStatus.enum.IN_PROGRESS; upsertExecutionState(nes.nodeId, nes); } - cancellations.clear(); }); socket.on('invocation_denoise_progress', (data) => { @@ -106,9 +102,10 @@ export const setEventListeners = ({ socket, dispatch, getState, setIsConnected } destination, percentage, session_id, + batch_id, } = data; - if (cancellations.has(session_id)) { + if (getIsCancelled({ session_id, batch_id, destination })) { // Do not update the progress if this session has been cancelled. This prevents a race condition where we get a // progress update after the session has been cancelled. return; @@ -131,7 +128,8 @@ export const setEventListeners = ({ socket, dispatch, getState, setIsConnected } } } - if (origin === 'generation' && destination === 'canvas') { + // This event is only relevant for the canvas + if (destination === 'canvas') { $lastCanvasProgressEvent.set(data); } }); @@ -464,16 +462,13 @@ export const setEventListeners = ({ socket, dispatch, getState, setIsConnected } /> ), }); - cancellations.add(session_id); } else if (status === 'canceled') { $lastProgressEvent.set(null); if (origin === 'canvas') { $lastCanvasProgressEvent.set(null); } - cancellations.add(session_id); } else if (status === 'completed') { $lastProgressEvent.set(null); - cancellations.add(session_id); } });