Video compression in composer (#4638)
Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {
|
||||
ImagePickerAsset,
|
||||
launchImageLibraryAsync,
|
||||
MediaTypeOptions,
|
||||
UIImagePickerPreferredAssetRepresentationMode,
|
||||
} from 'expo-image-picker'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
||||
|
||||
const VIDEO_MAX_DURATION = 90
|
||||
|
||||
type Props = {
|
||||
onSelectVideo: (video: ImagePickerAsset) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||
|
||||
const onPressSelectVideo = useCallback(async () => {
|
||||
if (isNative && !(await requestVideoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await launchImageLibraryAsync({
|
||||
exif: false,
|
||||
mediaTypes: MediaTypeOptions.Videos,
|
||||
videoMaxDuration: VIDEO_MAX_DURATION,
|
||||
quality: 1,
|
||||
legacy: true,
|
||||
preferredAssetRepresentationMode:
|
||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||
})
|
||||
if (response.assets && response.assets.length > 0) {
|
||||
onSelectVideo(response.assets[0])
|
||||
}
|
||||
}, [onSelectVideo, requestVideoAccessIfNeeded])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
testID="openGifBtn"
|
||||
onPress={onPressSelectVideo}
|
||||
label={_(msg`Select video`)}
|
||||
accessibilityHint={_(msg`Opens video picker`)}
|
||||
style={a.p_sm}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="primary"
|
||||
disabled={disabled}>
|
||||
<VideoClipIcon
|
||||
size="lg"
|
||||
style={disabled && t.atoms.text_contrast_low}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* eslint-disable @typescript-eslint/no-shadow */
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useVideoPlayer, VideoView} from 'expo-video'
|
||||
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export function VideoPreview({
|
||||
video,
|
||||
clear,
|
||||
}: {
|
||||
video: CompressedVideo
|
||||
clear: () => void
|
||||
}) {
|
||||
const player = useVideoPlayer(video.uri, player => {
|
||||
player.loop = true
|
||||
player.play()
|
||||
})
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 16 / 9},
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<VideoView
|
||||
player={player}
|
||||
style={a.flex_1}
|
||||
allowsPictureInPicture={false}
|
||||
nativeControls={false}
|
||||
/>
|
||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export function VideoPreview({
|
||||
video,
|
||||
clear,
|
||||
}: {
|
||||
video: CompressedVideo
|
||||
clear: () => void
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 16 / 9},
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||
<video src={video.uri} style={a.flex_1} autoPlay loop muted playsInline />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, {useEffect} from 'react'
|
||||
import {clearCache, createVideoThumbnail} from 'react-native-compressor'
|
||||
import Animated, {FadeIn} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
const {data: thumbnail} = useQuery({
|
||||
queryKey: ['thumbnail', uri],
|
||||
queryFn: async () => {
|
||||
return await createVideoThumbnail(uri)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearCache()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Animated.View style={a.flex_1} entering={FadeIn}>
|
||||
{thumbnail && (
|
||||
<Image
|
||||
style={a.flex_1}
|
||||
source={thumbnail.path}
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
blurRadius={15}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
return (
|
||||
<video src={uri} style={{flex: 1, filter: 'blur(10px)'}} muted autoPlay />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
// @ts-expect-error no type definition
|
||||
import ProgressPie from 'react-native-progress/Pie'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||
|
||||
export function VideoTranscodeProgress({
|
||||
input,
|
||||
progress,
|
||||
}: {
|
||||
input: ImagePickerAsset
|
||||
progress: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const aspectRatio = input.width / input.height
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.mt_md,
|
||||
t.atoms.bg_contrast_50,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
||||
]}>
|
||||
<VideoTranscodeBackdrop uri={input.uri} />
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.gap_lg,
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
]}>
|
||||
<ProgressPie
|
||||
size={64}
|
||||
borderWidth={4}
|
||||
borderColor={t.atoms.text.color}
|
||||
color={t.atoms.text.color}
|
||||
progress={progress}
|
||||
/>
|
||||
<Text>Compressing...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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`))
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user