From cfe86ec5417e855f22335819049a87640006caaa Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 4 Apr 2023 18:34:50 +1000 Subject: [PATCH] feat(ui): wip gallery migration --- invokeai/frontend/web/src/app/invokeai.d.ts | 10 +-- .../web/src/app/nodesSocketio/listeners.ts | 17 +++-- .../frontend/web/src/app/socketio/actions.ts | 10 ++- .../frontend/web/src/app/socketio/emitters.ts | 6 +- .../web/src/app/socketio/listeners.ts | 4 +- invokeai/frontend/web/src/app/store.ts | 5 +- .../web/src/common/util/buildNodes.ts | 13 ++-- .../src/features/canvas/store/canvasSlice.ts | 4 +- .../src/features/canvas/store/canvasTypes.ts | 4 +- .../store/thunks/mergeAndUploadCanvas.ts | 2 +- .../gallery/components/DeleteImageModal.tsx | 2 +- .../gallery/components/HoverableImage.tsx | 2 +- .../components/ImageGalleryContent.tsx | 63 +++++++++++++++---- .../ImageMetadataViewer.tsx | 2 +- .../features/gallery/store/gallerySlice.ts | 17 ++--- .../features/gallery/store/resultsSlice.ts | 28 +++------ .../features/gallery/store/uploadsSlice.ts | 60 ++++++++++++++++++ .../lightbox/components/ReactPanZoomImage.tsx | 2 +- .../parameters/store/generationSlice.ts | 4 +- .../web/src/services/invokeMiddleware.ts | 2 +- .../web/src/services/thunks/gallery.ts | 43 +++++-------- .../src/services/util/prepareResultImage.ts | 45 ------------- .../src/services/util/processImageField.ts | 46 ++++++++++++++ 23 files changed, 244 insertions(+), 147 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/store/uploadsSlice.ts delete mode 100644 invokeai/frontend/web/src/services/util/prepareResultImage.ts create mode 100644 invokeai/frontend/web/src/services/util/processImageField.ts diff --git a/invokeai/frontend/web/src/app/invokeai.d.ts b/invokeai/frontend/web/src/app/invokeai.d.ts index a8d0315ad7..6ebe05d7b5 100644 --- a/invokeai/frontend/web/src/app/invokeai.d.ts +++ b/invokeai/frontend/web/src/app/invokeai.d.ts @@ -113,7 +113,7 @@ export declare type Metadata = SystemGenerationMetadata & { }; // An Image has a UUID, url, modified timestamp, width, height and maybe metadata -export declare type Image = { +export declare type _Image = { uuid: string; url: string; thumbnail: string; @@ -130,7 +130,7 @@ export declare type Image = { /** * ResultImage */ -export declare type ResultImage = { +export declare type Image = { name: string; url: string; thumbnail: string; @@ -142,7 +142,7 @@ export declare type ResultImage = { // GalleryImages is an array of Image. export declare type GalleryImages = { - images: Array; + images: Array<_Image>; }; /** @@ -289,7 +289,7 @@ export declare type SystemStatusResponse = SystemStatus; export declare type SystemConfigResponse = SystemConfig; -export declare type ImageResultResponse = Omit & { +export declare type ImageResultResponse = Omit<_Image, 'uuid'> & { boundingBox?: IRect; generationMode: InvokeTabName; }; @@ -310,7 +310,7 @@ export declare type ErrorResponse = { }; export declare type GalleryImagesResponse = { - images: Array>; + images: Array>; areMoreImagesAvailable: boolean; category: GalleryCategory; }; diff --git a/invokeai/frontend/web/src/app/nodesSocketio/listeners.ts b/invokeai/frontend/web/src/app/nodesSocketio/listeners.ts index ec9074c17d..a58c8599a4 100644 --- a/invokeai/frontend/web/src/app/nodesSocketio/listeners.ts +++ b/invokeai/frontend/web/src/app/nodesSocketio/listeners.ts @@ -34,8 +34,11 @@ import { } from 'services/apiSlice'; import { emitUnsubscribe } from './actions'; import { resultAdded } from 'features/gallery/store/resultsSlice'; -import { getInitialResultsPage } from 'services/thunks/gallery'; -import { prepareResultImage } from 'services/util/prepareResultImage'; +import { + getNextResultsPage, + getNextUploadsPage, +} from 'services/thunks/gallery'; +import { processImageField } from 'services/util/processImageField'; /** * Returns an object containing listener callbacks @@ -54,10 +57,12 @@ const makeSocketIOListeners = ( dispatch(setIsConnected(true)); dispatch(setCurrentStatus(i18n.t('common.statusConnected'))); - // fetch more results, but only if we don't already have results - // maybe we should have a different thunk for `onConnect` vs when you click 'Load More'? + // fetch more images, but only if we don't already have images if (!getState().results.ids.length) { - dispatch(getInitialResultsPage()); + dispatch(getNextResultsPage()); + } + if (!getState().uploads.ids.length) { + dispatch(getNextUploadsPage()); } } catch (e) { console.error(e); @@ -94,7 +99,7 @@ const makeSocketIOListeners = ( try { const sessionId = data.graph_execution_state_id; if (data.result.type === 'image') { - const resultImage = prepareResultImage(data.result.image); + const resultImage = processImageField(data.result.image); dispatch(resultAdded(resultImage)); // // need to update the type for this or figure out how to get these values diff --git a/invokeai/frontend/web/src/app/socketio/actions.ts b/invokeai/frontend/web/src/app/socketio/actions.ts index 57758d1914..4907595c75 100644 --- a/invokeai/frontend/web/src/app/socketio/actions.ts +++ b/invokeai/frontend/web/src/app/socketio/actions.ts @@ -13,9 +13,13 @@ import { InvokeTabName } from 'features/ui/store/tabMap'; export const generateImage = createAction( 'socketio/generateImage' ); -export const runESRGAN = createAction('socketio/runESRGAN'); -export const runFacetool = createAction('socketio/runFacetool'); -export const deleteImage = createAction('socketio/deleteImage'); +export const runESRGAN = createAction('socketio/runESRGAN'); +export const runFacetool = createAction( + 'socketio/runFacetool' +); +export const deleteImage = createAction( + 'socketio/deleteImage' +); export const requestImages = createAction( 'socketio/requestImages' ); diff --git a/invokeai/frontend/web/src/app/socketio/emitters.ts b/invokeai/frontend/web/src/app/socketio/emitters.ts index 2aa1e03552..cd25319aee 100644 --- a/invokeai/frontend/web/src/app/socketio/emitters.ts +++ b/invokeai/frontend/web/src/app/socketio/emitters.ts @@ -91,7 +91,7 @@ const makeSocketIOEmitters = ( }) ); }, - emitRunESRGAN: (imageToProcess: InvokeAI.Image) => { + emitRunESRGAN: (imageToProcess: InvokeAI._Image) => { dispatch(setIsProcessing(true)); const { @@ -119,7 +119,7 @@ const makeSocketIOEmitters = ( }) ); }, - emitRunFacetool: (imageToProcess: InvokeAI.Image) => { + emitRunFacetool: (imageToProcess: InvokeAI._Image) => { dispatch(setIsProcessing(true)); const { @@ -150,7 +150,7 @@ const makeSocketIOEmitters = ( }) ); }, - emitDeleteImage: (imageToDelete: InvokeAI.Image) => { + emitDeleteImage: (imageToDelete: InvokeAI._Image) => { const { url, uuid, category, thumbnail } = imageToDelete; dispatch(removeImage(imageToDelete)); socketio.emit('deleteImage', url, thumbnail, uuid, category); diff --git a/invokeai/frontend/web/src/app/socketio/listeners.ts b/invokeai/frontend/web/src/app/socketio/listeners.ts index 08de671260..a709cffb08 100644 --- a/invokeai/frontend/web/src/app/socketio/listeners.ts +++ b/invokeai/frontend/web/src/app/socketio/listeners.ts @@ -262,7 +262,7 @@ const makeSocketIOListeners = ( */ // Generate a UUID for each image - const preparedImages = images.map((image): InvokeAI.Image => { + const preparedImages = images.map((image): InvokeAI._Image => { return { uuid: uuidv4(), ...image, @@ -334,7 +334,7 @@ const makeSocketIOListeners = ( if ( initialImage === url || - (initialImage as InvokeAI.Image)?.url === url + (initialImage as InvokeAI._Image)?.url === url ) { dispatch(clearInitialImage()); } diff --git a/invokeai/frontend/web/src/app/store.ts b/invokeai/frontend/web/src/app/store.ts index 346919120e..1fc09a2c99 100644 --- a/invokeai/frontend/web/src/app/store.ts +++ b/invokeai/frontend/web/src/app/store.ts @@ -8,6 +8,7 @@ import { getPersistConfig } from 'redux-deep-persist'; import canvasReducer from 'features/canvas/store/canvasSlice'; import galleryReducer from 'features/gallery/store/gallerySlice'; import resultsReducer from 'features/gallery/store/resultsSlice'; +import uploadsReducer from 'features/gallery/store/uploadsSlice'; import lightboxReducer from 'features/lightbox/store/lightboxSlice'; import generationReducer from 'features/parameters/store/generationSlice'; import postprocessingReducer from 'features/parameters/store/postprocessingSlice'; @@ -82,6 +83,7 @@ const rootReducer = combineReducers({ lightbox: lightboxReducer, api: apiReducer, results: resultsReducer, + uploads: uploadsReducer, }); const rootPersistConfig = getPersistConfig({ @@ -94,8 +96,9 @@ const rootPersistConfig = getPersistConfig({ ...galleryBlacklist, ...lightboxBlacklist, ...apiBlacklist, - // for now, never persist the results slice + // for now, never persist the results/uploads slices 'results', + 'uploads', ], debounce: 300, }); diff --git a/invokeai/frontend/web/src/common/util/buildNodes.ts b/invokeai/frontend/web/src/common/util/buildNodes.ts index 2899fba972..390e2ba62a 100644 --- a/invokeai/frontend/web/src/common/util/buildNodes.ts +++ b/invokeai/frontend/web/src/common/util/buildNodes.ts @@ -6,7 +6,7 @@ import { UpscaleInvocation, } from 'services/api'; -import { Image } from 'app/invokeai'; +import { _Image } from 'app/invokeai'; // fe todo fix model type (frontend uses null, backend uses undefined) // fe todo update front end to store to have whole image field (vs just name) @@ -83,7 +83,8 @@ export function buildImg2ImgNode( model, progress_images: shouldDisplayInProgressType === 'full-res', image: { - image_name: (initialImage as Image).name, + image_name: (initialImage as _Image).name!, + image_type: 'result', }, strength, fit, @@ -104,7 +105,9 @@ export function buildFacetoolNode( type: 'restore_face', image: { image_name: - typeof initialImage === 'string' ? initialImage : initialImage?.url, + (typeof initialImage === 'string' ? initialImage : initialImage?.url) || + '', + image_type: 'result', }, strength, }; @@ -125,7 +128,9 @@ export function buildUpscaleNode( type: 'upscale', image: { image_name: - typeof initialImage === 'string' ? initialImage : initialImage?.url, + (typeof initialImage === 'string' ? initialImage : initialImage?.url) || + '', + image_type: 'result', }, strength, level, diff --git a/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts b/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts index 3e564af907..34688ef659 100644 --- a/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts @@ -156,7 +156,7 @@ export const canvasSlice = createSlice({ setCursorPosition: (state, action: PayloadAction) => { state.cursorPosition = action.payload; }, - setInitialCanvasImage: (state, action: PayloadAction) => { + setInitialCanvasImage: (state, action: PayloadAction) => { const image = action.payload; const { stageDimensions } = state; @@ -291,7 +291,7 @@ export const canvasSlice = createSlice({ state, action: PayloadAction<{ boundingBox: IRect; - image: InvokeAI.Image; + image: InvokeAI._Image; }> ) => { const { boundingBox, image } = action.payload; diff --git a/invokeai/frontend/web/src/features/canvas/store/canvasTypes.ts b/invokeai/frontend/web/src/features/canvas/store/canvasTypes.ts index 984f0d4f6b..95cf573c3b 100644 --- a/invokeai/frontend/web/src/features/canvas/store/canvasTypes.ts +++ b/invokeai/frontend/web/src/features/canvas/store/canvasTypes.ts @@ -37,7 +37,7 @@ export type CanvasImage = { y: number; width: number; height: number; - image: InvokeAI.Image; + image: InvokeAI._Image; }; export type CanvasMaskLine = { @@ -125,7 +125,7 @@ export interface CanvasState { cursorPosition: Vector2d | null; doesCanvasNeedScaling: boolean; futureLayerStates: CanvasLayerState[]; - intermediateImage?: InvokeAI.Image; + intermediateImage?: InvokeAI._Image; isCanvasInitialized: boolean; isDrawing: boolean; isMaskEnabled: boolean; diff --git a/invokeai/frontend/web/src/features/canvas/store/thunks/mergeAndUploadCanvas.ts b/invokeai/frontend/web/src/features/canvas/store/thunks/mergeAndUploadCanvas.ts index 58e3af1523..a1a7bd3989 100644 --- a/invokeai/frontend/web/src/features/canvas/store/thunks/mergeAndUploadCanvas.ts +++ b/invokeai/frontend/web/src/features/canvas/store/thunks/mergeAndUploadCanvas.ts @@ -105,7 +105,7 @@ export const mergeAndUploadCanvas = const { url, width, height } = image; - const newImage: InvokeAI.Image = { + const newImage: InvokeAI._Image = { uuid: uuidv4(), category: shouldSaveToGallery ? 'result' : 'user', ...image, diff --git a/invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx b/invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx index 734dc3b682..a1276df6d9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx @@ -52,7 +52,7 @@ interface DeleteImageModalProps { /** * The image to delete. */ - image?: InvokeAI.Image; + image?: InvokeAI._Image; } /** diff --git a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx index 0d034ed976..4c515a4d35 100644 --- a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx @@ -33,7 +33,7 @@ import { setIsLightboxOpen } from 'features/lightbox/store/lightboxSlice'; import IAIIconButton from 'common/components/IAIIconButton'; interface HoverableImageProps { - image: InvokeAI.Image; + image: InvokeAI._Image; isSelected: boolean; } diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx index 92b5f4cbef..a8ff5a60da 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx @@ -25,11 +25,44 @@ import HoverableImage from './HoverableImage'; import Scrollable from 'features/ui/components/common/Scrollable'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; -import { selectResultsAll, selectResultsTotal } from '../store/resultsSlice'; -import { getNextResultsPage } from 'services/thunks/gallery'; +import { + resultsAdapter, + selectResultsAll, + selectResultsTotal, +} from '../store/resultsSlice'; +import { + getNextResultsPage, + getNextUploadsPage, +} from 'services/thunks/gallery'; +import { selectUploadsAll, uploadsAdapter } from '../store/uploadsSlice'; +import { createSelector } from '@reduxjs/toolkit'; +import { RootState } from 'app/store'; const GALLERY_SHOW_BUTTONS_MIN_WIDTH = 290; +const gallerySelector = createSelector( + [ + (state: RootState) => state.uploads, + (state: RootState) => state.results, + (state: RootState) => state.gallery, + ], + (uploads, results, gallery) => { + const { currentCategory } = gallery; + + return currentCategory === 'result' + ? { + images: resultsAdapter.getSelectors().selectAll(results), + isLoading: results.isLoading, + areMoreImagesAvailable: results.page < results.pages - 1, + } + : { + images: uploadsAdapter.getSelectors().selectAll(uploads), + isLoading: uploads.isLoading, + areMoreImagesAvailable: uploads.page < uploads.pages - 1, + }; + } +); + const ImageGalleryContent = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); @@ -37,7 +70,7 @@ const ImageGalleryContent = () => { const [shouldShouldIconButtons, setShouldShouldIconButtons] = useState(true); const { - images, + // images, currentCategory, currentImageUuid, shouldPinGallery, @@ -45,20 +78,24 @@ const ImageGalleryContent = () => { galleryGridTemplateColumns, galleryImageObjectFit, shouldAutoSwitchToNewImages, - areMoreImagesAvailable, + // areMoreImagesAvailable, shouldUseSingleGalleryColumn, } = useAppSelector(imageGallerySelector); - const allResultImages = useAppSelector(selectResultsAll); - const currentResultsPage = useAppSelector((state) => state.results.page); - const totalResultsPages = useAppSelector((state) => state.results.pages); - const isLoadingResults = useAppSelector((state) => state.results.isLoading); + const { images, areMoreImagesAvailable, isLoading } = + useAppSelector(gallerySelector); // const handleClickLoadMore = () => { // dispatch(requestImages(currentCategory)); // }; const handleClickLoadMore = () => { - dispatch(getNextResultsPage()); + if (currentCategory === 'result') { + dispatch(getNextResultsPage()); + } + + if (currentCategory === 'user') { + dispatch(getNextUploadsPage()); + } }; const handleChangeGalleryImageMinimumWidth = (v: number) => { @@ -223,17 +260,17 @@ const ImageGalleryContent = () => { /> ); })} */} - {allResultImages.map((image) => ( + {images.map((image) => ( ))} - {currentResultsPage !== totalResultsPages - 1 + {areMoreImagesAvailable ? t('gallery.loadMore') : t('gallery.allImagesLoaded')} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx index 130c716f6b..790cca088a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx @@ -113,7 +113,7 @@ const MetadataItem = ({ }; type ImageMetadataViewerProps = { - image: InvokeAI.Image; + image: InvokeAI._Image; }; // TODO: I don't know if this is needed. diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index dbb173c74a..a612476121 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -8,7 +8,7 @@ import { clamp } from 'lodash'; export type GalleryCategory = 'user' | 'result'; export type AddImagesPayload = { - images: Array; + images: Array; areMoreImagesAvailable: boolean; category: GalleryCategory; }; @@ -16,16 +16,16 @@ export type AddImagesPayload = { type GalleryImageObjectFitType = 'contain' | 'cover'; export type Gallery = { - images: InvokeAI.Image[]; + images: InvokeAI._Image[]; latest_mtime?: number; earliest_mtime?: number; areMoreImagesAvailable: boolean; }; export interface GalleryState { - currentImage?: InvokeAI.Image; + currentImage?: InvokeAI._Image; currentImageUuid: string; - intermediateImage?: InvokeAI.Image & { + intermediateImage?: InvokeAI._Image & { boundingBox?: IRect; generationMode?: InvokeTabName; }; @@ -69,7 +69,7 @@ export const gallerySlice = createSlice({ name: 'gallery', initialState, reducers: { - setCurrentImage: (state, action: PayloadAction) => { + setCurrentImage: (state, action: PayloadAction) => { state.currentImage = action.payload; state.currentImageUuid = action.payload.uuid; }, @@ -124,7 +124,7 @@ export const gallerySlice = createSlice({ addImage: ( state, action: PayloadAction<{ - image: InvokeAI.Image; + image: InvokeAI._Image; category: GalleryCategory; }> ) => { @@ -150,7 +150,10 @@ export const gallerySlice = createSlice({ setIntermediateImage: ( state, action: PayloadAction< - InvokeAI.Image & { boundingBox?: IRect; generationMode?: InvokeTabName } + InvokeAI._Image & { + boundingBox?: IRect; + generationMode?: InvokeTabName; + } > ) => { state.intermediateImage = action.payload; diff --git a/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts b/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts index 0adbb09bbc..0264a0600a 100644 --- a/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts @@ -1,17 +1,15 @@ import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; -import { ResultImage } from 'app/invokeai'; +import { Image } from 'app/invokeai'; import { RootState } from 'app/store'; -import { map } from 'lodash'; -import { getNextResultsPage } from 'services/thunks/gallery'; -import { isImageOutput } from 'services/types/guards'; -import { prepareResultImage } from 'services/util/prepareResultImage'; +import { getNextResultsPage, IMAGES_PER_PAGE } from 'services/thunks/gallery'; +import { processImageField } from 'services/util/processImageField'; // use `createEntityAdapter` to create a slice for results images // https://redux-toolkit.js.org/api/createEntityAdapter#overview // the "Entity" is InvokeAI.ResultImage, while the "entities" are instances of that type -const resultsAdapter = createEntityAdapter({ +export const resultsAdapter = createEntityAdapter({ // Provide a callback to get a stable, unique identifier for each entity. This defaults to // `(item) => item.id`, but for our result images, the `name` is the unique identifier. selectId: (image) => image.name, @@ -26,6 +24,7 @@ type AdditionalResultsState = { page: number; // current page we are on pages: number; // the total number of pages available isLoading: boolean; // whether we are loading more images or not, mostly a placeholder + nextPage: number; // the next page to request }; const resultsSlice = createSlice({ @@ -35,6 +34,7 @@ const resultsSlice = createSlice({ page: 0, pages: 0, isLoading: false, + nextPage: 0, }), reducers: { // the adapter provides some helper reducers; see the docs for all of them @@ -47,28 +47,20 @@ const resultsSlice = createSlice({ extraReducers: (builder) => { // here we can respond to a fulfilled call of the `getNextResultsPage` thunk // because we pass in the fulfilled thunk action creator, everything is typed - builder.addCase(getNextResultsPage.pending, (state, action) => { + builder.addCase(getNextResultsPage.pending, (state) => { state.isLoading = true; }); builder.addCase(getNextResultsPage.fulfilled, (state, action) => { const { items, page, pages } = action.payload; - // build flattened array of results ojects, use lodash `map()` to make results object an array - const allResults = items.flatMap((session) => map(session.results)); + const resultImages = items.map((image) => processImageField(image)); - // filter out non-image-outputs (eg latents, prompts, etc) - const imageOutputResults = allResults.filter(isImageOutput); - - // map results to ResultImage objects - const resultImages = imageOutputResults.map((result) => - prepareResultImage(result.image) - ); - - // use the adapter reducer to add all the results to resultsSlice state + // use the adapter reducer to append all the results to state resultsAdapter.addMany(state, resultImages); state.page = page; state.pages = pages; + state.nextPage = items.length < IMAGES_PER_PAGE ? page : page + 1; state.isLoading = false; }); }, diff --git a/invokeai/frontend/web/src/features/gallery/store/uploadsSlice.ts b/invokeai/frontend/web/src/features/gallery/store/uploadsSlice.ts new file mode 100644 index 0000000000..386a379973 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/store/uploadsSlice.ts @@ -0,0 +1,60 @@ +import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; +import { Image } from 'app/invokeai'; + +import { RootState } from 'app/store'; +import { getNextUploadsPage, IMAGES_PER_PAGE } from 'services/thunks/gallery'; +import { processImageField } from 'services/util/processImageField'; + +export const uploadsAdapter = createEntityAdapter({ + selectId: (image) => image.name, + sortComparer: (a, b) => b.timestamp - a.timestamp, +}); + +type AdditionalUploadsState = { + page: number; + pages: number; + isLoading: boolean; + nextPage: number; +}; + +const uploadsSlice = createSlice({ + name: 'uploads', + initialState: uploadsAdapter.getInitialState({ + page: 0, + pages: 0, + nextPage: 0, + isLoading: false, + }), + reducers: { + uploadAdded: uploadsAdapter.addOne, + }, + extraReducers: (builder) => { + builder.addCase(getNextUploadsPage.pending, (state) => { + state.isLoading = true; + }); + builder.addCase(getNextUploadsPage.fulfilled, (state, action) => { + const { items, page, pages } = action.payload; + + const images = items.map((image) => processImageField(image)); + + uploadsAdapter.addMany(state, images); + + state.page = page; + state.pages = pages; + state.nextPage = items.length < IMAGES_PER_PAGE ? page : page + 1; + state.isLoading = false; + }); + }, +}); + +export const { + selectAll: selectUploadsAll, + selectById: selectUploadsById, + selectEntities: selectUploadsEntities, + selectIds: selectUploadsIds, + selectTotal: selectUploadsTotal, +} = uploadsAdapter.getSelectors((state) => state.uploads); + +export const { uploadAdded } = uploadsSlice.actions; + +export default uploadsSlice.reducer; diff --git a/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomImage.tsx b/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomImage.tsx index 0658fd1756..8c5bc96885 100644 --- a/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomImage.tsx +++ b/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomImage.tsx @@ -3,7 +3,7 @@ import { TransformComponent, useTransformContext } from 'react-zoom-pan-pinch'; import * as InvokeAI from 'app/invokeai'; type ReactPanZoomProps = { - image: InvokeAI.Image; + image: InvokeAI._Image; styleClass?: string; alt?: string; ref?: React.Ref; diff --git a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts index 1cb3a98204..ea7a54140a 100644 --- a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts @@ -11,7 +11,7 @@ export interface GenerationState { height: number; img2imgStrength: number; infillMethod: string; - initialImage?: InvokeAI.Image | string; // can be an Image or url + initialImage?: InvokeAI._Image | string; // can be an Image or url iterations: number; maskPath: string; perlin: number; @@ -319,7 +319,7 @@ export const generationSlice = createSlice({ }, setInitialImage: ( state, - action: PayloadAction + action: PayloadAction ) => { state.initialImage = action.payload; }, diff --git a/invokeai/frontend/web/src/services/invokeMiddleware.ts b/invokeai/frontend/web/src/services/invokeMiddleware.ts index 5689786d7f..5398c22631 100644 --- a/invokeai/frontend/web/src/services/invokeMiddleware.ts +++ b/invokeai/frontend/web/src/services/invokeMiddleware.ts @@ -36,7 +36,7 @@ export const invokeMiddleware: Middleware = console.log('uploadImage.fulfilled'); // TODO: actually get correct attributes here - const newImage: InvokeAI.Image = { + const newImage: InvokeAI._Image = { uuid: uuidv4(), category: 'user', url: uploadLocation, diff --git a/invokeai/frontend/web/src/services/thunks/gallery.ts b/invokeai/frontend/web/src/services/thunks/gallery.ts index bdb7efd34c..8858036bca 100644 --- a/invokeai/frontend/web/src/services/thunks/gallery.ts +++ b/invokeai/frontend/web/src/services/thunks/gallery.ts @@ -1,41 +1,28 @@ import { createAppAsyncThunk } from 'app/storeUtils'; -import { SessionsService } from 'services/api'; +import { ImagesService } from 'services/api'; + +export const IMAGES_PER_PAGE = 20; -/** - * Get the last 10 sessions' worth of images. - * - * This should be at most 10 images so long as we continue to make a new session for every - * generation. - * - * If a session was created but no image generated, this will be < 10 images. - * - * When we allow more images per sesssion, this is kinda no longer a viable way to grab results, - * because a session could have many, many images. In that situation, barring a change to the api, - * we have to keep track of images we've grabbed and the session they came from, so that when we - * want to load more, we can "resume" fetching images from that session. - * - * The API should change. - */ export const getNextResultsPage = createAppAsyncThunk( - 'results/getMoreResultsImages', + 'results/getInitialResultsPage', async (_arg, { getState }) => { - const { page } = getState().results; - - const response = await SessionsService.listSessions({ - page: page + 1, - perPage: 10, + const response = await ImagesService.listImages({ + imageType: 'results', + page: getState().results.nextPage, + perPage: IMAGES_PER_PAGE, }); return response; } ); -export const getInitialResultsPage = createAppAsyncThunk( - 'results/getMoreResultsImages', - async (_arg) => { - const response = await SessionsService.listSessions({ - page: 0, - perPage: 10, +export const getNextUploadsPage = createAppAsyncThunk( + 'uploads/getNextUploadsPage', + async (_arg, { getState }) => { + const response = await ImagesService.listImages({ + imageType: 'uploads', + page: getState().uploads.nextPage, + perPage: IMAGES_PER_PAGE, }); return response; diff --git a/invokeai/frontend/web/src/services/util/prepareResultImage.ts b/invokeai/frontend/web/src/services/util/prepareResultImage.ts deleted file mode 100644 index 4793ff634c..0000000000 --- a/invokeai/frontend/web/src/services/util/prepareResultImage.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ResultImage } from 'app/invokeai'; -import { ImageField, ImageType } from 'services/api'; - -export const buildImageUrls = ( - imageType: ImageType, - imageName: string -): { imageUrl: string; thumbnailUrl: string } => { - const imageUrl = `api/v1/images/${imageType}/${imageName}`; - - const thumbnailUrl = `api/v1/images/${imageType}/thumbnails/${ - imageName.split('.')[0] - }.webp`; - - return { - imageUrl, - thumbnailUrl, - }; -}; - -export const extractTimestampFromResultImageName = (imageName: string) => { - const timestamp = imageName.split('_')?.pop()?.split('.')[0]; - - if (timestamp === undefined) { - return 0; - } - - return Number(timestamp); -}; - -export const prepareResultImage = (image: ImageField): ResultImage => { - const name = image.image_name; - - const { imageUrl, thumbnailUrl } = buildImageUrls('results', name); - - const timestamp = extractTimestampFromResultImageName(name); - - return { - name, - url: imageUrl, - thumbnail: thumbnailUrl, - timestamp, - height: 512, - width: 512, - }; -}; diff --git a/invokeai/frontend/web/src/services/util/processImageField.ts b/invokeai/frontend/web/src/services/util/processImageField.ts new file mode 100644 index 0000000000..550bf2449f --- /dev/null +++ b/invokeai/frontend/web/src/services/util/processImageField.ts @@ -0,0 +1,46 @@ +import { Image } from 'app/invokeai'; +import { ImageField, ImageType } from 'services/api'; + +export const buildImageUrls = ( + imageType: ImageType, + imageName: string +): { url: string; thumbnail: string } => { + const url = `api/v1/images/${imageType}/${imageName}`; + + const thumbnail = `api/v1/images/${imageType}/thumbnails/${ + imageName.split('.')[0] + }.webp`; + + return { + url, + thumbnail, + }; +}; + +export const extractTimestampFromImageName = (imageName: string) => { + const timestamp = imageName.split('_')?.pop()?.split('.')[0]; + + if (timestamp === undefined) { + return 0; + } + + return Number(timestamp); +}; + +export const processImageField = (image: ImageField): Image => { + const name = image.image_name; + const type = image.image_type; + + const { url, thumbnail } = buildImageUrls(type, name); + + const timestamp = extractTimestampFromImageName(name); + + return { + name, + url, + thumbnail, + timestamp, + height: 512, + width: 512, + }; +};