From 378107492194a5f408747790015c4ca1d624302b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 22 Nov 2024 17:58:29 +0000 Subject: [PATCH] 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 --- src/lib/constants.ts | 1 + src/lib/media/video/util.ts | 4 + src/view/com/composer/Composer.tsx | 35 ++++--- src/view/com/composer/state/video.ts | 13 --- .../com/composer/videos/SelectVideoBtn.tsx | 42 +++------ src/view/com/composer/videos/VideoPreview.tsx | 1 - .../com/composer/videos/VideoPreview.web.tsx | 86 +++++++---------- src/view/com/composer/videos/pickVideo.ts | 21 +++++ src/view/com/composer/videos/pickVideo.web.ts | 94 +++++++++++++++++++ 9 files changed, 183 insertions(+), 114 deletions(-) create mode 100644 src/view/com/composer/videos/pickVideo.ts create mode 100644 src/view/com/composer/videos/pickVideo.web.ts diff --git a/src/lib/constants.ts b/src/lib/constants.ts index cd9183c953f..ee066d9197e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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] diff --git a/src/lib/media/video/util.ts b/src/lib/media/video/util.ts index 87b422c2c91..b80e0a4a1eb 100644 --- a/src/lib/media/video/util.ts +++ b/src/lib/media/video/util.ts @@ -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}`) } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 5d9f607661a..e4b09cf0f8b 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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)} diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 8814a7e61c1..7ce4a0cf829 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -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, diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index ac9ae521c35..1b052ccdd3e 100644 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -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 -} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index fff7545a522..255174beabc 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -20,7 +20,6 @@ export function VideoPreview({ asset: ImagePickerAsset video: CompressedVideo isActivePost: boolean - setDimensions: (width: number, height: number) => void clear: () => void }) { const t = useTheme() diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx index 5b3f727a9b5..f20f8b383c8 100644 --- a/src/view/com/composer/videos/VideoPreview.web.tsx +++ b/src/view/com/composer/videos/VideoPreview.web.tsx @@ -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(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, ]}> -