Skip to content

Commit afe74e1

Browse files
authored
feat(inspect): Capture frame (#3133)
* capture and upload video frame Signed-off-by: Colorado, Camilo <camilo.colorado@intel.com> * add delete media item button Signed-off-by: Colorado, Camilo <camilo.colorado@intel.com> * capture animation Signed-off-by: Colorado, Camilo <camilo.colorado@intel.com> * call revokeObjectURL Signed-off-by: Colorado, Camilo <camilo.colorado@intel.com> --------- Signed-off-by: Colorado, Camilo <camilo.colorado@intel.com>
1 parent 09d9f27 commit afe74e1

17 files changed

+571
-81
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Image } from '@geti-inspect/icons';
2+
import { Flex } from '@geti/ui';
3+
import { clsx } from 'clsx';
4+
5+
import styles from './dataset-item.module.scss';
6+
7+
export const DatasetItemPlaceholder = () => {
8+
return (
9+
<Flex
10+
justifyContent={'center'}
11+
alignItems={'center'}
12+
UNSAFE_className={clsx(styles.datasetItemPlaceholder, styles.datasetItem)}
13+
>
14+
<Flex>
15+
<Image />
16+
</Flex>
17+
</Flex>
18+
);
19+
};

application/ui/src/features/inspect/dataset/dataset-item/dataset-item.component.tsx

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,45 @@
1-
import { Image } from '@geti-inspect/icons';
2-
import { Flex } from '@geti/ui';
1+
import { useEffect } from 'react';
2+
3+
import { Skeleton } from '@geti/ui';
4+
import { useQuery } from '@tanstack/react-query';
35
import { clsx } from 'clsx';
46

57
import { useInference } from '../../inference-provider.component';
68
import { useSelectedMediaItem } from '../../selected-media-item-provider.component';
9+
import { DeleteMediaItem } from '../delete-dataset-item/delete-dataset-item.component';
710
import { type MediaItem } from '../types';
811

912
import styles from './dataset-item.module.scss';
1013

11-
const DatasetItemPlaceholder = () => {
12-
return (
13-
<Flex
14-
justifyContent={'center'}
15-
alignItems={'center'}
16-
UNSAFE_className={clsx(styles.datasetItemPlaceholder, styles.datasetItem)}
17-
>
18-
<Flex>
19-
<Image />
20-
</Flex>
21-
</Flex>
22-
);
23-
};
24-
2514
interface DatasetItemProps {
2615
mediaItem: MediaItem;
2716
}
2817

