Skip to content

Commit 8883775

Browse files
feat(ui): rework image uploads (wip)
1 parent cfadb31 commit 8883775

File tree

7 files changed

+153
-55
lines changed

7 files changed

+153
-55
lines changed

invokeai/frontend/web/src/common/components/UploadButton.tsx

Whitespace-only changes.

invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Flex, Icon, type SystemStyleObject } from '@invoke-ai/ui-library';
12
import { logger } from 'app/logging/logger';
23
import { useAppSelector } from 'app/store/storeHooks';
34
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
@@ -7,14 +8,22 @@ import { useCallback } from 'react';
78
import type { FileRejection } from 'react-dropzone';
89
import { useDropzone } from 'react-dropzone';
910
import { useTranslation } from 'react-i18next';
10-
import { useUploadImageMutation } from 'services/api/endpoints/images';
11-
import type { PostUploadAction } from 'services/api/types';
11+
import { PiUploadSimpleBold } from 'react-icons/pi';
12+
import { uploadImages, useUploadImageMutation } from 'services/api/endpoints/images';
13+
import type { ImageDTO } from 'services/api/types';
14+
import { assert } from 'tsafe';
1215

13-
type UseImageUploadButtonArgs = {
14-
postUploadAction?: PostUploadAction;
15-
isDisabled?: boolean;
16-
allowMultiple?: boolean;
17-
};
16+
type UseImageUploadButtonArgs =
17+
| {
18+
isDisabled?: boolean;
19+
allowMultiple: false;
20+
onUpload?: (imageDTO: ImageDTO) => void;
21+
}
22+
| {
23+
isDisabled?: boolean;
24+
allowMultiple: true;
25+
onUpload?: (imageDTOs: ImageDTO[]) => void;
26+
};
1827

1928
const log = logger('gallery');
2029

