Add gif support to web (#6433)

* add gif support to web

* rm set dimensions

* rm effect from preview

* rm log

* rm use of {cause: error}

* fix lint
This commit is contained in:
Samuel Newman
2024-11-22 17:58:29 +00:00
committed by GitHub
parent 76ca72cf72
commit 3781074921
9 changed files with 183 additions and 114 deletions
+1
View File
@@ -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
View File
@@ -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}`)
}
@@ -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}`)
}
+20 -15
View File
@@ -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'
@@ -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 = {
@@ -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 (
@@ -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)}
-13
View File
@@ -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
@@ -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,
+11 -31
View File
@@ -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'
@@ -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
@@ -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') {
@@ -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
}
@@ -20,7 +20,6 @@ export function VideoPreview({
asset: ImagePickerAsset
video: CompressedVideo
isActivePost: boolean
setDimensions: (width: number, height: number) => void
clear: () => void
}) {
const t = useTheme()
@@ -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'
@@ -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)) {
@@ -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>
)
+21
View File
@@ -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')
}
@@ -0,0 +1,94 @@
import {ImagePickerAsset, ImagePickerResult} from 'expo-image-picker'
import {SUPPORTED_MIME_TYPES} from '#/lib/constants'
// mostly copied from expo-image-picker and adapted to support gifs
// also adds support for reading video metadata
export async function pickVideo(): Promise<ImagePickerResult> {
const input = document.createElement('input')
input.style.display = 'none'
input.setAttribute('type', 'file')
// TODO: do we need video/* here? -sfn
input.setAttribute('accept', SUPPORTED_MIME_TYPES.join(','))
input.setAttribute('id', String(Math.random()))
document.body.appendChild(input)
return new Promise(resolve => {
input.addEventListener('change', async () => {
if (input.files) {
const file = input.files[0]
resolve({
canceled: false,
assets: [await getVideoMetadata(file)],
})
} else {
resolve({canceled: true, assets: null})
}
document.body.removeChild(input)
})
const event = new MouseEvent('click')
input.dispatchEvent(event)
})
}
// TODO: we're converting to a dataUrl here, and then converting back to an
// ArrayBuffer in the compressVideo function. This is a bit wasteful, but it
// lets us use the ImagePickerAsset type, which the rest of the code expects.
// We should unwind this and just pass the ArrayBuffer/objectUrl through the system
// instead of a string -sfn
export const getVideoMetadata = (file: File): Promise<ImagePickerAsset> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const uri = reader.result as string
if (file.type === 'image/gif') {
const img = new Image()
img.onload = () => {
resolve({
uri,
mimeType: 'image/gif',
width: img.width,
height: img.height,
// todo: calculate gif duration. seems possible if you read the bytes
// https://codepen.io/Ryman/pen/nZpYwY
// for now let's just let the server reject it, since that seems uncommon -sfn
duration: null,
})
}
img.onerror = (_ev, _source, _lineno, _colno, error) => {
console.log('Failed to grab GIF metadata', error)
reject(new Error('Failed to grab GIF metadata'))
}
img.src = uri
} else {
const video = document.createElement('video')
const blobUrl = URL.createObjectURL(file)
video.preload = 'metadata'
video.src = blobUrl
video.onloadedmetadata = () => {
URL.revokeObjectURL(blobUrl)
resolve({
uri,
mimeType: file.type,
width: video.videoWidth,
height: video.videoHeight,
// convert seconds to ms
duration: video.duration * 1000,
})
}
video.onerror = (_ev, _source, _lineno, _colno, error) => {
URL.revokeObjectURL(blobUrl)
console.log('Failed to grab video metadata', error)
reject(new Error('Failed to grab video metadata'))
}
}
}
reader.readAsDataURL(file)
})
}