Compare commits

...

33 Commits

Author SHA1 Message Date
54e46faa54 fix(ui): restore missing galleryImageClicked reducer 2024-02-06 21:45:13 +11:00
183945077d chore(ui): remove unused components
These have moved to the viewer components
2024-02-06 21:19:45 +11:00
0fea08df7d tidy(ui): move viewerMode to uiSlice
Don't need a whole slice for this.
2024-02-06 21:15:24 +11:00
5fe40d228e fix(ui): floating panel buttons have spinner when not processing
Fixes the symptoms of #5666 but doesn't address the underlying issue.
2024-02-06 20:52:34 +11:00
1c507d088a fix(ui): do not show canvas progress in t2i/i2i 2024-02-06 20:52:34 +11:00
87893d29e9 feat(ui): simplified and working linear progress 2024-02-06 20:52:30 +11:00
5667465029 feat(ui): enable image toolbar buttons for latest received image 2024-02-06 20:52:30 +11:00
9021841a49 feat(ui): split out images from ViewerProgress, use IAIDndImage for the latest image 2024-02-06 20:52:30 +11:00
15703c8045 feat(ui): edge case batch id tracking 2024-02-06 20:52:29 +11:00
eb2bbf1609 fix(ui): fix progress bar state 2024-02-06 20:52:29 +11:00
aca953044e feat(ui): move progress denylist to persistconfig 2024-02-06 20:52:29 +11:00
f5a0050a00 feat(ui): add fallbackSrc to IAIDndImage, remove useThumbnailFallback 2024-02-06 20:52:29 +11:00
9e38558633 fix(ui): do not enable draggable when no data in IAIDndImage 2024-02-06 20:52:29 +11:00
93e589a738 feat(ui): split up ViewerToolbar
And fix disabled state for buttons
2024-02-06 20:52:29 +11:00
4f3dd6dbca feat(ui): add useIsProcessing hook 2024-02-06 20:52:29 +11:00
643ef964ac feat(ui): rotating hourglass progress icon 2024-02-06 20:52:29 +11:00
d8349ed42f chore(ui): bump @invoke-ai/ui-library 2024-02-06 20:52:29 +11:00
53f2008893 feat(ui): progress toggle button displays spinner when in progress 2024-02-06 20:52:29 +11:00
60acd4e02f feat(ui): switch from progress view to image view when gallery image clicked 2024-02-06 20:52:29 +11:00
82c471ec2a fix(ui): move galleryImageClicked action to gallerySlice
It was causing a circular dependency
2024-02-06 20:52:29 +11:00
47d76f8033 refactor(ui): remove imageSelected action, use only imageSelectionChanged
We had two actions to select images, only need the one.
2024-02-06 20:52:29 +11:00
b02e11d2b5 feat(ui): add progress slice and move progress to it
This slice tracks denoising progress for linear, canvas and workflow tabs.

`ViewerProgress` now uses it for showing progress images.
2024-02-06 20:52:29 +11:00
24d67c77e1 style(ui): update ImageMetadataViewer 2024-02-06 20:52:29 +11:00
b704941119 feat(ui): flesh out ViewerProgress 2024-02-06 20:52:29 +11:00
dd3b955b8a feat(ui): flesh out ViewerInfo 2024-02-06 20:52:29 +11:00
7613ef3d30 chore(ui): lint 2024-02-06 20:52:29 +11:00
fba7f36038 feat(ui): flesh our ViewerImage
It displays the currently selected image.
2024-02-06 20:52:29 +11:00
2bfd4407ad feat(ui): add more context to ViewerImageDropData
This is needed to disable the droppable when dragging the viewer's image itself - we don't want to allow dropping an image on the place it came from.
2024-02-06 20:52:29 +11:00
265ccab15f fix(ui): IAIDndImage should not render IAIDroppable when no droppableData is supplied 2024-02-06 20:52:29 +11:00
ebf1f1bf6b feat(ui): add dnd to viewer
This replaces the drop type `SET_CURRENT_IMAGE` with `SET_VIEWER_IMAGE`.
2024-02-06 20:52:29 +11:00
07ce7685b1 feat(ui): rough out viewer components
The toolbar is largely copied from `CurrentImageButtons.tsx`
2024-02-06 20:52:29 +11:00
521d91ea58 feat(ui): add viewer feature & slice
The viewer (main panel area on t2i/i2i tab) will now have 3 modes:
- Image
- Info
- Progress

This simplifies the logic for this part of the app and affords an improved progress image UX.
2024-02-06 20:52:29 +11:00
7521dff206 docs(ui): update STATE_MGMT.md 2024-02-06 20:52:29 +11:00
62 changed files with 1178 additions and 681 deletions

View File

@ -4,7 +4,23 @@ The app makes heavy use of Redux Toolkit, its Query library, and `nanostores`.
## Redux
TODO
We use [Redux Toolkit] + RTK Query extensively.
### Persistence
The usual persistence layer for redux is [redux-persist]. Unfortunately, it is abandoned, and not possible to fork. Past releases of it depend on a malicious package that was removed from npm, so it's very difficult (impossible?) to build. The current state of the repo is also non-functional, as it was abandoned mid-rewrite.
We had a need to debounce our persistence, and patched redux-persist's build directly to do so. This didn't feel great. We've since moved to [redux-remember], a well-designed, minimal, and actively maintained library.
#### Slice migration
When rehydrating state, we sometimes need to migrate data. This is handled by the `unserialize` function in [store.ts], which is used by redux-remember to rehydrate persisted state. This function uses some lodash utils to strip out unknown keys, and merge any new keys into the rehydrated state.
Sometimes the shape of state changes, but it keeps the same property name in the slice. In that case, we need to _transform_ incoming data.
To handle this, each persisted slice must have a `SliceConfig`, which includes its latest initial value, and a migrate function. The migrate function, defined in the slice, does does any transformations and updates the version.
The version of the slice is currently only incremented when we need to run _transform_ migrations. If keys are added or removed from state, the version is not bumped.
## `nanostores`
@ -36,3 +52,7 @@ const myStringOption = useStore($myStringOption);
- For performance-critical code and in callbacks, redux selectors can be problematic due to the declarative reactivity system. Consider refactoring to use `nanostores` if there's a **measurable** performance issue.
[nanostores]: https://github.com/nanostores/nanostores/
[Redux Toolkit]: https://redux-toolkit.js.org/
[redux-persist]: https://github.com/rt2zz/redux-persist
[redux-remember]: https://github.com/zewish/redux-remember
[store.ts]: invokeai/frontend/web/src/app/store/store.ts

View File

@ -54,7 +54,7 @@
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.0.16",
"@invoke-ai/ui-library": "^0.0.18",
"@invoke-ai/ui-library": "^0.0.20",
"@mantine/form": "6.0.21",
"@nanostores/react": "^0.7.1",
"@reduxjs/toolkit": "2.0.1",

View File