@@ -37,30 +46,46 @@ const log = logger('gallery');
3746
* <Button {...getUploadButtonProps()} /> // will open the file dialog on click
3847
* <input {...getUploadInputProps()} /> // hidden, handles native upload functionality
3948
*/
40-
export const useImageUploadButton = ({
41-
postUploadAction,
42-
isDisabled,
43-
allowMultiple = false,
44-
}: UseImageUploadButtonArgs) => {
49+
export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: UseImageUploadButtonArgs) => {
4550
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
4651
const [uploadImage] = useUploadImageMutation();
4752
const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
4853
const { t } = useTranslation();
4954

5055
const onDropAccepted = useCallback(
51-
(files: File[]) => {
52-
for (const [i, file] of files.entries()) {
53-
uploadImage({
56+
async (files: File[]) => {
57+
if (!allowMultiple) {
58+
if (files.length > 1) {
59+
log.warn('Multiple files dropped but only one allowed');
60+
return;
61+
}
62+
const file = files[0];
63+
assert(file !== undefined); // should never happen
64+
const imageDTO = await uploadImage({
5465
file,
5566
image_category: 'user',
5667
is_intermediate: false,
57-
postUploadAction: postUploadAction ?? { type: 'TOAST' },
5868
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
59-
isFirstUploadOfBatch: i === 0,
60-
});
69+
}).unwrap();
70+
if (onUpload) {
71+
onUpload(imageDTO);
72+
}
73+
} else {
74+
//
75+
const imageDTOs = await uploadImages(
76+
files.map((file) => ({
77+
file,
78+
image_category: 'user',
79+
is_intermediate: false,
80+
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
81+
}))
82+
);
83+
if (onUpload) {
84+
onUpload(imageDTOs);
85+
}
6186
}
6287
},
63-
[autoAddBoardId, postUploadAction, uploadImage]
88+
[allowMultiple, autoAddBoardId, onUpload, uploadImage]
6489
);
6590

6691
const onDropRejected = useCallback(
@@ -105,3 +130,34 @@ export const useImageUploadButton = ({
105130

106131
return { getUploadButtonProps, getUploadInputProps, openUploader };
107132
};
133+
134+
const sx = {
135+
w: 'full',
136+
h: 'full',
137+
alignItems: 'center',
138+
justifyContent: 'center',
139+
borderRadius: 'base',
140+
transitionProperty: 'common',
141+
transitionDuration: '0.1s',
142+
'&[data-disabled=false]': {
143+
color: 'base.500',
144+
},
145+
'&[data-disabled=true]': {
146+
cursor: 'pointer',
147+
bg: 'base.700',
148+
_hover: {
149+
bg: 'base.650',
150+
color: 'base.300',
151+
},
152+
},
153+
} satisfies SystemStyleObject;
154+
155+
export const UploadImageButton = (props: UseImageUploadButtonArgs) => {
156+
const uploadApi = useImageUploadButton(props);
157+
return (
158+
<Flex sx={sx} {...uploadApi.getUploadButtonProps()}>
159+
<Icon as={PiUploadSimpleBold} boxSize={16} />
160+
<input {...uploadApi.getUploadInputProps()} />
161+
</Flex>
162+
);
163+
};

invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapter.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Flex, IconButton } from '@invoke-ai/ui-library';
22
import { createMemoizedAppSelector } from 'app/store/createMemoizedSelector';
3-
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
3+
import { useAppStore } from 'app/store/nanostores/store';
4+
import { useAppSelector } from 'app/store/storeHooks';
45
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
56
import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct';
67
import { Weight } from 'features/controlLayers/components/common/Weight';
@@ -21,10 +22,11 @@ import { getFilterForModel } from 'features/controlLayers/store/filters';
2122
import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
2223
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
2324
import type { CanvasEntityIdentifier, ControlModeV2 } from 'features/controlLayers/store/types';
25+
import { replaceCanvasEntityObjectsWithImage } from 'features/imageActions/actions';
2426
import { memo, useCallback, useMemo } from 'react';
2527
import { useTranslation } from 'react-i18next';
2628
import { PiBoundingBoxBold, PiShootingStarFill, PiUploadBold } from 'react-icons/pi';
27-
import type { ControlNetModelConfig, PostUploadAction, T2IAdapterModelConfig } from 'services/api/types';
29+
import type { ControlNetModelConfig, ImageDTO, T2IAdapterModelConfig } from 'services/api/types';
2830

2931
const useControlLayerControlAdapter = (entityIdentifier: CanvasEntityIdentifier<'control_layer'>) => {
3032
const selectControlAdapter = useMemo(
@@ -41,7 +43,7 @@ const useControlLayerControlAdapter = (entityIdentifier: CanvasEntityIdentifier<
4143

4244
export const ControlLayerControlAdapter = memo(() => {
4345
const { t } = useTranslation();
44-
const dispatch = useAppDispatch();
46+
const { dispatch, getState } = useAppStore();
4547
const entityIdentifier = useEntityIdentifierContext('control_layer');
4648
const controlAdapter = useControlLayerControlAdapter(entityIdentifier);
4749
const filter = useEntityFilter(entityIdentifier);
@@ -113,11 +115,17 @@ export const ControlLayerControlAdapter = memo(() => {
113115

114116
const pullBboxIntoLayer = usePullBboxIntoLayer(entityIdentifier);
115117
const isBusy = useCanvasIsBusy();
116-
const postUploadAction = useMemo<PostUploadAction>(
117-
() => ({ type: 'REPLACE_LAYER_WITH_IMAGE', entityIdentifier }),
118-
[entityIdentifier]
118+
const uploadOptions = useMemo(
119+
() =>
120+
({
121+
onUpload: (imageDTO: ImageDTO) => {
122+
replaceCanvasEntityObjectsWithImage({ entityIdentifier, imageDTO, dispatch, getState });
123+
},
124+
allowMultiple: false,
125+
}) as const,
126+
[dispatch, entityIdentifier, getState]
119127
);
120-
const uploadApi = useImageUploadButton({ postUploadAction });
128+
const uploadApi = useImageUploadButton(uploadOptions);
121129

122130
return (
123131
<Flex flexDir="column" gap={3} position="relative" w="full">

invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library';
22
import { Flex } from '@invoke-ai/ui-library';
33
import { useStore } from '@nanostores/react';
44
import { skipToken } from '@reduxjs/toolkit/query';
5+
import { UploadImageButton } from 'common/hooks/useImageUploadButton';
56
import type { ImageWithDims } from 'features/controlLayers/store/types';
6-
import type {
7-
setGlobalReferenceImageDndTarget,
8-
setRegionalGuidanceReferenceImageDndTarget,
9-
} from 'features/dnd/dnd';
7+
import type { setGlobalReferenceImageDndTarget, setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd';
108
import { DndDropTarget } from 'features/dnd/DndDropTarget';
119
import { DndImage } from 'features/dnd/DndImage';
1210
import { DndImageIcon } from 'features/dnd/DndImageIcon';
@@ -58,8 +56,16 @@ export const IPAdapterImagePreview = memo(
5856
}
5957
}, [handleResetControlImage, isError, isConnected]);
6058

59+
const onUpload = useCallback(
60+
(imageDTO: ImageDTO) => {
61+
onChangeImage(imageDTO);
62+
},
63+
[onChangeImage]
64+
);
65+
6166
return (
6267
<Flex sx={sx} data-error={!imageDTO && !image?.image_name}>
68+
{!imageDTO && <UploadImageButton allowMultiple={false} onUpload={onUpload} />}
6369
{imageDTO && (
6470
<>
6571
<DndImage imageDTO={imageDTO} />

invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,11 @@ export const DndDropTarget = memo(<T extends AnyDndTarget>(props: Props<T>) => {
157157
continue;
158158
}
159159
const imageDTO = await uploadImage({
160-
blob: file,
161-
fileName: file.name,
160+
type: 'file',
161+
file: file,
162162
image_category: 'user',
163163
is_intermediate: false,
164164
});
165-
// Dnd.Util.handleDrop(Dnd.Source.singleImage.getData({ imageDTO }), targetData);
166165
}
167166
},
168167
}),

invokeai/frontend/web/src/features/gallery/components/GalleryUploadButton.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import { useAppSelector } from 'app/store/storeHooks';
33
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
44
import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
55
import { t } from 'i18next';
6+
import { useMemo } from 'react';
67
import { PiUploadBold } from 'react-icons/pi';
78

8-
const options = { postUploadAction: { type: 'TOAST' }, allowMultiple: true } as const;
9-
109
export const GalleryUploadButton = () => {
11-
const uploadApi = useImageUploadButton(options);
1210
const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
11+
const uploadOptions = useMemo(() => ({ allowMultiple: maxImageUploadCount !== 1 }), [maxImageUploadCount]);
12+
const uploadApi = useImageUploadButton(uploadOptions);
1313
return (
1414
<>
1515
<IconButton

invokeai/frontend/web/src/services/api/endpoints/images.ts

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import type {
1111
ImageDTO,
1212
ListImagesArgs,
1313
ListImagesResponse,
14-
PostUploadAction,
1514
} from 'services/api/types';
1615
import { getCategories, getListImagesUrl } from 'services/api/util';
16+
import type { JsonObject } from 'type-fest';
1717

1818
import type { ApiTagDescription } from '..';
1919
import { api, buildV1Url, LIST_TAG } from '..';
@@ -267,7 +267,6 @@ export const imagesApi = api.injectEndpoints({
267267
file: File;
268268
image_category: ImageCategory;
269269
is_intermediate: boolean;
270-
postUploadAction?: PostUploadAction;
271270
session_id?: string;
272271
board_id?: string;
273272
crop_visible?: boolean;
@@ -623,30 +622,60 @@ export const getImageMetadata = (
623622
return req.unwrap();
624623
};
625624

626-
export type UploadOptions = {
627-
blob: Blob;
628-
fileName: string;
625+
export type UploadImageArg = {
626+
file: File;
629627
image_category: ImageCategory;
630628
is_intermediate: boolean;
629+
session_id?: string;
630+
board_id?: string;
631631
crop_visible?: boolean;
632-
board_id?: BoardId;
633-
metadata?: SerializableObject;
632+
metadata?: JsonObject;
634633
};
635-
export const uploadImage = (arg: UploadOptions): Promise<ImageDTO> => {
636-
const { blob, fileName, image_category, is_intermediate, crop_visible = false, board_id, metadata } = arg;
634+
635+
export const uploadImage = (arg: UploadImageArg): Promise<ImageDTO> => {
636+
const { file, image_category, is_intermediate, crop_visible = false, board_id, metadata, session_id } = arg;
637637

638638
const { dispatch } = getStore();
639-
const file = new File([blob], fileName, { type: 'image/png' });
639+
640640
const req = dispatch(
641-
imagesApi.endpoints.uploadImage.initiate({
642-
file,
643-
image_category,
644-
is_intermediate,
645-
crop_visible,
646-
board_id,
647-
metadata,
648-
})
641+
imagesApi.endpoints.uploadImage.initiate(
642+
{
643+
file,
644+
image_category,
645+
is_intermediate,
646+
crop_visible,
647+
board_id,
648+
metadata,
649+
session_id,
650+
},
651+
{ track: false }
652+
)
649653
);
650-
req.reset();
651654
return req.unwrap();
652655
};
656+
657+
export const uploadImages = async (args: UploadImageArg[]): Promise<ImageDTO[]> => {
658+
const { dispatch } = getStore();
659+
const results = await Promise.allSettled(
660+
args.map((arg, i) => {
661+
const { file, image_category, is_intermediate, crop_visible = false, board_id, metadata, session_id } = arg;
662+
const req = dispatch(
663+
imagesApi.endpoints.uploadImage.initiate(
664+
{
665+
file,
666+
image_category,
667+
is_intermediate,
668+
crop_visible,
669+
board_id,
670+
metadata,
671+
session_id,
672+
isFirstUploadOfBatch: i === 0,
673+
},
674+
{ track: false }
675+
)
676+
);
677+
return req.unwrap();
678+
})
679+
);
680+
return results.filter((r): r is PromiseFulfilledResult<ImageDTO> => r.status === 'fulfilled').map((r) => r.value);
681+
};

0 commit comments

Comments
 (0)