From dbf6b1b68a240399f30d4397e3b0b817706b88ac Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 4 Apr 2023 00:31:17 +1000 Subject: [PATCH] feat(ui): clean up & comment results slice --- .../web/src/app/nodesSocketio/listeners.ts | 34 +++----- invokeai/frontend/web/src/app/store.ts | 2 + .../components/ImageGalleryContent.tsx | 16 +++- .../features/gallery/store/resultsSlice.ts | 74 +++++++++++++--- .../frontend/web/src/services/thunks/extra.ts | 86 +++---------------- .../web/src/services/util/buildImageUrls.ts | 17 ---- .../extractTimestampFromResultImageName.ts | 9 -- .../src/services/util/prepareResultImage.ts | 45 ++++++++++ 8 files changed, 147 insertions(+), 136 deletions(-) delete mode 100644 invokeai/frontend/web/src/services/util/buildImageUrls.ts delete mode 100644 invokeai/frontend/web/src/services/util/extractTimestampFromResultImageName.ts create mode 100644 invokeai/frontend/web/src/services/util/prepareResultImage.ts diff --git a/invokeai/frontend/web/src/app/nodesSocketio/listeners.ts b/invokeai/frontend/web/src/app/nodesSocketio/listeners.ts index febf1109cf..adc45f809b 100644 --- a/invokeai/frontend/web/src/app/nodesSocketio/listeners.ts +++ b/invokeai/frontend/web/src/app/nodesSocketio/listeners.ts @@ -33,10 +33,9 @@ import { STATUS, } from 'services/apiSlice'; import { emitUnsubscribe } from './actions'; -import { getGalleryImages } from 'services/thunks/extra'; import { resultAdded } from 'features/gallery/store/resultsSlice'; -import { buildImageUrls } from 'services/util/buildImageUrls'; -import { extractTimestampFromResultImageName } from 'services/util/extractTimestampFromResultImageName'; +import { getNextResultsPage } from 'services/thunks/extra'; +import { prepareResultImage } from 'services/util/prepareResultImage'; /** * Returns an object containing listener callbacks @@ -54,7 +53,12 @@ const makeSocketIOListeners = ( try { dispatch(setIsConnected(true)); dispatch(setCurrentStatus(i18n.t('common.statusConnected'))); - dispatch(getGalleryImages({ count: 20 })); + + // 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'? + if (!getState().results.ids.length) { + dispatch(getNextResultsPage()); + } } catch (e) { console.error(e); } @@ -90,15 +94,9 @@ const makeSocketIOListeners = ( try { const sessionId = data.graph_execution_state_id; if (data.result.type === 'image') { - const { image_name: imageName } = data.result.image; - - const { imageUrl, thumbnailUrl } = buildImageUrls( - 'results', - imageName - ); - - const timestamp = extractTimestampFromResultImageName(imageName); + const resultImage = prepareResultImage(data.result.image); + dispatch(resultAdded(resultImage)); // // need to update the type for this or figure out how to get these values // dispatch( // addImage({ @@ -116,20 +114,10 @@ const makeSocketIOListeners = ( // }) // ); - dispatch( - resultAdded({ - name: imageName, - url: imageUrl, - thumbnail: thumbnailUrl, - width: 512, - height: 512, - timestamp, - }) - ); dispatch( addLogEntry({ timestamp: dateFormat(new Date(), 'isoDateTime'), - message: `Generated: ${imageName}`, + message: `Generated: ${data.result.image.image_name}`, }) ); dispatch(setIsProcessing(false)); diff --git a/invokeai/frontend/web/src/app/store.ts b/invokeai/frontend/web/src/app/store.ts index 1d469d329a..346919120e 100644 --- a/invokeai/frontend/web/src/app/store.ts +++ b/invokeai/frontend/web/src/app/store.ts @@ -94,6 +94,8 @@ const rootPersistConfig = getPersistConfig({ ...galleryBlacklist, ...lightboxBlacklist, ...apiBlacklist, + // for now, never persist the results slice + 'results', ], debounce: 300, }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx index 7599ee8abf..8d8061e067 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx @@ -25,7 +25,8 @@ import HoverableImage from './HoverableImage'; import Scrollable from 'features/ui/components/common/Scrollable'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; -import { selectResultsAll } from '../store/resultsSlice'; +import { selectResultsAll, selectResultsTotal } from '../store/resultsSlice'; +import { getNextResultsPage } from 'services/thunks/extra'; const GALLERY_SHOW_BUTTONS_MIN_WIDTH = 290; @@ -49,9 +50,15 @@ const ImageGalleryContent = () => { } = 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 handleClickLoadMore = () => { + // dispatch(requestImages(currentCategory)); + // }; const handleClickLoadMore = () => { - dispatch(requestImages(currentCategory)); + dispatch(getNextResultsPage()); }; const handleChangeGalleryImageMinimumWidth = (v: number) => { @@ -222,10 +229,11 @@ const ImageGalleryContent = () => { - {areMoreImagesAvailable + {currentResultsPage !== totalResultsPages - 1 ? t('gallery.loadMore') : t('gallery.allImagesLoaded')} diff --git a/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts b/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts index 6abf2c38ba..2d0dea3805 100644 --- a/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts @@ -1,27 +1,81 @@ import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; +import { ResultImage } from 'app/invokeai'; -import * as InvokeAI from 'app/invokeai'; import { RootState } from 'app/store'; +import { map } from 'lodash'; +import { getNextResultsPage } from 'services/thunks/extra'; +import { isImageOutput } from 'services/types/guards'; +import { prepareResultImage } from 'services/util/prepareResultImage'; -const resultsAdapter = createEntityAdapter({ - // Image IDs are just their filename +// 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({ + // 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, - // Keep the "all IDs" array sorted based on result timestamps + // Order all images by their time (in descending order) sortComparer: (a, b) => b.timestamp - a.timestamp, }); +// This type is intersected with the Entity type to create the shape of the state +type AdditionalResultsState = { + // these are a bit misleading; they refer to sessions, not results, but we don't have a route + // to list all images directly at this time... + 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 +}; + const resultsSlice = createSlice({ name: 'results', - initialState: resultsAdapter.getInitialState(), + initialState: resultsAdapter.getInitialState({ + // provide the additional initial state + page: 0, + pages: 0, + isLoading: false, + }), reducers: { - // Can pass adapter functions directly as case reducers. Because we're passing this - // as a value, `createSlice` will auto-generate the action type / creator + // the adapter provides some helper reducers; see the docs for all of them + // can use them as helper functions within a reducer, or use the function itself as a reducer + + // here we just use the function itself as the reducer. we'll call this on `invocation_complete` + // to add a single result resultAdded: resultsAdapter.addOne, - resultsReceived: resultsAdapter.setAll, + }, + 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) => { + 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)); + + // 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 + resultsAdapter.addMany(state, resultImages); + + state.page = page; + state.pages = pages; + state.isLoading = false; + }); }, }); -// Can create a set of memoized selectors based on the location of this entity state +// Create a set of memoized selectors based on the location of this entity state +// to be used as selectors in a `useAppSelector()` call export const { selectAll: selectResultsAll, selectById: selectResultsById, @@ -30,6 +84,6 @@ export const { selectTotal: selectResultsTotal, } = resultsAdapter.getSelectors((state) => state.results); -export const { resultAdded, resultsReceived } = resultsSlice.actions; +export const { resultAdded } = resultsSlice.actions; export default resultsSlice.reducer; diff --git a/invokeai/frontend/web/src/services/thunks/extra.ts b/invokeai/frontend/web/src/services/thunks/extra.ts index 5a3aaef0b4..d9a985e855 100644 --- a/invokeai/frontend/web/src/services/thunks/extra.ts +++ b/invokeai/frontend/web/src/services/thunks/extra.ts @@ -1,91 +1,31 @@ import { createAppAsyncThunk } from 'app/storeUtils'; -import { map } from 'lodash'; import { SessionsService } from 'services/api'; -import { isImageOutput } from 'services/types/guards'; -import { buildImageUrls } from 'services/util/buildImageUrls'; -import { extractTimestampFromResultImageName } from 'services/util/extractTimestampFromResultImageName'; -import { resultsReceived } from 'features/gallery/store/resultsSlice'; - -type GetGalleryImagesArg = { - count: number; -}; /** - * Get the last 20 sessions' worth of images. + * Get the last 10 sessions' worth of images. * - * This should be at most 20 images so long as we continue to make a new session for every + * 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 < 20 images. + * 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 getGalleryImages = createAppAsyncThunk( - 'api/getGalleryImages', - async (arg: GetGalleryImagesArg, { dispatch }) => { +export const getNextResultsPage = createAppAsyncThunk( + 'results/getMoreResultsImages', + async (_arg, { getState }) => { + const { page } = getState().results; + const response = await SessionsService.listSessions({ - page: 0, - perPage: 20, + page: page + 1, + perPage: 10, }); - // build flattened array of results ojects, use lodash `map()` to make results object an array - const allResults = response.items.flatMap((session) => - map(session.results) - ); - - // filter out non-image-outputs (eg latents, prompts, etc) - const imageOutputResults = allResults.filter(isImageOutput); - - // build ResultImage objects - const resultImages = imageOutputResults.map((result) => { - const name = result.image.image_name; - - const { imageUrl, thumbnailUrl } = buildImageUrls('results', name); - const timestamp = extractTimestampFromResultImageName(name); - - return { - name, - url: imageUrl, - thumbnail: thumbnailUrl, - timestamp, - height: 512, - width: 512, - }; - }); - - // update the results slice - dispatch(resultsReceived(resultImages)); - - // response.items.forEach((session) => { - // forEach(session.results, (result) => { - // if (isImageOutput(result)) { - // const { imageUrl, thumbnailUrl } = buildImageUrls( - // result.image.image_type!, // fix the generated types to avoid non-null assertion - // result.image.image_name! // fix the generated types to avoid non-null assertion - // ); - - // dispatch - - // dispatch( - // addImage({ - // category: 'result', - // image: { - // uuid: uuidv4(), - // url: imageUrl, - // thumbnail: , - // width: 512, - // height: 512, - // category: 'result', - // name: result.image.image_name, - // mtime: new Date().getTime(), - // }, - // }) - // ); - // } - // }); - // }); + return response; } ); diff --git a/invokeai/frontend/web/src/services/util/buildImageUrls.ts b/invokeai/frontend/web/src/services/util/buildImageUrls.ts deleted file mode 100644 index 3f2b1860f3..0000000000 --- a/invokeai/frontend/web/src/services/util/buildImageUrls.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { 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, - }; -}; diff --git a/invokeai/frontend/web/src/services/util/extractTimestampFromResultImageName.ts b/invokeai/frontend/web/src/services/util/extractTimestampFromResultImageName.ts deleted file mode 100644 index fe3dc65ec5..0000000000 --- a/invokeai/frontend/web/src/services/util/extractTimestampFromResultImageName.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const extractTimestampFromResultImageName = (imageName: string) => { - const timestamp = imageName.split('_')?.pop()?.split('.')[0]; - - if (timestamp === undefined) { - return 0; - } - - return Number(timestamp); -}; diff --git a/invokeai/frontend/web/src/services/util/prepareResultImage.ts b/invokeai/frontend/web/src/services/util/prepareResultImage.ts new file mode 100644 index 0000000000..4793ff634c --- /dev/null +++ b/invokeai/frontend/web/src/services/util/prepareResultImage.ts @@ -0,0 +1,45 @@ +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, + }; +};