diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index e75c4505eb..1480088dd9 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1650,6 +1650,11 @@ "storeNotInitialized": "Store is not initialized" }, "controlLayers": { + "generateMode": "Generate", + "generateModeDesc": "Create individual images. Generated images are added directly to the gallery.", + "composeMode": "Compose", + "composeModeDesc": "Compose your work iterative. Generated images are added back to the canvas.", + "autoSave": "Auto-save to Gallery", "resetCanvas": "Reset Canvas", "resetAll": "Reset All", "clearCaches": "Clear Caches", diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts index d9cfae607a..9111eba123 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts @@ -21,7 +21,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) assert(manager, 'No model found in state'); let didStartStaging = false; - if (!state.canvasV2.session.isStaging) { + if (!state.canvasV2.session.isStaging && state.canvasV2.session.mode === 'compose') { dispatch(sessionStartedStaging()); didStartStaging = true; } @@ -50,7 +50,6 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) req.reset(); await req.unwrap(); } catch (error) { - console.log('Error in enqueueRequestedLinear', error); if (didStartStaging && getState().canvasV2.session.isStaging) { dispatch(sessionStagingAreaReset()); } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasModeSwitcher.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasModeSwitcher.tsx new file mode 100644 index 0000000000..81b9114153 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasModeSwitcher.tsx @@ -0,0 +1,26 @@ +import { Button, ButtonGroup } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { sessionModeChanged } from 'features/controlLayers/store/canvasV2Slice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const CanvasModeSwitcher = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const mode = useAppSelector((s) => s.canvasV2.session.mode); + const onClickGenerate = useCallback(() => dispatch(sessionModeChanged({ mode: 'generate' })), [dispatch]); + const onClickCompose = useCallback(() => dispatch(sessionModeChanged({ mode: 'compose' })), [dispatch]); + + return ( + + + + + ); +}); + +CanvasModeSwitcher.displayName = 'CanvasModeSwitcher'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasResetViewButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasResetViewButton.tsx index 756a31e837..11de7601a1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasResetViewButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasResetViewButton.tsx @@ -23,7 +23,7 @@ export const CanvasResetViewButton = memo(() => { if (!canvasManager) { return; } - canvasManager.stage.resetView(); + canvasManager.stage.fitLayersToStage(); }, [canvasManager]); const onReset = useCallback(() => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx index ec968f19f8..5dcb801b4e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx @@ -1,6 +1,7 @@ /* eslint-disable i18next/no-literal-string */ import { Flex, Spacer } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; +import { CanvasModeSwitcher } from 'features/controlLayers/components/CanvasModeSwitcher'; import { CanvasResetViewButton } from 'features/controlLayers/components/CanvasResetViewButton'; import { CanvasScale } from 'features/controlLayers/components/CanvasScale'; import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover'; @@ -23,11 +24,12 @@ export const ControlLayersToolbar = memo(() => { {tool === 'brush' && } {tool === 'eraser' && } + - + diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasProgressImageModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasProgressImageModule.ts index baaedd2c07..4d306c4a5c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasProgressImageModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasProgressImageModule.ts @@ -70,13 +70,6 @@ export class CanvasProgressImageModule { return; } - const { isStaging } = this.manager.stateApi.getSession(); - - if (!isStaging) { - release(); - return; - } - this.isLoading = true; const { x, y, width, height } = this.manager.stateApi.getBbox().rect; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts index 85504b527b..a88b48913b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts @@ -33,6 +33,7 @@ export class CanvasStageModule { const resizeObserver = new ResizeObserver(this.fitStageToContainer); resizeObserver.observe(this.container); this.fitStageToContainer(); + this.fitLayersToStage(); return () => { this.log.debug('Destroying stage'); @@ -91,10 +92,20 @@ export class CanvasStageModule { } }; - resetView() { - this.log.trace('Resetting view'); - const { width, height } = this.getSize(); + fitBboxToStage = () => { + this.log.trace('Fitting bbox to stage'); + const bbox = this.manager.stateApi.getBbox(); + this.fitRect(bbox.rect); + }; + + fitLayersToStage() { + this.log.trace('Fitting layers to stage'); const rect = this.getVisibleRect(); + this.fitRect(rect); + } + + fitRect = (rect: Rect) => { + const { width, height } = this.getSize(); const padding = 20; // Padding in absolute pixels @@ -118,7 +129,7 @@ export class CanvasStageModule { y, scale, }); - } + }; /** * Gets the center of the stage in either absolute or relative coordinates diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts index 7d6e19179f..688457d18f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts @@ -132,6 +132,7 @@ const initialState: CanvasV2State = { refinerStart: 0.8, }, session: { + mode: 'generate', isStaging: false, stagedImages: [], selectedStagedImageIndex: 0, @@ -474,6 +475,7 @@ export const { clipToBboxChanged, canvasReset, settingsDynamicGridToggled, + settingsAutoSaveToggled, // All entities entitySelected, entityNameChanged, @@ -601,6 +603,7 @@ export const { sessionStagingAreaReset, sessionNextStagedImageSelected, sessionPrevStagedImageSelected, + sessionModeChanged, } = canvasV2Slice.actions; export const selectCanvasV2Slice = (state: RootState) => state.canvasV2; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/sessionReducers.ts b/invokeai/frontend/web/src/features/controlLayers/store/sessionReducers.ts index 94c42aedea..8d2aeaeb11 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/sessionReducers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/sessionReducers.ts @@ -1,5 +1,5 @@ import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit'; -import type { CanvasV2State, StagingAreaImage } from 'features/controlLayers/store/types'; +import type { CanvasV2State, SessionMode, StagingAreaImage } from 'features/controlLayers/store/types'; export const sessionReducers = { sessionStartedStaging: (state) => { @@ -45,4 +45,8 @@ export const sessionReducers = { state.tool.selectedBuffer = null; } }, + sessionModeChanged: (state, action: PayloadAction<{ mode: SessionMode }>) => { + const { mode } = action.payload; + state.session.mode = mode; + }, } satisfies SliceCaseReducers; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/settingsReducers.ts b/invokeai/frontend/web/src/features/controlLayers/store/settingsReducers.ts index 5001c2f06a..324077f8eb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/settingsReducers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/settingsReducers.ts @@ -8,4 +8,7 @@ export const settingsReducers = { settingsDynamicGridToggled: (state) => { state.settings.dynamicGrid = !state.settings.dynamicGrid; }, + settingsAutoSaveToggled: (state) => { + state.settings.autoSave = !state.settings.autoSave; + }, } satisfies SliceCaseReducers; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 8b483d7899..d9b7d52a53 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -834,6 +834,8 @@ export type StagingAreaImage = { offsetY: number; }; +export type SessionMode = 'generate' | 'compose'; + export type CanvasV2State = { _version: 3; selectedEntityIdentifier: CanvasEntityIdentifier | null; @@ -929,6 +931,7 @@ export type CanvasV2State = { refinerStart: number; }; session: { + mode: SessionMode; isStaging: boolean; stagedImages: StagingAreaImage[]; selectedStagedImageIndex: number; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts index 47d5fe44a0..53f8875152 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts @@ -41,7 +41,7 @@ export const buildSD1Graph = async (state: RootState, manager: CanvasManager): P const generationMode = manager.compositor.getGenerationMode(); log.debug({ generationMode }, 'Building SD1/SD2 graph'); - const { bbox, params } = state.canvasV2; + const { bbox, params, session, settings } = state.canvasV2; const { model, @@ -249,10 +249,11 @@ export const buildSD1Graph = async (state: RootState, manager: CanvasManager): P canvasOutput = addWatermarker(g, canvasOutput); } - // This is the terminal node and must always save to gallery. + const shouldSaveToGallery = session.mode === 'generate' || settings.autoSave; + g.updateNode(canvasOutput, { id: CANVAS_OUTPUT, - is_intermediate: false, + is_intermediate: !shouldSaveToGallery, use_cache: false, board: getBoardField(state), }); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts index ee34f09172..d55d632c6a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts @@ -40,7 +40,7 @@ export const buildSDXLGraph = async (state: RootState, manager: CanvasManager): const generationMode = manager.compositor.getGenerationMode(); log.debug({ generationMode }, 'Building SDXL graph'); - const { bbox, params } = state.canvasV2; + const { bbox, params, session, settings } = state.canvasV2; const { model, @@ -246,10 +246,11 @@ export const buildSDXLGraph = async (state: RootState, manager: CanvasManager): canvasOutput = addWatermarker(g, canvasOutput); } - // This is the terminal node and must always save to gallery. + const shouldSaveToGallery = session.mode === 'generate' || settings.autoSave; + g.updateNode(canvasOutput, { id: CANVAS_OUTPUT, - is_intermediate: false, + is_intermediate: !shouldSaveToGallery, use_cache: false, board: getBoardField(state), }); diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.ts b/invokeai/frontend/web/src/services/events/onInvocationComplete.ts new file mode 100644 index 0000000000..b87713379e --- /dev/null +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.ts @@ -0,0 +1,145 @@ +import { logger } from 'app/logging/logger'; +import type { AppDispatch, RootState } from 'app/store/store'; +import type { SerializableObject } from 'common/types'; +import { deepClone } from 'common/util/deepClone'; +import { sessionImageStaged } from 'features/controlLayers/store/canvasV2Slice'; +import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice'; +import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState'; +import { zNodeStatus } from 'features/nodes/types/invocation'; +import { boardsApi } from 'services/api/endpoints/boards'; +import { getImageDTO, imagesApi } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; +import { getCategories, getListImagesUrl } from 'services/api/util'; +import type { InvocationCompleteEvent, InvocationDenoiseProgressEvent } from 'services/events/types'; + +const log = logger('events'); + +export const buildOnInvocationComplete = ( + getState: () => RootState, + dispatch: AppDispatch, + nodeTypeDenylist: string[], + setLastProgressEvent: (event: InvocationDenoiseProgressEvent | null) => void, + setLastCanvasProgressEvent: (event: InvocationDenoiseProgressEvent | null) => void +) => { + const addImageToGallery = (imageDTO: ImageDTO) => { + if (imageDTO.is_intermediate) { + return; + } + + // update the total images for the board + dispatch( + boardsApi.util.updateQueryData('getBoardImagesTotal', imageDTO.board_id ?? 'none', (draft) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + draft.total += 1; + }) + ); + + dispatch( + imagesApi.util.invalidateTags([ + { type: 'Board', id: imageDTO.board_id ?? 'none' }, + { + type: 'ImageList', + id: getListImagesUrl({ + board_id: imageDTO.board_id ?? 'none', + categories: getCategories(imageDTO), + }), + }, + ]) + ); + + const { shouldAutoSwitch, galleryView, selectedBoardId } = getState().gallery; + + // If auto-switch is enabled, select the new image + if (shouldAutoSwitch) { + // if auto-add is enabled, switch the gallery view and board if needed as the image comes in + if (galleryView !== 'images') { + dispatch(galleryViewChanged('images')); + } + + if (imageDTO.board_id && imageDTO.board_id !== selectedBoardId) { + dispatch( + boardIdSelected({ + boardId: imageDTO.board_id, + selectedImageName: imageDTO.image_name, + }) + ); + } + + dispatch(offsetChanged({ offset: 0 })); + + if (!imageDTO.board_id && selectedBoardId !== 'none') { + dispatch( + boardIdSelected({ + boardId: 'none', + selectedImageName: imageDTO.image_name, + }) + ); + } + + dispatch(imageSelected(imageDTO)); + } + }; + + return async (data: InvocationCompleteEvent) => { + log.debug( + { data } as SerializableObject, + `Invocation complete (${data.invocation.type}, ${data.invocation_source_id})` + ); + + const { result, invocation_source_id } = data; + + // Update the node execution states - the image output is handled below + if (data.origin === 'workflows') { + const nes = deepClone($nodeExecutionStates.get()[invocation_source_id]); + if (nes) { + nes.status = zNodeStatus.enum.COMPLETED; + if (nes.progress !== null) { + nes.progress = 1; + } + nes.outputs.push(result); + upsertExecutionState(nes.nodeId, nes); + } + } + + // This complete event has an associated image output + if ( + (data.result.type === 'image_output' || data.result.type === 'canvas_v2_mask_and_crop_output') && + !nodeTypeDenylist.includes(data.invocation.type) + ) { + const { image_name } = data.result.image; + const { session } = getState().canvasV2; + + const imageDTO = await getImageDTO(image_name); + + if (!imageDTO) { + log.error({ data } as SerializableObject, 'Failed to fetch image DTO after generation'); + return; + } + + if (data.origin === 'canvas') { + if (data.invocation_source_id !== 'canvas_output') { + // Not a canvas output image - ignore + return; + } + if (session.mode === 'compose' && session.isStaging) { + if (data.result.type === 'canvas_v2_mask_and_crop_output') { + const { offset_x, offset_y } = data.result; + if (session.isStaging) { + dispatch(sessionImageStaged({ stagingAreaImage: { imageDTO, offsetX: offset_x, offsetY: offset_y } })); + } + } else if (data.result.type === 'image_output') { + if (session.isStaging) { + dispatch(sessionImageStaged({ stagingAreaImage: { imageDTO, offsetX: 0, offsetY: 0 } })); + } + } + addImageToGallery(imageDTO); + } else { + addImageToGallery(imageDTO); + setLastCanvasProgressEvent(null); + } + } + } + + setLastProgressEvent(null); + }; +}; diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index 3c08c7953e..9468dd707f 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -7,8 +7,6 @@ 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 { sessionImageStaged } from 'features/controlLayers/store/canvasV2Slice'; -import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState'; import { zNodeStatus } from 'features/nodes/types/invocation'; import ErrorToastDescription, { getTitleFromErrorType } from 'features/toast/ErrorToastDescription'; @@ -17,11 +15,9 @@ import { t } from 'i18next'; import { forEach } from 'lodash-es'; import { atom, computed } from 'nanostores'; import { api, LIST_TAG } from 'services/api'; -import { boardsApi } from 'services/api/endpoints/boards'; -import { imagesApi } from 'services/api/endpoints/images'; import { modelsApi } from 'services/api/endpoints/models'; import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue'; -import { getCategories, getListImagesUrl } from 'services/api/util'; +import { buildOnInvocationComplete } from 'services/events/onInvocationComplete'; import type { ClientToServerEvents, InvocationDenoiseProgressEvent, ServerToClientEvents } from 'services/events/types'; import type { Socket } from 'socket.io-client'; @@ -147,116 +143,14 @@ export const setEventListeners = ({ socket, dispatch, getState, setIsConnected } } }); - socket.on('invocation_complete', async (data) => { - log.debug( - { data } as SerializableObject, - `Invocation complete (${data.invocation.type}, ${data.invocation_source_id})` - ); - - const { result, invocation_source_id } = data; - - if (data.origin === 'workflows') { - const nes = deepClone($nodeExecutionStates.get()[invocation_source_id]); - if (nes) { - nes.status = zNodeStatus.enum.COMPLETED; - if (nes.progress !== null) { - nes.progress = 1; - } - nes.outputs.push(result); - upsertExecutionState(nes.nodeId, nes); - } - } - - // This complete event has an associated image output - if ( - (data.result.type === 'image_output' || data.result.type === 'canvas_v2_mask_and_crop_output') && - !nodeTypeDenylist.includes(data.invocation.type) - ) { - const { image_name } = data.result.image; - const { gallery, canvasV2 } = getState(); - - // This populates the `getImageDTO` cache - const imageDTORequest = dispatch( - imagesApi.endpoints.getImageDTO.initiate(image_name, { - forceRefetch: true, - }) - ); - - const imageDTO = await imageDTORequest.unwrap(); - imageDTORequest.unsubscribe(); - - // handle tab-specific logic - if (data.origin === 'canvas' && data.invocation_source_id === 'canvas_output') { - if (data.result.type === 'canvas_v2_mask_and_crop_output') { - const { offset_x, offset_y } = data.result; - if (canvasV2.session.isStaging) { - dispatch(sessionImageStaged({ stagingAreaImage: { imageDTO, offsetX: offset_x, offsetY: offset_y } })); - } - } else if (data.result.type === 'image_output') { - if (canvasV2.session.isStaging) { - dispatch(sessionImageStaged({ stagingAreaImage: { imageDTO, offsetX: 0, offsetY: 0 } })); - } - } - } - - if (!imageDTO.is_intermediate) { - // update the total images for the board - dispatch( - boardsApi.util.updateQueryData('getBoardImagesTotal', imageDTO.board_id ?? 'none', (draft) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - draft.total += 1; - }) - ); - - dispatch( - imagesApi.util.invalidateTags([ - { type: 'Board', id: imageDTO.board_id ?? 'none' }, - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: imageDTO.board_id ?? 'none', - categories: getCategories(imageDTO), - }), - }, - ]) - ); - - const { shouldAutoSwitch } = gallery; - - // If auto-switch is enabled, select the new image - if (shouldAutoSwitch) { - // if auto-add is enabled, switch the gallery view and board if needed as the image comes in - if (gallery.galleryView !== 'images') { - dispatch(galleryViewChanged('images')); - } - - if (imageDTO.board_id && imageDTO.board_id !== gallery.selectedBoardId) { - dispatch( - boardIdSelected({ - boardId: imageDTO.board_id, - selectedImageName: imageDTO.image_name, - }) - ); - } - - dispatch(offsetChanged({ offset: 0 })); - - if (!imageDTO.board_id && gallery.selectedBoardId !== 'none') { - dispatch( - boardIdSelected({ - boardId: 'none', - selectedImageName: imageDTO.image_name, - }) - ); - } - - dispatch(imageSelected(imageDTO)); - } - } - } - - $lastProgressEvent.set(null); - }); + const onInvocationComplete = buildOnInvocationComplete( + getState, + dispatch, + nodeTypeDenylist, + $lastProgressEvent.set, + $lastCanvasProgressEvent.set + ); + socket.on('invocation_complete', onInvocationComplete); socket.on('model_load_started', (data) => { const { config, submodel_type } = data;