29-
const DatasetItem = ({ mediaItem }: DatasetItemProps) => {
18+
export const DatasetItem = ({ mediaItem }: DatasetItemProps) => {
3019
const { selectedMediaItem, onSetSelectedMediaItem } = useSelectedMediaItem();
3120
const { onInference, selectedModelId } = useInference();
3221

3322
const isSelected = selectedMediaItem?.id === mediaItem.id;
3423

35-
const mediaUrl = `/api/projects/${mediaItem.project_id}/images/${mediaItem.id}/thumbnail`;
24+
const { data: thumbnailBlob, isLoading } = useQuery({
25+
queryKey: ['media', mediaItem.id],
26+
queryFn: async () => {
27+
const response = await fetch(`/api/projects/${mediaItem.project_id}/images/${mediaItem.id}/thumbnail`);
28+
29+
if (!response.ok) {
30+
throw new Error('Network response was not ok');
31+
}
32+
return URL.createObjectURL(await response.blob());
33+
},
34+
retry: 3,
35+
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 10000),
36+
});
37+
38+
useEffect(() => {
39+
return () => {
40+
thumbnailBlob && URL.revokeObjectURL(thumbnailBlob);
41+
};
42+
}, [thumbnailBlob]);
3643

3744
const handleClick = async () => {
3845
const selection = mediaItem.id === selectedMediaItem?.id ? undefined : mediaItem;
@@ -42,25 +49,22 @@ const DatasetItem = ({ mediaItem }: DatasetItemProps) => {
4249
};
4350

4451
return (
45-
<div
46-
className={clsx(styles.datasetItem, {
47-
[styles.datasetItemSelected]: isSelected,
48-
})}
49-
onClick={handleClick}
50-
>
51-
<img src={mediaUrl} alt={mediaItem.filename} />
52+
<div className={clsx(styles.datasetItem, { [styles.datasetItemSelected]: isSelected })} onClick={handleClick}>
53+
{isLoading || !thumbnailBlob ? (
54+
<Skeleton width={'100%'} height={'100%'} />
55+
) : (
56+
<>
57+
<img src={thumbnailBlob} alt={mediaItem.filename} />
58+
<div className={clsx(styles.floatingContainer, styles.rightTopElement)}>
59+
<DeleteMediaItem
60+
itemsIds={[String(mediaItem.id)]}
61+
onDeleted={() => {
62+
selectedMediaItem?.id === mediaItem.id && onSetSelectedMediaItem(undefined);
63+
}}
64+
/>
65+
</div>
66+
</>
67+
)}
5268
</div>
5369
);
5470
};
55-
56-
interface DatasetItemContainerProps {
57-
mediaItem: MediaItem | undefined;
58-
}
59-
60-
export const DatasetItemContainer = ({ mediaItem }: DatasetItemContainerProps) => {
61-
if (mediaItem === undefined) {
62-
return <DatasetItemPlaceholder />;
63-
}
64-
65-
return <DatasetItem mediaItem={mediaItem} />;
66-
};

application/ui/src/features/inspect/dataset/dataset-item/dataset-item.module.scss

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
.datasetItem {
22
aspect-ratio: 4/3;
33
cursor: pointer;
4+
position: relative;
45

56
img {
67
display: block;
@@ -11,8 +12,13 @@
1112
}
1213

1314
border: var(--spectrum-alias-border-size-thick) solid transparent;
14-
1515
transition: border-color 0.3s ease-in-out;
16+
17+
&:hover {
18+
.floatingContainer {
19+
opacity: 1;
20+
}
21+
}
1622
}
1723

1824
.datasetItemSelected {
@@ -23,3 +29,17 @@
2329
border: 1px dashed var(--spectrum-global-color-gray-700);
2430
background-color: var(--spectrum-global-color-gray-200);
2531
}
32+
33+
.floatingContainer {
34+
opacity: 0;
35+
position: absolute;
36+
line-height: 0px;
37+
padding: var(--spectrum-global-dimension-size-125);
38+
border-radius: var(--spectrum-global-dimension-size-50);
39+
background-color: var(--spectrum-gray-100);
40+
}
41+
42+
.rightTopElement {
43+
top: var(--spectrum-global-dimension-size-50);
44+
right: var(--spectrum-global-dimension-size-50);
45+
}

application/ui/src/features/inspect/dataset/dataset-list.component.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Flex, Grid, Heading, minmax, repeat } from '@geti/ui';
2+
import isEmpty from 'lodash-es/isEmpty';
23

3-
import { DatasetItemContainer } from './dataset-item/dataset-item.component';
4+
import { DatasetItemPlaceholder } from './dataset-item/dataset-item-placeholder.component';
5+
import { DatasetItem } from './dataset-item/dataset-item.component';
46
import { MediaItem } from './types';
57
import { REQUIRED_NUMBER_OF_NORMAL_IMAGES_TO_TRIGGER_TRAINING } from './utils';
68

@@ -22,14 +24,18 @@ export const DatasetList = ({ mediaItems }: DatasetItemProps) => {
2224

2325
<Grid
2426
flex={1}
25-
columns={repeat('auto-fill', minmax('size-1600', '1fr'))}
26-
rows={['max-content', '1fr']}
2727
gap={'size-100'}
28+
rows={['max-content', '1fr']}
2829
alignContent={'start'}
30+
columns={repeat('auto-fill', minmax('size-1600', '1fr'))}
2931
>
30-
{mediaItemsToRender.map((mediaItem, index) => (
31-
<DatasetItemContainer key={mediaItem?.id ?? index} mediaItem={mediaItem} />
32-
))}
32+
{mediaItemsToRender.map((mediaItem, index) =>
33+
isEmpty(mediaItem) ? (
34+
<DatasetItemPlaceholder key={index} />
35+
) : (
36+
<DatasetItem key={mediaItem.id} mediaItem={mediaItem} />
37+
)
38+
)}
3339
</Grid>
3440
</Flex>
3541
);

