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:
@@ -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]
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user