feat(ui): wip gallery migration

This commit is contained in:
psychedelicious 2023-04-04 18:34:50 +10:00
parent b7de3162c3
commit cfe86ec541
23 changed files with 244 additions and 147 deletions

View File

@ -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<Image>;
images: Array<_Image>;
};
/**
@ -289,7 +289,7 @@ export declare type SystemStatusResponse = SystemStatus;
export declare type SystemConfigResponse = SystemConfig;
export declare type ImageResultResponse = Omit<Image, 'uuid'> & {
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<Omit<Image, 'uuid'>>;
images: Array<Omit<_Image, 'uuid'>>;
areMoreImagesAvailable: boolean;
category: GalleryCategory;
};

View File

@ -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

View File

@ -13,9 +13,13 @@ import { InvokeTabName } from 'features/ui/store/tabMap';
export const generateImage = createAction<InvokeTabName>(
'socketio/generateImage'
);
export const runESRGAN = createAction<InvokeAI.Image>('socketio/runESRGAN');
export const runFacetool = createAction<InvokeAI.Image>('socketio/runFacetool');
export const deleteImage = createAction<InvokeAI.Image>('socketio/deleteImage');
export const runESRGAN = createAction<InvokeAI._Image>('socketio/runESRGAN');
export const runFacetool = createAction<InvokeAI._Image>(
'socketio/runFacetool'
);
export const deleteImage = createAction<InvokeAI._Image>(
'socketio/deleteImage'
);
export const requestImages = createAction<GalleryCategory>(
'socketio/requestImages'
);

View File

@ -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);

View File

@ -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());
}

View File

@ -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,
});

View File

@ -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,

View File

@ -156,7 +156,7 @@ export const canvasSlice = createSlice({
setCursorPosition: (state, action: PayloadAction<Vector2d | null>) => {
state.cursorPosition = action.payload;
},
setInitialCanvasImage: (state, action: PayloadAction<InvokeAI.Image>) => {
setInitialCanvasImage: (state, action: PayloadAction<InvokeAI._Image>) => {
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;

View File

@ -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;

View File

@ -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,

View File

@ -52,7 +52,7 @@ interface DeleteImageModalProps {
/**
* The image to delete.
*/
image?: InvokeAI.Image;
image?: InvokeAI._Image;
}
/**

View File

@ -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;
}

View File

@ -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) => (
<Image key={image.name} src={image.thumbnail} />
))}
</Grid>
<IAIButton
onClick={handleClickLoadMore}
isDisabled={currentResultsPage === totalResultsPages - 1}
isLoading={isLoadingResults}
isDisabled={!areMoreImagesAvailable}
isLoading={isLoading}
flexShrink={0}
>
{currentResultsPage !== totalResultsPages - 1
{areMoreImagesAvailable
? t('gallery.loadMore')
: t('gallery.allImagesLoaded')}
</IAIButton>

View File

@ -113,7 +113,7 @@ const MetadataItem = ({
};
type ImageMetadataViewerProps = {
image: InvokeAI.Image;
image: InvokeAI._Image;
};
// TODO: I don't know if this is needed.

View File

@ -8,7 +8,7 @@ import { clamp } from 'lodash';
export type GalleryCategory = 'user' | 'result';
export type AddImagesPayload = {
images: Array<InvokeAI.Image>;
images: Array<InvokeAI._Image>;
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<InvokeAI.Image>) => {
setCurrentImage: (state, action: PayloadAction<InvokeAI._Image>) => {
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;

View File

@ -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<ResultImage>({
export const resultsAdapter = createEntityAdapter<Image>({
// 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;
});
},

View File

@ -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<Image>({
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<AdditionalUploadsState>({
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<RootState>((state) => state.uploads);
export const { uploadAdded } = uploadsSlice.actions;
export default uploadsSlice.reducer;

View File

@ -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<HTMLImageElement>;

View File

@ -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<InvokeAI.Image | string>
action: PayloadAction<InvokeAI._Image | string>
) => {
state.initialImage = action.payload;
},

View File

@ -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,

View File

@ -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;

View File

@ -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,
};
};

View File

@ -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,
};
};