application/ui/src/features/inspect/dataset/dataset.component.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,7 @@ const useMediaItems = () => {
1313
const { projectId } = useProjectIdentifier();
1414

1515
const { data } = $api.useSuspenseQuery('get', '/api/projects/{project_id}/images', {
16-
params: {
17-
path: {
18-
project_id: projectId,
19-
},
20-
},
16+
params: { path: { project_id: projectId } },
2117
});
2218

2319
return {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { AlertDialog, Text } from '@geti/ui';
2+
3+
import { useEventListener } from '../../../../hooks/event-listener/event-listener.hook';
4+
5+
type AlertDialogContentProps = {
6+
itemsIds: string[];
7+
onPrimaryAction: () => void;
8+
};
9+
10+
export const AlertDialogContent = ({ itemsIds, onPrimaryAction }: AlertDialogContentProps) => {
11+
useEventListener('keydown', (event) => {
12+
if (event.key === 'Enter') {
13+
event.preventDefault();
14+
onPrimaryAction();
15+
}
16+
});
17+
18+
return (
19+
<AlertDialog
20+
maxHeight={'size-6000'}
21+
title='Delete Items'
22+
variant='confirmation'
23+
primaryActionLabel='Confirm'
24+
secondaryActionLabel='Close'
25+
onPrimaryAction={onPrimaryAction}
26+
>
27+
<Text>{`Are you sure you want to delete ${itemsIds.length} item(s)?`}</Text>
28+
</AlertDialog>
29+
);
30+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useProjectIdentifier } from '@geti-inspect/hooks';
5+
import { ActionButton, DialogContainer, toast } from '@geti/ui';
6+
import { Delete } from '@geti/ui/icons';
7+
import { useOverlayTriggerState } from '@react-stately/overlays';
8+
import { isFunction } from 'lodash-es';
9+
10+
import { $api } from '../../../../api/client';
11+
import { AlertDialogContent } from './alert-dialog-content.component';
12+
13+
import classes from './delete-dataset-item.module.scss';
14+
15+
export interface DeleteMediaItemProps {
16+
itemsIds: string[];
17+
onDeleted?: (deletedIds: string[]) => void;
18+
}
19+
20+
const isFulfilled = (response: PromiseSettledResult<{ itemId: string }>) => response.status === 'fulfilled';
21+
22+
export const DeleteMediaItem = ({ itemsIds = [], onDeleted }: DeleteMediaItemProps) => {
23+
const alertDialogState = useOverlayTriggerState({});
24+
const { projectId: project_id } = useProjectIdentifier();
25+
26+
const removeMutation = $api.useMutation('delete', `/api/projects/{project_id}/images/{media_id}`, {
27+
meta: { invalidates: [['get', '/api/projects/{project_id}/images', { params: { path: { project_id } } }]] },
28+
onError: (error, { params: { path } }) => {
29+
const { media_id: itemId } = path;
30+
31+
toast({
32+
id: itemId,
33+
type: 'error',
34+
message: `Failed to delete, ${error?.detail}`,
35+
});
36+
},
37+
});
38+
39+
const handleRemoveItems = async () => {
40+
alertDialogState.close();
41+
42+
toast({ id: 'deleting-notification', type: 'info', message: `Deleting items...` });
43+
44+
const deleteItemPromises = itemsIds.map(async (media_id) => {
45+
await removeMutation.mutateAsync({ params: { path: { project_id, media_id } } });
46+
47+
return { itemId: media_id };
48+
});
49+
50+
const responses = await Promise.allSettled(deleteItemPromises);
51+
const deletedIds = responses.filter(isFulfilled).map(({ value }) => value.itemId);
52+
53+
isFunction(onDeleted) && onDeleted(deletedIds);
54+
55+
toast({
56+
id: 'deleting-notification',
57+
type: 'success',
58+
message: `${deletedIds.length} item(s) deleted successfully`,
59+
duration: 3000,
60+
});
61+
};
62+
63+
return (
64+
<>
65+
<ActionButton
66+
isQuiet
67+
aria-label='delete media item'
68+
onPress={alertDialogState.open}
69+
isDisabled={removeMutation.isPending}
70+
UNSAFE_className={classes.deleteButton}
71+
>
72+
<Delete />
73+
</ActionButton>
74+
75+
<DialogContainer onDismiss={alertDialogState.close}>
76+
{alertDialogState.isOpen && (
77+
<AlertDialogContent itemsIds={itemsIds} onPrimaryAction={handleRemoveItems} />
78+
)}
79+
</DialogContainer>
80+
</>
81+
);
82+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
div:has(> .deleteButton) {
5+
padding: var(--spectrum-global-dimension-size-10);
6+
}

0 commit comments

Comments
 (0)