[Video] Make compress/upload cancelable (#4996)

* add abort controller to video upload system

* rm log

* rm log 2
zio/stable
Samuel Newman 2024-08-29 17:00:12 +01:00 committed by GitHub
parent 551c4a4f32
commit ea5ab99399
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 104 additions and 58 deletions

View File

@ -0,0 +1,20 @@
export function cancelable<A, T>(
f: (args: A) => Promise<T>,
signal: AbortSignal,
) {
return (args: A) => {
return new Promise<T>((resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new AbortError())
})
f(args).then(resolve, reject)
})
}
}
export class AbortError extends Error {
constructor() {
super('Aborted')
this.name = 'AbortError'
}
}

View File

@ -8,19 +8,25 @@ export type CompressedVideo = {
export async function compressVideo( export async function compressVideo(
file: string, file: string,
opts?: { opts?: {
getCancellationId?: (id: string) => void signal?: AbortSignal
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
}, },
): Promise<CompressedVideo> { ): Promise<CompressedVideo> {
const {onProgress, getCancellationId} = opts || {} const {onProgress, signal} = opts || {}
const compressed = await Video.compress( const compressed = await Video.compress(
file, file,
{ {
getCancellationId,
compressionMethod: 'manual', compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps bitrate: 3_000_000, // 3mbps
maxSize: 1920, maxSize: 1920,
getCancellationId: id => {
if (signal) {
signal.addEventListener('abort', () => {
Video.cancelCompression(id)
})
}
},
}, },
onProgress, onProgress,
) )

View File

@ -10,8 +10,9 @@ export type CompressedVideo = {
// doesn't actually compress, but throws if >100MB // doesn't actually compress, but throws if >100MB
export async function compressVideo( export async function compressVideo(
file: string, file: string,
_callbacks?: { _opts?: {
onProgress: (progress: number) => void signal?: AbortSignal
onProgress?: (progress: number) => void
}, },
): Promise<CompressedVideo> { ): Promise<CompressedVideo> {
const blob = await fetch(file).then(res => res.blob()) const blob = await fetch(file).then(res => res.blob())

View File

@ -1,23 +1,30 @@
import {ImagePickerAsset} from 'expo-image-picker' import {ImagePickerAsset} from 'expo-image-picker'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {cancelable} from '#/lib/async/cancelable'
import {CompressedVideo, compressVideo} from 'lib/media/video/compress' import {CompressedVideo, compressVideo} from 'lib/media/video/compress'
export function useCompressVideoMutation({ export function useCompressVideoMutation({
onProgress, onProgress,
onSuccess, onSuccess,
onError, onError,
signal,
}: { }: {
onProgress: (progress: number) => void onProgress: (progress: number) => void
onError: (e: any) => void onError: (e: any) => void
onSuccess: (video: CompressedVideo) => void onSuccess: (video: CompressedVideo) => void
signal: AbortSignal
}) { }) {
return useMutation({ return useMutation({
mutationFn: async (asset: ImagePickerAsset) => { mutationKey: ['video', 'compress'],
return await compressVideo(asset.uri, { mutationFn: cancelable(
onProgress: num => onProgress(trunc2dp(num)), (asset: ImagePickerAsset) =>
}) compressVideo(asset.uri, {
}, onProgress: num => onProgress(trunc2dp(num)),
signal,
}),
signal,
),
onError, onError,
onSuccess, onSuccess,
onMutate: () => { onMutate: () => {

View File

@ -3,6 +3,7 @@ import {AppBskyVideoDefs} from '@atproto/api'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable'
import {CompressedVideo} from '#/lib/media/video/compress' import {CompressedVideo} from '#/lib/media/video/compress'
import {createVideoEndpointUrl} from '#/state/queries/video/util' import {createVideoEndpointUrl} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
@ -11,16 +12,19 @@ export const useUploadVideoMutation = ({
onSuccess, onSuccess,
onError, onError,
setProgress, setProgress,
signal,
}: { }: {
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
onError: (e: any) => void onError: (e: any) => void
setProgress: (progress: number) => void setProgress: (progress: number) => void
signal: AbortSignal
}) => { }) => {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
return useMutation({ return useMutation({
mutationFn: async (video: CompressedVideo) => { mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did, did: currentAccount!.did,
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
@ -59,7 +63,7 @@ export const useUploadVideoMutation = ({
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
return responseBody return responseBody
}, }, signal),
onError, onError,
onSuccess, onSuccess,
}) })

View File

@ -2,6 +2,7 @@ import {AppBskyVideoDefs} from '@atproto/api'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable'
import {CompressedVideo} from '#/lib/media/video/compress' import {CompressedVideo} from '#/lib/media/video/compress'
import {createVideoEndpointUrl} from '#/state/queries/video/util' import {createVideoEndpointUrl} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
@ -10,16 +11,19 @@ export const useUploadVideoMutation = ({
onSuccess, onSuccess,
onError, onError,
setProgress, setProgress,
signal,
}: { }: {
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
onError: (e: any) => void onError: (e: any) => void
setProgress: (progress: number) => void setProgress: (progress: number) => void
signal: AbortSignal
}) => { }) => {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
return useMutation({ return useMutation({
mutationFn: async (video: CompressedVideo) => { mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did, did: currentAccount!.did,
name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4' name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4'
@ -70,7 +74,7 @@ export const useUploadVideoMutation = ({
) )
return res return res
}, }, signal),
onError, onError,
onSuccess, onSuccess,
}) })

View File

@ -3,7 +3,7 @@ import {ImagePickerAsset} from 'expo-image-picker'
import {AppBskyVideoDefs, BlobRef} from '@atproto/api' import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query' import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger' import {logger} from '#/logger'
import {CompressedVideo} from 'lib/media/video/compress' import {CompressedVideo} from 'lib/media/video/compress'
@ -32,33 +32,41 @@ export interface State {
jobStatus?: AppBskyVideoDefs.JobStatus jobStatus?: AppBskyVideoDefs.JobStatus
blobRef?: BlobRef blobRef?: BlobRef
error?: string error?: string
abortController: AbortController
} }
function reducer(state: State, action: Action): State { function reducer(queryClient: QueryClient) {
let updatedState = state return (state: State, action: Action): State => {
if (action.type === 'SetStatus') { let updatedState = state
updatedState = {...state, status: action.status} if (action.type === 'SetStatus') {
} else if (action.type === 'SetProgress') { updatedState = {...state, status: action.status}
updatedState = {...state, progress: action.progress} } else if (action.type === 'SetProgress') {
} else if (action.type === 'SetError') { updatedState = {...state, progress: action.progress}
updatedState = {...state, error: action.error} } else if (action.type === 'SetError') {
} else if (action.type === 'Reset') { updatedState = {...state, error: action.error}
updatedState = { } else if (action.type === 'Reset') {
status: 'idle', state.abortController.abort()
progress: 0, queryClient.cancelQueries({
video: null, queryKey: ['video'],
blobRef: undefined, })
updatedState = {
status: 'idle',
progress: 0,
video: null,
blobRef: undefined,
abortController: new AbortController(),
}
} 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}
} else if (action.type === 'SetBlobRef') {
updatedState = {...state, blobRef: action.blobRef}
} }
} else if (action.type === 'SetAsset') { return updatedState
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}
} else if (action.type === 'SetBlobRef') {
updatedState = {...state, blobRef: action.blobRef}
} }
return updatedState
} }
export function useUploadVideo({ export function useUploadVideo({
@ -69,10 +77,12 @@ export function useUploadVideo({
onSuccess: () => void onSuccess: () => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const [state, dispatch] = React.useReducer(reducer, { const queryClient = useQueryClient()
const [state, dispatch] = React.useReducer(reducer(queryClient), {
status: 'idle', status: 'idle',
progress: 0, progress: 0,
video: null, video: null,
abortController: new AbortController(),
}) })
const {setJobId} = useUploadStatusQuery({ const {setJobId} = useUploadStatusQuery({
@ -116,6 +126,7 @@ export function useUploadVideo({
setProgress: p => { setProgress: p => {
dispatch({type: 'SetProgress', progress: p}) dispatch({type: 'SetProgress', progress: p})
}, },
signal: state.abortController.signal,
}) })
const {mutate: onSelectVideo} = useCompressVideoMutation({ const {mutate: onSelectVideo} = useCompressVideoMutation({
@ -148,6 +159,7 @@ export function useUploadVideo({
}) })
onVideoCompressed(video) onVideoCompressed(video)
}, },
signal: state.abortController.signal,
}) })
const selectVideo = (asset: ImagePickerAsset) => { const selectVideo = (asset: ImagePickerAsset) => {
@ -163,7 +175,6 @@ export function useUploadVideo({
} }
const clearVideo = () => { const clearVideo = () => {
// @TODO cancel any running jobs
dispatch({type: 'Reset'}) dispatch({type: 'Reset'})
} }
@ -187,7 +198,7 @@ const useUploadStatusQuery = ({
const [jobId, setJobId] = React.useState<string>() const [jobId, setJobId] = React.useState<string>()
const {isLoading, isError} = useQuery({ const {isLoading, isError} = useQuery({
queryKey: ['video-upload', jobId], queryKey: ['video', 'upload status', jobId],
queryFn: async () => { queryFn: async () => {
if (!jobId) return // this won't happen, can ignore if (!jobId) return // this won't happen, can ignore

View File

@ -1,5 +1,4 @@
import React, { import React, {
Suspense,
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
@ -700,15 +699,10 @@ export const ComposePost = observer(function ComposePost({
<VideoTranscodeProgress <VideoTranscodeProgress
asset={videoUploadState.asset} asset={videoUploadState.asset}
progress={videoUploadState.progress} progress={videoUploadState.progress}
clear={clearVideo}
/> />
) : videoUploadState.video ? ( ) : videoUploadState.video ? (
// remove suspense when we get rid of lazy <VideoPreview video={videoUploadState.video} clear={clearVideo} />
<Suspense fallback={null}>
<VideoPreview
video={videoUploadState.video}
clear={clearVideo}
/>
</Suspense>
) : null} ) : null}
</View> </View>
</Animated.ScrollView> </Animated.ScrollView>

View File

@ -25,8 +25,8 @@ export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) {
}} }}
onPress={onRemove} onPress={onRemove}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Remove image preview`)} accessibilityLabel={_(msg`Remove attachment`)}
accessibilityHint={_(msg`Removes the image preview`)} accessibilityHint={_(msg`Removes the attachment`)}
onAccessibilityEscape={onRemove}> onAccessibilityEscape={onRemove}>
<FontAwesomeIcon size={18} icon="xmark" style={s.white} /> <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</TouchableOpacity> </TouchableOpacity>

View File

@ -3,18 +3,19 @@ import {View} from 'react-native'
// @ts-expect-error no type definition // @ts-expect-error no type definition
import ProgressPie from 'react-native-progress/Pie' import ProgressPie from 'react-native-progress/Pie'
import {ImagePickerAsset} from 'expo-image-picker' import {ImagePickerAsset} from 'expo-image-picker'
import {Trans} from '@lingui/macro'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography' import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
export function VideoTranscodeProgress({ export function VideoTranscodeProgress({
asset, asset,
progress, progress,
clear,
}: { }: {
asset: ImagePickerAsset asset: ImagePickerAsset
progress: number progress: number
clear: () => void
}) { }) {
const t = useTheme() const t = useTheme()
@ -41,16 +42,14 @@ export function VideoTranscodeProgress({
a.inset_0, a.inset_0,
]}> ]}>
<ProgressPie <ProgressPie
size={64} size={48}
borderWidth={4} borderWidth={3}
borderColor={t.atoms.text.color} borderColor={t.atoms.text.color}
color={t.atoms.text.color} color={t.atoms.text.color}
progress={progress} progress={progress}
/> />
<Text>
<Trans>Compressing...</Trans>
</Text>
</View> </View>
<ExternalEmbedRemoveBtn onRemove={clear} />
</View> </View>
) )
} }