From 8ddb28d3c54b63fb81ca361e741e5a6a46c1d25f Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 30 Jul 2024 08:25:31 -0700 Subject: [PATCH] [Video] Uploads (#4754) * state for video uploads * get upload working * add a debug log * add post progress * progress * fetch data * add some progress info, web uploads * post on finished uploading (wip) * add a note * add some todos * clear video * merge some stuff * convert to `createUploadTask` * patch expo modules core * working native upload progress * platform fork * upload progress for web * cleanup * cleanup * more tweaks * simplify * fix type errors --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- patches/expo-modules-core+1.12.11.patch | 12 + src/lib/api/index.ts | 4 + src/lib/media/video/types.ts | 36 +++ src/state/queries/video/compress-video.ts | 31 +++ src/state/queries/video/util.ts | 15 ++ src/state/queries/video/video-upload.ts | 59 +++++ src/state/queries/video/video-upload.web.ts | 66 ++++++ src/state/queries/video/video.ts | 212 ++++++++++++++++++ src/state/shell/post-progress.tsx | 18 ++ src/view/com/composer/Composer.tsx | 193 +++++++++++----- src/view/com/composer/videos/VideoPreview.tsx | 1 + .../videos/VideoTranscodeProgress.tsx | 8 +- src/view/com/composer/videos/state.ts | 51 ----- 13 files changed, 594 insertions(+), 112 deletions(-) create mode 100644 src/lib/media/video/types.ts create mode 100644 src/state/queries/video/compress-video.ts create mode 100644 src/state/queries/video/util.ts create mode 100644 src/state/queries/video/video-upload.ts create mode 100644 src/state/queries/video/video-upload.web.ts create mode 100644 src/state/queries/video/video.ts create mode 100644 src/state/shell/post-progress.tsx delete mode 100644 src/view/com/composer/videos/state.ts diff --git a/patches/expo-modules-core+1.12.11.patch b/patches/expo-modules-core+1.12.11.patch index 4878bb9f..a4ee027c 100644 --- a/patches/expo-modules-core+1.12.11.patch +++ b/patches/expo-modules-core+1.12.11.patch @@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644 Map constants = new HashMap<>(3); constants.put(MODULES_CONSTANTS_KEY, new HashMap<>()); +diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js +index 109d3fe..c7fce9e 100644 +--- a/node_modules/expo-modules-core/build/uuid/uuid.js ++++ b/node_modules/expo-modules-core/build/uuid/uuid.js +@@ -1,5 +1,7 @@ + import bytesToUuid from './lib/bytesToUuid'; + import { Uuidv5Namespace } from './uuid.types'; ++import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled'; ++ensureNativeModulesAreInstalled(); + const nativeUuidv4 = globalThis?.expo?.uuidv4; + const nativeUuidv5 = globalThis?.expo?.uuidv5; + function uuidv4() { diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 5b1c998c..12e30bf6 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -54,6 +54,10 @@ interface PostOpts { uri: string cid: string } + video?: { + uri: string + cid: string + } extLink?: ExternalEmbedDraft images?: ImageModel[] labels?: string[] diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts new file mode 100644 index 00000000..c458da96 --- /dev/null +++ b/src/lib/media/video/types.ts @@ -0,0 +1,36 @@ +/** + * TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION. + * @temporary + * PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented. + * Not joking, this is temporary. + */ + +export interface JobStatus { + jobId: string + did: string + cid: string + state: JobState + progress?: number + errorHuman?: string + errorMachine?: string +} + +export enum JobState { + JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED', + JOB_STATE_CREATED = 'JOB_STATE_CREATED', + JOB_STATE_ENCODING = 'JOB_STATE_ENCODING', + JOB_STATE_ENCODED = 'JOB_STATE_ENCODED', + JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING', + JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED', + JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING', + JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED', + JOB_STATE_FAILED = 'JOB_STATE_FAILED', + JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED', +} + +export interface UploadVideoResponse { + job_id: string + did: string + cid: string + state: JobState +} diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts new file mode 100644 index 00000000..a2c739cf --- /dev/null +++ b/src/state/queries/video/compress-video.ts @@ -0,0 +1,31 @@ +import {ImagePickerAsset} from 'expo-image-picker' +import {useMutation} from '@tanstack/react-query' + +import {CompressedVideo, compressVideo} from 'lib/media/video/compress' + +export function useCompressVideoMutation({ + onProgress, + onSuccess, + onError, +}: { + onProgress: (progress: number) => void + onError: (e: any) => void + onSuccess: (video: CompressedVideo) => void +}) { + return useMutation({ + mutationFn: async (asset: ImagePickerAsset) => { + return await compressVideo(asset.uri, { + onProgress: num => onProgress(trunc2dp(num)), + }) + }, + onError, + onSuccess, + onMutate: () => { + onProgress(0) + }, + }) +} + +function trunc2dp(num: number) { + return Math.trunc(num * 100) / 100 +} diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts new file mode 100644 index 00000000..266d8aee --- /dev/null +++ b/src/state/queries/video/util.ts @@ -0,0 +1,15 @@ +const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? '' + +export const createVideoEndpointUrl = ( + route: string, + params?: Record, +) => { + const url = new URL(`${UPLOAD_ENDPOINT}`) + url.pathname = route + if (params) { + for (const key in params) { + url.searchParams.set(key, params[key]) + } + } + return url.href +} diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts new file mode 100644 index 00000000..4d7f7995 --- /dev/null +++ b/src/state/queries/video/video-upload.ts @@ -0,0 +1,59 @@ +import {createUploadTask, FileSystemUploadType} from 'expo-file-system' +import {useMutation} from '@tanstack/react-query' +import {nanoid} from 'nanoid/non-secure' + +import {CompressedVideo} from 'lib/media/video/compress' +import {UploadVideoResponse} from 'lib/media/video/types' +import {createVideoEndpointUrl} from 'state/queries/video/util' +import {useSession} from 'state/session' +const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' + +export const useUploadVideoMutation = ({ + onSuccess, + onError, + setProgress, +}: { + onSuccess: (response: UploadVideoResponse) => void + onError: (e: any) => void + setProgress: (progress: number) => void +}) => { + const {currentAccount} = useSession() + + return useMutation({ + mutationFn: async (video: CompressedVideo) => { + const uri = createVideoEndpointUrl('/upload', { + did: currentAccount!.did, + name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? + }) + + const uploadTask = createUploadTask( + uri, + video.uri, + { + headers: { + 'dev-key': UPLOAD_HEADER, + 'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4? + }, + httpMethod: 'POST', + uploadType: FileSystemUploadType.BINARY_CONTENT, + }, + p => { + setProgress(p.totalBytesSent / p.totalBytesExpectedToSend) + }, + ) + const res = await uploadTask.uploadAsync() + + if (!res?.body) { + throw new Error('No response') + } + + // @TODO rm, useful for debugging/getting video cid + console.log('[VIDEO]', res.body) + const responseBody = JSON.parse(res.body) as UploadVideoResponse + onSuccess(responseBody) + return responseBody + }, + onError, + onSuccess, + }) +} diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts new file mode 100644 index 00000000..b5b9e93b --- /dev/null +++ b/src/state/queries/video/video-upload.web.ts @@ -0,0 +1,66 @@ +import {useMutation} from '@tanstack/react-query' +import {nanoid} from 'nanoid/non-secure' + +import {CompressedVideo} from 'lib/media/video/compress' +import {UploadVideoResponse} from 'lib/media/video/types' +import {createVideoEndpointUrl} from 'state/queries/video/util' +import {useSession} from 'state/session' +const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' + +export const useUploadVideoMutation = ({ + onSuccess, + onError, + setProgress, +}: { + onSuccess: (response: UploadVideoResponse) => void + onError: (e: any) => void + setProgress: (progress: number) => void +}) => { + const {currentAccount} = useSession() + + return useMutation({ + mutationFn: async (video: CompressedVideo) => { + const uri = createVideoEndpointUrl('/upload', { + did: currentAccount!.did, + name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? + }) + + const bytes = await fetch(video.uri).then(res => res.arrayBuffer()) + + const xhr = new XMLHttpRequest() + const res = (await new Promise((resolve, reject) => { + xhr.upload.addEventListener('progress', e => { + const progress = e.loaded / e.total + setProgress(progress) + }) + xhr.onloadend = () => { + if (xhr.readyState === 4) { + const uploadRes = JSON.parse( + xhr.responseText, + ) as UploadVideoResponse + resolve(uploadRes) + onSuccess(uploadRes) + } else { + reject() + onError(new Error('Failed to upload video')) + } + } + xhr.onerror = () => { + reject() + onError(new Error('Failed to upload video')) + } + xhr.open('POST', uri) + xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type? + // @TODO remove this header for prod + xhr.setRequestHeader('dev-key', UPLOAD_HEADER) + xhr.send(bytes) + })) as UploadVideoResponse + + // @TODO rm for prod + console.log('[VIDEO]', res) + return res + }, + onError, + onSuccess, + }) +} diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts new file mode 100644 index 00000000..295db38b --- /dev/null +++ b/src/state/queries/video/video.ts @@ -0,0 +1,212 @@ +import React from 'react' +import {ImagePickerAsset} from 'expo-image-picker' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQuery} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {CompressedVideo} from 'lib/media/video/compress' +import {VideoTooLargeError} from 'lib/media/video/errors' +import {JobState, JobStatus} from 'lib/media/video/types' +import {useCompressVideoMutation} from 'state/queries/video/compress-video' +import {createVideoEndpointUrl} from 'state/queries/video/util' +import {useUploadVideoMutation} from 'state/queries/video/video-upload' + +type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done' + +type Action = + | { + type: 'SetStatus' + status: Status + } + | { + type: 'SetProgress' + progress: number + } + | { + type: 'SetError' + error: string | undefined + } + | {type: 'Reset'} + | {type: 'SetAsset'; asset: ImagePickerAsset} + | {type: 'SetVideo'; video: CompressedVideo} + | {type: 'SetJobStatus'; jobStatus: JobStatus} + +export interface State { + status: Status + progress: number + asset?: ImagePickerAsset + video: CompressedVideo | null + jobStatus?: JobStatus + error?: string +} + +function reducer(state: State, action: Action): State { + let updatedState = state + if (action.type === 'SetStatus') { + updatedState = {...state, status: action.status} + } else if (action.type === 'SetProgress') { + updatedState = {...state, progress: action.progress} + } else if (action.type === 'SetError') { + updatedState = {...state, error: action.error} + } else if (action.type === 'Reset') { + updatedState = { + status: 'idle', + progress: 0, + video: null, + } + } else if (action.type === 'SetAsset') { + updatedState = {...state, asset: action.asset} + } else if (action.type === 'SetVideo') { + updatedState = {...state, video: action.video} + } else if (action.type === 'SetJobStatus') { + updatedState = {...state, jobStatus: action.jobStatus} + } + return updatedState +} + +export function useUploadVideo({ + setStatus, + onSuccess, +}: { + setStatus: (status: string) => void + onSuccess: () => void +}) { + const {_} = useLingui() + const [state, dispatch] = React.useReducer(reducer, { + status: 'idle', + progress: 0, + video: null, + }) + + const {setJobId} = useUploadStatusQuery({ + onStatusChange: (status: JobStatus) => { + // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user + // Leaving it for now though + dispatch({ + type: 'SetJobStatus', + jobStatus: status, + }) + setStatus(status.state.toString()) + }, + onSuccess: () => { + dispatch({ + type: 'SetStatus', + status: 'idle', + }) + onSuccess() + }, + }) + + const {mutate: onVideoCompressed} = useUploadVideoMutation({ + onSuccess: response => { + dispatch({ + type: 'SetStatus', + status: 'processing', + }) + setJobId(response.job_id) + }, + onError: e => { + dispatch({ + type: 'SetError', + error: _(msg`An error occurred while uploading the video.`), + }) + logger.error('Error uploading video', {safeMessage: e}) + }, + setProgress: p => { + dispatch({type: 'SetProgress', progress: p}) + }, + }) + + const {mutate: onSelectVideo} = useCompressVideoMutation({ + onProgress: p => { + dispatch({type: 'SetProgress', progress: p}) + }, + onError: e => { + if (e instanceof VideoTooLargeError) { + dispatch({ + type: 'SetError', + error: _(msg`The selected video is larger than 100MB.`), + }) + } else { + dispatch({ + type: 'SetError', + // @TODO better error message from server, left untranslated on purpose + error: 'An error occurred while compressing the video.', + }) + logger.error('Error compressing video', {safeMessage: e}) + } + }, + onSuccess: (video: CompressedVideo) => { + dispatch({ + type: 'SetVideo', + video, + }) + dispatch({ + type: 'SetStatus', + status: 'uploading', + }) + onVideoCompressed(video) + }, + }) + + const selectVideo = (asset: ImagePickerAsset) => { + dispatch({ + type: 'SetAsset', + asset, + }) + dispatch({ + type: 'SetStatus', + status: 'compressing', + }) + onSelectVideo(asset) + } + + const clearVideo = () => { + // @TODO cancel any running jobs + dispatch({type: 'Reset'}) + } + + return { + state, + dispatch, + selectVideo, + clearVideo, + } +} + +const useUploadStatusQuery = ({ + onStatusChange, + onSuccess, +}: { + onStatusChange: (status: JobStatus) => void + onSuccess: () => void +}) => { + const [enabled, setEnabled] = React.useState(true) + const [jobId, setJobId] = React.useState() + + const {isLoading, isError} = useQuery({ + queryKey: ['video-upload'], + queryFn: async () => { + const url = createVideoEndpointUrl(`/job/${jobId}/status`) + const res = await fetch(url) + const status = (await res.json()) as JobStatus + if (status.state === JobState.JOB_STATE_COMPLETED) { + setEnabled(false) + onSuccess() + } + onStatusChange(status) + return status + }, + enabled: Boolean(jobId && enabled), + refetchInterval: 1500, + }) + + return { + isLoading, + isError, + setJobId: (_jobId: string) => { + setJobId(_jobId) + }, + } +} diff --git a/src/state/shell/post-progress.tsx b/src/state/shell/post-progress.tsx new file mode 100644 index 00000000..0df2a6be --- /dev/null +++ b/src/state/shell/post-progress.tsx @@ -0,0 +1,18 @@ +import React from 'react' + +interface PostProgressState { + progress: number + status: 'pending' | 'success' | 'error' | 'idle' + error?: string +} + +const PostProgressContext = React.createContext({ + progress: 0, + status: 'idle', +}) + +export function Provider() {} + +export function usePostProgress() { + return React.useContext(PostProgressContext) +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 72b6fae5..08ce4441 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -13,10 +13,16 @@ import { Keyboard, KeyboardAvoidingView, LayoutChangeEvent, + StyleProp, StyleSheet, View, + ViewStyle, } from 'react-native' +// @ts-expect-error no type definition +import ProgressCircle from 'react-native-progress/Circle' import Animated, { + FadeIn, + FadeOut, interpolateColor, useAnimatedStyle, useSharedValue, @@ -55,6 +61,7 @@ import { import {useProfileQuery} from '#/state/queries/profile' import {Gif} from '#/state/queries/tenor' import {ThreadgateSetting} from '#/state/queries/threadgate' +import {useUploadVideo} from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {useAnalytics} from 'lib/analytics/analytics' @@ -70,6 +77,7 @@ import {colors, s} from 'lib/styles' import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection' import {useDialogStateControlContext} from 'state/dialogs' import {GalleryModel} from 'state/models/media/gallery' +import {State as VideoUploadState} from 'state/queries/video/video' import {ComposerOpts} from 'state/shell/composer' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' import {atoms as a, useTheme} from '#/alf' @@ -96,7 +104,6 @@ import {TextInput, TextInputRef} from './text-input/TextInput' import {ThreadgateBtn} from './threadgate/ThreadgateBtn' import {useExternalLinkFetch} from './useExternalLinkFetch' import {SelectVideoBtn} from './videos/SelectVideoBtn' -import {useVideoState} from './videos/state' import {VideoPreview} from './videos/VideoPreview' import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress' @@ -159,14 +166,21 @@ export const ComposePost = observer(function ComposePost({ const [quote, setQuote] = useState( initQuote, ) + const { - video, - onSelectVideo, - videoPending, - videoProcessingData, + selectVideo, clearVideo, - videoProcessingProgress, - } = useVideoState({setError}) + state: videoUploadState, + } = useUploadVideo({ + setStatus: (status: string) => setProcessingState(status), + onSuccess: () => { + if (publishOnUpload) { + onPressPublish(true) + } + }, + }) + const [publishOnUpload, setPublishOnUpload] = useState(false) + const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [extGif, setExtGif] = useState() const [labels, setLabels] = useState([]) @@ -274,7 +288,7 @@ export const ComposePost = observer(function ComposePost({ return false }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled]) - const onPressPublish = async () => { + const onPressPublish = async (finishedUploading?: boolean) => { if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } @@ -283,6 +297,15 @@ export const ComposePost = observer(function ComposePost({ return } + if ( + !finishedUploading && + videoUploadState.status !== 'idle' && + videoUploadState.asset + ) { + setPublishOnUpload(true) + return + } + setError('') if ( @@ -387,8 +410,12 @@ export const ComposePost = observer(function ComposePost({ : _(msg`What's up?`) const canSelectImages = - gallery.size < 4 && !extLink && !video && !videoPending - const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video) + gallery.size < 4 && + !extLink && + videoUploadState.status === 'idle' && + !videoUploadState.video + const hasMedia = + gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video) const onEmojiButtonPress = useCallback(() => { openPicker?.(textInput.current?.getCursorPosition()) @@ -500,7 +527,10 @@ export const ComposePost = observer(function ComposePost({ shape="default" size="small" style={[a.rounded_full, a.py_sm]} - onPress={onPressPublish}> + onPress={() => onPressPublish()} + disabled={ + videoUploadState.status !== 'idle' && publishOnUpload + }> {replyTo ? ( Reply @@ -572,7 +602,7 @@ export const ComposePost = observer(function ComposePost({ autoFocus setRichText={setRichText} onPhotoPasted={onPhotoPasted} - onPressPublish={onPressPublish} + onPressPublish={() => onPressPublish()} onNewLink={onNewLink} onError={setError} accessible={true} @@ -602,29 +632,33 @@ export const ComposePost = observer(function ComposePost({ )} - {quote ? ( - - - + + {quote ? ( + + + + + {quote.uri !== initQuote?.uri && ( + setQuote(undefined)} /> + )} - {quote.uri !== initQuote?.uri && ( - setQuote(undefined)} /> - )} - - ) : null} - {videoPending && videoProcessingData ? ( - - ) : ( - video && ( + ) : null} + {videoUploadState.status === 'compressing' && + videoUploadState.asset ? ( + + ) : videoUploadState.video ? ( // remove suspense when we get rid of lazy - + - ) - )} + ) : null} + @@ -641,33 +675,37 @@ export const ComposePost = observer(function ComposePost({ t.atoms.border_contrast_medium, styles.bottomBar, ]}> - - - {gate('videos') && ( - + ) : ( + + + {gate('videos') && ( + + )} + + - )} - - - {!isMobile ? ( - - ) : null} - + {!isMobile ? ( + + ) : null} + + )} @@ -893,3 +931,44 @@ const styles = StyleSheet.create({ borderTopWidth: StyleSheet.hairlineWidth, }, }) + +function ToolbarWrapper({ + style, + children, +}: { + style: StyleProp + children: React.ReactNode +}) { + if (isWeb) return children + return ( + + {children} + + ) +} + +function VideoUploadToolbar({state}: {state: VideoUploadState}) { + const t = useTheme() + + const progress = + state.status === 'compressing' || state.status === 'uploading' + ? state.progress + : state.jobStatus?.progress ?? 100 + + return ( + + + {state.status} + + ) +} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index b04cdf1c..8e2a2285 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -17,6 +17,7 @@ export function VideoPreview({ const player = useVideoPlayer(video.uri, player => { player.loop = true player.play() + player.volume = 0 }) return ( diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx index 79407cd3..db58448a 100644 --- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx +++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx @@ -9,15 +9,15 @@ import {Text} from '#/components/Typography' import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' export function VideoTranscodeProgress({ - input, + asset, progress, }: { - input: ImagePickerAsset + asset: ImagePickerAsset progress: number }) { const t = useTheme() - const aspectRatio = input.width / input.height + const aspectRatio = asset.width / asset.height return ( - + void}) { - const {_} = useLingui() - const [progress, setProgress] = useState(0) - - const {mutate, data, isPending, isError, reset, variables} = useMutation({ - mutationFn: async (asset: ImagePickerAsset) => { - const compressed = await compressVideo(asset.uri, { - onProgress: num => setProgress(trunc2dp(num)), - }) - - return compressed - }, - onError: (e: any) => { - // Don't log these errors in sentry, just let the user know - if (e instanceof VideoTooLargeError) { - Toast.show(_(msg`Videos cannot be larger than 100MB`), 'xmark') - return - } - logger.error('Failed to compress video', {safeError: e}) - setError(_(msg`Could not compress video`)) - }, - onMutate: () => { - setProgress(0) - }, - }) - - return { - video: data, - onSelectVideo: mutate, - videoPending: isPending, - videoProcessingData: variables, - videoError: isError, - clearVideo: reset, - videoProcessingProgress: progress, - } -} - -function trunc2dp(num: number) { - return Math.trunc(num * 100) / 100 -}