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/mpeg',
|
||||||
'video/webm',
|
'video/webm',
|
||||||
'video/quicktime',
|
'video/quicktime',
|
||||||
|
'image/gif',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
|
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
|
|||||||
return 'mpeg'
|
return 'mpeg'
|
||||||
case 'video/quicktime':
|
case 'video/quicktime':
|
||||||
return 'mov'
|
return 'mov'
|
||||||
|
case 'image/gif':
|
||||||
|
return 'gif'
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported mime type: ${mimeType}`)
|
throw new Error(`Unsupported mime type: ${mimeType}`)
|
||||||
}
|
}
|
||||||
@@ -47,6 +49,8 @@ export function extToMime(ext: string) {
|
|||||||
return 'video/mpeg'
|
return 'video/mpeg'
|
||||||
case 'mov':
|
case 'mov':
|
||||||
return 'video/quicktime'
|
return 'video/quicktime'
|
||||||
|
case 'gif':
|
||||||
|
return 'image/gif'
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported file extension: ${ext}`)
|
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 * as apilib from '#/lib/api/index'
|
||||||
import {EmbeddingDisabledError} from '#/lib/api/resolve'
|
import {EmbeddingDisabledError} from '#/lib/api/resolve'
|
||||||
import {until} from '#/lib/async/until'
|
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 {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||||
import {useEmail} from '#/lib/hooks/useEmail'
|
import {useEmail} from '#/lib/hooks/useEmail'
|
||||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
|
import {mimeToExt} from '#/lib/media/video/util'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {colors, s} from '#/lib/styles'
|
import {colors, s} from '#/lib/styles'
|
||||||
@@ -130,6 +135,7 @@ import {
|
|||||||
ThreadDraft,
|
ThreadDraft,
|
||||||
} from './state/composer'
|
} from './state/composer'
|
||||||
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
|
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
|
||||||
|
import {getVideoMetadata} from './videos/pickVideo'
|
||||||
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
||||||
|
|
||||||
type CancelRef = {
|
type CancelRef = {
|
||||||
@@ -746,14 +752,24 @@ let ComposerPost = React.memo(function ComposerPost({
|
|||||||
|
|
||||||
const onPhotoPasted = useCallback(
|
const onPhotoPasted = useCallback(
|
||||||
async (uri: string) => {
|
async (uri: string) => {
|
||||||
if (uri.startsWith('data:video/')) {
|
if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) {
|
||||||
onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0})
|
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 {
|
} else {
|
||||||
const res = await pasteImage(uri)
|
const res = await pasteImage(uri)
|
||||||
onImageAdd([res])
|
onImageAdd([res])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[post.id, onSelectVideo, onImageAdd],
|
[post.id, onSelectVideo, onImageAdd, _],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1009,17 +1025,6 @@ function ComposerEmbeds({
|
|||||||
asset={video.asset}
|
asset={video.asset}
|
||||||
video={video.video}
|
video={video.video}
|
||||||
isActivePost={isActivePost}
|
isActivePost={isActivePost}
|
||||||
setDimensions={(width: number, height: number) => {
|
|
||||||
dispatch({
|
|
||||||
type: 'embed_update_video',
|
|
||||||
videoAction: {
|
|
||||||
type: 'update_dimensions',
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
signal: video.abortController.signal,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
clear={clearVideo}
|
clear={clearVideo}
|
||||||
/>
|
/>
|
||||||
) : null)}
|
) : null)}
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ export type VideoAction =
|
|||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}
|
}
|
||||||
| {type: 'update_progress'; progress: number; signal: AbortSignal}
|
| {type: 'update_progress'; progress: number; signal: AbortSignal}
|
||||||
| {
|
|
||||||
type: 'update_dimensions'
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
signal: AbortSignal
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: 'update_alt_text'
|
type: 'update_alt_text'
|
||||||
altText: string
|
altText: string
|
||||||
@@ -185,13 +179,6 @@ export function videoReducer(
|
|||||||
progress: action.progress,
|
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') {
|
} else if (action.type === 'update_alt_text') {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import {useCallback} from 'react'
|
import {useCallback} from 'react'
|
||||||
import {Keyboard} from 'react-native'
|
import {Keyboard} from 'react-native'
|
||||||
import {
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
ImagePickerAsset,
|
|
||||||
launchImageLibraryAsync,
|
|
||||||
MediaTypeOptions,
|
|
||||||
UIImagePickerPreferredAssetRepresentationMode,
|
|
||||||
} from 'expo-image-picker'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
@@ -22,6 +17,7 @@ import {useDialogControl} from '#/components/Dialog'
|
|||||||
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
|
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
|
||||||
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {pickVideo} from './pickVideo'
|
||||||
|
|
||||||
const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds
|
const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds
|
||||||
|
|
||||||
@@ -52,24 +48,22 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
|||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
control.open()
|
control.open()
|
||||||
} else {
|
} else {
|
||||||
const response = await launchImageLibraryAsync({
|
const response = await pickVideo()
|
||||||
exif: false,
|
|
||||||
mediaTypes: MediaTypeOptions.Videos,
|
|
||||||
quality: 1,
|
|
||||||
legacy: true,
|
|
||||||
preferredAssetRepresentationMode:
|
|
||||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
|
||||||
})
|
|
||||||
if (response.assets && response.assets.length > 0) {
|
if (response.assets && response.assets.length > 0) {
|
||||||
const asset = response.assets[0]
|
const asset = response.assets[0]
|
||||||
try {
|
try {
|
||||||
if (isWeb) {
|
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
|
// compression step on native converts to mp4, so no need to check there
|
||||||
const mimeType = getMimeType(asset)
|
|
||||||
if (
|
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 {
|
} else {
|
||||||
if (typeof asset.duration !== 'number') {
|
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
|
asset: ImagePickerAsset
|
||||||
video: CompressedVideo
|
video: CompressedVideo
|
||||||
isActivePost: boolean
|
isActivePost: boolean
|
||||||
setDimensions: (width: number, height: number) => void
|
|
||||||
clear: () => void
|
clear: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import {useEffect, useRef} from 'react'
|
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {ImagePickerAsset} from 'expo-image-picker'
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
@@ -12,58 +11,22 @@ import * as Toast from '#/view/com/util/Toast'
|
|||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
|
||||||
const MAX_DURATION = 60
|
|
||||||
|
|
||||||
export function VideoPreview({
|
export function VideoPreview({
|
||||||
asset,
|
asset,
|
||||||
video,
|
video,
|
||||||
setDimensions,
|
|
||||||
clear,
|
clear,
|
||||||
}: {
|
}: {
|
||||||
asset: ImagePickerAsset
|
asset: ImagePickerAsset
|
||||||
video: CompressedVideo
|
video: CompressedVideo
|
||||||
setDimensions: (width: number, height: number) => void
|
|
||||||
clear: () => void
|
clear: () => void
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
|
||||||
const {_} = useLingui()
|
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()
|
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
|
let aspectRatio = asset.width / asset.height
|
||||||
|
|
||||||
if (isNaN(aspectRatio)) {
|
if (isNaN(aspectRatio)) {
|
||||||
@@ -83,19 +46,34 @@ export function VideoPreview({
|
|||||||
a.relative,
|
a.relative,
|
||||||
]}>
|
]}>
|
||||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||||
<video
|
{video.mimeType === 'image/gif' ? (
|
||||||
ref={ref}
|
<img
|
||||||
src={video.uri}
|
src={video.uri}
|
||||||
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
||||||
autoPlay={!autoplayDisabled}
|
alt="GIF"
|
||||||
loop
|
/>
|
||||||
muted
|
) : (
|
||||||
playsInline
|
<>
|
||||||
/>
|
<video
|
||||||
{autoplayDisabled && (
|
src={video.uri}
|
||||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
||||||
<PlayButtonIcon />
|
autoPlay={!autoplayDisabled}
|
||||||
</View>
|
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>
|
</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