@ -29,8 +29,8 @@ dependencies:
specifier: ^5.0.16
version: 5.0.16
'@invoke-ai/ui-library':
specifier: ^0.0.18
version: 0.0.18(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.16)(@internationalized/date@3.5.1)(@types/react@18.2.48)(i18next@23.7.16)(react-dom@18.2.0)(react@18.2.0)
specifier: ^0.0.20
version: 0.0.20(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.16)(@internationalized/date@3.5.1)(@types/react@18.2.48)(i18next@23.7.16)(react-dom@18.2.0)(react@18.2.0)
'@mantine/form':
specifier: 6.0.21
version: 6.0.21(react@18.2.0)
@ -1700,6 +1700,13 @@ packages:
dependencies:
regenerator-runtime: 0.14.1
/@babel/runtime@7.23.9:
resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==}
engines: {node: '>=6.9.0'}
dependencies:
regenerator-runtime: 0.14.1
dev: false
/@babel/template@7.22.15:
resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==}
engines: {node: '>=6.9.0'}
@ -1960,7 +1967,7 @@ packages:
dependencies:
'@chakra-ui/dom-utils': 2.1.0
react: 18.2.0
react-focus-lock: 2.9.6(@types/react@18.2.48)(react@18.2.0)
react-focus-lock: 2.9.7(@types/react@18.2.48)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
dev: false
@ -2992,7 +2999,7 @@ packages:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.23.8
'@babel/runtime': 7.23.9
'@emotion/babel-plugin': 11.11.0
'@emotion/is-prop-valid': 1.2.1
'@emotion/react': 11.11.3(@types/react@18.2.48)(react@18.2.0)
@ -3587,8 +3594,8 @@ packages:
prettier: 3.2.4
dev: true
/@invoke-ai/ui-library@0.0.18(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.16)(@internationalized/date@3.5.1)(@types/react@18.2.48)(i18next@23.7.16)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Yme+2+pzYy3TPb7ZT0hYmBwahH29ZRSVIxLKSexh3BsbJXbTzGssRQU78QvK6Ymxemgbso3P8Rs+IW0zNhQKjQ==}
/@invoke-ai/ui-library@0.0.20(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.16)(@internationalized/date@3.5.1)(@types/react@18.2.48)(i18next@23.7.16)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-8BDL9LWbmpAZHTJB0B+zMJ0kBq7vQF0tem6q9WB03CPqdE307FiDmZ++NZF7BP8Rp4Sivdi6OagaXL7WV6e0Pw==}
peerDependencies:
'@fontsource-variable/inter': ^5.0.16
react: ^18.2.0
@ -3610,8 +3617,8 @@ packages:
framer-motion: 10.18.0(react-dom@18.2.0)(react@18.2.0)
lodash-es: 4.17.21
nanostores: 0.9.5
overlayscrollbars: 2.4.7
overlayscrollbars-react: 0.5.4(overlayscrollbars@2.4.7)(react@18.2.0)
overlayscrollbars: 2.5.0
overlayscrollbars-react: 0.5.4(overlayscrollbars@2.5.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-i18next: 14.0.1(i18next@23.7.16)(react-dom@18.2.0)(react@18.2.0)
@ -11333,13 +11340,13 @@ packages:
react: 18.2.0
dev: false
/overlayscrollbars-react@0.5.4(overlayscrollbars@2.4.7)(react@18.2.0):
/overlayscrollbars-react@0.5.4(overlayscrollbars@2.5.0)(react@18.2.0):
resolution: {integrity: sha512-FPKx9XnXovTnI4+2JXig5uEaTLSEJ6svOwPzIfBBXTHBRNsz2+WhYUmfM0K/BNYxjgDEwuPm+NQhEoOA0RoG1g==}
peerDependencies:
overlayscrollbars: ^2.0.0
react: '>=16.8.0'
dependencies:
overlayscrollbars: 2.4.7
overlayscrollbars: 2.5.0
react: 18.2.0
dev: false
@ -11347,8 +11354,8 @@ packages:
resolution: {integrity: sha512-C7tmhetwMv9frEvIT/RfkAVEgbjRNz/Gh2zE8BVmN+jl35GRaAnz73rlGQCMRoC2arpACAXyMNnJkzHb7GBrcA==}
dev: false
/overlayscrollbars@2.4.7:
resolution: {integrity: sha512-02X2/nHno35dzebCx+EO2tRDaKAOltZqUKdUqvq3Pt8htCuhJbYi+mjr0CYerVeGRRoZ2Uo6/8XrNg//DJJ+GA==}
/overlayscrollbars@2.5.0:
resolution: {integrity: sha512-CWVC2dwS07XZfLHDm5GmZN1iYggiJ8Vufnvzwt0gwR9Yz1hVckKeTxg7VILZeYVGhDYJHZ1Xc8Xfys5dWZ1qiA==}
dev: false
/p-limit@2.3.0:
@ -11836,7 +11843,7 @@ packages:
peerDependencies:
react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
dependencies:
'@babel/runtime': 7.23.8
'@babel/runtime': 7.23.9
react: 18.2.0
dev: false
@ -11922,8 +11929,8 @@ packages:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
dev: false
/react-focus-lock@2.9.6(@types/react@18.2.48)(react@18.2.0):
resolution: {integrity: sha512-B7gYnCjHNrNYwY2juS71dHbf0+UpXXojt02svxybj8N5bxceAkzPChKEncHuratjUHkIFNCn06k2qj1DRlzTug==}
/react-focus-lock@2.9.7(@types/react@18.2.48)(react@18.2.0):
resolution: {integrity: sha512-EfhX040SELLqnQ9JftqsmQCG49iByg8F5X5m19Er+n371OaETZ35dlNPZrLOOTlnnwD4c2Zv0KDgabDTc7dPHw==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
@ -11931,7 +11938,7 @@ packages:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.23.8
'@babel/runtime': 7.23.9
'@types/react': 18.2.48
focus-lock: 1.0.0
prop-types: 15.8.1
@ -11993,7 +12000,7 @@ packages:
react-native:
optional: true
dependencies:
'@babel/runtime': 7.23.8
'@babel/runtime': 7.23.9
html-parse-stringify: 3.0.1
i18next: 23.7.16
react: 18.2.0

View File

@ -1609,6 +1609,13 @@
"showProgressImages": "Show Progress Images",
"swapSizes": "Swap Sizes"
},
"viewer": {
"viewerModeImage": "Image",
"viewerModeInfo": "Info",
"viewerModeProgress": "Progress",
"dropLabel": "View Image",
"noProgress": "Nothing in Progress"
},
"unifiedCanvas": {
"accept": "Accept",
"activeLayer": "Active Layer",

View File

@ -1,25 +1,25 @@
import { isAnyOf } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { canvasBatchIdsReset, commitStagingAreaImage, discardStagedImages } from 'features/canvas/store/canvasSlice';
import { matchAnyStagingAreaDismissed } from 'features/canvas/store/canvasSlice';
import { addToast } from 'features/system/store/systemSlice';
import { t } from 'i18next';
import { queueApi } from 'services/api/endpoints/queue';
import { startAppListening } from '..';
const matcher = isAnyOf(commitStagingAreaImage, discardStagedImages);
export const addCommitStagingAreaImageListener = () => {
startAppListening({
matcher,
matcher: matchAnyStagingAreaDismissed,
effect: async (_, { dispatch, getState }) => {
const log = logger('canvas');
const state = getState();
const { batchIds } = state.canvas;
const { canvasBatchIds } = state.progress;
try {
const req = dispatch(
queueApi.endpoints.cancelByBatchIds.initiate({ batch_ids: batchIds }, { fixedCacheKey: 'cancelByBatchIds' })
queueApi.endpoints.cancelByBatchIds.initiate(
{ batch_ids: canvasBatchIds },
{ fixedCacheKey: 'cancelByBatchIds' }
)
);
const { canceled } = await req.unwrap();
req.reset();
@ -32,7 +32,6 @@ export const addCommitStagingAreaImageListener = () => {
})
);
}
dispatch(canvasBatchIdsReset());
} catch {
log.error('Failed to cancel canvas batches');
dispatch(

View File

@ -1,5 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imageSelectionChanged } from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageCache } from 'services/api/types';
@ -28,7 +28,7 @@ export const addFirstListImagesListener = () => {
if (data.ids.length > 0) {
// Select the first image
const firstImage = imagesSelectors.selectAll(data)[0];
dispatch(imageSelected(firstImage ?? null));
dispatch(imageSelectionChanged(firstImage ?? null));
}
},
});

View File

@ -1,5 +1,5 @@
import { isAnyOf } from '@reduxjs/toolkit';
import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice';
import { boardIdSelected, galleryViewChanged, imageSelectionChanged } from 'features/gallery/store/gallerySlice';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { imagesApi } from 'services/api/endpoints/images';
import { imagesSelectors } from 'services/api/util';
@ -37,17 +37,17 @@ export const addBoardIdSelectedListener = () => {
if (boardImagesData && boardIdSelected.match(action) && action.payload.selectedImageName) {
const selectedImage = imagesSelectors.selectById(boardImagesData, action.payload.selectedImageName);
dispatch(imageSelected(selectedImage || null));
dispatch(imageSelectionChanged(selectedImage ?? null));
} else if (boardImagesData) {
const firstImage = imagesSelectors.selectAll(boardImagesData)[0];
dispatch(imageSelected(firstImage || null));
dispatch(imageSelectionChanged(firstImage ?? null));
} else {
// board has no images - deselect
dispatch(imageSelected(null));
dispatch(imageSelectionChanged(null));
}
} else {
// fallback - deselect
dispatch(imageSelected(null));
dispatch(imageSelectionChanged(null));
}
},
});

View File

@ -2,13 +2,14 @@ import { logger } from 'app/logging/logger';
import { enqueueRequested } from 'app/store/actions';
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { parseify } from 'common/util/serialize';
import { canvasBatchIdAdded, stagingAreaInitialized } from 'features/canvas/store/canvasSlice';
import { stagingAreaInitialized } from 'features/canvas/store/canvasSlice';
import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
import { getCanvasData } from 'features/canvas/util/getCanvasData';
import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode';
import { canvasGraphBuilt } from 'features/nodes/store/actions';
import { buildCanvasGraph } from 'features/nodes/util/graph/buildCanvasGraph';
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
import { canvasBatchEnqueued } from 'features/progress/store/progressSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { queueApi } from 'services/api/endpoints/queue';
import type { ImageDTO } from 'services/api/types';
@ -121,8 +122,6 @@ export const addEnqueueRequestedCanvasListener = () => {
const enqueueResult = await req.unwrap();
req.reset();
const batchId = enqueueResult.batch.batch_id as string; // we know the is a string, backend provides it
// Prep the canvas staging area if it is not yet initialized
if (!state.canvas.layerState.stagingArea.boundingBox) {
dispatch(
@ -135,8 +134,9 @@ export const addEnqueueRequestedCanvasListener = () => {
);
}
// Associate the session with the canvas session ID
dispatch(canvasBatchIdAdded(batchId));
if (enqueueResult.batch.batch_id) {
dispatch(canvasBatchEnqueued(enqueueResult.batch.batch_id));
}
} catch {
// no-op
}

View File

@ -1,19 +1,10 @@
import { createAction } from '@reduxjs/toolkit';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { galleryImageClicked, imageSelectionChanged } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
import { startAppListening } from '..';
export const galleryImageClicked = createAction<{
imageDTO: ImageDTO;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}>('gallery/imageClicked');
/**
* This listener handles the logic for selecting images in the gallery.
*
@ -52,16 +43,16 @@ export const addGalleryImageClickedListener = () => {
const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = imageDTOs.slice(start, end + 1);
dispatch(selectionChanged(selection.concat(imagesToSelect)));
dispatch(imageSelectionChanged(selection.concat(imagesToSelect)));
}
} else if (ctrlKey || metaKey) {
if (selection.some((i) => i.image_name === imageDTO.image_name) && selection.length > 1) {
dispatch(selectionChanged(selection.filter((n) => n.image_name !== imageDTO.image_name)));
dispatch(imageSelectionChanged(selection.filter((n) => n.image_name !== imageDTO.image_name)));
} else {
dispatch(selectionChanged(selection.concat(imageDTO)));
dispatch(imageSelectionChanged(selection.concat(imageDTO)));
}
} else {
dispatch(selectionChanged([imageDTO]));
dispatch(imageSelectionChanged(imageDTO));
}
},
});

View File

@ -9,7 +9,7 @@ import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imageSelectionChanged } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { isImageFieldInputInstance } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
@ -62,9 +62,9 @@ export const addRequestedSingleImageDeletionListener = () => {
const newSelectedImageDTO = filteredImageDTOs[newSelectedImageIndex];
if (newSelectedImageDTO) {
dispatch(imageSelected(newSelectedImageDTO));
dispatch(imageSelectionChanged(newSelectedImageDTO));
} else {
dispatch(imageSelected(null));
dispatch(imageSelectionChanged(null));
}
}
@ -160,9 +160,9 @@ export const addRequestedMultipleImageDeletionListener = () => {
const newSelectedImageDTO = data ? imagesSelectors.selectAll(data)[0] : undefined;
if (newSelectedImageDTO) {
dispatch(imageSelected(newSelectedImageDTO));
dispatch(imageSelectionChanged(newSelectedImageDTO));
} else {
dispatch(imageSelected(null));
dispatch(imageSelectionChanged(null));
}
dispatch(isModalOpenChanged(false));

View File

@ -7,7 +7,7 @@ import {
controlAdapterIsEnabledChanged,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imageSelectionChanged } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { initialImageChanged, selectOptimalDimension } from 'features/parameters/store/generationSlice';
import { imagesApi } from 'services/api/endpoints/images';
@ -37,14 +37,14 @@ export const addImageDroppedListener = () => {
}
/**
* Image dropped on current image
* Image dropped on viewer
*/
if (
overData.actionType === 'SET_CURRENT_IMAGE' &&
overData.actionType === 'SET_VIEWER_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(imageSelected(activeData.payload.imageDTO));
dispatch(imageSelectionChanged(activeData.payload.imageDTO));
return;
}

View File

@ -1,4 +1,4 @@
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { imageSelectionChanged } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
@ -25,7 +25,7 @@ export const addImagesStarredListener = () => {
updatedSelection.push(selectedImageDTO);
}
});
dispatch(selectionChanged(updatedSelection));
dispatch(imageSelectionChanged(updatedSelection));
},
});
};

View File

@ -1,4 +1,4 @@
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { imageSelectionChanged } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
@ -25,7 +25,7 @@ export const addImagesUnstarredListener = () => {
updatedSelection.push(selectedImageDTO);
}
});
dispatch(selectionChanged(updatedSelection));
dispatch(imageSelectionChanged(updatedSelection));
},
});
};

View File

