Skip to content

Commit

Permalink
Add gif support to web (#6433)
Browse files Browse the repository at this point in the history
* add gif support to web

* rm set dimensions

* rm effect from preview

* rm log

* rm use of {cause: error}

* fix lint
  • Loading branch information
mozzius authored Nov 22, 2024
1 parent 76ca72c commit 3781074
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 114 deletions.
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export const SUPPORTED_MIME_TYPES = [
'video/mpeg',
'video/webm',
'video/quicktime',
'image/gif',
] as const

export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
4 changes: 4 additions & 0 deletions src/lib/media/video/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
return 'mpeg'
case 'video/quicktime':
return 'mov'
case 'image/gif':
return 'gif'
default:
throw new Error(`Unsupported mime type: ${mimeType}`)
}
Expand All @@ -47,6 +49,8 @@ export function extToMime(ext: string) {
return 'video/mpeg'
case 'mov':
return 'video/quicktime'
case 'gif':
return 'image/gif'
default:
throw new Error(`Unsupported file extension: ${ext}`)
}
Expand Down
35 changes: 20 additions & 15 deletions src/view/com/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,18 @@ import {useQueryClient} from '@tanstack/react-query'
import * as apilib from '#/lib/api/index'
import {EmbeddingDisabledError} from '#/lib/api/resolve'
import {until} from '#/lib/async/until'
import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
import {
MAX_GRAPHEME_LENGTH,
SUPPORTED_MIME_TYPES,
SupportedMimeTypes,
} from '#/lib/constants'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useEmail} from '#/lib/hooks/useEmail'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {mimeToExt} from '#/lib/media/video/util'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {colors, s} from '#/lib/styles'
Expand Down Expand Up @@ -130,6 +135,7 @@ import {
ThreadDraft,
} from './state/composer'
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
import {getVideoMetadata} from './videos/pickVideo'
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'

