Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui, api): support for bulk client-side uploads #7851

Merged
merged 4 commits into from
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions invokeai/app/api/routers/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ async def upload_image(
raise HTTPException(status_code=500, detail="Failed to create image")


class ImageUploadEntry(BaseModel):
image_dto: ImageDTO = Body(description="The image DTO")
presigned_url: str = Body(description="The URL to get the presigned URL for the image upload")


@images_router.post("/", operation_id="create_image_upload_entry")
async def create_image_upload_entry(
width: int = Body(description="The width of the image"),
height: int = Body(description="The height of the image"),
board_id: Optional[str] = Body(default=None, description="The board to add this image to, if any"),
) -> ImageUploadEntry:
"""Uploads an image from a URL, not implemented"""

raise HTTPException(status_code=501, detail="Not implemented")


@images_router.delete("/i/{image_name}", operation_id="delete_image")
async def delete_image(
image_name: str = Path(description="The name of the image to delete"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { isAnyOf } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import type { RootState } from 'app/store/store';
import { imageUploadedClientSide } from 'features/gallery/store/actions';
import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors';
import { boardIdSelected, galleryViewChanged } from 'features/gallery/store/gallerySlice';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { omit } from 'lodash-es';
import { boardsApi } from 'services/api/endpoints/boards';
import { imagesApi } from 'services/api/endpoints/images';

import type { ImageDTO } from 'services/api/types';
import { getCategories, getListImagesUrl } from 'services/api/util';
const log = logger('gallery');

/**
Expand All @@ -34,19 +37,56 @@ let lastUploadedToastTimeout: number | null = null;

export const addImageUploadedFulfilledListener = (startAppListening: AppStartListening) => {
startAppListening({
matcher: imagesApi.endpoints.uploadImage.matchFulfilled,
matcher: isAnyOf(imagesApi.endpoints.uploadImage.matchFulfilled, imageUploadedClientSide),
effect: (action, { dispatch, getState }) => {
const imageDTO = action.payload;
const state = getState();
let imageDTO: ImageDTO;
let silent;
let isFirstUploadOfBatch = true;

log.debug({ imageDTO }, 'Image uploaded');
if (imageUploadedClientSide.match(action)) {
imageDTO = action.payload.imageDTO;
silent = action.payload.silent;
isFirstUploadOfBatch = action.payload.isFirstUploadOfBatch;
} else if (imagesApi.endpoints.uploadImage.matchFulfilled(action)) {
imageDTO = action.payload;
silent = action.meta.arg.originalArgs.silent;
isFirstUploadOfBatch = action.meta.arg.originalArgs.isFirstUploadOfBatch ?? true;
} else {
return;
}

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
if (silent || imageDTO.is_intermediate) {
// If the image is silent or intermediate, we don't want to show a toast
return;
}

if (imageUploadedClientSide.match(action)) {
const categories = getCategories(imageDTO);
const boardId = imageDTO.board_id ?? 'none';
dispatch(
imagesApi.util.invalidateTags([
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories,
}),
},
{
type: 'Board',
id: boardId,
},
{
type: 'BoardImagesTotal',
id: boardId,
},
])
);
}
const state = getState();

log.debug({ imageDTO }, 'Image uploaded');

const boardId = imageDTO.board_id ?? 'none';

const DEFAULT_UPLOADED_TOAST = {
Expand Down Expand Up @@ -80,7 +120,7 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis
*
* Default to true to not require _all_ image upload handlers to set this value
*/
const isFirstUploadOfBatch = action.meta.arg.originalArgs.isFirstUploadOfBatch ?? true;

if (isFirstUploadOfBatch) {
dispatch(boardIdSelected({ boardId }));
dispatch(galleryViewChanged('assets'));
Expand Down
2 changes: 1 addition & 1 deletion invokeai/frontend/web/src/app/types/invokeai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export type AppConfig = {
maxUpscaleDimension?: number;
allowPrivateBoards: boolean;
allowPrivateStylePresets: boolean;
allowClientSideUpload: boolean;
disabledTabs: TabName[];
disabledFeatures: AppFeature[];
disabledSDFeatures: SDFeature[];
Expand All @@ -81,7 +82,6 @@ export type AppConfig = {
metadataFetchDebounce?: number;
workflowFetchDebounce?: number;
isLocal?: boolean;
maxImageUploadCount?: number;
sd: {
defaultModel?: string;
disabledControlNetModels: string[];
Expand Down
105 changes: 105 additions & 0 deletions invokeai/frontend/web/src/common/hooks/useClientSideUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useStore } from '@nanostores/react';
import { $authToken } from 'app/store/nanostores/authToken';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { imageUploadedClientSide } from 'features/gallery/store/actions';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { useCallback } from 'react';
import { useCreateImageUploadEntryMutation } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
export const useClientSideUpload = () => {
const dispatch = useAppDispatch();
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const authToken = useStore($authToken);
const [createImageUploadEntry] = useCreateImageUploadEntryMutation();

const clientSideUpload = useCallback(
async (file: File, i: number): Promise<ImageDTO> => {
const image = new Image();
const objectURL = URL.createObjectURL(file);
image.src = objectURL;
let width = 0;
let height = 0;
let thumbnail: Blob | undefined;

await new Promise<void>((resolve) => {
image.onload = () => {
width = image.naturalWidth;
height = image.naturalHeight;

// Calculate thumbnail dimensions maintaining aspect ratio
let thumbWidth = width;
let thumbHeight = height;
if (width > height && width > 256) {
thumbWidth = 256;
thumbHeight = Math.round((height * 256) / width);
} else if (height > 256) {
thumbHeight = 256;
thumbWidth = Math.round((width * 256) / height);
}

const canvas = document.createElement('canvas');
canvas.width = thumbWidth;
canvas.height = thumbHeight;
const ctx = canvas.getContext('2d');
ctx?.drawImage(image, 0, 0, thumbWidth, thumbHeight);

canvas.toBlob(
(blob) => {
if (blob) {
thumbnail = blob;
// Clean up resources
URL.revokeObjectURL(objectURL);
image.src = ''; // Clear image source
image.remove(); // Remove the image element
canvas.width = 0; // Clear canvas
canvas.height = 0;
resolve();
}
},
'image/webp',
0.8
);
};

// Handle load errors
image.onerror = () => {
URL.revokeObjectURL(objectURL);
image.remove();
resolve();
};
});
const { presigned_url, image_dto } = await createImageUploadEntry({
width,
height,
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
}).unwrap();

await fetch(`${presigned_url}/?type=full`, {
method: 'PUT',
body: file,
...(authToken && {
headers: {
Authorization: `Bearer ${authToken}`,
},
}),
});

await fetch(`${presigned_url}/?type=thumbnail`, {
method: 'PUT',
body: thumbnail,
...(authToken && {
headers: {
Authorization: `Bearer ${authToken}`,
},
}),
});

dispatch(imageUploadedClientSide({ imageDTO: image_dto, silent: false, isFirstUploadOfBatch: i === 0 }));

return image_dto;
},
[autoAddBoardId, authToken, createImageUploadEntry, dispatch]
);

return clientSideUpload;
};
43 changes: 23 additions & 20 deletions invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { IconButton } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { useAppSelector } from 'app/store/storeHooks';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
import { selectIsClientSideUploadEnabled } from 'features/system/store/configSlice';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import type { FileRejection } from 'react-dropzone';
Expand All @@ -15,6 +15,7 @@ import type { ImageDTO } from 'services/api/types';
import { assert } from 'tsafe';
import type { SetOptional } from 'type-fest';

import { useClientSideUpload } from './useClientSideUpload';
type UseImageUploadButtonArgs =
| {
isDisabled?: boolean;
Expand Down Expand Up @@ -50,8 +51,9 @@ const log = logger('gallery');
*/
export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: UseImageUploadButtonArgs) => {
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const isClientSideUploadEnabled = useAppSelector(selectIsClientSideUploadEnabled);
const [uploadImage, request] = useUploadImageMutation();
const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
const clientSideUpload = useClientSideUpload();
const { t } = useTranslation();

const onDropAccepted = useCallback(
Expand Down Expand Up @@ -79,22 +81,27 @@ export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: Us
onUpload(imageDTO);
}
} else {
const imageDTOs = await uploadImages(
files.map((file, i) => ({
file,
image_category: 'user',
is_intermediate: false,
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
silent: false,
isFirstUploadOfBatch: i === 0,
}))
);
let imageDTOs: ImageDTO[] = [];
if (isClientSideUploadEnabled) {
imageDTOs = await Promise.all(files.map((file, i) => clientSideUpload(file, i)));
} else {
imageDTOs = await uploadImages(
files.map((file, i) => ({
file,
image_category: 'user',
is_intermediate: false,
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
silent: false,
isFirstUploadOfBatch: i === 0,
}))
);
}
if (onUpload) {
onUpload(imageDTOs);
}
}
},
[allowMultiple, autoAddBoardId, onUpload, uploadImage]
[allowMultiple, autoAddBoardId, onUpload, uploadImage, isClientSideUploadEnabled, clientSideUpload]
);

const onDropRejected = useCallback(
Expand All @@ -105,10 +112,7 @@ export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: Us
file: rejection.file.path,
}));
log.error({ errors }, 'Invalid upload');
const description =
maxImageUploadCount === undefined
? t('toast.uploadFailedInvalidUploadDesc')
: t('toast.uploadFailedInvalidUploadDesc_withCount', { count: maxImageUploadCount });
const description = t('toast.uploadFailedInvalidUploadDesc');

toast({
id: 'UPLOAD_FAILED',
Expand All @@ -120,7 +124,7 @@ export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: Us
return;
}
},
[maxImageUploadCount, t]
[t]
);

const {
Expand All @@ -137,8 +141,7 @@ export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: Us
onDropRejected,
disabled: isDisabled,
noDrag: true,
multiple: allowMultiple && (maxImageUploadCount === undefined || maxImageUploadCount > 1),
maxFiles: maxImageUploadCount,
multiple: allowMultiple,
});

return { getUploadButtonProps, getUploadInputProps, openUploader, request };
Expand Down
Loading