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:
Samuel Newman
2024-07-06 01:50:03 +01:00
committed by GitHub
parent 56b688744e
commit 8f06ba70bb
23 changed files with 483 additions and 33 deletions
@@ -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>
)
}
+51
View File
@@ -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
}