Refactor video uploads (#5570)

* Remove unused video field

* Stop exposing video dispatch

* Move cancellation out of the reducer

* Make useUploadStatusQuery controlled by jobId

* Rename SetStatus to SetProcessing

This action only has one callsite and it's always passing "processing".

* Move jobId into video reducer state

* Make cancellation scoped

* Inline useCompressVideoMutation

* Move processVideo down the file

* Extract getErrorMessage

* useServiceAuthToken -> getServiceAuthToken

* useVideoAgent -> createVideoAgent

* useVideoUploadLimits -> getVideoUploadLimits

* useUploadVideoMutation -> uploadVideo

* Use async/await in processVideo

* Inline onVideoCompressed into processVideo

* Use async/await for uploadVideo

* Factor out error messages

* Guard dispatch with signal

This lets us remove the scattered signal checks around dispatch.

* Move job polling out of RQ

* Handle poll failures

* Remove unnecessary guards

* Slightly more accurate condition

* Move initVideoUri handling out of the hook

* Remove dead argument

It wasn't being used before either.

* Remove unused detailed status

This isn't being used because we're only respecting that state variable when isProcessing=true, but isProcessing is always false during video upload.

If we want to re-add this later, it should really just be derived from the reducer state.

* Harden the video reducer

* Tie all spawned work to a signal

* Preserve asset/media for nicer error state

* Rename actions to match states

* Inline useUploadVideo

This abstraction is getting in the way of some future work.

* Move MIME check to the only place that handles it
This commit is contained in:
dan
2024-10-03 11:41:23 +09:00
committed by GitHub
parent c2dac855cc
commit d2392d2d64
8 changed files with 657 additions and 572 deletions
+78 -46
View File
@@ -36,6 +36,7 @@ import Animated, {
ZoomOut,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {ImagePickerAsset} from 'expo-image-picker'
import {
AppBskyFeedDefs,
AppBskyFeedGetPostThread,
@@ -82,9 +83,10 @@ import {Gif} from '#/state/queries/tenor'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
import {
createVideoState,
processVideo,
State as VideoUploadState,
useUploadVideo,
VideoUploadDispatch,
videoReducer,
} from '#/state/queries/video/video'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
@@ -147,7 +149,8 @@ export const ComposePost = ({
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
const currentDid = currentAccount!.did
const {data: currentProfile} = useProfileQuery({did: currentDid})
const {isModalActive} = useModals()
const {closeComposer} = useComposerControls()
const pal = usePalette('default')
@@ -189,21 +192,50 @@ export const ComposePost = ({
const [videoAltText, setVideoAltText] = useState('')
const [captions, setCaptions] = useState<{lang: string; file: File}[]>([])
const {
selectVideo,
clearVideo,
state: videoUploadState,
updateVideoDimensions,
dispatch: videoUploadDispatch,
} = useUploadVideo({
setStatus: setProcessingState,
onSuccess: () => {
if (publishOnUpload) {
onPressPublish(true)
}
const [videoUploadState, videoDispatch] = useReducer(
videoReducer,
undefined,
createVideoState,
)
const selectVideo = React.useCallback(
(asset: ImagePickerAsset) => {
processVideo(
asset,
videoDispatch,
agent,
currentDid,
videoUploadState.abortController.signal,
_,
)
},
initialVideoUri: initVideoUri,
})
[_, videoUploadState.abortController, videoDispatch, agent, currentDid],
)
// Whenever we receive an initial video uri, we should immediately run compression if necessary
useEffect(() => {
if (initVideoUri) {
selectVideo({uri: initVideoUri} as ImagePickerAsset)
}
}, [initVideoUri, selectVideo])
const clearVideo = React.useCallback(() => {
videoUploadState.abortController.abort()
videoDispatch({type: 'to_idle', nextController: new AbortController()})
}, [videoUploadState.abortController, videoDispatch])
const updateVideoDimensions = useCallback(
(width: number, height: number) => {
videoDispatch({
type: 'update_dimensions',
width,
height,
signal: videoUploadState.abortController.signal,
})
},
[videoUploadState.abortController],
)
const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -400,19 +432,18 @@ export const ComposePost = ({
postgate,
onStateChange: setProcessingState,
langs: toPostLanguages(langPrefs.postLanguage),
video: videoUploadState.pendingPublish?.blobRef
? {
blobRef: videoUploadState.pendingPublish.blobRef,
altText: videoAltText,
captions: captions,
aspectRatio: videoUploadState.asset
? {
width: videoUploadState.asset?.width,
height: videoUploadState.asset?.height,
}
: undefined,
}
: undefined,
video:
videoUploadState.status === 'done'
? {
blobRef: videoUploadState.pendingPublish.blobRef,
altText: videoAltText,
captions: captions,
aspectRatio: {
width: videoUploadState.asset.width,
height: videoUploadState.asset.height,
},
}
: undefined,
})
).uri
try {
@@ -694,7 +725,7 @@ export const ComposePost = ({
error={error}
videoUploadState={videoUploadState}
clearError={() => setError('')}
videoUploadDispatch={videoUploadDispatch}
clearVideo={clearVideo}
/>
</Animated.View>
<Animated.ScrollView
@@ -1083,25 +1114,25 @@ function ErrorBanner({
error: standardError,
videoUploadState,
clearError,
videoUploadDispatch,
clearVideo,
}: {
error: string
videoUploadState: VideoUploadState
clearError: () => void
videoUploadDispatch: VideoUploadDispatch
clearVideo: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const videoError =
videoUploadState.status !== 'idle' ? videoUploadState.error : undefined
videoUploadState.status === 'error' ? videoUploadState.error : undefined
const error = standardError || videoError
const onClearError = () => {
if (standardError) {
clearError()
} else {
videoUploadDispatch({type: 'Reset'})
clearVideo()
}
}
@@ -1136,7 +1167,7 @@ function ErrorBanner({
<ButtonIcon icon={X} />
</Button>
</View>
{videoError && videoUploadState.jobStatus?.jobId && (
{videoError && videoUploadState.jobId && (
<NewText
style={[
{paddingLeft: 28},
@@ -1145,7 +1176,7 @@ function ErrorBanner({
a.leading_snug,
t.atoms.text_contrast_low,
]}>
<Trans>Job ID: {videoUploadState.jobStatus.jobId}</Trans>
<Trans>Job ID: {videoUploadState.jobId}</Trans>
</NewText>
)}
</View>
@@ -1174,9 +1205,7 @@ function ToolbarWrapper({
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const t = useTheme()
const {_} = useLingui()
const progress = state.jobStatus?.progress
? state.jobStatus.progress / 100
: state.progress
const progress = state.progress
const shouldRotate =
state.status === 'processing' && (progress === 0 || progress === 1)
let wheelProgress = shouldRotate ? 0.33 : progress
@@ -1212,16 +1241,15 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
case 'processing':
text = _('Processing video...')
break
case 'error':
text = _('Error')
wheelProgress = 100
break
case 'done':
text = _('Video uploaded')
break
}
if (state.error) {
text = _('Error')
wheelProgress = 100
}
return (
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
<Animated.View style={[animatedStyle]}>
@@ -1229,7 +1257,11 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
size={30}
borderWidth={1}
borderColor={t.atoms.border_contrast_low.borderColor}
color={state.error ? t.palette.negative_500 : t.palette.primary_500}
color={
state.status === 'error'
? t.palette.negative_500
: t.palette.primary_500
}
progress={wheelProgress}
/>
</Animated.View>
+36 -11
View File
@@ -9,12 +9,14 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {BSKY_SERVICE} from '#/lib/constants'
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
import {getHostnameFromUrl} from '#/lib/strings/url-helpers'
import {isWeb} from '#/platform/detection'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session'
import {BSKY_SERVICE} from 'lib/constants'
import {getHostnameFromUrl} from 'lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
@@ -58,16 +60,25 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
UIImagePickerPreferredAssetRepresentationMode.Current,
})
if (response.assets && response.assets.length > 0) {
if (isNative) {
if (typeof response.assets[0].duration !== 'number')
throw Error('Asset is not a video')
if (response.assets[0].duration > VIDEO_MAX_DURATION) {
setError(_(msg`Videos must be less than 60 seconds long`))
return
}
}
const asset = response.assets[0]
try {
onSelectVideo(response.assets[0])
if (isWeb) {
// compression step on native converts to mp4, so no need to check there
const mimeType = getMimeType(asset)
if (
!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)
) {
throw Error(_(msg`Unsupported video type: ${mimeType}`))
}
} else {
if (typeof asset.duration !== 'number') {
throw Error('Asset is not a video')
}
if (asset.duration > VIDEO_MAX_DURATION) {
throw Error(_(msg`Videos must be less than 60 seconds long`))
}
}
onSelectVideo(asset)
} catch (err) {
if (err instanceof Error) {
setError(err.message)
@@ -132,3 +143,17 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
/>
)
}
function getMimeType(asset: ImagePickerAsset) {
if (isWeb) {
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
if (!mimeType) {
throw new Error('Could not determine mime type')
}
return mimeType
}
if (!asset.mimeType) {
throw new Error('Could not determine mime type')
}
return asset.mimeType
}