diff --git a/keyserver/src/uploads/media-utils.js b/keyserver/src/uploads/media-utils.js index 2290f1404..3bcc6158f 100644 --- a/keyserver/src/uploads/media-utils.js +++ b/keyserver/src/uploads/media-utils.js @@ -1,219 +1,227 @@ // @flow import bmp from '@vingle/bmp-js'; import invariant from 'invariant'; import sharp from 'sharp'; import { serverTranscodableTypes, serverCanHandleTypes, readableFilename, mediaConfig, } from 'lib/media/file-utils.js'; import { getImageProcessingPlan } from 'lib/media/image-utils.js'; import type { Dimensions } from 'lib/types/media-types.js'; import { deepFileInfoFromData } from 'web/media/file-utils.js'; import type { UploadInput } from '../creators/upload-creator.js'; function initializeSharp(buffer: Buffer, mime: string) { if (mime !== 'image/bmp') { return sharp(buffer); } const bitmap = bmp.decode(buffer, true); return sharp(bitmap.data, { raw: { width: bitmap.width, height: bitmap.height, channels: 4, }, }); } +function getMediaType(inputMimeType: string): 'photo' | 'video' | null { + if (!serverCanHandleTypes.has(inputMimeType)) { + return null; + } + const mediaType = mediaConfig[inputMimeType]?.mediaType; + invariant( + mediaType === 'photo' || mediaType === 'video', + `mediaType for ${inputMimeType} should be photo or video`, + ); + return mediaType; +} + type ValidateAndConvertInput = { +initialBuffer: Buffer, +initialName: string, +inputDimensions: ?Dimensions, +inputLoop: boolean, +inputEncryptionKey: ?string, +inputMimeType: ?string, +size: number, // in bytes }; async function validateAndConvert( input: ValidateAndConvertInput, ): Promise { const { initialBuffer, initialName, inputDimensions, inputLoop, inputEncryptionKey, inputMimeType, size, // in bytes } = input; // we don't want to transcode encrypted files if (inputEncryptionKey) { invariant( inputMimeType, 'inputMimeType should be set in validateAndConvert for encrypted files', ); invariant( inputDimensions, 'inputDimensions should be set in validateAndConvert for encrypted files', ); - if (!serverCanHandleTypes.has(inputMimeType)) { + const mediaType = getMediaType(inputMimeType); + if (!mediaType) { return null; } - const mediaType = mediaConfig[inputMimeType]?.mediaType; - invariant( - mediaType === 'photo' || mediaType === 'video', - `mediaType for ${inputMimeType} should be photo or video`, - ); return { name: initialName, mime: inputMimeType, mediaType, content: { storage: 'keyserver', buffer: initialBuffer }, dimensions: inputDimensions, loop: inputLoop, encryptionKey: inputEncryptionKey, }; } const { mime, mediaType } = deepFileInfoFromData(initialBuffer); if (!mime || !mediaType) { return null; } if (!serverCanHandleTypes.has(mime)) { return null; } if (mediaType === 'video') { invariant( inputDimensions, 'inputDimensions should be set in validateAndConvert', ); return { mime: mime, mediaType: mediaType, name: initialName, content: { storage: 'keyserver', buffer: initialBuffer }, dimensions: inputDimensions, loop: inputLoop, }; } if (!serverTranscodableTypes.has(mime)) { // This should've gotten converted on the client return null; } return convertImage( initialBuffer, mime, initialName, inputDimensions, inputLoop, size, ); } async function convertImage( initialBuffer: Buffer, mime: string, initialName: string, inputDimensions: ?Dimensions, inputLoop: boolean, size: number, ): Promise { let sharpImage, metadata; try { sharpImage = initializeSharp(initialBuffer, mime); metadata = await sharpImage.metadata(); } catch (e) { return null; } let initialDimensions = inputDimensions; if (!initialDimensions) { if (metadata.orientation && metadata.orientation > 4) { initialDimensions = { width: metadata.height, height: metadata.width }; } else { initialDimensions = { width: metadata.width, height: metadata.height }; } } const plan = getImageProcessingPlan({ inputMIME: mime, inputDimensions: initialDimensions, inputFileSize: size, inputOrientation: metadata.orientation, }); if (plan.action === 'none') { const name = readableFilename(initialName, mime); invariant(name, `should be able to construct filename for ${mime}`); return { mime, mediaType: 'photo', name, content: { storage: 'keyserver', buffer: initialBuffer }, dimensions: initialDimensions, loop: inputLoop, }; } console.log(`processing image with ${JSON.stringify(plan)}`); const { targetMIME, compressionRatio, fitInside, shouldRotate } = plan; if (shouldRotate) { sharpImage = sharpImage.rotate(); } if (fitInside) { sharpImage = sharpImage.resize(fitInside.width, fitInside.height, { fit: 'inside', withoutEnlargement: true, }); } if (targetMIME === 'image/png') { sharpImage = sharpImage.png(); } else { sharpImage = sharpImage.jpeg({ quality: compressionRatio * 100 }); } const { data: convertedBuffer, info } = await sharpImage.toBuffer({ resolveWithObject: true, }); const convertedDimensions = { width: info.width, height: info.height }; const { mime: convertedMIME, mediaType: convertedMediaType } = deepFileInfoFromData(convertedBuffer); if ( !convertedMIME || !convertedMediaType || convertedMIME !== targetMIME || convertedMediaType !== 'photo' ) { return null; } const convertedName = readableFilename(initialName, targetMIME); if (!convertedName) { return null; } return { mime: targetMIME, mediaType: 'photo', name: convertedName, content: { storage: 'keyserver', buffer: convertedBuffer }, dimensions: convertedDimensions, loop: inputLoop, }; } -export { validateAndConvert }; +export { getMediaType, validateAndConvert }; diff --git a/keyserver/src/uploads/uploads.js b/keyserver/src/uploads/uploads.js index 055a5af64..fc1a169de 100644 --- a/keyserver/src/uploads/uploads.js +++ b/keyserver/src/uploads/uploads.js @@ -1,171 +1,215 @@ // @flow import type { $Request, $Response, Middleware } from 'express'; import invariant from 'invariant'; import multer from 'multer'; import { Readable } from 'stream'; +import t from 'tcomb'; import type { + UploadMediaMetadataRequest, UploadMultimediaResult, UploadDeletionRequest, Dimensions, } from 'lib/types/media-types.js'; import { ServerError } from 'lib/utils/errors.js'; +import { tShape } from 'lib/utils/validation-utils.js'; -import { validateAndConvert } from './media-utils.js'; +import { getMediaType, validateAndConvert } from './media-utils.js'; +import type { UploadInput } from '../creators/upload-creator.js'; import createUploads from '../creators/upload-creator.js'; import { deleteUpload } from '../deleters/upload-deleters.js'; import { fetchUpload, fetchUploadChunk, getUploadSize, } from '../fetchers/upload-fetchers.js'; import type { MulterRequest } from '../responders/handlers.js'; import type { Viewer } from '../session/viewer.js'; +import { validateInput } from '../utils/validation-utils.js'; const upload = multer(); const multerProcessor: Middleware<> = upload.array('multimedia'); type MultimediaUploadResult = { results: UploadMultimediaResult[], }; async function multimediaUploadResponder( viewer: Viewer, req: MulterRequest, ): Promise { const { files, body } = req; if (!files || !body || typeof body !== 'object') { throw new ServerError('invalid_parameters'); } const overrideFilename = files.length === 1 && body.filename ? body.filename : null; if (overrideFilename && typeof overrideFilename !== 'string') { throw new ServerError('invalid_parameters'); } const inputHeight = files.length === 1 && body.height ? parseInt(body.height) : null; const inputWidth = files.length === 1 && body.width ? parseInt(body.width) : null; if (!!inputHeight !== !!inputWidth) { throw new ServerError('invalid_parameters'); } const inputDimensions: ?Dimensions = inputHeight && inputWidth ? { height: inputHeight, width: inputWidth } : null; const inputLoop = !!(files.length === 1 && body.loop); const inputEncryptionKey = files.length === 1 && body.encryptionKey ? body.encryptionKey : null; if (inputEncryptionKey && typeof inputEncryptionKey !== 'string') { throw new ServerError('invalid_parameters'); } const inputMimeType = files.length === 1 && body.mimeType ? body.mimeType : null; if (inputMimeType && typeof inputMimeType !== 'string') { throw new ServerError('invalid_parameters'); } const validationResults = await Promise.all( files.map(({ buffer, size, originalname }) => validateAndConvert({ initialBuffer: buffer, initialName: overrideFilename ? overrideFilename : originalname, inputDimensions, inputLoop, inputEncryptionKey, inputMimeType, size, }), ), ); const uploadInfos = validationResults.filter(Boolean); if (uploadInfos.length === 0) { throw new ServerError('invalid_parameters'); } const results = await createUploads(viewer, uploadInfos); return { results }; } +const uploadMediaMetadataInputValidator = tShape({ + filename: t.String, + width: t.Number, + height: t.Number, + blobHolder: t.String, + encryptionKey: t.String, + mimeType: t.String, + loop: t.maybe(t.Boolean), +}); + +async function uploadMediaMetadataResponder( + viewer: Viewer, + input: any, +): Promise { + const request: UploadMediaMetadataRequest = input; + await validateInput(viewer, uploadMediaMetadataInputValidator, input); + + const mediaType = getMediaType(request.mimeType); + if (!mediaType) { + throw new ServerError('invalid_parameters'); + } + + const { filename, blobHolder, encryptionKey, mimeType, width, height, loop } = + request; + const uploadInfo: UploadInput = { + name: filename, + mime: mimeType, + mediaType, + content: { storage: 'blob_service', blobHolder }, + encryptionKey, + dimensions: { width, height }, + loop: loop ?? false, + }; + + const [result] = await createUploads(viewer, [uploadInfo]); + return result; +} + async function uploadDownloadResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { const { uploadID, secret } = req.params; if (!uploadID || !secret) { throw new ServerError('invalid_parameters'); } if (!req.headers.range) { const { content, mime } = await fetchUpload(viewer, uploadID, secret); res.type(mime); res.set('Cache-Control', 'public, max-age=31557600, immutable'); if (process.env.NODE_ENV === 'development') { // Add a CORS header to allow local development using localhost const port = process.env.PORT || '3000'; res.set('Access-Control-Allow-Origin', `http://localhost:${port}`); res.set('Access-Control-Allow-Methods', 'GET'); } res.send(content); } else { const totalUploadSize = await getUploadSize(uploadID, secret); const range = req.range(totalUploadSize); if (typeof range === 'number' && range < 0) { throw new ServerError( range === -1 ? 'unsatisfiable_range' : 'malformed_header_string', ); } invariant( Array.isArray(range), 'range should be Array in uploadDownloadResponder!', ); const { start, end } = range[0]; const respWidth = end - start + 1; const { content, mime } = await fetchUploadChunk( uploadID, secret, start, respWidth, ); const respRange = `${start}-${end}/${totalUploadSize}`; const respHeaders: { [key: string]: string } = { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${respRange}`, 'Content-Type': mime, 'Content-Length': respWidth.toString(), }; if (process.env.NODE_ENV === 'development') { // Add a CORS header to allow local development using localhost const port = process.env.PORT || '3000'; respHeaders['Access-Control-Allow-Origin'] = `http://localhost:${port}`; respHeaders['Access-Control-Allow-Methods'] = 'GET'; } // HTTP 206 Partial Content // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206 res.writeHead(206, respHeaders); const stream = new Readable(); stream.push(content); stream.push(null); stream.pipe(res); } } async function uploadDeletionResponder( viewer: Viewer, request: UploadDeletionRequest, ): Promise { const { id } = request; await deleteUpload(viewer, id); } export { multerProcessor, multimediaUploadResponder, uploadDownloadResponder, uploadDeletionResponder, + uploadMediaMetadataResponder, }; diff --git a/lib/types/media-types.js b/lib/types/media-types.js index 4b7ceafd6..da7c305ec 100644 --- a/lib/types/media-types.js +++ b/lib/types/media-types.js @@ -1,710 +1,719 @@ // @flow import t, { type TInterface, type TUnion } from 'tcomb'; import type { Shape } from './core.js'; import { type Platform } from './device-types.js'; import { tShape, tString, tID } from '../utils/validation-utils.js'; export type Dimensions = $ReadOnly<{ +height: number, +width: number, }>; export const dimensionsValidator: TInterface = tShape({ height: t.Number, width: t.Number, }); export type MediaType = 'photo' | 'video'; export type EncryptedMediaType = 'encrypted_photo' | 'encrypted_video'; export type AvatarMediaInfo = { +type: 'photo', +uri: string, }; export type ClientDBMediaInfo = { +id: string, +uri: string, +type: 'photo' | 'video', +extras: string, }; export type Corners = Shape<{ +topLeft: boolean, +topRight: boolean, +bottomLeft: boolean, +bottomRight: boolean, }>; export type MediaInfo = | { ...Image, +index: number, } | { ...Video, +index: number, } | { ...EncryptedImage, +index: number, } | { ...EncryptedVideo, +index: number, }; export type UploadMultimediaResult = { +id: string, +uri: string, +dimensions: Dimensions, +mediaType: MediaType, +loop: boolean, }; export type UpdateMultimediaMessageMediaPayload = { +messageID: string, +currentMediaID: string, +mediaUpdate: Shape, }; export type UploadDeletionRequest = { +id: string, }; +export type UploadMediaMetadataRequest = { + ...Dimensions, + +filename: string, + +blobHolder: string, + +encryptionKey: string, + +mimeType: string, + +loop?: boolean, +}; + export type FFmpegStatistics = { // seconds of video being processed per second +speed: number, // total milliseconds of video processed so far +time: number, // total result file size in bytes so far +size: number, +videoQuality: number, +videoFrameNumber: number, +videoFps: number, +bitrate: number, }; export type TranscodeVideoMediaMissionStep = { +step: 'video_ffmpeg_transcode', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +returnCode: ?number, +newPath: ?string, +stats: ?FFmpegStatistics, }; export type VideoGenerateThumbnailMediaMissionStep = { +step: 'video_generate_thumbnail', +success: boolean, +time: number, // ms +returnCode: number, +thumbnailURI: string, }; export type VideoInfo = { +codec: ?string, +dimensions: ?Dimensions, +duration: number, // seconds +format: $ReadOnlyArray, }; export type VideoProbeMediaMissionStep = { +step: 'video_probe', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +validFormat: boolean, +duration: ?number, // seconds +codec: ?string, +format: ?$ReadOnlyArray, +dimensions: ?Dimensions, }; export type ReadFileHeaderMediaMissionStep = { +step: 'read_file_header', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +mime: ?string, +mediaType: ?MediaType, }; export type DetermineFileTypeMediaMissionStep = { +step: 'determine_file_type', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputFilename: string, +outputMIME: ?string, +outputMediaType: ?MediaType, +outputFilename: ?string, }; export type FrameCountMediaMissionStep = { +step: 'frame_count', +success: boolean, +exceptionMessage: ?string, +time: number, +path: string, +mime: string, +hasMultipleFrames: ?boolean, }; export type DisposeTemporaryFileMediaMissionStep = { +step: 'dispose_temporary_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type MakeDirectoryMediaMissionStep = { +step: 'make_directory', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type AndroidScanFileMediaMissionStep = { +step: 'android_scan_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type FetchFileHashMediaMissionStep = { +step: 'fetch_file_hash', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +hash: ?string, }; export type CopyFileMediaMissionStep = { +step: 'copy_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +source: string, +destination: string, }; export type GetOrientationMediaMissionStep = { +step: 'exif_fetch', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +orientation: ?number, }; export type EncryptFileMediaMissionStep = | { +step: 'read_plaintext_file', +file: string, +time: number, // ms +success: boolean, +exceptionMessage: ?string, } | { +step: 'encrypt_data', +dataSize: number, +time: number, // ms +isPadded: boolean, +sha256: ?string, +success: boolean, +exceptionMessage: ?string, } | { +step: 'write_encrypted_file', +file: string, +time: number, // ms +success: boolean, +exceptionMessage: ?string, }; export type MediaLibrarySelection = | { +step: 'photo_library', +dimensions: Dimensions, +filename: ?string, +uri: string, +mediaNativeID: ?string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, } | { +step: 'video_library', +dimensions: Dimensions, +filename: ?string, +uri: string, +mediaNativeID: ?string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, +duration: number, // seconds }; export const mediaLibrarySelectionValidator: TUnion = t.union([ tShape({ step: tString('photo_library'), dimensions: dimensionsValidator, filename: t.maybe(t.String), uri: t.String, mediaNativeID: t.maybe(t.String), selectTime: t.Number, sendTime: t.Number, retries: t.Number, }), tShape({ step: tString('video_library'), dimensions: dimensionsValidator, filename: t.maybe(t.String), uri: t.String, mediaNativeID: t.maybe(t.String), selectTime: t.Number, sendTime: t.Number, retries: t.Number, duration: t.Number, }), ]); export type PhotoCapture = { +step: 'photo_capture', +time: number, // ms +dimensions: Dimensions, +filename: string, +uri: string, +captureTime: number, // ms timestamp +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, }; export const photoCaptureValidator: TInterface = tShape({ step: tString('photo_capture'), time: t.Number, dimensions: dimensionsValidator, filename: t.String, uri: t.String, captureTime: t.Number, selectTime: t.Number, sendTime: t.Number, retries: t.Number, }); export type PhotoPaste = { +step: 'photo_paste', +dimensions: Dimensions, +filename: string, +uri: string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, }; export const photoPasteValidator: TInterface = tShape({ step: tString('photo_paste'), dimensions: dimensionsValidator, filename: t.String, uri: t.String, selectTime: t.Number, sendTime: t.Number, retries: t.Number, }); export type NativeMediaSelection = | MediaLibrarySelection | PhotoCapture | PhotoPaste; export const nativeMediaSelectionValidator: TUnion = t.union([ mediaLibrarySelectionValidator, photoCaptureValidator, photoPasteValidator, ]); export type MediaMissionStep = | NativeMediaSelection | { +step: 'web_selection', +filename: string, +size: number, // in bytes +mime: string, +selectTime: number, // ms timestamp } | { +step: 'asset_info_fetch', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +localURI: ?string, +orientation: ?number, } | { +step: 'stat_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +fileSize: ?number, } | ReadFileHeaderMediaMissionStep | DetermineFileTypeMediaMissionStep | FrameCountMediaMissionStep | { +step: 'photo_manipulation', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +manipulation: Object, +newMIME: ?string, +newDimensions: ?Dimensions, +newURI: ?string, } | VideoProbeMediaMissionStep | TranscodeVideoMediaMissionStep | VideoGenerateThumbnailMediaMissionStep | DisposeTemporaryFileMediaMissionStep | { +step: 'save_media', +uri: string, +time: number, // ms timestamp } | { +step: 'permissions_check', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +platform: Platform, +permissions: $ReadOnlyArray, } | MakeDirectoryMediaMissionStep | AndroidScanFileMediaMissionStep | { +step: 'ios_save_to_library', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, } | { +step: 'fetch_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputURI: string, +uri: string, +size: ?number, +mime: ?string, } | { +step: 'data_uri_from_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +first255Chars: ?string, } | { +step: 'array_buffer_from_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms } | { +step: 'mime_check', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +mime: ?string, } | { +step: 'write_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +length: number, } | FetchFileHashMediaMissionStep | CopyFileMediaMissionStep | EncryptFileMediaMissionStep | GetOrientationMediaMissionStep | { +step: 'preload_image', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +dimensions: ?Dimensions, } | { +step: 'reorient_image', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: ?string, } | { +step: 'upload', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputFilename: string, +outputMediaType: ?MediaType, +outputURI: ?string, +outputDimensions: ?Dimensions, +outputLoop: ?boolean, +hasWiFi?: boolean, } | { +step: 'wait_for_capture_uri_unload', +success: boolean, +time: number, // ms +uri: string, }; export type MediaMissionFailure = | { +success: false, +reason: 'no_file_path', } | { +success: false, +reason: 'file_stat_failed', +uri: string, } | { +success: false, +reason: 'photo_manipulation_failed', +size: number, // in bytes } | { +success: false, +reason: 'media_type_fetch_failed', +detectedMIME: ?string, } | { +success: false, +reason: 'mime_type_mismatch', +reportedMediaType: MediaType, +reportedMIME: string, +detectedMIME: string, } | { +success: false, +reason: 'http_upload_failed', +exceptionMessage: ?string, } | { +success: false, +reason: 'video_too_long', +duration: number, // in seconds } | { +success: false, +reason: 'video_probe_failed', } | { +success: false, +reason: 'video_transcode_failed', } | { +success: false, +reason: 'video_generate_thumbnail_failed', } | { +success: false, +reason: 'processing_exception', +time: number, // ms +exceptionMessage: ?string, } | { +success: false, +reason: 'encryption_exception', +time: number, // ms +exceptionMessage: ?string, } | { +success: false, +reason: 'save_unsupported', } | { +success: false, +reason: 'missing_permission', } | { +success: false, +reason: 'make_directory_failed', } | { +success: false, +reason: 'resolve_failed', +uri: string, } | { +success: false, +reason: 'save_to_library_failed', +uri: string, } | { +success: false, +reason: 'fetch_failed', } | { +success: false, +reason: 'data_uri_failed', } | { +success: false, +reason: 'array_buffer_failed', } | { +success: false, +reason: 'mime_check_failed', +mime: ?string, } | { +success: false, +reason: 'write_file_failed', } | { +success: false, +reason: 'fetch_file_hash_failed', } | { +success: false, +reason: 'copy_file_failed', } | { +success: false, +reason: 'exif_fetch_failed', } | { +success: false, +reason: 'reorient_image_failed', } | { +success: false, +reason: 'web_sibling_validation_failed', } | { +success: false, +reason: 'encryption_failed', } | { +success: false, +reason: 'digest_failed' }; export type MediaMissionResult = MediaMissionFailure | { +success: true }; export type MediaMission = { +steps: $ReadOnlyArray, +result: MediaMissionResult, +userTime: number, +totalTime: number, }; export type Image = { +id: string, +uri: string, +type: 'photo', +dimensions: Dimensions, // stored on native only during creation in case retry needed after state lost +localMediaSelection?: NativeMediaSelection, }; export const imageValidator: TInterface = tShape({ id: tID, uri: t.String, type: tString('photo'), dimensions: dimensionsValidator, localMediaSelection: t.maybe(nativeMediaSelectionValidator), }); export type EncryptedImage = { +id: string, // a media URI for keyserver uploads / blob holder for Blob service uploads +holder: string, +encryptionKey: string, +type: 'encrypted_photo', +dimensions: Dimensions, }; export const encryptedImageValidator: TInterface = tShape({ id: tID, holder: t.String, encryptionKey: t.String, type: tString('encrypted_photo'), dimensions: dimensionsValidator, }); export type Video = { +id: string, +uri: string, +type: 'video', +dimensions: Dimensions, +loop?: boolean, +thumbnailID: string, +thumbnailURI: string, // stored on native only during creation in case retry needed after state lost +localMediaSelection?: NativeMediaSelection, }; export const videoValidator: TInterface