fix(ui): image uploading handling

Rework uploadImage and uploadImages helpers and the RTK listener, ensuring gallery view isn't changed unexpectedly and preventing extraneous toasts.

Fix staging area save to gallery button to essentially make a copy of the image, instead of changing its intermediate status.
This commit is contained in:
psychedelicious
2024-11-14 11:26:16 -08:00
committed by Kent Keirsey
parent d5ff7ef250
commit 878093f64e
12 changed files with 136 additions and 130 deletions

View File

@@ -41,29 +41,33 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis
log.debug({ imageDTO }, 'Image uploaded');
if (action.meta.arg.originalArgs.silent || imageDTO.is_intermediate) {
// When a "silent" upload is requested, or the image is intermediate, we can skip all post-upload actions,
// like toasts and switching the gallery view
return;
}
const boardId = imageDTO.board_id ?? 'none';
if (action.meta.arg.originalArgs.withToast) {
const DEFAULT_UPLOADED_TOAST = {
id: 'IMAGE_UPLOADED',
title: t('toast.imageUploaded'),
status: 'success',
} as const;
const DEFAULT_UPLOADED_TOAST = {
id: 'IMAGE_UPLOADED',
title: t('toast.imageUploaded'),
status: 'success',
} as const;
// default action - just upload and alert user
if (lastUploadedToastTimeout !== null) {
window.clearTimeout(lastUploadedToastTimeout);
}
const toastApi = toast({
...DEFAULT_UPLOADED_TOAST,
title: DEFAULT_UPLOADED_TOAST.title,
description: getUploadedToastDescription(boardId, state),
duration: null, // we will close the toast manually
});
lastUploadedToastTimeout = window.setTimeout(() => {
toastApi.close();
}, 3000);
// default action - just upload and alert user
if (lastUploadedToastTimeout !== null) {
window.clearTimeout(lastUploadedToastTimeout);
}
const toastApi = toast({
...DEFAULT_UPLOADED_TOAST,
title: DEFAULT_UPLOADED_TOAST.title,
description: getUploadedToastDescription(boardId, state),
duration: null, // we will close the toast manually
});
lastUploadedToastTimeout = window.setTimeout(() => {
toastApi.close();
}, 3000);
/**
* We only want to change the board and view if this is the first upload of a batch, else we end up hijacking

View File

@@ -75,11 +75,13 @@ export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: Us
} else {
//
const imageDTOs = await uploadImages(
files.map((file) => ({
files.map((file, i) => ({
file,
image_category: 'user',
is_intermediate: false,
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
silent: false,
isFirstUploadOfBatch: i === 0,
}))
);
if (onUpload) {

View File

@@ -1,17 +1,19 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { withResultAsync } from 'common/util/result';
import { selectSelectedImage } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFloppyDiskBold } from 'react-icons/pi';
import { useAddImagesToBoardMutation, useChangeImageIsIntermediateMutation } from 'services/api/endpoints/images';
import { uploadImage } from 'services/api/endpoints/images';
const TOAST_ID = 'SAVE_STAGING_AREA_IMAGE_TO_GALLERY';
export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const selectedImage = useAppSelector(selectSelectedImage);
const [addImageToBoard] = useAddImagesToBoardMutation();
const [changeIsImageIntermediate] = useChangeImageIsIntermediateMutation();
const { t } = useTranslation();
@@ -19,21 +21,42 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
if (!selectedImage) {
return;
}
if (autoAddBoardId !== 'none') {
await addImageToBoard({ imageDTOs: [selectedImage.imageDTO], board_id: autoAddBoardId }).unwrap();
// The changeIsImageIntermediate request will use the board_id on this specific imageDTO object, so we need to
// update it before making the request - else the optimistic board updates will get out of whack.
changeIsImageIntermediate({
imageDTO: { ...selectedImage.imageDTO, board_id: autoAddBoardId },
// To save the image to gallery, we will download it and re-upload it. This allows the user to delete the image
// the gallery without borking the canvas, which may need this image to exist.
const result = await withResultAsync(async () => {
// Download the image
const res = await fetch(selectedImage.imageDTO.image_url);
const blob = await res.blob();
// Create a new file with the same name, which we will upload
const file = new File([blob], `copy_of_${selectedImage.imageDTO.image_name}`, { type: 'image/png' });
await uploadImage({
file,
// Image should show up in the Images tab
image_category: 'general',
is_intermediate: false,
// TODO(psyche): Maybe this should just save to the currently-selected board?
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
// We will do our own toast - opt out of the default handling
silent: true,
});
});
if (result.isOk()) {
toast({
id: TOAST_ID,
title: t('controlLayers.savedToGalleryOk'),
status: 'success',
});
} else {
changeIsImageIntermediate({
imageDTO: selectedImage.imageDTO,
is_intermediate: false,
toast({
id: TOAST_ID,
title: t('controlLayers.savedToGalleryError'),
status: 'error',
});
}
}, [addImageToBoard, autoAddBoardId, changeIsImageIntermediate, selectedImage]);
}, [autoAddBoardId, selectedImage, t]);
return (
<IconButton
@@ -42,7 +65,7 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
icon={<PiFloppyDiskBold />}
onClick={saveSelectedImageToGallery}
colorScheme="invokeBlue"
isDisabled={!selectedImage || !selectedImage.imageDTO.is_intermediate}
isDisabled={!selectedImage}
/>
);
});

View File

@@ -25,6 +25,8 @@ import type {
RegionalGuidanceReferenceImageState,
} from 'features/controlLayers/store/types';
import { imageDTOToImageObject, imageDTOToImageWithDims, initialControlNet } from 'features/controlLayers/store/util';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import type { BoardId } from 'features/gallery/store/types';
import { toast } from 'features/toast/toast';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -70,6 +72,11 @@ const useSaveCanvas = ({ region, saveToGallery, toastOk, toastError, onSave, wit
metadata = selectCanvasMetadata(store.getState());
}
let boardId: BoardId | undefined = undefined;
if (saveToGallery) {
boardId = selectAutoAddBoardId(store.getState());
}
const result = await withResultAsync(() => {
const rasterAdapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer');
return canvasManager.compositor.getCompositeImageDTO(
@@ -78,6 +85,8 @@ const useSaveCanvas = ({ region, saveToGallery, toastOk, toastError, onSave, wit
{
is_intermediate: !saveToGallery,
metadata,
board_id: boardId,
silent: true,
},
undefined,
true // force upload the image to ensure it gets added to the gallery
@@ -222,8 +231,8 @@ export const useNewRasterLayerFromBbox = () => {
toastError: t('controlLayers.newRasterLayerError'),
};
}, [dispatch, t]);
const newRasterLayerFromBbox = useSaveCanvas(arg);
return newRasterLayerFromBbox;
const func = useSaveCanvas(arg);
return func;
};
export const useNewControlLayerFromBbox = () => {

View File

@@ -28,19 +28,17 @@ import type {
} from 'features/controlLayers/store/types';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { imageDTOToImageObject } from 'features/controlLayers/store/util';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { atom, computed } from 'nanostores';
import type { Logger } from 'roarr';
import { serializeError } from 'serialize-error';
import type { UploadImageArg } from 'services/api/endpoints/images';
import { getImageDTOSafe, uploadImage } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { ImageDTO, UploadImageArg } from 'services/api/types';
import stableHash from 'stable-hash';
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
import type { JsonObject } from 'type-fest';
import type { JsonObject, SetOptional } from 'type-fest';
type CompositingOptions = {
/**
@@ -259,7 +257,7 @@ export class CanvasCompositorModule extends CanvasModuleBase {
getCompositeImageDTO = async (
adapters: CanvasEntityAdapter[],
rect: Rect,
uploadOptions: Pick<UploadImageArg, 'is_intermediate' | 'metadata'>,
uploadOptions: SetOptional<Omit<UploadImageArg, 'file'>, 'image_category'>,
compositingOptions?: CompositingOptions,
forceUpload?: boolean
): Promise<ImageDTO> => {
@@ -299,10 +297,7 @@ export class CanvasCompositorModule extends CanvasModuleBase {
uploadImage({
file: new File([blob], 'canvas-composite.png', { type: 'image/png' }),
image_category: 'general',
is_intermediate: uploadOptions.is_intermediate,
board_id: uploadOptions.is_intermediate ? undefined : selectAutoAddBoardId(this.manager.store.getState()),
metadata: uploadOptions.metadata,
withToast: false,
...uploadOptions,
})
);
this.$isUploading.set(false);

View File

@@ -493,7 +493,7 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
file: new File([blob], `${this.id}_rasterized.png`, { type: 'image/png' }),
image_category: 'other',
is_intermediate: true,
withToast: false,
silent: true,
});
const imageObject = imageDTOToImageObject(imageDTO);
if (replaceObjects) {

View File

@@ -13,8 +13,9 @@ import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
import { toast } from 'features/toast/toast';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type UploadImageArg, uploadImages } from 'services/api/endpoints/images';
import { uploadImages } from 'services/api/endpoints/images';
import { useBoardName } from 'services/api/hooks/useBoardName';
import type { UploadImageArg } from 'services/api/types';
import { z } from 'zod';
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpg', 'image/jpeg'];
@@ -94,11 +95,12 @@ export const FullscreenDropzone = memo(() => {
}
const autoAddBoardId = selectAutoAddBoardId(getState());
const uploadArgs: UploadImageArg[] = files.map((file) => ({
const uploadArgs: UploadImageArg[] = files.map((file, i) => ({
file,
image_category: 'user',
is_intermediate: false,
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
isFirstUploadOfBatch: i === 0,
}));
uploadImages(uploadArgs);

View File

@@ -36,7 +36,10 @@ export const addImageToImage = async ({
}: AddImageToImageArg): Promise<Invocation<'img_resize' | 'l2i' | 'flux_vae_decode' | 'sd3_l2i'>> => {
denoise.denoising_start = denoising_start;
const adapters = manager.compositor.getVisibleAdaptersOfType('raster_layer');
const { image_name } = await manager.compositor.getCompositeImageDTO(adapters, bbox.rect, { is_intermediate: true });
const { image_name } = await manager.compositor.getCompositeImageDTO(adapters, bbox.rect, {
is_intermediate: true,
silent: true,
});
if (!isEqual(scaledSize, originalSize)) {
// Resize the initial image to the scaled size, denoise, then resize back to the original size

View File

@@ -51,11 +51,13 @@ export const addInpaint = async ({
const rasterAdapters = manager.compositor.getVisibleAdaptersOfType('raster_layer');
const initialImage = await manager.compositor.getCompositeImageDTO(rasterAdapters, bbox.rect, {
is_intermediate: true,
silent: true,
});
const inpaintMaskAdapters = manager.compositor.getVisibleAdaptersOfType('inpaint_mask');
const maskImage = await manager.compositor.getCompositeImageDTO(inpaintMaskAdapters, bbox.rect, {
is_intermediate: true,
silent: true,
});
if (!isEqual(scaledSize, originalSize)) {

View File

@@ -52,11 +52,13 @@ export const addOutpaint = async ({
const rasterAdapters = manager.compositor.getVisibleAdaptersOfType('raster_layer');
const initialImage = await manager.compositor.getCompositeImageDTO(rasterAdapters, bbox.rect, {
is_intermediate: true,
silent: true,
});
const inpaintMaskAdapters = manager.compositor.getVisibleAdaptersOfType('inpaint_mask');
const maskImage = await manager.compositor.getCompositeImageDTO(inpaintMaskAdapters, bbox.rect, {
is_intermediate: true,
silent: true,
});
const infill = getInfill(g, params);

View File

@@ -6,10 +6,10 @@ import type { components, paths } from 'services/api/schema';
import type {
DeleteBoardResult,
GraphAndWorkflowResponse,
ImageCategory,
ImageDTO,
ListImagesArgs,
ListImagesResponse,
UploadImageArg,
} from 'services/api/types';
import { getCategories, getListImagesUrl } from 'services/api/util';
import type { JsonObject } from 'type-fest';
@@ -260,20 +260,7 @@ export const imagesApi = api.injectEndpoints({
return [];
},
}),
uploadImage: build.mutation<
ImageDTO,
{
file: File;
image_category: ImageCategory;
is_intermediate: boolean;
session_id?: string;
board_id?: string;
crop_visible?: boolean;
metadata?: JsonObject;
isFirstUploadOfBatch?: boolean;
withToast?: boolean;
}
>({
uploadImage: build.mutation<ImageDTO, UploadImageArg>({
query: ({ file, image_category, is_intermediate, session_id, board_id, crop_visible, metadata }) => {
const formData = new FormData();
formData.append('file', file);
@@ -622,79 +609,17 @@ export const getImageMetadata = (
return req.unwrap();
};
export type UploadImageArg = {
file: File;
image_category: ImageCategory;
is_intermediate: boolean;
session_id?: string;
board_id?: string;
crop_visible?: boolean;
metadata?: JsonObject;
withToast?: boolean;
};
export const uploadImage = (arg: UploadImageArg): Promise<ImageDTO> => {
const {
file,
image_category,
is_intermediate,
crop_visible = false,
board_id,
metadata,
session_id,
withToast = true,
} = arg;
const { dispatch } = getStore();
const req = dispatch(
imagesApi.endpoints.uploadImage.initiate(
{
file,
image_category,
is_intermediate,
crop_visible,
board_id,
metadata,
session_id,
withToast,
},
{ track: false }
)
);
const req = dispatch(imagesApi.endpoints.uploadImage.initiate(arg, { track: false }));
return req.unwrap();
};
export const uploadImages = async (args: UploadImageArg[]): Promise<ImageDTO[]> => {
const { dispatch } = getStore();
const results = await Promise.allSettled(
args.map((arg, i) => {
const {
file,
image_category,
is_intermediate,
crop_visible = false,
board_id,
metadata,
session_id,
withToast = true,
} = arg;
const req = dispatch(
imagesApi.endpoints.uploadImage.initiate(
{
file,
image_category,
is_intermediate,
crop_visible,
board_id,
metadata,
session_id,
isFirstUploadOfBatch: i === 0,
withToast,
},
{ track: false }
)
);
args.map((arg) => {
const req = dispatch(imagesApi.endpoints.uploadImage.initiate(arg, { track: false }));
return req.unwrap();
})
);

View File

@@ -1,5 +1,5 @@
import type { components, paths } from 'services/api/schema';
import type { SetRequired } from 'type-fest';
import type { JsonObject, SetRequired } from 'type-fest';
export type S = components['schemas'];
@@ -287,3 +287,42 @@ export type SetHFTokenResponse = NonNullable<
export type SetHFTokenArg = NonNullable<
paths['/api/v2/models/hf_login']['post']['requestBody']['content']['application/json']
>;
export type UploadImageArg = {
/**
* The file object to upload
*/
file: File;
/**
* THe category of image to upload
*/
image_category: ImageCategory;
/**
* Whether the uploaded image is an intermediate image (intermediate images are not shown int he gallery)
*/
is_intermediate: boolean;
/**
* The session with which to associate the uploaded image
*/
session_id?: string;
/**
* The board id to add the image to
*/
board_id?: string;
/**
* Whether or not to crop the image to its bounding box before saving
*/
crop_visible?: boolean;
/**
* Metadata to embed in the image when saving it
*/
metadata?: JsonObject;
/**
* Whether this upload should be "silent" (no toast on upload, no changing of gallery view)
*/
silent?: boolean;
/**
* Whether this is the first upload of a batch (used when displaying user feedback with toasts - ignored if the upload is silent)
*/
isFirstUploadOfBatch?: boolean;
};