@ -1,10 +1,11 @@
import { logger } from 'app/logging/logger';
import { parseify } from 'common/util/serialize';
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice';
import { boardIdSelected, galleryViewChanged, imageSelectionChanged } from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { isImageOutput } from 'features/nodes/types/common';
import { LINEAR_UI_OUTPUT, nodeIDDenyList } from 'features/nodes/util/graph/constants';
import { imageInvocationComplete } from 'features/progress/store/progressSlice';
import { boardsApi } from 'services/api/endpoints/boards';
import { imagesApi } from 'services/api/endpoints/images';
import { imagesAdapter } from 'services/api/util';
@ -29,7 +30,7 @@ export const addInvocationCompleteEventListener = () => {
// This complete event has an associated image output
if (isImageOutput(result) && !nodeTypeDenylist.includes(node.type) && !nodeIDDenyList.includes(source_node_id)) {
const { image_name } = result.image;
const { canvas, gallery } = getState();
const { gallery, progress } = getState();
// This populates the `getImageDTO` cache
const imageDTORequest = dispatch(
@ -41,8 +42,10 @@ export const addInvocationCompleteEventListener = () => {
const imageDTO = await imageDTORequest.unwrap();
imageDTORequest.unsubscribe();
dispatch(imageInvocationComplete({ data, imageDTO }));
// Add canvas images to the staging area
if (canvas.batchIds.includes(queue_batch_id) && [LINEAR_UI_OUTPUT].includes(data.source_node_id)) {
if (progress.canvasBatchIds.includes(queue_batch_id) && [LINEAR_UI_OUTPUT].includes(data.source_node_id)) {
dispatch(addImageToStagingArea(imageDTO));
}
@ -102,7 +105,7 @@ export const addInvocationCompleteEventListener = () => {
);
}
dispatch(imageSelected(imageDTO));
dispatch(imageSelectionChanged(imageDTO));
}
}
}

View File

@ -20,6 +20,7 @@ import { nodesTemplatesSlice } from 'features/nodes/store/nodeTemplatesSlice';
import { workflowPersistConfig, workflowSlice } from 'features/nodes/store/workflowSlice';
import { generationPersistConfig, generationSlice } from 'features/parameters/store/generationSlice';
import { postprocessingPersistConfig, postprocessingSlice } from 'features/parameters/store/postprocessingSlice';
import { progressPersistConfig, progressSlice } from 'features/progress/store/progressSlice';
import { queueSlice } from 'features/queue/store/queueSlice';
import { sdxlPersistConfig, sdxlSlice } from 'features/sdxl/store/sdxlSlice';
import { configSlice } from 'features/system/store/configSlice';
@ -61,6 +62,7 @@ const allReducers = {
[queueSlice.name]: queueSlice.reducer,
[workflowSlice.name]: workflowSlice.reducer,
[hrfSlice.name]: hrfSlice.reducer,
[progressSlice.name]: progressSlice.reducer,
[api.reducerPath]: api.reducer,
};
@ -105,6 +107,7 @@ const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = {
[loraPersistConfig.name]: loraPersistConfig,
[modelManagerPersistConfig.name]: modelManagerPersistConfig,
[hrfPersistConfig.name]: hrfPersistConfig,
[progressPersistConfig.name]: progressPersistConfig,
};
const unserialize: UnserializeFunction = (data, key) => {

View File

@ -37,17 +37,18 @@ type IAIDndImageProps = FlexProps & {
isSelected?: boolean;
thumbnail?: boolean;
noContentFallback?: ReactElement;
useThumbailFallback?: boolean;
withHoverOverlay?: boolean;
children?: JSX.Element;
uploadElement?: ReactNode;
dataTestId?: string;
fallbackSrc?: string;
};
const IAIDndImage = (props: IAIDndImageProps) => {
const {
imageDTO,
onError,
onLoad,
onClick,
withMetadataOverlay = false,
isDropDisabled = false,
@ -64,7 +65,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
thumbnail = false,
noContentFallback = defaultNoContentFallback,
uploadElement = defaultUploadElement,
useThumbailFallback,
fallbackSrc,
withHoverOverlay = false,
children,
onMouseOver,
@ -150,8 +151,8 @@ const IAIDndImage = (props: IAIDndImageProps) => {
<Image
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
fallbackStrategy="beforeLoadOrError"
fallbackSrc={useThumbailFallback ? imageDTO.thumbnail_url : undefined}
fallback={useThumbailFallback ? undefined : <IAILoadingImageFallback image={imageDTO} />}
fallbackSrc={fallbackSrc ?? imageDTO.thumbnail_url}
fallback={fallbackSrc ? undefined : <IAILoadingImageFallback image={imageDTO} />}
onError={onError}
draggable={false}
w={imageDTO.width}
@ -161,6 +162,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
borderRadius="base"
sx={imageSx}
data-testid={dataTestId}
onLoad={onLoad}
/>
{withMetadataOverlay && <ImageMetadataOverlay imageDTO={imageDTO} />}
<SelectionOverlay isSelected={isSelected} isHovered={withHoverOverlay ? isHovered : false} />
@ -175,11 +177,13 @@ const IAIDndImage = (props: IAIDndImageProps) => {
</>
)}
{!imageDTO && isUploadDisabled && noContentFallback}
{imageDTO && !isDragDisabled && (
{imageDTO && !isDragDisabled && draggableData && (
<IAIDraggable data={draggableData} disabled={isDragDisabled || !imageDTO} onClick={onClick} />
)}
{children}
{!isDropDisabled && <IAIDroppable data={droppableData} disabled={isDropDisabled} dropLabel={dropLabel} />}
{!isDropDisabled && droppableData && (
<IAIDroppable data={droppableData} disabled={isDropDisabled} dropLabel={dropLabel} />
)}
</Flex>
)}
</ImageContextMenu>

View File

@ -28,7 +28,6 @@ import { Layer, Stage } from 'react-konva';
import IAICanvasBoundingBoxOverlay from './IAICanvasBoundingBoxOverlay';
import IAICanvasGrid from './IAICanvasGrid';
import IAICanvasIntermediateImage from './IAICanvasIntermediateImage';
import IAICanvasMaskCompositer from './IAICanvasMaskCompositer';
import IAICanvasMaskLines from './IAICanvasMaskLines';
import IAICanvasObjectRenderer from './IAICanvasObjectRenderer';
@ -55,7 +54,7 @@ const IAICanvas = () => {
const shouldShowBoundingBox = useAppSelector((s) => s.canvas.shouldShowBoundingBox);
const shouldShowGrid = useAppSelector((s) => s.canvas.shouldShowGrid);
const stageScale = useAppSelector((s) => s.canvas.stageScale);
const shouldShowIntermediates = useAppSelector((s) => s.canvas.shouldShowIntermediates);
// const shouldShowIntermediates = useAppSelector((s) => s.canvas.shouldShowIntermediates);
const shouldAntialias = useAppSelector((s) => s.canvas.shouldAntialias);
const shouldRestrictStrokesToBox = useAppSelector((s) => s.canvas.shouldRestrictStrokesToBox);
const { stageCoordinates, stageDimensions } = useAppSelector(selector);
@ -184,7 +183,7 @@ const IAICanvas = () => {
<Layer id="preview" imageSmoothingEnabled={shouldAntialias}>
{!isStaging && <IAICanvasToolPreview visible={tool !== 'move'} listening={false} />}
<IAICanvasStagingArea listening={false} visible={isStaging} />
{shouldShowIntermediates && <IAICanvasIntermediateImage />}
{/* {shouldShowIntermediates && <IAICanvasIntermediateImage />} */}
<IAICanvasBoundingBox visible={shouldShowBoundingBox && !isStaging} />
</Layer>
</ChakraStage>

View File

@ -1,20 +1,33 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import { selectSystemSlice } from 'features/system/store/systemSlice';
import { selectProgressSlice } from 'features/progress/store/progressSlice';
import { memo, useEffect, useState } from 'react';
import { Image as KonvaImage } from 'react-konva';
const progressImageSelector = createMemoizedSelector([selectSystemSlice, selectCanvasSlice], (system, canvas) => {
const { denoiseProgress } = system;
const { batchIds } = canvas;
export const progressImageSelector = createMemoizedSelector(
[selectProgressSlice, selectCanvasSlice],
(progress, canvas) => {
const isLatestProgressFromCanvas =
progress.latestDenoiseProgress && progress.canvasBatchIds.includes(progress.latestDenoiseProgress.queue_batch_id);
const { selectedImageIndex, images } = canvas.layerState.stagingArea;
const _currentStagingAreaImage =
images.length > 0 && selectedImageIndex !== undefined ? images[selectedImageIndex] : undefined;
const isProgressImageIncomplete =
progress.latestDenoiseProgress?.graph_execution_state_id !==
progress.latestImageOutputEvent?.graph_execution_state_id;
return {
progressImage:
denoiseProgress && batchIds.includes(denoiseProgress.batch_id) ? denoiseProgress.progress_image : undefined,
progress.latestDenoiseProgress && isLatestProgressFromCanvas && isProgressImageIncomplete
? progress.latestDenoiseProgress.progress_image
: undefined,
boundingBox: canvas.layerState.stagingArea.boundingBox,
};
});
}
);
const IAICanvasIntermediateImage = () => {
const { progressImage, boundingBox } = useAppSelector(progressImageSelector);

View File

@ -5,7 +5,7 @@ import type { GroupConfig } from 'konva/lib/Group';
import { memo } from 'react';
import { Group, Rect } from 'react-konva';
import IAICanvasImage from './IAICanvasImage';
import { IAICanvasStagingAreaImage } from './IAICanvasStagingAreaImage';
const dash = [4, 4];
@ -37,12 +37,11 @@ const selector = createMemoizedSelector(selectCanvasSlice, (canvas) => {
type Props = GroupConfig;
const IAICanvasStagingArea = (props: Props) => {
const { currentStagingAreaImage, shouldShowStagingImage, shouldShowStagingOutline, x, y, width, height } =
useAppSelector(selector);
const { shouldShowStagingOutline, x, y, width, height } = useAppSelector(selector);
return (
<Group {...props}>
{shouldShowStagingImage && currentStagingAreaImage && <IAICanvasImage canvasImage={currentStagingAreaImage} />}
<IAICanvasStagingAreaImage />
{shouldShowStagingOutline && (
<Group listening={false}>
<Rect

View File

@ -0,0 +1,71 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { $authToken } from 'app/store/nanostores/authToken';
import { useAppSelector } from 'app/store/storeHooks';
import IAICanvasIntermediateImage, {
progressImageSelector,
} from 'features/canvas/components/IAICanvasIntermediateImage';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import { selectProgressSlice } from 'features/progress/store/progressSlice';
import { memo } from 'react';
import { Image } from 'react-konva';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import useImage from 'use-image';
import IAICanvasImageErrorFallback from './IAICanvasImageErrorFallback';
const selector = createMemoizedSelector([selectProgressSlice, selectCanvasSlice], (progress, canvas) => {
const { selectedImageIndex, images } = canvas.layerState.stagingArea;
const currentStagingAreaImage =
images.length > 0 && selectedImageIndex !== undefined ? images[selectedImageIndex] : undefined;
const progressImage =
progress.latestDenoiseProgress && progress.canvasBatchIds.includes(progress.latestDenoiseProgress.queue_batch_id)
? progress.latestDenoiseProgress.progress_image
: undefined;
const boundingBox = canvas.layerState.stagingArea.boundingBox ?? {
...canvas.boundingBoxCoordinates,
...canvas.boundingBoxDimensions,
};
return {
currentStagingAreaImage,
progressImage,
boundingBox,
};
});
export const IAICanvasStagingAreaImage = memo(() => {
const { currentStagingAreaImage, boundingBox } = useAppSelector(selector);
const { progressImage } = useAppSelector(progressImageSelector);
const { currentData: imageDTO, isError } = useGetImageDTOQuery(currentStagingAreaImage?.imageName ?? skipToken);
const [stagedImageEl, stagedImageElStatus] = useImage(
imageDTO?.image_url ?? '',
$authToken.get() ? 'use-credentials' : 'anonymous'
);
if (currentStagingAreaImage && (isError || stagedImageElStatus === 'failed')) {
return <IAICanvasImageErrorFallback canvasImage={currentStagingAreaImage} />;
}
if (progressImage) {
return <IAICanvasIntermediateImage />;
}
if (stagedImageEl) {
return (
<Image
x={boundingBox.x}
y={boundingBox.y}
width={boundingBox.width}
height={boundingBox.height}
image={stagedImageEl}
listening={false}
/>
);
}
});
IAICanvasStagingAreaImage.displayName = 'IAICanvasStagingAreaImage';

View File

@ -1,12 +1,14 @@
import { createSelector } from '@reduxjs/toolkit';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { selectProgressSlice } from 'features/progress/store/progressSlice';
import { selectCanvasSlice } from './canvasSlice';
import { isCanvasBaseImage } from './canvasTypes';
export const isStagingSelector = createSelector(
selectProgressSlice,
selectCanvasSlice,
(canvas) => canvas.batchIds.length > 0 || canvas.layerState.stagingArea.images.length > 0
(progress, canvas) => progress.canvasBatchIds.length > 0 || canvas.layerState.stagingArea.images.length > 0
);
export const initialCanvasImageSelector = createMemoizedSelector(selectCanvasSlice, (canvas) =>

View File

@ -1,5 +1,5 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { createSlice, isAnyOf } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple';
import calculateCoordinates from 'features/canvas/util/calculateCoordinates';
@ -15,9 +15,7 @@ import { getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/
import type { IRect, Vector2d } from 'konva/lib/types';
import { clamp, cloneDeep } from 'lodash-es';
import type { RgbaColor } from 'react-colorful';
import { queueApi } from 'services/api/endpoints/queue';
import type { ImageDTO } from 'services/api/types';
import { socketQueueItemStatusChanged } from 'services/events/actions';
import type {
BoundingBoxScaleMethod,
@ -79,7 +77,6 @@ export const initialCanvasState: CanvasState = {
stageCoordinates: { x: 0, y: 0 },
stageDimensions: { width: 0, height: 0 },
stageScale: 1,
batchIds: [],
aspectRatio: {
id: '1:1',
value: 1,
@ -180,7 +177,6 @@ export const canvasSlice = createSlice({
],
};
state.futureLayerStates = [];
state.batchIds = [];
const newScale = calculateScale(
stageDimensions.width,
@ -237,12 +233,6 @@ export const canvasSlice = createSlice({
setShouldShowBoundingBox: (state, action: PayloadAction<boolean>) => {
state.shouldShowBoundingBox = action.payload;
},
canvasBatchIdAdded: (state, action: PayloadAction<string>) => {
state.batchIds.push(action.payload);
},
canvasBatchIdsReset: (state) => {
state.batchIds = [];
},
stagingAreaInitialized: (
state,
action: PayloadAction<{
@ -293,7 +283,6 @@ export const canvasSlice = createSlice({
state.futureLayerStates = [];
state.shouldShowStagingOutline = true;
state.shouldShowStagingImage = true;
state.batchIds = [];
},
addFillRect: (state) => {
const { boundingBoxCoordinates, boundingBoxDimensions, brushColor } = state;
@ -426,7 +415,6 @@ export const canvasSlice = createSlice({
state.pastLayerStates.push(cloneDeep(state.layerState));
state.layerState = cloneDeep(initialLayerState);
state.futureLayerStates = [];
state.batchIds = [];
state.boundingBoxCoordinates = {
...initialCanvasState.boundingBoxCoordinates,
};
@ -536,7 +524,6 @@ export const canvasSlice = createSlice({
state.futureLayerStates = [];
state.shouldShowStagingOutline = true;
state.shouldShowStagingImage = true;
state.batchIds = [];
},
setBoundingBoxScaleMethod: {
reducer: (state, action: PayloadActionWithOptimalDimension<BoundingBoxScaleMethod>) => {
@ -644,23 +631,6 @@ export const canvasSlice = createSlice({
optimalDimension
);
});
builder.addCase(socketQueueItemStatusChanged, (state, action) => {
const batch_status = action.payload.data.batch_status;
if (!state.batchIds.includes(batch_status.batch_id)) {
return;
}
if (batch_status.in_progress === 0 && batch_status.pending === 0) {
state.batchIds = state.batchIds.filter((id) => id !== batch_status.batch_id);
}
});
builder.addMatcher(queueApi.endpoints.clearQueue.matchFulfilled, (state) => {
state.batchIds = [];
});
builder.addMatcher(queueApi.endpoints.cancelByBatchIds.matchFulfilled, (state, action) => {
state.batchIds = state.batchIds.filter((id) => !action.meta.arg.originalArgs.batch_ids.includes(id));
});
},
});
@ -713,8 +683,6 @@ export const {
stagingAreaInitialized,
setShouldAntialias,
canvasResized,
canvasBatchIdAdded,
canvasBatchIdsReset,
aspectRatioChanged,
scaledBoundingBoxDimensionsReset,
} = canvasSlice.actions;
@ -736,3 +704,5 @@ export const canvasPersistConfig: PersistConfig<CanvasState> = {
migrate: migrateCanvasState,
persistDenylist: [],
};
export const matchAnyStagingAreaDismissed = isAnyOf(commitStagingAreaImage, discardStagedImages);

View File

@ -142,7 +142,6 @@ export interface CanvasState {
stageDimensions: Dimensions;
stageScale: number;
generationMode?: GenerationMode;
batchIds: string[];
aspectRatio: AspectRatioState;
}

View File

@ -12,14 +12,19 @@ import type {
} from '@dnd-kit/core';
import type { BoardId } from 'features/gallery/store/types';
import type { FieldInputInstance, FieldInputTemplate } from 'features/nodes/types/field';
import type { ViewerMode } from 'features/ui/store/uiTypes';
import type { ImageDTO } from 'services/api/types';
type BaseDropData = {
id: string;
};
export type CurrentImageDropData = BaseDropData & {
actionType: 'SET_CURRENT_IMAGE';
export type ViewerImageDropData = BaseDropData & {
actionType: 'SET_VIEWER_IMAGE';
context: {
viewerMode: ViewerMode;
currentImageName?: string | null;
};
};
export type InitialImageDropData = BaseDropData & {
@ -59,13 +64,13 @@ export type RemoveFromBoardDropData = BaseDropData & {
};
export type TypesafeDroppableData =
| CurrentImageDropData
| InitialImageDropData
| ControlAdapterDropData
| CanvasInitialImageDropData
| NodesImageDropData
| AddToBoardDropData
| RemoveFromBoardDropData;
| RemoveFromBoardDropData
| ViewerImageDropData;
type BaseDragData = {
id: string;

View File

@ -13,8 +13,11 @@ export const isValidDrop = (overData: TypesafeDroppableData | undefined, active:
}
switch (actionType) {
case 'SET_CURRENT_IMAGE':
return payloadType === 'IMAGE_DTO';
case 'SET_VIEWER_IMAGE':
return (
payloadType === 'IMAGE_DTO' &&
overData.context.currentImageName !== active.data.current.payload.imageDTO.image_name
);
case 'SET_INITIAL_IMAGE':
return payloadType === 'IMAGE_DTO';
case 'SET_CONTROL_ADAPTER_IMAGE':

View File

@ -1,295 +0,0 @@
import { ButtonGroup, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppToaster } from 'app/components/Toaster';
import { upscaleRequested } from 'app/store/middleware/listenerMiddleware/listeners/upscaleRequested';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
import { sentImageToImg2Img } from 'features/gallery/store/actions';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
import ParamUpscalePopover from 'features/parameters/components/Upscale/ParamUpscaleSettings';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import { useIsQueueMutationInProgress } from 'features/queue/hooks/useIsQueueMutationInProgress';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectSystemSlice } from 'features/system/store/systemSlice';
import { setShouldShowImageDetails, setShouldShowProgressInViewer } from 'features/ui/store/uiSlice';
import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiArrowsCounterClockwiseBold,
PiAsteriskBold,
PiDotsThreeOutlineFill,
PiFlowArrowBold,
PiHourglassHighBold,
PiInfoBold,
PiPlantBold,
PiQuotesBold,
PiRulerBold,
} from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
const selectShouldDisableToolbarButtons = createSelector(
selectSystemSlice,
selectGallerySlice,
selectLastSelectedImage,
(system, gallery, lastSelectedImage) => {
const hasProgressImage = Boolean(system.denoiseProgress?.progress_image);
return hasProgressImage || !lastSelectedImage;
}
);
const CurrentImageButtons = () => {
const dispatch = useAppDispatch();
const isConnected = useAppSelector((s) => s.system.isConnected);
const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails);
const shouldShowProgressInViewer = useAppSelector((s) => s.ui.shouldShowProgressInViewer);
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
const shouldDisableToolbarButtons = useAppSelector(selectShouldDisableToolbarButtons);
const isUpscalingEnabled = useFeatureStatus('upscaling').isFeatureEnabled;
const isQueueMutationInProgress = useIsQueueMutationInProgress();
const toaster = useAppToaster();
const { t } = useTranslation();
const { recallBothPrompts, recallSeed, recallWidthAndHeight, recallAllParameters } = useRecallParameters();
const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken);
const { metadata, isLoading: isLoadingMetadata } = useDebouncedMetadata(lastSelectedImage?.image_name);
const { getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult } = useGetAndLoadEmbeddedWorkflow({});
const handleLoadWorkflow = useCallback(() => {
if (!lastSelectedImage || !lastSelectedImage.has_workflow) {
return;
}
getAndLoadEmbeddedWorkflow(lastSelectedImage.image_name);
}, [getAndLoadEmbeddedWorkflow, lastSelectedImage]);
useHotkeys('w', handleLoadWorkflow, [lastSelectedImage]);
const handleClickUseAllParameters = useCallback(() => {
recallAllParameters(metadata);
}, [metadata, recallAllParameters]);
useHotkeys('a', handleClickUseAllParameters, [metadata]);
const handleUseSeed = useCallback(() => {
recallSeed(metadata?.seed);
}, [metadata?.seed, recallSeed]);
useHotkeys('s', handleUseSeed, [metadata]);
const handleUsePrompt = useCallback(() => {
recallBothPrompts(
metadata?.positive_prompt,
metadata?.negative_prompt,
metadata?.positive_style_prompt,
metadata?.negative_style_prompt
);
}, [
metadata?.negative_prompt,
metadata?.positive_prompt,
metadata?.positive_style_prompt,
metadata?.negative_style_prompt,
recallBothPrompts,
]);
useHotkeys('p', handleUsePrompt, [metadata]);
const handleRemixImage = useCallback(() => {
// Recalls all metadata parameters except seed
recallAllParameters({
...metadata,
seed: undefined,
});
}, [metadata, recallAllParameters]);
useHotkeys('r', handleRemixImage, [metadata]);
const handleUseSize = useCallback(() => {
recallWidthAndHeight(metadata?.width, metadata?.height);
}, [metadata?.width, metadata?.height, recallWidthAndHeight]);
useHotkeys('d', handleUseSize, [metadata]);
const handleSendToImageToImage = useCallback(() => {
dispatch(sentImageToImg2Img());
dispatch(initialImageSelected(imageDTO));
}, [dispatch, imageDTO]);
useHotkeys('shift+i', handleSendToImageToImage, [imageDTO]);
const handleClickUpscale = useCallback(() => {
if (!imageDTO) {
return;
}
dispatch(upscaleRequested({ imageDTO }));
}, [dispatch, imageDTO]);
const handleDelete = useCallback(() => {
if (!imageDTO) {
return;
}
dispatch(imagesToDeleteSelected([imageDTO]));
}, [dispatch, imageDTO]);
useHotkeys(
'Shift+U',
() => {
handleClickUpscale();
},
{
enabled: () => Boolean(isUpscalingEnabled && !shouldDisableToolbarButtons && isConnected),
},
[isUpscalingEnabled, imageDTO, shouldDisableToolbarButtons, isConnected]
);
const handleClickShowImageDetails = useCallback(
() => dispatch(setShouldShowImageDetails(!shouldShowImageDetails)),
[dispatch, shouldShowImageDetails]
);
useHotkeys(
'i',
() => {
if (imageDTO) {
handleClickShowImageDetails();
} else {
toaster({
title: t('toast.metadataLoadFailed'),
status: 'error',
duration: 2500,
isClosable: true,
});
}
},
[imageDTO, shouldShowImageDetails, toaster]
);
useHotkeys(
'delete',
() => {
handleDelete();
},
[dispatch, imageDTO]
);
const handleClickProgressImagesToggle = useCallback(() => {
dispatch(setShouldShowProgressInViewer(!shouldShowProgressInViewer));
}, [dispatch, shouldShowProgressInViewer]);
return (
<>
<Flex flexWrap="wrap" justifyContent="center" alignItems="center" gap={2}>
<ButtonGroup isDisabled={shouldDisableToolbarButtons}>
<Menu isLazy>
<MenuButton
as={IconButton}
aria-label={t('parameters.imageActions')}
tooltip={t('parameters.imageActions')}
isDisabled={!imageDTO}
icon={<PiDotsThreeOutlineFill />}
/>
<MenuList>{imageDTO && <SingleSelectionMenuItems imageDTO={imageDTO} />}</MenuList>
</Menu>
</ButtonGroup>
<ButtonGroup isDisabled={shouldDisableToolbarButtons}>
<IconButton
icon={<PiFlowArrowBold />}
tooltip={`${t('nodes.loadWorkflow')} (W)`}
aria-label={`${t('nodes.loadWorkflow')} (W)`}
isDisabled={!imageDTO?.has_workflow}
onClick={handleLoadWorkflow}
isLoading={getAndLoadEmbeddedWorkflowResult.isLoading}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiArrowsCounterClockwiseBold />}
tooltip={`${t('parameters.remixImage')} (R)`}
aria-label={`${t('parameters.remixImage')} (R)`}
isDisabled={!metadata?.positive_prompt}
onClick={handleRemixImage}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiQuotesBold />}
tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={!metadata?.positive_prompt}
onClick={handleUsePrompt}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiPlantBold />}
tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={metadata?.seed === null || metadata?.seed === undefined}
onClick={handleUseSeed}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiRulerBold />}
tooltip={`${t('parameters.useSize')} (D)`}
aria-label={`${t('parameters.useSize')} (D)`}
isDisabled={
metadata?.height === null ||
metadata?.height === undefined ||
metadata?.width === null ||
metadata?.width === undefined
}
onClick={handleUseSize}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiAsteriskBold />}
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={!metadata}
onClick={handleClickUseAllParameters}
/>
</ButtonGroup>
{isUpscalingEnabled && (
<ButtonGroup isDisabled={isQueueMutationInProgress}>
{isUpscalingEnabled && <ParamUpscalePopover imageDTO={imageDTO} />}
</ButtonGroup>
)}
<ButtonGroup>
<IconButton
icon={<PiInfoBold />}
tooltip={`${t('parameters.info')} (I)`}
aria-label={`${t('parameters.info')} (I)`}
isChecked={shouldShowImageDetails}
onClick={handleClickShowImageDetails}
/>
</ButtonGroup>
<ButtonGroup>
<IconButton
aria-label={t('settings.displayInProgress')}
tooltip={t('settings.displayInProgress')}
icon={<PiHourglassHighBold />}
isChecked={shouldShowProgressInViewer}
onClick={handleClickProgressImagesToggle}
/>
</ButtonGroup>
<ButtonGroup>
<DeleteImageButton onClick={handleDelete} />
</ButtonGroup>
</Flex>
</>
);
};
export default memo(CurrentImageButtons);

View File

@ -1,24 +0,0 @@
import { Flex } from '@invoke-ai/ui-library';
import { memo } from 'react';
import CurrentImageButtons from './CurrentImageButtons';
import CurrentImagePreview from './CurrentImagePreview';
const CurrentImageDisplay = () => {
return (
<Flex
position="relative"
flexDirection="column"
height="100%"
width="100%"
rowGap={4}
alignItems="center"
justifyContent="center"
>
<CurrentImageButtons />
<CurrentImagePreview />
</Flex>
);
};
export default memo(CurrentImageDisplay);

View File

@ -1,129 +0,0 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import IAIDndImage from 'common/components/IAIDndImage';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
import ProgressImage from 'features/gallery/components/CurrentImage/ProgressImage';
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImageBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const selectLastSelectedImageName = createSelector(
selectLastSelectedImage,
(lastSelectedImage) => lastSelectedImage?.image_name
);
const CurrentImagePreview = () => {
const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails);
const imageName = useAppSelector(selectLastSelectedImageName);
const hasDenoiseProgress = useAppSelector((s) => Boolean(s.system.denoiseProgress));
const shouldShowProgressInViewer = useAppSelector((s) => s.ui.shouldShowProgressInViewer);
const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken);
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
if (imageDTO) {
return {
id: 'current-image',
payloadType: 'IMAGE_DTO',
payload: { imageDTO },
};
}
}, [imageDTO]);
const droppableData = useMemo<TypesafeDroppableData | undefined>(
() => ({
id: 'current-image',
actionType: 'SET_CURRENT_IMAGE',
}),
[]
);
// Show and hide the next/prev buttons on mouse move
const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState<boolean>(false);
const timeoutId = useRef(0);
const { t } = useTranslation();
const handleMouseOver = useCallback(() => {
setShouldShowNextPrevButtons(true);
window.clearTimeout(timeoutId.current);
}, []);
const handleMouseOut = useCallback(() => {
timeoutId.current = window.setTimeout(() => {
setShouldShowNextPrevButtons(false);
}, 500);
}, []);
return (
<Flex
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
width="full"
height="full"
alignItems="center"
justifyContent="center"
position="relative"
>
{hasDenoiseProgress && shouldShowProgressInViewer ? (
<ProgressImage />
) : (
<IAIDndImage
imageDTO={imageDTO}
droppableData={droppableData}
draggableData={draggableData}
isUploadDisabled={true}
fitContainer
useThumbailFallback
dropLabel={t('gallery.setCurrentImage')}
noContentFallback={<IAINoContentFallback icon={PiImageBold} label={t('gallery.noImageSelected')} />}
dataTestId="image-preview"
/>
)}
{shouldShowImageDetails && imageDTO && (
<Box position="absolute" top="0" width="full" height="full" borderRadius="base">
<ImageMetadataViewer image={imageDTO} />
</Box>
)}
<AnimatePresence>
{!shouldShowImageDetails && imageDTO && shouldShowNextPrevButtons && (
<motion.div key="nextPrevButtons" initial={initial} animate={animate} exit={exit} style={motionStyles}>
<NextPrevImageButtons />
</motion.div>
)}
</AnimatePresence>
</Flex>
);
};
export default memo(CurrentImagePreview);
const initial: AnimationProps['initial'] = {
opacity: 0,
};
const animate: AnimationProps['animate'] = {
opacity: 1,
transition: { duration: 0.1 },
};
const exit: AnimationProps['exit'] = {
opacity: 0,
transition: { duration: 0.1 },
};
const motionStyles: CSSProperties = {
position: 'absolute',
top: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
};

View File

@ -1,38 +0,0 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Image } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { memo, useMemo } from 'react';
const CurrentImagePreview = () => {
const progress_image = useAppSelector((s) => s.system.denoiseProgress?.progress_image);
const shouldAntialiasProgressImage = useAppSelector((s) => s.system.shouldAntialiasProgressImage);
const sx = useMemo<SystemStyleObject>(
() => ({
imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated',
}),
[shouldAntialiasProgressImage]
);
if (!progress_image) {
return null;
}
return (
<Image
src={progress_image.dataURL}
width={progress_image.width}
height={progress_image.height}
draggable={false}
data-testid="progress-image"
objectFit="contain"
maxWidth="full"
maxHeight="full"
position="absolute"
borderRadius="base"
sx={sx}
/>
);
};
export default memo(CurrentImagePreview);

View File

@ -28,12 +28,11 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
<Flex
layerStyle="first"
padding={4}
gap={1}
gap={4}
flexDirection="column"
width="full"
height="full"
borderRadius="base"
position="absolute"
overflow="hidden"
>
<ExternalLink href={image.image_url} label={image.image_name} />

View File

@ -5,7 +5,7 @@ import { imageListContainerTestId } from 'features/gallery/components/ImageGrid/
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imageSelectionChanged } from 'features/gallery/store/gallerySlice';
import { getIsVisible } from 'features/gallery/util/getIsVisible';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { clamp } from 'lodash-es';
@ -144,7 +144,7 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
if (!image || index === lastSelectedImageIndex) {
return;
}
dispatch(imageSelected(image));
dispatch(imageSelectionChanged(image));
scrollToImage(image.image_name, index);
},
[dispatch, lastSelectedImageIndex, data]

View File

@ -1,7 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectGallerySlice, selectionChanged } from 'features/gallery/store/gallerySlice';
import { galleryImageClicked, imageSelectionChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import type { MouseEvent } from 'react';
import { useCallback, useMemo } from 'react';
@ -26,7 +25,7 @@ export const useMultiselect = (imageDTO?: ImageDTO) => {
return;
}
if (!isMultiSelectEnabled) {
dispatch(selectionChanged([imageDTO]));
dispatch(imageSelectionChanged(imageDTO));
return;
}

View File

@ -1,5 +1,5 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice, isAnyOf } from '@reduxjs/toolkit';
import { createAction, createSlice, isAnyOf } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { uniqBy } from 'lodash-es';
import { boardsApi } from 'services/api/endpoints/boards';
@ -26,11 +26,16 @@ export const gallerySlice = createSlice({
name: 'gallery',
initialState: initialGalleryState,
reducers: {
imageSelected: (state, action: PayloadAction<ImageDTO | null>) => {
state.selection = action.payload ? [action.payload] : [];
},
selectionChanged: (state, action: PayloadAction<ImageDTO[]>) => {
imageSelectionChanged: (state, action: PayloadAction<ImageDTO[] | ImageDTO | null>) => {
if (!action.payload) {
state.selection = [];
return;
}
if (Array.isArray(action.payload)) {
state.selection = uniqBy(action.payload, (i) => i.image_name);
return;
}
state.selection = [action.payload];
},
shouldAutoSwitchChanged: (state, action: PayloadAction<boolean>) => {
state.shouldAutoSwitch = action.payload;
@ -97,14 +102,13 @@ export const gallerySlice = createSlice({
});
export const {
imageSelected,
shouldAutoSwitchChanged,
autoAssignBoardOnClickChanged,
setGalleryImageMinimumWidth,
boardIdSelected,
autoAddBoardIdChanged,
galleryViewChanged,
selectionChanged,
imageSelectionChanged,
boardSearchTextChanged,
moreImagesLoaded,
} = gallerySlice.actions;
@ -130,3 +134,10 @@ export const galleryPersistConfig: PersistConfig<GalleryState> = {
migrate: migrateGalleryState,
persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'offset', 'limit'],
};
export const galleryImageClicked = createAction<{
imageDTO: ImageDTO;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}>(`${gallerySlice.name}/galleryImageClicked`);

View File

@ -38,7 +38,7 @@ const CurrentImageNode = (props: NodeProps) => {
if (imageDTO) {
return (
<Wrapper nodeProps={props}>
<IAIDndImage imageDTO={imageDTO} isDragDisabled useThumbailFallback />
<IAIDndImage imageDTO={imageDTO} isDragDisabled fallbackSrc={imageDTO?.thumbnail_url} />
</Wrapper>
);
}

View File

@ -71,7 +71,7 @@ const ImageFieldInputComponent = (props: FieldComponentProps<ImageFieldInputInst
droppableData={droppableData}
draggableData={draggableData}
postUploadAction={postUploadAction}
useThumbailFallback
fallbackSrc={imageDTO?.thumbnail_url}
uploadElement={<UploadElement />}
dropLabel={<DropLabel />}
minSize={8}

View File

@ -0,0 +1,13 @@
# Progress
We have 3 different places to display progress images:
- TextToImage & ImageToImage
- Canvas
- Workflow
The progress slice tracks the latest denoising progress events, latest image output, and active batch ids for each of the workspaces.
Each of these have different requirements for displaying progress images, but much of the logic around tracking progress is the same, so it is consolidated here.
It also holds the latest progress event separately, which is used for the progress bar.

View File

@ -0,0 +1,29 @@
import { Progress } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useIsProcessing } from 'features/queue/hooks/useIsProcessing';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
const ProgressBar = () => {
const { t } = useTranslation();
const isConnected = useAppSelector((s) => s.system.isConnected);
const isProcessing = useIsProcessing();
const hasSteps = useAppSelector((s) => isProcessing && Boolean(s.progress.currentDenoiseProgress));
const value = useAppSelector((s) => (isProcessing ? (s.progress.currentDenoiseProgress?.percentage ?? 0) * 100 : 0));
const isIndeterminate = useMemo(() => {
return isConnected && isProcessing && !hasSteps;
}, [hasSteps, isConnected, isProcessing]);
return (
<Progress
value={value}
aria-label={t('accessibility.invokeProgressBar')}
isIndeterminate={isIndeterminate}
h={2}
w="full"
colorScheme="invokeBlue"
/>
);
};
export default memo(ProgressBar);

View File

@ -0,0 +1,147 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice, isAnyOf } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import {
commitStagingAreaImage,
discardStagedImages,
resetCanvas,
setInitialCanvasImage,
} from 'features/canvas/store/canvasSlice';
import type { DenoiseProgress } from 'features/progress/store/types';
import { calculateStepPercentage } from 'features/system/util/calculateStepPercentage';
import { queueApi } from 'services/api/endpoints/queue';
import type { ImageDTO } from 'services/api/types';
import {
socketConnected,
socketDisconnected,
socketGeneratorProgress,
socketInvocationComplete,
socketQueueItemStatusChanged,
} from 'services/events/actions';
import type { GeneratorProgressEvent, InvocationCompleteEvent } from 'services/events/types';
export type ProgressTab = 'linear' | 'canvas' | 'workflow';
export type LatestImageOutputEvent = InvocationCompleteEvent & {
image_name: string;
};
export type ProgressState = {
_version: 1;
/**
* The current denoise progress. Only has a value if denoising is in progress.
*/
currentDenoiseProgress: DenoiseProgress | null;
/**
* The latest denoise progress, regardless of tab.
*/
latestDenoiseProgress: DenoiseProgress | null;
/**
* The latest image output event.
*/
latestImageOutputEvent: LatestImageOutputEvent | null;
/**
* Batch ids for batches initiated on the canvas tab.
*/
canvasBatchIds: string[];
};
export const initialProgressState: ProgressState = {
_version: 1,
currentDenoiseProgress: null,
latestDenoiseProgress: null,
latestImageOutputEvent: null,
canvasBatchIds: [],
};
export const progressSlice = createSlice({
name: 'progress',
initialState: initialProgressState,
reducers: {
canvasBatchEnqueued: (state, action: PayloadAction<string>) => {
state.canvasBatchIds.push(action.payload);
},
imageInvocationComplete: (state, action: PayloadAction<{ data: InvocationCompleteEvent; imageDTO: ImageDTO }>) => {
const { data, imageDTO } = action.payload;
state.latestImageOutputEvent = { ...data, image_name: imageDTO.image_name };
},
latestImageLoaded: () => {
// state.latestDenoiseProgress = null;
},
},
extraReducers(builder) {
builder.addCase(socketConnected, (state) => {
state.latestDenoiseProgress = null;
state.latestImageOutputEvent = null;
});
builder.addCase(socketDisconnected, (state) => {
state.latestDenoiseProgress = null;
state.latestImageOutputEvent = null;
});
builder.addCase(socketGeneratorProgress, (state, action) => {
const denoiseProgress = buildDenoiseProgress(action.payload.data);
state.latestDenoiseProgress = denoiseProgress;
state.currentDenoiseProgress = denoiseProgress;
});
builder.addCase(socketInvocationComplete, (state) => {
state.currentDenoiseProgress = null;
});
builder.addCase(socketQueueItemStatusChanged, (state, action) => {
state.currentDenoiseProgress = null;
const { status } = action.payload.data.queue_item;
if (status === 'canceled' || status === 'failed') {
state.latestDenoiseProgress = null;
}
});
builder.addMatcher(
isAnyOf(commitStagingAreaImage, discardStagedImages, resetCanvas, setInitialCanvasImage),
(state) => {
state.canvasBatchIds = [];
}
);
builder.addMatcher(queueApi.endpoints.clearQueue.matchFulfilled, (state) => {
// When the queue is cleared, all progress is cleared
state.latestDenoiseProgress = null;
});
builder.addMatcher(queueApi.endpoints.cancelByBatchIds.matchFulfilled, (state, action) => {
// When a batch is canceled, remove it from the list of batch ids and clear its progress if it is stored.
const canceled_batch_ids = action.meta.arg.originalArgs.batch_ids;
if (state.latestDenoiseProgress && canceled_batch_ids.includes(state.latestDenoiseProgress.queue_batch_id)) {
state.latestDenoiseProgress = null;
}
});
},
});
export const { imageInvocationComplete, canvasBatchEnqueued, latestImageLoaded } = progressSlice.actions;
export const selectProgressSlice = (state: RootState) => state.progress;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateProgressState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};
const buildDenoiseProgress = (data: GeneratorProgressEvent): DenoiseProgress => ({
...data,
percentage: calculateStepPercentage(data.step, data.total_steps, data.order),
});
export const progressPersistConfig: PersistConfig<ProgressState> = {
name: progressSlice.name,
initialState: initialProgressState,
migrate: migrateProgressState,
persistDenylist: ['currentDenoiseProgress', 'latestDenoiseProgress', 'latestImageOutputEvent'],
};

View File

@ -0,0 +1,7 @@
import type { GeneratorProgressEvent } from 'services/events/types';
export type SystemStatus = 'CONNECTED' | 'DISCONNECTED' | 'PROCESSING' | 'ERROR' | 'LOADING_MODEL';
export type DenoiseProgress = GeneratorProgressEvent & {
percentage: number;
};

View File

@ -1,7 +1,7 @@
import { ButtonGroup, Flex, Spacer } from '@invoke-ai/ui-library';
import ProgressBar from 'features/progress/components/ProgressBar';
import ClearQueueIconButton from 'features/queue/components/ClearQueueIconButton';
import QueueFrontButton from 'features/queue/components/QueueFrontButton';
import ProgressBar from 'features/system/components/ProgressBar';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';

View File

@ -0,0 +1,13 @@
import { useMemo } from 'react';
import { useGetQueueStatusQuery } from 'services/api/endpoints/queue';
export const useIsProcessing = () => {
const { currentData } = useGetQueueStatusQuery();
const isProcessing = useMemo(() => {
if ((currentData?.processor.is_processing && currentData?.queue.pending) || currentData?.queue.in_progress) {
return true;
}
return false;
}, [currentData?.processor.is_processing, currentData?.queue.in_progress, currentData?.queue.pending]);
return isProcessing;
};

View File

@ -1,33 +0,0 @@
import { Progress } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectSystemSlice } from 'features/system/store/systemSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetQueueStatusQuery } from 'services/api/endpoints/queue';
const selectProgressValue = createSelector(
selectSystemSlice,
(system) => (system.denoiseProgress?.percentage ?? 0) * 100
);
const ProgressBar = () => {
const { t } = useTranslation();
const { data: queueStatus } = useGetQueueStatusQuery();
const isConnected = useAppSelector((s) => s.system.isConnected);
const hasSteps = useAppSelector((s) => Boolean(s.system.denoiseProgress));
const value = useAppSelector(selectProgressValue);
return (
<Progress
value={value}
aria-label={t('accessibility.invokeProgressBar')}
isIndeterminate={isConnected && Boolean(queueStatus?.queue.in_progress) && !hasSteps}
h={2}
w="full"
colorScheme="invokeBlue"
/>
);
};
export default memo(ProgressBar);

View File

@ -4,13 +4,13 @@ import CancelCurrentQueueItemIconButton from 'features/queue/components/CancelCu
import ClearQueueConfirmationAlertDialog from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { ClearAllQueueIconButton } from 'features/queue/components/ClearQueueIconButton';
import { QueueButtonTooltip } from 'features/queue/components/QueueButtonTooltip';
import { useIsProcessing } from 'features/queue/hooks/useIsProcessing';
import { useQueueBack } from 'features/queue/hooks/useQueueBack';
import type { UsePanelReturn } from 'features/ui/hooks/usePanel';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCircleNotchBold, PiSlidersHorizontalBold } from 'react-icons/pi';
import { RiSparklingFill } from 'react-icons/ri';
import { useGetQueueStatusQuery } from 'services/api/endpoints/queue';
const floatingButtonStyles: SystemStyleObject = {
borderStartRadius: 0,
@ -24,16 +24,16 @@ type Props = {
const FloatingSidePanelButtons = (props: Props) => {
const { t } = useTranslation();
const { queueBack, isLoading, isDisabled } = useQueueBack();
const { data: queueStatus } = useGetQueueStatusQuery();
const isProcessing = useIsProcessing();
const queueButtonIcon = useMemo(
() =>
!isDisabled && queueStatus?.processor.is_processing ? (
!isDisabled && isProcessing ? (
<Icon boxSize={6} as={PiCircleNotchBold} animation={spinAnimation} />
) : (
<RiSparklingFill size="16px" />
),
[isDisabled, queueStatus?.processor.is_processing]
[isDisabled, isProcessing]
);
const disclosure = useDisclosure();

View File

@ -1,13 +1,11 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay';
import { Box } from '@invoke-ai/ui-library';
import { Viewer } from 'features/viewer/components/Viewer';
import { memo } from 'react';
const TextToImageTab = () => {
return (
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
<Flex w="full" h="full">
<CurrentImageDisplay />
</Flex>
<Viewer />
</Box>
);
};

View File

@ -1,10 +1,11 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { galleryImageClicked } from 'features/gallery/store/gallerySlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import type { InvokeTabName } from './tabMap';
import type { UIState } from './uiTypes';
import type { UIState, ViewerMode } from './uiTypes';
export const initialUIState: UIState = {
_version: 1,
@ -14,6 +15,7 @@ export const initialUIState: UIState = {
panels: {},
accordions: {},
expanders: {},
viewerMode: 'image',
};
export const uiSlice = createSlice({
@ -40,11 +42,22 @@ export const uiSlice = createSlice({
const { id, isOpen } = action.payload;
state.expanders[id] = isOpen;
},
viewerModeChanged: (state, action: PayloadAction<ViewerMode>) => {
state.viewerMode = action.payload;
},
},
extraReducers(builder) {
builder.addCase(initialImageChanged, (state) => {
state.activeTab = 'img2img';
});
builder.addCase(galleryImageClicked, (state) => {
// When a gallery image is clicked and we are in progress mode, switch to image mode.
// This is not the same as the gallery _selection_ being changed.
if (state.viewerMode === 'progress') {
state.viewerMode = 'image';
}
});
},
});
@ -55,6 +68,7 @@ export const {
panelsChanged,
accordionStateChanged,
expanderStateChanged,
viewerModeChanged,
} = uiSlice.actions;
export const selectUiSlice = (state: RootState) => state.ui;

View File

@ -29,4 +29,10 @@ export interface UIState {
* The state of expanders. The key is the id of the expander, and the value is a boolean representing the open state.
*/
expanders: Record<string, boolean>;
/**
* The currently-selected viewer mode.
*/
viewerMode: ViewerMode;
}
export type ViewerMode = 'image' | 'info' | 'progress';

View File

@ -0,0 +1,9 @@
# Viewer
The viewer is the main panel in the `TextToImage` and `ImageToImage` tabs.
It has 3 modes:
- Image: Displays the currently selected image
- Info: Displays info for the currently selected image
- Progress: Displays generation progress, and eventually the image associated with that progress (if there was one)

View File

@ -0,0 +1,26 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { ViewerDroppable } from 'features/viewer/components/ViewerDroppable';
import { ViewerImage } from 'features/viewer/components/ViewerImage';
import { ViewerInfo } from 'features/viewer/components/ViewerInfo';
import { ViewerProgress } from 'features/viewer/components/ViewerProgress';
import { ViewerToolbar } from 'features/viewer/components/ViewerToolbar';
import { memo } from 'react';
export const Viewer = memo(() => {
const viewerMode = useAppSelector((s) => s.ui.viewerMode);
return (
<Flex position="relative" flexDirection="column" height="full" width="full" gap={4}>
<ViewerToolbar />
<Flex height="full" width="full" alignItems="center" justifyContent="center">
{viewerMode === 'image' && <ViewerImage />}
{viewerMode === 'info' && <ViewerInfo />}
{viewerMode === 'progress' && <ViewerProgress />}
</Flex>
<ViewerDroppable />
</Flex>
);
});
Viewer.displayName = 'Viewer';

View File

@ -0,0 +1,33 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import IAIDroppable from 'common/components/IAIDroppable';
import type { ViewerImageDropData } from 'features/dnd/types';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
const selectLastSelectedImageName = createSelector(
selectLastSelectedImage,
(lastSelectedImage) => lastSelectedImage?.image_name
);
export const ViewerDroppable = memo(() => {
const { t } = useTranslation();
const currentImageName = useAppSelector(selectLastSelectedImageName);
const viewerMode = useAppSelector((s) => s.ui.viewerMode);
const viewerDropData = useMemo<ViewerImageDropData>(
() => ({
id: 'viewer-image',
actionType: 'SET_VIEWER_IMAGE',
context: {
currentImageName,
viewerMode,
},
}),
[currentImageName, viewerMode]
);
return <IAIDroppable data={viewerDropData} dropLabel={t('viewer.dropLabel')} />;
});
ViewerDroppable.displayName = 'ViewerDroppable';

View File

@ -0,0 +1,48 @@
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import IAIDndImage from 'common/components/IAIDndImage';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import type { TypesafeDraggableData } from 'features/dnd/types';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImageBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const selectLastSelectedImageName = createSelector(
selectLastSelectedImage,
(lastSelectedImage) => lastSelectedImage?.image_name
);
export const ViewerImage = memo(() => {
const imageName = useAppSelector(selectLastSelectedImageName);
const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken);
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
if (imageDTO) {
return {
id: 'current-image',
payloadType: 'IMAGE_DTO',
payload: { imageDTO },
};
}
}, [imageDTO]);
const { t } = useTranslation();
return (
<IAIDndImage
imageDTO={imageDTO}
draggableData={draggableData}
isUploadDisabled={true}
fitContainer
fallbackSrc={imageDTO?.thumbnail_url}
noContentFallback={<IAINoContentFallback icon={PiImageBold} label={t('gallery.noImageSelected')} />}
dataTestId="image-preview"
/>
);
});
ViewerImage.displayName = 'ViewerImage';

View File

@ -0,0 +1,29 @@
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImageBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const selectLastSelectedImageName = createSelector(
selectLastSelectedImage,
(lastSelectedImage) => lastSelectedImage?.image_name
);
export const ViewerInfo = memo(() => {
const { t } = useTranslation();
const imageName = useAppSelector(selectLastSelectedImageName);
const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken);
if (!imageDTO) {
return <IAINoContentFallback icon={PiImageBold} label={t('gallery.noImageSelected')} />;
}
return <ImageMetadataViewer image={imageDTO} />;
});
ViewerInfo.displayName = 'ViewerInfo';

View File

@ -0,0 +1,36 @@
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { ViewerProgressLatestImage } from 'features/viewer/components/ViewerProgressLatestImage';
import { ViewerProgressLinearDenoiseProgress } from 'features/viewer/components/ViewerProgressLinearDenoiseProgress';
import { useLatestImageDTO } from 'features/viewer/hooks/useLatestImageDTO';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiHourglassBold } from 'react-icons/pi';
export const ViewerProgress = memo(() => {
const { t } = useTranslation();
const latestDenoiseProgress = useAppSelector((s) => {
if (!s.progress.latestDenoiseProgress) {
return null;
}
if (s.progress.canvasBatchIds.includes(s.progress.latestDenoiseProgress.queue_batch_id)) {
return null;
}
return s.progress.latestDenoiseProgress;
});
const latestImageDTO = useLatestImageDTO();
if (latestImageDTO) {
return <ViewerProgressLatestImage imageDTO={latestImageDTO} />;
}
if (latestDenoiseProgress?.progress_image) {
return <ViewerProgressLinearDenoiseProgress progressImage={latestDenoiseProgress.progress_image} />;
}
return <IAINoContentFallback icon={PiHourglassBold} label={t('viewer.noProgress')} />;
});
ViewerProgress.displayName = 'ViewerProgress';

View File

@ -0,0 +1,44 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIDndImage from 'common/components/IAIDndImage';
import type { TypesafeDraggableData } from 'features/dnd/types';
import { latestImageLoaded } from 'features/progress/store/progressSlice';
import { useProgressImageRenderingStyles } from 'features/viewer/hooks/useProgressImageRenderingStyles';
import { memo, useCallback, useMemo } from 'react';
import type { ImageDTO } from 'services/api/types';
type Props = {
imageDTO: ImageDTO;
};
export const ViewerProgressLatestImage = memo(({ imageDTO }: Props) => {
const dispatch = useAppDispatch();
const progressImageDataURL = useAppSelector((s) => s.progress.latestDenoiseProgress?.progress_image?.dataURL);
const sx = useProgressImageRenderingStyles();
const draggableData = useMemo<TypesafeDraggableData | undefined>(
() => ({
id: 'current-image',
payloadType: 'IMAGE_DTO',
payload: { imageDTO },
}),
[imageDTO]
);
const onLoad = useCallback(() => {
dispatch(latestImageLoaded());
}, [dispatch]);
return (
<IAIDndImage
imageDTO={imageDTO}
draggableData={draggableData}
isUploadDisabled={true}
fitContainer
fallbackSrc={progressImageDataURL}
dataTestId="progress-latest-image"
onLoad={onLoad}
sx={sx}
/>
);
});
ViewerProgressLatestImage.displayName = 'ViewerProgressLatestImage';

View File

@ -0,0 +1,30 @@
import { Image } from '@invoke-ai/ui-library';
import { useProgressImageRenderingStyles } from 'features/viewer/hooks/useProgressImageRenderingStyles';
import { memo } from 'react';
import type { ProgressImage } from 'services/events/types';
type Props = {
progressImage: ProgressImage;
};
export const ViewerProgressLinearDenoiseProgress = memo(({ progressImage }: Props) => {
const sx = useProgressImageRenderingStyles();
return (
<Image
src={progressImage.dataURL}
width={progressImage.width}
height={progressImage.height}
draggable={false}
data-testid="progress-image"
objectFit="contain"
maxWidth="full"
maxHeight="full"
position="absolute"
borderRadius="base"
sx={sx}
/>
);
});
ViewerProgressLinearDenoiseProgress.displayName = 'ViewerProgressLinearDenoiseProgress';

View File

@ -0,0 +1,23 @@
import { Flex } from '@invoke-ai/ui-library';
import { ViewerToolbarImageButtons } from 'features/viewer/components/ViewerToolbarImageButtons';
import { ViewerToolbarImageMenu } from 'features/viewer/components/ViewerToolbarImageMenu';
import { ViewerToolbarModeButtons } from 'features/viewer/components/ViewerToolbarModeButtons';
import { memo } from 'react';
export const ViewerToolbar = memo(() => {
return (
<Flex flexWrap="wrap" justifyContent="center" alignItems="center" gap={2} w="full">
<Flex flexGrow={1} flexWrap="wrap" justifyContent="flex-start" alignItems="center" gap={2}>
<ViewerToolbarImageMenu />
</Flex>
<Flex flexGrow={1} flexWrap="wrap" justifyContent="center" alignItems="center" gap={2}>
<ViewerToolbarImageButtons />
</Flex>
<Flex flexGrow={1} flexWrap="wrap" justifyContent="flex-end" alignItems="center" gap={2}>
<ViewerToolbarModeButtons />
</Flex>
</Flex>
);
});
ViewerToolbar.displayName = 'ViewerToolbar';

View File

@ -0,0 +1,204 @@
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { upscaleRequested } from 'app/store/middleware/listenerMiddleware/listeners/upscaleRequested';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import { sentImageToImg2Img } from 'features/gallery/store/actions';
import ParamUpscalePopover from 'features/parameters/components/Upscale/ParamUpscaleSettings';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import { useIsQueueMutationInProgress } from 'features/queue/hooks/useIsQueueMutationInProgress';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useCurrentImageDTO } from 'features/viewer/hooks/useCurrentImageDTO';
import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiArrowsCounterClockwiseBold,
PiAsteriskBold,
PiFlowArrowBold,
PiPlantBold,
PiQuotesBold,
PiRulerBold,
} from 'react-icons/pi';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
export const ViewerToolbarImageButtons = memo(() => {
const dispatch = useAppDispatch();
const isConnected = useAppSelector((s) => s.system.isConnected);
const isUpscalingEnabled = useFeatureStatus('upscaling').isFeatureEnabled;
const isQueueMutationInProgress = useIsQueueMutationInProgress();
const { t } = useTranslation();
const imageDTO = useCurrentImageDTO();
const { recallBothPrompts, recallSeed, recallWidthAndHeight, recallAllParameters } = useRecallParameters();
const { metadata, isLoading: isLoadingMetadata } = useDebouncedMetadata(imageDTO?.image_name);
const { getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult } = useGetAndLoadEmbeddedWorkflow({});
const handleLoadWorkflow = useCallback(() => {
if (!imageDTO || !imageDTO.has_workflow) {
return;
}
getAndLoadEmbeddedWorkflow(imageDTO.image_name);
}, [getAndLoadEmbeddedWorkflow, imageDTO]);
useHotkeys('w', handleLoadWorkflow, [imageDTO]);
const handleClickUseAllParameters = useCallback(() => {
recallAllParameters(metadata);
}, [metadata, recallAllParameters]);
useHotkeys('a', handleClickUseAllParameters, [metadata]);
const handleUseSeed = useCallback(() => {
recallSeed(metadata?.seed);
}, [metadata?.seed, recallSeed]);
useHotkeys('s', handleUseSeed, [metadata]);
const handleUsePrompt = useCallback(() => {
recallBothPrompts(
metadata?.positive_prompt,
metadata?.negative_prompt,
metadata?.positive_style_prompt,
metadata?.negative_style_prompt
);
}, [
metadata?.negative_prompt,
metadata?.positive_prompt,
metadata?.positive_style_prompt,
metadata?.negative_style_prompt,
recallBothPrompts,
]);
useHotkeys('p', handleUsePrompt, [metadata]);
const handleRemixImage = useCallback(() => {
// Recalls all metadata parameters except seed
recallAllParameters({
...metadata,
seed: undefined,
});
}, [metadata, recallAllParameters]);
useHotkeys('r', handleRemixImage, [metadata]);
const handleUseSize = useCallback(() => {
recallWidthAndHeight(metadata?.width, metadata?.height);
}, [metadata?.width, metadata?.height, recallWidthAndHeight]);
useHotkeys('d', handleUseSize, [metadata]);
const handleSendToImageToImage = useCallback(() => {
dispatch(sentImageToImg2Img());
dispatch(initialImageSelected(imageDTO));
}, [dispatch, imageDTO]);
useHotkeys('shift+i', handleSendToImageToImage, [imageDTO]);
const handleClickUpscale = useCallback(() => {
if (!imageDTO) {
return;
}
dispatch(upscaleRequested({ imageDTO }));
}, [dispatch, imageDTO]);
const handleDelete = useCallback(() => {
if (!imageDTO) {
return;
}
dispatch(imagesToDeleteSelected([imageDTO]));
}, [dispatch, imageDTO]);
useHotkeys(
'Shift+U',
() => {
handleClickUpscale();
},
{
enabled: () => Boolean(isUpscalingEnabled && imageDTO && isConnected),
},
[isUpscalingEnabled, imageDTO, imageDTO, isConnected]
);
useHotkeys(
'delete',
() => {
handleDelete();
},
[dispatch, imageDTO]
);
return (
<>
<ButtonGroup>
<IconButton
icon={<PiFlowArrowBold />}
tooltip={`${t('nodes.loadWorkflow')} (W)`}
aria-label={`${t('nodes.loadWorkflow')} (W)`}
isDisabled={!imageDTO || !imageDTO?.has_workflow}
onClick={handleLoadWorkflow}
isLoading={getAndLoadEmbeddedWorkflowResult.isLoading}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiArrowsCounterClockwiseBold />}
tooltip={`${t('parameters.remixImage')} (R)`}
aria-label={`${t('parameters.remixImage')} (R)`}
isDisabled={!imageDTO || !metadata?.positive_prompt}
onClick={handleRemixImage}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiQuotesBold />}
tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={!imageDTO || !metadata?.positive_prompt}
onClick={handleUsePrompt}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiPlantBold />}
tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={!imageDTO || metadata?.seed === null || metadata?.seed === undefined}
onClick={handleUseSeed}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiRulerBold />}
tooltip={`${t('parameters.useSize')} (D)`}
aria-label={`${t('parameters.useSize')} (D)`}
isDisabled={
!imageDTO ||
metadata?.height === null ||
metadata?.height === undefined ||
metadata?.width === null ||
metadata?.width === undefined
}
onClick={handleUseSize}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiAsteriskBold />}
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={!imageDTO || !metadata}
onClick={handleClickUseAllParameters}
/>
</ButtonGroup>
{isUpscalingEnabled && (
<ButtonGroup isDisabled={isQueueMutationInProgress || !imageDTO}>
{isUpscalingEnabled && <ParamUpscalePopover imageDTO={imageDTO} />}
</ButtonGroup>
)}
<DeleteImageButton onClick={handleDelete} isDisabled={!imageDTO} />
</>
);
});
ViewerToolbarImageButtons.displayName = 'ViewerToolbarImageButtons';

View File

@ -0,0 +1,26 @@
import { IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
import { useCurrentImageDTO } from 'features/viewer/hooks/useCurrentImageDTO';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDotsThreeOutlineFill } from 'react-icons/pi';
export const ViewerToolbarImageMenu = memo(() => {
const imageDTO = useCurrentImageDTO();
const { t } = useTranslation();
return (
<Menu isLazy>
<MenuButton
as={IconButton}
aria-label={t('parameters.imageActions')}
tooltip={t('parameters.imageActions')}
isDisabled={!imageDTO}
icon={<PiDotsThreeOutlineFill />}
/>
<MenuList>{imageDTO && <SingleSelectionMenuItems imageDTO={imageDTO} />}</MenuList>
</Menu>
);
});
ViewerToolbarImageMenu.displayName = 'ViewerToolbarImageMenu';

View File

@ -0,0 +1,64 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { ButtonGroup, IconButton, spinAnimation } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsProcessing } from 'features/queue/hooks/useIsProcessing';
import { viewerModeChanged } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiCircleNotchBold, PiEyeBold, PiHourglassMediumFill, PiInfoBold } from 'react-icons/pi';
const loadingStyles: SystemStyleObject = {
svg: { animation: spinAnimation },
};
export const ViewerToolbarModeButtons = memo(() => {
const dispatch = useAppDispatch();
const isProcessing = useIsProcessing();
const viewerMode = useAppSelector((s) => s.ui.viewerMode);
const { t } = useTranslation();
const handleSelectViewerImage = useCallback(() => {
dispatch(viewerModeChanged('image'));
}, [dispatch]);
// TODO: hotkey
const handleSelectViewerInfo = useCallback(() => {
dispatch(viewerModeChanged('info'));
}, [dispatch]);
useHotkeys('i', handleSelectViewerInfo, [handleSelectViewerInfo]);
const handleSelectViewerProgress = useCallback(() => {
dispatch(viewerModeChanged('progress'));
}, [dispatch]);
// TODO: hotkey
return (
<ButtonGroup>
<IconButton
icon={<PiEyeBold />}
tooltip={`${t('viewer.viewerModeImage')}`}
aria-label={`${t('viewer.viewerModeImage')}`}
isChecked={viewerMode === 'image'}
onClick={handleSelectViewerImage}
/>
<IconButton
icon={<PiInfoBold />}
tooltip={`${t('viewer.viewerModeInfo')} (I)`}
aria-label={`${t('viewer.viewerModeInfo')} (I)`}
isChecked={viewerMode === 'info'}
onClick={handleSelectViewerInfo}
/>
<IconButton
aria-label={`${t('viewer.viewerModeProgress')}`}
tooltip={`${t('viewer.viewerModeProgress')}`}
icon={isProcessing ? <PiCircleNotchBold /> : <PiHourglassMediumFill />}
isChecked={viewerMode === 'progress'}
onClick={handleSelectViewerProgress}
sx={isProcessing ? loadingStyles : undefined}
/>
</ButtonGroup>
);
});
ViewerToolbarModeButtons.displayName = 'ViewerToolbarModeButtons';

View File

@ -0,0 +1,19 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { selectLatestImageName } from 'features/viewer/hooks/useLatestImageDTO';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
/**
* The currently displayed image's DTO. If the viewer mode is 'progress', returns the latest image's DTO.
*/
export const useCurrentImageDTO = () => {
const latestImageName = useAppSelector(selectLatestImageName);
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
const viewerMode = useAppSelector((s) => s.ui.viewerMode);
const { currentData: imageDTO } = useGetImageDTOQuery(
(viewerMode === 'progress' ? latestImageName : lastSelectedImage?.image_name) ?? skipToken
);
return imageDTO;
};

View File

@ -0,0 +1,39 @@
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import { selectProgressSlice } from 'features/progress/store/progressSlice';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const selectLatestCanvasImageName = createSelector(selectProgressSlice, selectCanvasSlice, (progress, _canvas) => {
const { latestDenoiseProgress, latestImageOutputEvent } = progress;
if (!latestImageOutputEvent) {
return null;
}
if (!progress.canvasBatchIds.includes(latestImageOutputEvent.queue_batch_id)) {
return null;
}
if (
latestDenoiseProgress &&
latestDenoiseProgress.graph_execution_state_id === latestImageOutputEvent.graph_execution_state_id
) {
return latestImageOutputEvent.image_name;
}
if (!latestDenoiseProgress?.progress_image) {
return latestImageOutputEvent.image_name;
}
return null;
});
/**
* Returns the latest image's DTO. This is not the currently selected image, just the last image received.
*/
export const useLatestCanvasImageDTO = () => {
const latestImageName = useAppSelector(selectLatestCanvasImageName);
const { currentData: imageDTO } = useGetImageDTOQuery(latestImageName ?? skipToken);
return imageDTO;
};

View File

@ -0,0 +1,39 @@
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { selectProgressSlice } from 'features/progress/store/progressSlice';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
export const selectLatestImageName = createSelector(selectProgressSlice, (progress) => {
const { latestDenoiseProgress, latestImageOutputEvent, canvasBatchIds } = progress;
if (!latestImageOutputEvent) {
return null;
}
if (canvasBatchIds.includes(latestImageOutputEvent.queue_batch_id)) {
return null;
}
if (
latestDenoiseProgress &&
latestDenoiseProgress.graph_execution_state_id === latestImageOutputEvent.graph_execution_state_id
) {
return latestImageOutputEvent.image_name;
}
if (!latestDenoiseProgress?.progress_image) {
return latestImageOutputEvent.image_name;
}
return null;
});
/**
* Returns the latest image's DTO. This is not the currently selected image, just the last image received.
*/
export const useLatestImageDTO = () => {
const latestImageName = useAppSelector(selectLatestImageName);
const { currentData: imageDTO } = useGetImageDTOQuery(latestImageName ?? skipToken);
return imageDTO;
};

View File

@ -0,0 +1,16 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useMemo } from 'react';
export const useProgressImageRenderingStyles = () => {
const shouldAntialiasProgressImage = useAppSelector((s) => s.system.shouldAntialiasProgressImage);
const styles = useMemo<SystemStyleObject>(
() => ({
imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated',
}),
[shouldAntialiasProgressImage]
);
return styles;
};