[Video] Make compress/upload cancelable (#4996)
* add abort controller to video upload system * rm log * rm log 2zio/stable
parent
551c4a4f32
commit
ea5ab99399
|
@ -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'
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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: () => {
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue