From 1c6ede453fe64a9f9a87d1de5f3aabb4a6cf922a Mon Sep 17 00:00:00 2001 From: Samy Ouyahia <103439265+souyahia-monk@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:45:44 +0200 Subject: [PATCH] Feat/mn 562/network video requests (#815) * Added isVideoCapture param to the CreateInspection request * Added AddVideoFrameOptions to the add image request * Fixed Pr comments --- .../src/__mocks__/@monkvision/network.ts | 4 +- .../src/PhotoCapture/hooks/useUploadQueue.ts | 14 +- .../PhotoCapture/hooks/useUploadQueue.test.ts | 12 +- packages/network/src/api/image/requests.ts | 139 +++++++++++++++--- packages/network/src/api/index.ts | 2 + .../network/src/api/inspection/mappers.ts | 1 + .../network/test/api/image/requests.test.ts | 56 ++++++- .../data/apiInspectionPost.data.json | 3 +- .../inspection/data/apiInspectionPost.data.ts | 1 + packages/types/src/api.ts | 6 + packages/types/src/state/image.ts | 10 ++ packages/types/src/state/vehicle.ts | 2 +- 12 files changed, 210 insertions(+), 40 deletions(-) diff --git a/configs/test-utils/src/__mocks__/@monkvision/network.ts b/configs/test-utils/src/__mocks__/@monkvision/network.ts index 781ce9dd7..d81cd2425 100644 --- a/configs/test-utils/src/__mocks__/@monkvision/network.ts +++ b/configs/test-utils/src/__mocks__/@monkvision/network.ts @@ -1,4 +1,5 @@ -const { MonkApiPermission, MonkNetworkError } = jest.requireActual('@monkvision/network'); +const { MonkApiPermission, MonkNetworkError, ImageUploadType } = + jest.requireActual('@monkvision/network'); const MonkApi = { getInspection: jest.fn(() => Promise.resolve()), @@ -13,6 +14,7 @@ export = { /* Actual exports */ MonkApiPermission, MonkNetworkError, + ImageUploadType, /* Mocks */ decodeMonkJwt: jest.fn((str) => str), diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts index 35373e656..693aa26ef 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts @@ -1,12 +1,6 @@ import { Queue, uniq, useQueue } from '@monkvision/common'; -import { AddImageOptions, MonkApiConfig, useMonkApi } from '@monkvision/network'; -import { - CaptureAppConfig, - ComplianceOptions, - ImageType, - MonkPicture, - TaskName, -} from '@monkvision/types'; +import { AddImageOptions, ImageUploadType, MonkApiConfig, useMonkApi } from '@monkvision/network'; +import { CaptureAppConfig, ComplianceOptions, MonkPicture, TaskName } from '@monkvision/types'; import { useRef } from 'react'; import { useMonitoring } from '@monkvision/monitoring'; import { PhotoCaptureMode } from './useAddDamageMode'; @@ -116,7 +110,7 @@ function createAddImageOptions( ): AddImageOptions { if (upload.mode === PhotoCaptureMode.SIGHT) { return { - type: ImageType.BEAUTY_SHOT, + uploadType: ImageUploadType.BEAUTY_SHOT, picture: upload.picture, sightId: upload.sightId, tasks: additionalTasks ? uniq([...upload.tasks, ...additionalTasks]) : upload.tasks, @@ -125,7 +119,7 @@ function createAddImageOptions( }; } return { - type: ImageType.CLOSE_UP, + uploadType: ImageUploadType.CLOSE_UP_2_SHOT, picture: upload.picture, siblingKey: `closeup-sibling-key-${siblingId}`, firstShot: upload.mode === PhotoCaptureMode.ADD_DAMAGE_1ST_SHOT, diff --git a/packages/inspection-capture-web/test/PhotoCapture/hooks/useUploadQueue.test.ts b/packages/inspection-capture-web/test/PhotoCapture/hooks/useUploadQueue.test.ts index 6b015f74a..b197429c2 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/hooks/useUploadQueue.test.ts +++ b/packages/inspection-capture-web/test/PhotoCapture/hooks/useUploadQueue.test.ts @@ -7,8 +7,8 @@ import { UploadQueueParams, useUploadQueue, } from '../../../src/PhotoCapture/hooks'; -import { ComplianceIssue, ImageType, TaskName } from '@monkvision/types'; -import { useMonkApi } from '@monkvision/network'; +import { ComplianceIssue, TaskName } from '@monkvision/types'; +import { ImageUploadType, useMonkApi } from '@monkvision/network'; import { useMonitoring } from '@monkvision/monitoring'; import { act } from '@testing-library/react'; @@ -78,7 +78,7 @@ describe('useUploadQueue hook', () => { await process(defaultUploadOptions); expect(addImageMock).toHaveBeenCalledWith({ - type: ImageType.BEAUTY_SHOT, + uploadType: ImageUploadType.BEAUTY_SHOT, picture: defaultUploadOptions.picture, sightId: defaultUploadOptions.sightId, tasks: defaultUploadOptions.tasks, @@ -102,7 +102,7 @@ describe('useUploadQueue hook', () => { await process({ ...defaultUploadOptions, tasks }); expect(addImageMock).toHaveBeenCalledWith({ - type: ImageType.BEAUTY_SHOT, + uploadType: ImageUploadType.BEAUTY_SHOT, picture: defaultUploadOptions.picture, sightId: defaultUploadOptions.sightId, tasks: expect.arrayContaining([ @@ -139,7 +139,7 @@ describe('useUploadQueue hook', () => { await process(upload1); }); expect(addImageMock).toHaveBeenCalledWith({ - type: ImageType.CLOSE_UP, + uploadType: ImageUploadType.CLOSE_UP_2_SHOT, picture: upload1.picture, siblingKey: expect.any(String), firstShot: true, @@ -162,7 +162,7 @@ describe('useUploadQueue hook', () => { await process(upload2); expect(addImageMock).toHaveBeenCalledWith({ - type: ImageType.CLOSE_UP, + uploadType: ImageUploadType.CLOSE_UP_2_SHOT, picture: upload2.picture, siblingKey, firstShot: false, diff --git a/packages/network/src/api/image/requests.ts b/packages/network/src/api/image/requests.ts index 51cf99ee5..806684489 100644 --- a/packages/network/src/api/image/requests.ts +++ b/packages/network/src/api/image/requests.ts @@ -20,14 +20,33 @@ import { ApiImage, ApiImagePost, ApiImagePostTask } from '../models'; import { MonkApiResponse } from '../types'; import { mapApiImage } from './mappers'; +/** + * The different upload types for inspection images. + */ +export enum ImageUploadType { + /** + * Upload type corresponding to a sight image in the PhotoCapture process (beauty shot image). + */ + BEAUTY_SHOT = 'beauty_shot', + /** + * Upload type corresponding to a close-up picture (add-damage) in the PhotoCapture process, when using the 2-shot + * add damage workflow. + */ + CLOSE_UP_2_SHOT = 'close_up_2_shot', + /** + * Upload type corresponding to a video frame in the VideoCapture process. + */ + VIDEO_FRAME = 'video_frame', +} + /** * Options specififed when adding a beauty shot (normal "sight" image) to an inspection. */ export interface AddBeautyShotImageOptions { /** - * The type of the image : `ImageType.BEAUTY_SHOT`; + * The type of the image upload : `ImageUploadType.BEAUTY_SHOT`; */ - type: ImageType.BEAUTY_SHOT; + uploadType: ImageUploadType.BEAUTY_SHOT; /** * The picture to add to the inspection. */ @@ -55,9 +74,9 @@ export interface AddBeautyShotImageOptions { */ export interface Add2ShotCloseUpImageOptions { /** - * The type of the image : `ImageType.CLOSE_UP`; + * The type of the image upload : `ImageUploadType.CLOSE_UP_2_SHOT`; */ - type: ImageType.CLOSE_UP; + uploadType: ImageUploadType.CLOSE_UP_2_SHOT; /** * The picture to add to the inspection. */ @@ -83,15 +102,65 @@ export interface Add2ShotCloseUpImageOptions { compliance?: ComplianceOptions; } +/** + * Options specififed when adding a video frame to a VideoCapture inspection. + */ +export interface AddVideoFrameOptions { + /** + * The type of the image upload : `ImageUploadType.VIDEO_FRAME`; + */ + uploadType: ImageUploadType.VIDEO_FRAME; + /** + * The picture to add to the inspection. + */ + picture: MonkPicture; + /** + * The ID of the inspection to add the video frame to. + */ + inspectionId: string; + /** + * The index of the frame in the video. This index starts at 0 and increases by 1 for each video frame uploaded. + */ + frameIndex: number; + /** + * The duration (in milliseconds) between this video frame capture time and the previous one. For the first frame of + * the video, this value is equal to 0. + */ + timestamp: number; +} + /** * Union type describing the different options that can be specified when adding a picture to an inspection. */ -export type AddImageOptions = AddBeautyShotImageOptions | Add2ShotCloseUpImageOptions; +export type AddImageOptions = + | AddBeautyShotImageOptions + | Add2ShotCloseUpImageOptions + | AddVideoFrameOptions; + +interface AddImageData { + filename: string; + body: ApiImagePost; +} + +function getImageType(options: AddImageOptions): ImageType { + if (options.uploadType === ImageUploadType.CLOSE_UP_2_SHOT) { + return ImageType.CLOSE_UP; + } + return ImageType.BEAUTY_SHOT; +} function getImageLabel(options: AddImageOptions): TranslationObject | undefined { - if (options.type === ImageType.BEAUTY_SHOT) { + if (options.uploadType === ImageUploadType.BEAUTY_SHOT) { return sights[options.sightId] ? labels[sights[options.sightId].label] : undefined; } + if (options.uploadType === ImageUploadType.VIDEO_FRAME) { + return { + en: `Video Frame ${options.frameIndex}`, + fr: `Trame Vidéo ${options.frameIndex}`, + de: `Videobild ${options.frameIndex}`, + nl: `Videoframe ${options.frameIndex}`, + }; + } return { en: options.firstShot ? 'Close Up (part)' : 'Close Up (damage)', fr: options.firstShot ? 'Photo Zoomée (partie)' : 'Photo Zoomée (dégât)', @@ -105,9 +174,13 @@ function getAdditionalData(options: AddImageOptions): ImageAdditionalData { label: getImageLabel(options), created_at: new Date().toISOString(), }; - if (options.type === ImageType.BEAUTY_SHOT) { + if (options.uploadType === ImageUploadType.BEAUTY_SHOT) { additionalData.sight_id = options.sightId; } + if (options.uploadType === ImageUploadType.VIDEO_FRAME) { + additionalData.frame_index = options.frameIndex; + additionalData.timestamp = options.timestamp; + } return additionalData; } @@ -117,7 +190,7 @@ const MULTIPART_KEY_JSON = 'json'; function createBeautyShotImageData( options: AddBeautyShotImageOptions, filetype: string, -): { filename: string; body: ApiImagePost } { +): AddImageData { const filename = `${options.sightId}-${options.inspectionId}-${Date.now()}.${filetype}`; const tasks = options.tasks.filter( (task) => ![TaskName.COMPLIANCES, TaskName.HUMAN_IN_THE_LOOP].includes(task), @@ -151,7 +224,7 @@ function createBeautyShotImageData( function createCloseUpImageData( options: Add2ShotCloseUpImageOptions, filetype: string, -): { filename: string; body: ApiImagePost } { +): AddImageData { const prefix = options.firstShot ? 'closeup-part' : 'closeup-damage'; const filename = `${prefix}-${options.inspectionId}-${Date.now()}.${filetype}`; @@ -177,18 +250,42 @@ function createCloseUpImageData( return { filename, body }; } -async function createImageFormData( - options: AddImageOptions | Add2ShotCloseUpImageOptions, -): Promise { +function createVideoFrameData(options: AddVideoFrameOptions, filetype: string): AddImageData { + const filename = `video-frame-${options.frameIndex}.${filetype}`; + + const body: ApiImagePost = { + acquisition: { + strategy: 'upload_multipart_form_keys', + file_key: MULTIPART_KEY_IMAGE, + }, + tasks: [TaskName.DAMAGE_DETECTION], + additional_data: getAdditionalData(options), + }; + + return { filename, body }; +} + +function getAddImageData(options: AddImageOptions, filetype: string): AddImageData { + switch (options.uploadType) { + case ImageUploadType.BEAUTY_SHOT: + return createBeautyShotImageData(options, filetype); + case ImageUploadType.CLOSE_UP_2_SHOT: + return createCloseUpImageData(options, filetype); + case ImageUploadType.VIDEO_FRAME: + return createVideoFrameData(options, filetype); + default: + throw new Error('Unknown image upload type.'); + } +} + +async function createImageFormData(options: AddImageOptions): Promise { const extensions = getFileExtensions(options.picture.mimetype); if (!extensions) { throw new Error(`Unknown picture mimetype : ${options.picture.mimetype}`); } const filetype = extensions[0]; - const { filename, body } = - options.type === ImageType.BEAUTY_SHOT - ? createBeautyShotImageData(options, filetype) - : createCloseUpImageData(options, filetype); + + const { filename, body } = getAddImageData(options, filetype); const file = new File([options.picture.blob], filename, { type: filetype }); @@ -210,7 +307,7 @@ function createLocalImage(options: AddImageOptions): Image { height: options.picture.height, size: -1, mimetype: options.picture.mimetype, - type: options.type, + type: getImageType(options), status: ImageStatus.UPLOADING, label: getImageLabel(options), additionalData, @@ -219,7 +316,7 @@ function createLocalImage(options: AddImageOptions): Image { views: [], renderedOutputs: [], }; - if (options.type === ImageType.CLOSE_UP) { + if (options.uploadType === ImageUploadType.CLOSE_UP_2_SHOT) { image.siblingKey = options.siblingKey; image.subtype = options.firstShot ? ImageSubtype.CLOSE_UP_PART : ImageSubtype.CLOSE_UP_DAMAGE; } @@ -265,7 +362,11 @@ export async function addImage( body: formData, }); const body = await response.json(); - const image = mapApiImage(body, options.inspectionId, options.compliance); + const image = mapApiImage( + body, + options.inspectionId, + (options as AddBeautyShotImageOptions).compliance, + ); dispatch?.({ type: MonkActionType.CREATED_ONE_IMAGE, payload: { diff --git a/packages/network/src/api/index.ts b/packages/network/src/api/index.ts index 8457617b5..58ef09400 100644 --- a/packages/network/src/api/index.ts +++ b/packages/network/src/api/index.ts @@ -11,6 +11,8 @@ export { type AddBeautyShotImageOptions, type Add2ShotCloseUpImageOptions, type AddImageOptions, + type AddVideoFrameOptions, + ImageUploadType, } from './image'; export { type UpdateProgressStatus, diff --git a/packages/network/src/api/inspection/mappers.ts b/packages/network/src/api/inspection/mappers.ts index 6717d4b54..44fdacbd2 100644 --- a/packages/network/src/api/inspection/mappers.ts +++ b/packages/network/src/api/inspection/mappers.ts @@ -498,6 +498,7 @@ export function mapApiInspectionPost(options: CreateInspectionOptions): ApiInspe monk_sdk_version: sdkVersion, damage_detection_version: 'v2', use_dynamic_crops: options.useDynamicCrops ?? true, + is_video_capture: options.isVideoCapture ?? false, }, }; } diff --git a/packages/network/test/api/image/requests.test.ts b/packages/network/test/api/image/requests.test.ts index 7bd397590..bfa0e5f7d 100644 --- a/packages/network/test/api/image/requests.test.ts +++ b/packages/network/test/api/image/requests.test.ts @@ -18,6 +18,8 @@ import { Add2ShotCloseUpImageOptions, AddBeautyShotImageOptions, addImage, + AddVideoFrameOptions, + ImageUploadType, } from '../../../src/api/image'; import { mapApiImage } from '../../../src/api/image/mappers'; @@ -25,7 +27,7 @@ const apiConfig = { apiDomain: 'apiDomain', authToken: 'authToken' }; function createBeautyShotImageOptions(): AddBeautyShotImageOptions { return { - type: ImageType.BEAUTY_SHOT, + uploadType: ImageUploadType.BEAUTY_SHOT, picture: { blob: { size: 424 } as Blob, uri: 'test-uri', @@ -45,7 +47,7 @@ function createBeautyShotImageOptions(): AddBeautyShotImageOptions { function createCloseUpImageOptions(): Add2ShotCloseUpImageOptions { return { - type: ImageType.CLOSE_UP, + uploadType: ImageUploadType.CLOSE_UP_2_SHOT, picture: { blob: { size: 424 } as Blob, uri: 'test-uri', @@ -63,6 +65,22 @@ function createCloseUpImageOptions(): Add2ShotCloseUpImageOptions { }; } +function createVideoFrameOptions(): AddVideoFrameOptions { + return { + uploadType: ImageUploadType.VIDEO_FRAME, + picture: { + blob: { size: 424 } as Blob, + uri: 'test-uri', + height: 720, + width: 1280, + mimetype: 'image/jpeg', + }, + inspectionId: 'test-inspection-id', + frameIndex: 12, + timestamp: 2312, + }; +} + describe('Image requests', () => { let fileMock: File; let fileConstructorSpy: jest.SpyInstance; @@ -280,6 +298,40 @@ describe('Image requests', () => { ); }); + it('should properly create the formdata for a video frame', async () => { + const options = createVideoFrameOptions(); + await addImage(options, apiConfig); + + expect(ky.post).toHaveBeenCalled(); + const formData = (ky.post as jest.Mock).mock.calls[0][1].body as FormData; + expect(typeof formData?.get('json')).toBe('string'); + expect(JSON.parse(formData.get('json') as string)).toEqual({ + acquisition: { + strategy: 'upload_multipart_form_keys', + file_key: 'image', + }, + tasks: [TaskName.DAMAGE_DETECTION], + additional_data: { + label: { + en: `Video Frame ${options.frameIndex}`, + fr: `Trame Vidéo ${options.frameIndex}`, + de: `Videobild ${options.frameIndex}`, + nl: `Videoframe ${options.frameIndex}`, + }, + created_at: expect.any(String), + frame_index: options.frameIndex, + timestamp: options.timestamp, + }, + }); + expect(getFileExtensions).toHaveBeenCalledWith(options.picture.mimetype); + const filetype = (getFileExtensions as jest.Mock).mock.results[0].value[0]; + expect(fileConstructorSpy).toHaveBeenCalledWith( + [options.picture.blob], + `video-frame-${options.frameIndex}.${filetype}`, + { type: filetype }, + ); + }); + it('should properly set up the live compliance', async () => { const options = createBeautyShotImageOptions(); options.compliance = { enableCompliance: true, useLiveCompliance: true }; diff --git a/packages/network/test/api/inspection/data/apiInspectionPost.data.json b/packages/network/test/api/inspection/data/apiInspectionPost.data.json index 7c788e18d..1c38ae0e6 100644 --- a/packages/network/test/api/inspection/data/apiInspectionPost.data.json +++ b/packages/network/test/api/inspection/data/apiInspectionPost.data.json @@ -24,6 +24,7 @@ }, "additional_data": { "damage_detection_version": "v2", - "use_dynamic_crops": true + "use_dynamic_crops": true, + "is_video_capture": true } } diff --git a/packages/network/test/api/inspection/data/apiInspectionPost.data.ts b/packages/network/test/api/inspection/data/apiInspectionPost.data.ts index e1203e144..12e6f135e 100644 --- a/packages/network/test/api/inspection/data/apiInspectionPost.data.ts +++ b/packages/network/test/api/inspection/data/apiInspectionPost.data.ts @@ -13,4 +13,5 @@ export default { ], vehicleType: 'hatchback', useDynamicCrops: true, + isVideoCapture: true, }; diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 7582fddfd..f2c228fdb 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -126,4 +126,10 @@ export interface CreateInspectionOptions { * @default true */ useDynamicCrops?: boolean; + /** + * Boolean indicating if the inspection to create will be used with the VideoCapture workflow or not. + * + * @default false + */ + isVideoCapture?: boolean; } diff --git a/packages/types/src/state/image.ts b/packages/types/src/state/image.ts index 637eac8fc..a91e12524 100644 --- a/packages/types/src/state/image.ts +++ b/packages/types/src/state/image.ts @@ -26,6 +26,16 @@ export interface ImageAdditionalData extends AdditionalData { * the PhotoCapture component of the MonkJs SDK. */ label?: TranslationObject; + /** + * The index of the video frame starting from 0 (only defined if the image is a picture uploaded in a VideoCapture + * workflow). + */ + frame_index?: number; + /** + * The duration (in milliseconds) between this video frame capture time and the previous one. For the first frame of + * the video, this value is equal to 0. + */ + timestamp?: number; } /** diff --git a/packages/types/src/state/vehicle.ts b/packages/types/src/state/vehicle.ts index a335c7dfa..9cf26df15 100644 --- a/packages/types/src/state/vehicle.ts +++ b/packages/types/src/state/vehicle.ts @@ -10,7 +10,7 @@ export enum MileageUnit { } /** - * An object containing all the information about a vehicle that is being inspected during an inspection. + * An object containing all the information abou t a vehicle that is being inspected during an inspection. */ export interface Vehicle extends MonkEntity { /**