Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Samuel Newman and committed by
GitHub
37810749 76ca72cf

+183 -114
+1
src/lib/constants.ts
··· 154 154 'video/mpeg', 155 155 'video/webm', 156 156 'video/quicktime', 157 + 'image/gif', 157 158 ] as const 158 159 159 160 export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
+4
src/lib/media/video/util.ts
··· 32 32 return 'mpeg' 33 33 case 'video/quicktime': 34 34 return 'mov' 35 + case 'image/gif': 36 + return 'gif' 35 37 default: 36 38 throw new Error(`Unsupported mime type: ${mimeType}`) 37 39 } ··· 47 49 return 'video/mpeg' 48 50 case 'mov': 49 51 return 'video/quicktime' 52 + case 'gif': 53 + return 'image/gif' 50 54 default: 51 55 throw new Error(`Unsupported file extension: ${ext}`) 52 56 }
+20 -15
src/view/com/composer/Composer.tsx
··· 56 56 import * as apilib from '#/lib/api/index' 57 57 import {EmbeddingDisabledError} from '#/lib/api/resolve' 58 58 import {until} from '#/lib/async/until' 59 - import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' 59 + import { 60 + MAX_GRAPHEME_LENGTH, 61 + SUPPORTED_MIME_TYPES, 62 + SupportedMimeTypes, 63 + } from '#/lib/constants' 60 64 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 61 65 import {useEmail} from '#/lib/hooks/useEmail' 62 66 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 63 67 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 64 68 import {usePalette} from '#/lib/hooks/usePalette' 65 69 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 70 + import {mimeToExt} from '#/lib/media/video/util' 66 71 import {logEvent} from '#/lib/statsig/statsig' 67 72 import {cleanError} from '#/lib/strings/errors' 68 73 import {colors, s} from '#/lib/styles' ··· 130 135 ThreadDraft, 131 136 } from './state/composer' 132 137 import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' 138 + import {getVideoMetadata} from './videos/pickVideo' 133 139 import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' 134 140 135 141 type CancelRef = { ··· 746 752 747 753 const onPhotoPasted = useCallback( 748 754 async (uri: string) => { 749 - if (uri.startsWith('data:video/')) { 750 - onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0}) 755 + if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) { 756 + if (isNative) return // web only 757 + const [mimeType] = uri.slice('data:'.length).split(';') 758 + if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { 759 + Toast.show(_(msg`Unsupported video type`), 'xmark') 760 + return 761 + } 762 + const name = `pasted.${mimeToExt(mimeType)}` 763 + const file = await fetch(uri) 764 + .then(res => res.blob()) 765 + .then(blob => new File([blob], name, {type: mimeType})) 766 + onSelectVideo(post.id, await getVideoMetadata(file)) 751 767 } else { 752 768 const res = await pasteImage(uri) 753 769 onImageAdd([res]) 754 770 } 755 771 }, 756 - [post.id, onSelectVideo, onImageAdd], 772 + [post.id, onSelectVideo, onImageAdd, _], 757 773 ) 758 774 759 775 return ( ··· 1009 1025 asset={video.asset} 1010 1026 video={video.video} 1011 1027 isActivePost={isActivePost} 1012 - setDimensions={(width: number, height: number) => { 1013 - dispatch({ 1014 - type: 'embed_update_video', 1015 - videoAction: { 1016 - type: 'update_dimensions', 1017 - width, 1018 - height, 1019 - signal: video.abortController.signal, 1020 - }, 1021 - }) 1022 - }} 1023 1028 clear={clearVideo} 1024 1029 /> 1025 1030 ) : null)}
-13
src/view/com/composer/state/video.ts
··· 37 37 } 38 38 | {type: 'update_progress'; progress: number; signal: AbortSignal} 39 39 | { 40 - type: 'update_dimensions' 41 - width: number 42 - height: number 43 - signal: AbortSignal 44 - } 45 - | { 46 40 type: 'update_alt_text' 47 41 altText: string 48 42 signal: AbortSignal ··· 183 177 return { 184 178 ...state, 185 179 progress: action.progress, 186 - } 187 - } 188 - } else if (action.type === 'update_dimensions') { 189 - if (state.asset) { 190 - return { 191 - ...state, 192 - asset: {...state.asset, width: action.width, height: action.height}, 193 180 } 194 181 } 195 182 } else if (action.type === 'update_alt_text') {
+11 -31
src/view/com/composer/videos/SelectVideoBtn.tsx
··· 1 1 import {useCallback} from 'react' 2 2 import {Keyboard} from 'react-native' 3 - import { 4 - ImagePickerAsset, 5 - launchImageLibraryAsync, 6 - MediaTypeOptions, 7 - UIImagePickerPreferredAssetRepresentationMode, 8 - } from 'expo-image-picker' 3 + import {ImagePickerAsset} from 'expo-image-picker' 9 4 import {msg} from '@lingui/macro' 10 5 import {useLingui} from '@lingui/react' 11 6 ··· 22 17 import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 23 18 import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' 24 19 import * as Prompt from '#/components/Prompt' 20 + import {pickVideo} from './pickVideo' 25 21 26 22 const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds 27 23 ··· 52 48 Keyboard.dismiss() 53 49 control.open() 54 50 } else { 55 - const response = await launchImageLibraryAsync({ 56 - exif: false, 57 - mediaTypes: MediaTypeOptions.Videos, 58 - quality: 1, 59 - legacy: true, 60 - preferredAssetRepresentationMode: 61 - UIImagePickerPreferredAssetRepresentationMode.Current, 62 - }) 51 + const response = await pickVideo() 63 52 if (response.assets && response.assets.length > 0) { 64 53 const asset = response.assets[0] 65 54 try { 66 55 if (isWeb) { 56 + // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) 57 + if (asset.duration && asset.duration > VIDEO_MAX_DURATION) { 58 + throw Error(_(msg`Videos must be less than 60 seconds long`)) 59 + } 67 60 // compression step on native converts to mp4, so no need to check there 68 - const mimeType = getMimeType(asset) 69 61 if ( 70 - !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes) 62 + !SUPPORTED_MIME_TYPES.includes( 63 + asset.mimeType as SupportedMimeTypes, 64 + ) 71 65 ) { 72 - throw Error(_(msg`Unsupported video type: ${mimeType}`)) 66 + throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) 73 67 } 74 68 } else { 75 69 if (typeof asset.duration !== 'number') { ··· 142 136 </> 143 137 ) 144 138 } 145 - 146 - function getMimeType(asset: ImagePickerAsset) { 147 - if (isWeb) { 148 - const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') 149 - if (!mimeType) { 150 - throw new Error('Could not determine mime type') 151 - } 152 - return mimeType 153 - } 154 - if (!asset.mimeType) { 155 - throw new Error('Could not determine mime type') 156 - } 157 - return asset.mimeType 158 - }
-1
src/view/com/composer/videos/VideoPreview.tsx
··· 20 20 asset: ImagePickerAsset 21 21 video: CompressedVideo 22 22 isActivePost: boolean 23 - setDimensions: (width: number, height: number) => void 24 23 clear: () => void 25 24 }) { 26 25 const t = useTheme()
+32 -54
src/view/com/composer/videos/VideoPreview.web.tsx
··· 1 - import {useEffect, useRef} from 'react' 2 1 import {View} from 'react-native' 3 2 import {ImagePickerAsset} from 'expo-image-picker' 4 3 import {msg} from '@lingui/macro' ··· 12 11 import {atoms as a} from '#/alf' 13 12 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' 14 13 15 - const MAX_DURATION = 60 16 - 17 14 export function VideoPreview({ 18 15 asset, 19 16 video, 20 - setDimensions, 17 + 21 18 clear, 22 19 }: { 23 20 asset: ImagePickerAsset 24 21 video: CompressedVideo 25 - setDimensions: (width: number, height: number) => void 22 + 26 23 clear: () => void 27 24 }) { 28 - const ref = useRef<HTMLVideoElement>(null) 29 25 const {_} = useLingui() 26 + // TODO: figure out how to pause a GIF for reduced motion 27 + // it's not possible using an img tag -sfn 30 28 const autoplayDisabled = useAutoplayDisabled() 31 29 32 - useEffect(() => { 33 - if (!ref.current) return 34 - 35 - const abortController = new AbortController() 36 - const {signal} = abortController 37 - ref.current.addEventListener( 38 - 'loadedmetadata', 39 - function () { 40 - setDimensions(this.videoWidth, this.videoHeight) 41 - if (!isNaN(this.duration)) { 42 - if (this.duration > MAX_DURATION) { 43 - Toast.show( 44 - _(msg`Videos must be less than 60 seconds long`), 45 - 'xmark', 46 - ) 47 - clear() 48 - } 49 - } 50 - }, 51 - {signal}, 52 - ) 53 - ref.current.addEventListener( 54 - 'error', 55 - () => { 56 - Toast.show(_(msg`Could not process your video`), 'xmark') 57 - clear() 58 - }, 59 - {signal}, 60 - ) 61 - 62 - return () => { 63 - abortController.abort() 64 - } 65 - }, [setDimensions, _, clear]) 66 - 67 30 let aspectRatio = asset.width / asset.height 68 31 69 32 if (isNaN(aspectRatio)) { ··· 83 46 a.relative, 84 47 ]}> 85 48 <ExternalEmbedRemoveBtn onRemove={clear} /> 86 - <video 87 - ref={ref} 88 - src={video.uri} 89 - style={{width: '100%', height: '100%', objectFit: 'cover'}} 90 - autoPlay={!autoplayDisabled} 91 - loop 92 - muted 93 - playsInline 94 - /> 95 - {autoplayDisabled && ( 96 - <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 97 - <PlayButtonIcon /> 98 - </View> 49 + {video.mimeType === 'image/gif' ? ( 50 + <img 51 + src={video.uri} 52 + style={{width: '100%', height: '100%', objectFit: 'cover'}} 53 + alt="GIF" 54 + /> 55 + ) : ( 56 + <> 57 + <video 58 + src={video.uri} 59 + style={{width: '100%', height: '100%', objectFit: 'cover'}} 60 + autoPlay={!autoplayDisabled} 61 + loop 62 + muted 63 + playsInline 64 + onError={err => { 65 + console.error('Error loading video', err) 66 + Toast.show(_(msg`Could not process your video`), 'xmark') 67 + clear() 68 + }} 69 + /> 70 + {autoplayDisabled && ( 71 + <View 72 + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 73 + <PlayButtonIcon /> 74 + </View> 75 + )} 76 + </> 99 77 )} 100 78 </View> 101 79 )
+21
src/view/com/composer/videos/pickVideo.ts
··· 1 + import { 2 + ImagePickerAsset, 3 + launchImageLibraryAsync, 4 + MediaTypeOptions, 5 + UIImagePickerPreferredAssetRepresentationMode, 6 + } from 'expo-image-picker' 7 + 8 + export async function pickVideo() { 9 + return await launchImageLibraryAsync({ 10 + exif: false, 11 + mediaTypes: MediaTypeOptions.Videos, 12 + quality: 1, 13 + legacy: true, 14 + preferredAssetRepresentationMode: 15 + UIImagePickerPreferredAssetRepresentationMode.Current, 16 + }) 17 + } 18 + 19 + export const getVideoMetadata = (_file: File): Promise<ImagePickerAsset> => { 20 + throw new Error('getVideoMetadata is web only') 21 + }
+94
src/view/com/composer/videos/pickVideo.web.ts
··· 1 + import {ImagePickerAsset, ImagePickerResult} from 'expo-image-picker' 2 + 3 + import {SUPPORTED_MIME_TYPES} from '#/lib/constants' 4 + 5 + // mostly copied from expo-image-picker and adapted to support gifs 6 + // also adds support for reading video metadata 7 + 8 + export async function pickVideo(): Promise<ImagePickerResult> { 9 + const input = document.createElement('input') 10 + input.style.display = 'none' 11 + input.setAttribute('type', 'file') 12 + // TODO: do we need video/* here? -sfn 13 + input.setAttribute('accept', SUPPORTED_MIME_TYPES.join(',')) 14 + input.setAttribute('id', String(Math.random())) 15 + 16 + document.body.appendChild(input) 17 + 18 + return new Promise(resolve => { 19 + input.addEventListener('change', async () => { 20 + if (input.files) { 21 + const file = input.files[0] 22 + resolve({ 23 + canceled: false, 24 + assets: [await getVideoMetadata(file)], 25 + }) 26 + } else { 27 + resolve({canceled: true, assets: null}) 28 + } 29 + document.body.removeChild(input) 30 + }) 31 + 32 + const event = new MouseEvent('click') 33 + input.dispatchEvent(event) 34 + }) 35 + } 36 + 37 + // TODO: we're converting to a dataUrl here, and then converting back to an 38 + // ArrayBuffer in the compressVideo function. This is a bit wasteful, but it 39 + // lets us use the ImagePickerAsset type, which the rest of the code expects. 40 + // We should unwind this and just pass the ArrayBuffer/objectUrl through the system 41 + // instead of a string -sfn 42 + export const getVideoMetadata = (file: File): Promise<ImagePickerAsset> => { 43 + return new Promise((resolve, reject) => { 44 + const reader = new FileReader() 45 + reader.onload = () => { 46 + const uri = reader.result as string 47 + 48 + if (file.type === 'image/gif') { 49 + const img = new Image() 50 + img.onload = () => { 51 + resolve({ 52 + uri, 53 + mimeType: 'image/gif', 54 + width: img.width, 55 + height: img.height, 56 + // todo: calculate gif duration. seems possible if you read the bytes 57 + // https://codepen.io/Ryman/pen/nZpYwY 58 + // for now let's just let the server reject it, since that seems uncommon -sfn 59 + duration: null, 60 + }) 61 + } 62 + img.onerror = (_ev, _source, _lineno, _colno, error) => { 63 + console.log('Failed to grab GIF metadata', error) 64 + reject(new Error('Failed to grab GIF metadata')) 65 + } 66 + img.src = uri 67 + } else { 68 + const video = document.createElement('video') 69 + const blobUrl = URL.createObjectURL(file) 70 + 71 + video.preload = 'metadata' 72 + video.src = blobUrl 73 + 74 + video.onloadedmetadata = () => { 75 + URL.revokeObjectURL(blobUrl) 76 + resolve({ 77 + uri, 78 + mimeType: file.type, 79 + width: video.videoWidth, 80 + height: video.videoHeight, 81 + // convert seconds to ms 82 + duration: video.duration * 1000, 83 + }) 84 + } 85 + video.onerror = (_ev, _source, _lineno, _colno, error) => { 86 + URL.revokeObjectURL(blobUrl) 87 + console.log('Failed to grab video metadata', error) 88 + reject(new Error('Failed to grab video metadata')) 89 + } 90 + } 91 + } 92 + reader.readAsDataURL(file) 93 + }) 94 + }