Video compression in composer (#4638)

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
zio/stable
Samuel Newman 2024-07-06 01:50:03 +01:00 committed by GitHub
parent 56b688744e
commit 8f06ba70bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 483 additions and 33 deletions

View File

@ -211,6 +211,8 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
},
],
'expo-video',
'react-native-compressor',
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@ -136,6 +136,7 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
"expo-video": "^1.1.10",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
@ -166,6 +167,7 @@
"react-dom": "^18.2.0",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.74.1",
"react-native-compressor": "^1.8.24",
"react-native-date-picker": "^4.4.2",
"react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-fs": "^2.20.0",

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const VideoClip_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z',
})

View File

@ -48,6 +48,35 @@ export function usePhotoLibraryPermission() {
return {requestPhotoAccessIfNeeded}
}
export function useVideoLibraryPermission() {
const [res, requestPermission] = MediaLibrary.usePermissions({
granularPermissions: ['video'],
})
const requestVideoAccessIfNeeded = async () => {
// On the, we use <input type="file"> to produce a filepicker
// This does not need any permission granting.
if (isWeb) {
return true
}
if (res?.granted) {
return true
} else if (!res || res.status === 'undetermined' || res?.canAskAgain) {
const {canAskAgain, granted, status} = await requestPermission()
if (!canAskAgain && status === 'undetermined') {
openPermissionAlert('video library')
}
return granted
} else {
openPermissionAlert('video library')
return false
}
}
return {requestVideoAccessIfNeeded}
}
export function useCameraPermission() {
const [res, requestPermission] = Camera.useCameraPermissions()

View File

@ -14,3 +14,11 @@ export function useCameraPermission() {
return {requestCameraAccessIfNeeded}
}
export function useVideoLibraryPermission() {
const requestVideoAccessIfNeeded = async () => {
return true
}
return {requestVideoAccessIfNeeded}
}

View File

@ -0,0 +1,30 @@
import {getVideoMetaData, Video} from 'react-native-compressor'
export type CompressedVideo = {
uri: string
size: number
}
export async function compressVideo(
file: string,
opts?: {
getCancellationId?: (id: string) => void
onProgress?: (progress: number) => void
},
): Promise<CompressedVideo> {
const {onProgress, getCancellationId} = opts || {}
const compressed = await Video.compress(
file,
{
getCancellationId,
compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps
maxSize: 1920,
},
onProgress,
)
const info = await getVideoMetaData(compressed)
return {uri: compressed, size: info.size}
}

View File

@ -0,0 +1,28 @@
import {VideoTooLargeError} from 'lib/media/video/errors'
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
export type CompressedVideo = {
uri: string
size: number
}
// doesn't actually compress, but throws if >100MB
export async function compressVideo(
file: string,
_callbacks?: {
onProgress: (progress: number) => void
},
): Promise<CompressedVideo> {
const blob = await fetch(file).then(res => res.blob())
const video = URL.createObjectURL(blob)
if (blob.size > MAX_VIDEO_SIZE) {
throw new VideoTooLargeError()
}
return {
size: blob.size,
uri: video,
}
}

View File

@ -0,0 +1,6 @@
export class VideoTooLargeError extends Error {
constructor() {
super('Videos cannot be larger than 100MB')
this.name = 'VideoTooLargeError'
}
}

View File

@ -11,3 +11,4 @@ export type Gate =
| 'suggested_feeds_interstitial'
| 'suggested_follows_interstitial'
| 'ungroup_follow_backs'
| 'videos'

View File

@ -1,4 +1,5 @@
import React, {
Suspense,
useCallback,
useEffect,
useImperativeHandle,
@ -42,7 +43,7 @@ import {
} from '#/lib/gif-alt-text'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {LikelyType} from '#/lib/link-meta/link-meta'
import {logEvent} from '#/lib/statsig/statsig'
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {emitPostCreated} from '#/state/events'
import {useModalControls} from '#/state/modals'
@ -96,6 +97,10 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage'
import {TextInput, TextInputRef} from './text-input/TextInput'
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
import {useExternalLinkFetch} from './useExternalLinkFetch'
import {SelectVideoBtn} from './videos/SelectVideoBtn'
import {useVideoState} from './videos/state'
import {VideoPreview} from './videos/VideoPreview'
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
import hairlineWidth = StyleSheet.hairlineWidth
type CancelRef = {
@ -115,6 +120,7 @@ export const ComposePost = observer(function ComposePost({
}: Props & {
cancelRef?: React.RefObject<CancelRef>
}) {
const gate = useGate()
const {currentAccount} = useSession()
const agent = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@ -156,6 +162,14 @@ export const ComposePost = observer(function ComposePost({
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
initQuote,
)
const {
video,
onSelectVideo,
videoPending,
videoProcessingData,
clearVideo,
videoProcessingProgress,
} = useVideoState({setError})
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([])
@ -375,8 +389,9 @@ export const ComposePost = observer(function ComposePost({
? _(msg`Write your reply`)
: _(msg`What's up?`)
const canSelectImages = gallery.size < 4 && !extLink
const hasMedia = gallery.size > 0 || Boolean(extLink)
const canSelectImages =
gallery.size < 4 && !extLink && !video && !videoPending
const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
const onEmojiButtonPress = useCallback(() => {
openPicker?.(textInput.current?.getCursorPosition())
@ -600,7 +615,20 @@ export const ComposePost = observer(function ComposePost({
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : undefined}
) : null}
{videoPending && videoProcessingData ? (
<VideoTranscodeProgress
input={videoProcessingData}
progress={videoProcessingProgress}
/>
) : (
video && (
// remove suspense when we get rid of lazy
<Suspense fallback={null}>
<VideoPreview video={video} clear={clearVideo} />
</Suspense>
)
)}
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
@ -619,6 +647,12 @@ export const ComposePost = observer(function ComposePost({
]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
{gate('videos') && (
<SelectVideoBtn
onSelectVideo={onSelectVideo}
disabled={!canSelectImages}
/>
)}
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectGifBtn
onClose={focusTextInput}

View File

@ -1,12 +1,9 @@
import React from 'react'
import {StyleProp, TouchableOpacity, View, ViewStyle} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {ExternalEmbedDraft} from 'lib/api/index'
import {s} from 'lib/styles'
import {Gif} from 'state/queries/tenor'
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
@ -22,7 +19,6 @@ export const ExternalEmbed = ({
gif?: Gif
}) => {
const t = useTheme()
const {_} = useLingui()
const linkInfo = React.useMemo(
() =>
@ -70,25 +66,7 @@ export const ExternalEmbed = ({
<ExternalLinkEmbed link={linkInfo} hideAlt />
</View>
) : null}
<TouchableOpacity
style={{
position: 'absolute',
top: 16,
right: 10,
height: 36,
width: 36,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
}}
onPress={onRemove}
accessibilityRole="button"
accessibilityLabel={_(msg`Remove image preview`)}
accessibilityHint={_(msg`Removes default thumbnail from ${link.uri}`)}
onAccessibilityEscape={onRemove}>
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</TouchableOpacity>
<ExternalEmbedRemoveBtn onRemove={onRemove} />
</View>
)
}

View File

@ -0,0 +1,34 @@
import React from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {s} from 'lib/styles'
export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) {
const {_} = useLingui()
return (
<TouchableOpacity
style={{
position: 'absolute',
top: 10,
right: 10,
height: 36,
width: 36,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
}}
onPress={onRemove}
accessibilityRole="button"
accessibilityLabel={_(msg`Remove image preview`)}
accessibilityHint={_(msg`Removes the image preview`)}
onAccessibilityEscape={onRemove}>
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</TouchableOpacity>
)
}

View File

@ -1,13 +1,14 @@
import React from 'react'
import {View} from 'react-native'
import {Text} from '../../util/text/Text'
// @ts-ignore no type definition -prf
import ProgressCircle from 'react-native-progress/Circle'
// @ts-ignore no type definition -prf
import ProgressPie from 'react-native-progress/Pie'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {Text} from '../../util/text/Text'
const DANGER_LENGTH = MAX_GRAPHEME_LENGTH

View File

@ -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>
</>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 />
)
}

View File

@ -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>
)
}

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
}

View File

@ -57,7 +57,7 @@ export const ExternalLinkEmbed = ({
}
return (
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden]}>
<LinkWrapper link={link} onOpen={onOpen} style={style}>
{imageUri && !embedPlayerParams ? (
<Image

View File

@ -12302,6 +12302,11 @@ expo-updates@~0.25.14:
ignore "^5.3.1"
resolve-from "^5.0.0"
expo-video@^1.1.10:
version "1.1.10"
resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-1.1.10.tgz#b47c0d40c21f401236639424bd25d70c09316b7b"
integrity sha512-k9ecpgtwAK8Ut8enm8Jv398XkB/uVOyLLqk80M/d8pH9EN5CVrBQ7iEzWlR3quvVUFM7Uf5wRukJ4hk3mZ8NCg==
expo-web-browser@~13.0.3:
version "13.0.3"
resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-13.0.3.tgz#dceb05dbc187b498ca937b02adf385b0232a4e92"
@ -18847,6 +18852,11 @@ react-keyed-flatten-children@^3.0.0:
dependencies:
react-is "^18.2.0"
react-native-compressor@^1.8.24:
version "1.8.24"
resolved "https://registry.yarnpkg.com/react-native-compressor/-/react-native-compressor-1.8.24.tgz#3cc481ad6dfe2787ec4385275dd24791f04d9e71"
integrity sha512-PdwOBdnyBnpOag1FRX9ks4cb0GiMLKFU9HSaFTHdb/uw6fVIrnCHpELASeliOxlabWb5rOyVPbc58QpGIfZQIQ==
react-native-date-picker@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/react-native-date-picker/-/react-native-date-picker-4.4.2.tgz#f7bb9daa8559237e08bd30f907ee8487a6e2a6ec"