diff options
author | Samuel Newman <mozzius@protonmail.com> | 2024-11-22 17:58:29 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-11-22 17:58:29 +0000 |
commit | 378107492194a5f408747790015c4ca1d624302b (patch) | |
tree | e12e121265d081b49c50d7cd0cc5f71c5f477bf7 /src | |
parent | 76ca72cf727e926101ec60eb232f0797e6584b49 (diff) | |
download | voidsky-378107492194a5f408747790015c4ca1d624302b.tar.zst |
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
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/constants.ts | 1 | ||||
-rw-r--r-- | src/lib/media/video/util.ts | 4 | ||||
-rw-r--r-- | src/view/com/composer/Composer.tsx | 35 | ||||
-rw-r--r-- | src/view/com/composer/state/video.ts | 13 | ||||
-rw-r--r-- | src/view/com/composer/videos/SelectVideoBtn.tsx | 42 | ||||
-rw-r--r-- | src/view/com/composer/videos/VideoPreview.tsx | 1 | ||||
-rw-r--r-- | src/view/com/composer/videos/VideoPreview.web.tsx | 86 | ||||
-rw-r--r-- | src/view/com/composer/videos/pickVideo.ts | 21 | ||||
-rw-r--r-- | src/view/com/composer/videos/pickVideo.web.ts | 94 |
9 files changed, 183 insertions, 114 deletions
diff --git a/src/lib/constants.ts b/src/lib/constants.ts index cd9183c95..ee066d919 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 87b422c2c..b80e0a4a1 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 5d9f60766..e4b09cf0f 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 8814a7e61..7ce4a0cf8 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -37,12 +37,6 @@ export type VideoAction = } | {type: 'update_progress'; progress: number; signal: AbortSignal} | { - type: 'update_dimensions' - width: number - height: number - signal: AbortSignal - } - | { type: 'update_alt_text' altText: string signal: AbortSignal @@ -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 ac9ae521c..1b052ccdd 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 fff7545a5..255174bea 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 5b3f727a9..f20f8b383 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<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> ) diff --git a/src/view/com/composer/videos/pickVideo.ts b/src/view/com/composer/videos/pickVideo.ts new file mode 100644 index 000000000..0edf7d0de --- /dev/null +++ b/src/view/com/composer/videos/pickVideo.ts @@ -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') +} diff --git a/src/view/com/composer/videos/pickVideo.web.ts b/src/view/com/composer/videos/pickVideo.web.ts new file mode 100644 index 000000000..56a38fa56 --- /dev/null +++ b/src/view/com/composer/videos/pickVideo.web.ts @@ -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) + }) +} |