[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>
zio/stable
Hailey 2024-07-30 08:25:31 -07:00 committed by GitHub
parent 43ba0f21f6
commit 8ddb28d3c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 594 additions and 112 deletions

View File

@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644
Map<String, Object> 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() {

View File

@ -54,6 +54,10 @@ interface PostOpts {
uri: string
cid: string
}
video?: {
uri: string
cid: string
}
extLink?: ExternalEmbedDraft
images?: ImageModel[]
labels?: string[]

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,15 @@
const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? ''
export const createVideoEndpointUrl = (
route: string,
params?: Record<string, string>,
) => {
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
}

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

@ -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<string>()
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)
},
}
}

View File

@ -0,0 +1,18 @@
import React from 'react'
interface PostProgressState {
progress: number
status: 'pending' | 'success' | 'error' | 'idle'
error?: string
}
const PostProgressContext = React.createContext<PostProgressState>({
progress: 0,
status: 'idle',
})
export function Provider() {}
export function usePostProgress() {
return React.useContext(PostProgressContext)
}

View File

@ -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<ComposerOpts['quote'] | undefined>(
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<Gif>()
const [labels, setLabels] = useState<string[]>([])
@ -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
}>
<ButtonText style={[a.text_md]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
@ -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({
</View>
)}
{quote ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
<View style={[a.mt_md]}>
{quote ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : null}
{videoPending && videoProcessingData ? (
<VideoTranscodeProgress
input={videoProcessingData}
progress={videoProcessingProgress}
/>
) : (
video && (
) : null}
{videoUploadState.status === 'compressing' &&
videoUploadState.asset ? (
<VideoTranscodeProgress
asset={videoUploadState.asset}
progress={videoUploadState.progress}
/>
) : videoUploadState.video ? (
// remove suspense when we get rid of lazy
<Suspense fallback={null}>
<VideoPreview video={video} clear={clearVideo} />
<VideoPreview
video={videoUploadState.video}
clear={clearVideo}
/>
</Suspense>
)
)}
) : null}
</View>
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
@ -641,33 +675,37 @@ export const ComposePost = observer(function ComposePost({
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
{gate('videos') && (
<SelectVideoBtn
onSelectVideo={onSelectVideo}
disabled={!canSelectImages}
{videoUploadState.status !== 'idle' ? (
<VideoUploadToolbar state={videoUploadState} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
{gate('videos') && (
<SelectVideoBtn
onSelectVideo={selectVideo}
disabled={!canSelectImages}
/>
)}
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
disabled={hasMedia}
/>
)}
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
disabled={hasMedia}
/>
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}
variant="ghost"
shape="round"
color="primary">
<EmojiSmile size="lg" />
</Button>
) : null}
</View>
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}
variant="ghost"
shape="round"
color="primary">
<EmojiSmile size="lg" />
</Button>
) : null}
</ToolbarWrapper>
)}
<View style={a.flex_1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
@ -893,3 +931,44 @@ const styles = StyleSheet.create({
borderTopWidth: StyleSheet.hairlineWidth,
},
})
function ToolbarWrapper({
style,
children,
}: {
style: StyleProp<ViewStyle>
children: React.ReactNode
}) {
if (isWeb) return children
return (
<Animated.View
style={style}
entering={FadeIn.duration(400)}
exiting={FadeOut.duration(400)}>
{children}
</Animated.View>
)
}
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const t = useTheme()
const progress =
state.status === 'compressing' || state.status === 'uploading'
? state.progress
: state.jobStatus?.progress ?? 100
return (
<ToolbarWrapper
style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
<ProgressCircle
size={30}
borderWidth={1}
borderColor={t.atoms.border_contrast_low.borderColor}
color={t.palette.primary_500}
progress={progress}
/>
<Text>{state.status}</Text>
</ToolbarWrapper>
)
}

View File

@ -17,6 +17,7 @@ export function VideoPreview({
const player = useVideoPlayer(video.uri, player => {
player.loop = true
player.play()
player.volume = 0
})
return (

View File

@ -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 (
<View
@ -29,7 +29,7 @@ export function VideoTranscodeProgress({
a.overflow_hidden,
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
]}>
<VideoTranscodeBackdrop uri={input.uri} />
<VideoTranscodeBackdrop uri={asset.uri} />
<View
style={[
a.flex_1,

View File

@ -1,51 +0,0 @@
import {useState} from 'react'
import {ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {compressVideo} from '#/lib/media/video/compress'
import {logger} from '#/logger'
import {VideoTooLargeError} from 'lib/media/video/errors'
import * as Toast from 'view/com/util/Toast'
export function useVideoState({setError}: {setError: (error: string) => 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
}