type CancelRef = {
Expand Down Expand Up @@ -746,14 +752,24 @@ let ComposerPost = React.memo(function ComposerPost({

const onPhotoPasted = useCallback(
async (uri: string) => {
if (uri.startsWith('data:video/')) {
onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0})
if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) {
if (isNative) return // web only
const [mimeType] = uri.slice('data:'.length).split(';')
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
Toast.show(_(msg`Unsupported video type`), 'xmark')
return
}
const name = `pasted.${mimeToExt(mimeType)}`
const file = await fetch(uri)
.then(res => res.blob())
.then(blob => new File([blob], name, {type: mimeType}))
onSelectVideo(post.id, await getVideoMetadata(file))
} else {
const res = await pasteImage(uri)
onImageAdd([res])
}
},
[post.id, onSelectVideo, onImageAdd],
[post.id, onSelectVideo, onImageAdd, _],
)

return (
Expand Down Expand Up @@ -1009,17 +1025,6 @@ function ComposerEmbeds({
asset={video.asset}
video={video.video}
isActivePost={isActivePost}
setDimensions={(width: number, height: number) => {
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_dimensions',
width,
height,
signal: video.abortController.signal,
},
})
}}
clear={clearVideo}
/>
) : null)}
Expand Down
13 changes: 0 additions & 13 deletions src/view/com/composer/state/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,6 @@ export type VideoAction =
signal: AbortSignal
}
| {type: 'update_progress'; progress: number; signal: AbortSignal}
| {
type: 'update_dimensions'
width: number
height: number
signal: AbortSignal
}
| {
type: 'update_alt_text'
altText: string
Expand Down Expand Up @@ -185,13 +179,6 @@ export function videoReducer(
progress: action.progress,
}
}
} else if (action.type === 'update_dimensions') {
if (state.asset) {
return {
...state,
asset: {...state.asset, width: action.width, height: action.height},
}
}
} else if (action.type === 'update_alt_text') {
return {
...state,
Expand Down
42 changes: 11 additions & 31 deletions src/view/com/composer/videos/SelectVideoBtn.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import {useCallback} from 'react'
import {Keyboard} from 'react-native'
import {
ImagePickerAsset,
launchImageLibraryAsync,
MediaTypeOptions,
UIImagePickerPreferredAssetRepresentationMode,
} from 'expo-image-picker'
import {ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'

Expand All @@ -22,6 +17,7 @@ import {useDialogControl} from '#/components/Dialog'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
import * as Prompt from '#/components/Prompt'
import {pickVideo} from './pickVideo'

const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds

Expand Down Expand Up @@ -52,24 +48,22 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
Keyboard.dismiss()
control.open()
} else {
const response = await launchImageLibraryAsync({
exif: false,
mediaTypes: MediaTypeOptions.Videos,
quality: 1,
legacy: true,
preferredAssetRepresentationMode:
UIImagePickerPreferredAssetRepresentationMode.Current,
})
const response = await pickVideo()
if (response.assets && response.assets.length > 0) {
const asset = response.assets[0]
try {
if (isWeb) {
// asset.duration is null for gifs (see the TODO in pickVideo.web.ts)
if (asset.duration && asset.duration > VIDEO_MAX_DURATION) {
throw Error(_(msg`Videos must be less than 60 seconds long`))
}
// 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)
!SUPPORTED_MIME_TYPES.includes(
asset.mimeType as SupportedMimeTypes,
)
) {
throw Error(_(msg`Unsupported video type: ${mimeType}`))
throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
}
} else {
if (typeof asset.duration !== 'number') {
Expand Down Expand Up @@ -142,17 +136,3 @@ 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
}
1 change: 0 additions & 1 deletion src/view/com/composer/videos/VideoPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export function VideoPreview({
asset: ImagePickerAsset
video: CompressedVideo
isActivePost: boolean
setDimensions: (width: number, height: number) => void
clear: () => void
}) {
const t = useTheme()
Expand Down
86 changes: 32 additions & 54 deletions src/view/com/composer/videos/VideoPreview.web.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {useEffect, useRef} from 'react'
import {View} from 'react-native'
import {ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
Expand All @@ -12,58 +11,22 @@ import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'

const MAX_DURATION = 60

export function VideoPreview({
asset,
video,
setDimensions,

clear,
}: {
asset: ImagePickerAsset
video: CompressedVideo
setDimensions: (width: number, height: number) => void

clear: () => void
}) {
const ref = useRef<HTMLVideoElement>(null)
const {_} = useLingui()
// TODO: figure out how to pause a GIF for reduced motion
// it's not possible using an img tag -sfn
const autoplayDisabled = useAutoplayDisabled()

useEffect(() => {
if (!ref.current) return

const abortController = new AbortController()
const {signal} = abortController
ref.current.addEventListener(
'loadedmetadata',
function () {
setDimensions(this.videoWidth, this.videoHeight)
if (!isNaN(this.duration)) {
if (this.duration > MAX_DURATION) {
Toast.show(
_(msg`Videos must be less than 60 seconds long`),
'xmark',
)
clear()
}
}
},
{signal},
)
ref.current.addEventListener(
'error',
() => {
Toast.show(_(msg`Could not process your video`), 'xmark')
clear()
},
{signal},
)

return () => {
abortController.abort()
}
}, [setDimensions, _, clear])

let aspectRatio = asset.width / asset.height

if (isNaN(aspectRatio)) {
Expand All @@ -83,19 +46,34 @@ export function VideoPreview({
a.relative,
]}>
<ExternalEmbedRemoveBtn onRemove={clear} />
<video
ref={ref}
src={video.uri}
style={{width: '100%', height: '100%', objectFit: 'cover'}}
autoPlay={!autoplayDisabled}
loop
muted
playsInline
/>
{autoplayDisabled && (
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon />
</View>
{video.mimeType === 'image/gif' ? (
<img
src={video.uri}
style={{width: '100%', height: '100%', objectFit: 'cover'}}
alt="GIF"
/>
) : (
<>
<video
src={video.uri}
style={{width: '100%', height: '100%', objectFit: 'cover'}}
autoPlay={!autoplayDisabled}
loop
muted
playsInline
onError={err => {
console.error('Error loading video', err)
Toast.show(_(msg`Could not process your video`), 'xmark')
clear()
}}
/>
{autoplayDisabled && (
<View
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon />
</View>
)}
</>
)}
</View>
)
Expand Down
21 changes: 21 additions & 0 deletions src/view/com/composer/videos/pickVideo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {
ImagePickerAsset,
launchImageLibraryAsync,
MediaTypeOptions,
UIImagePickerPreferredAssetRepresentationMode,
} from 'expo-image-picker'

export async function pickVideo() {
return await launchImageLibraryAsync({
exif: false,
mediaTypes: MediaTypeOptions.Videos,
quality: 1,
legacy: true,
preferredAssetRepresentationMode:
UIImagePickerPreferredAssetRepresentationMode.Current,
})
}

export const getVideoMetadata = (_file: File): Promise<ImagePickerAsset> => {
throw new Error('getVideoMetadata is web only')
}
Loading

0 comments on commit 3781074

Please sign in to comment.