Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
122
fork

Configure Feed

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

Only treat animated gifs as videos, leave static gifs as images (#9814)

* only treat *animated* gifs as videos

* fix export

authored by

Samuel Newman and committed by
GitHub
da5c356f 6f01503e

+82 -5
+23 -5
src/view/com/composer/SelectMediaButton.tsx
··· 1 1 import {useCallback, useEffect, useRef} from 'react' 2 2 import {Keyboard} from 'react-native' 3 + import {File} from 'expo-file-system' 3 4 import {type ImagePickerAsset} from 'expo-image-picker' 4 5 import {msg, plural} from '@lingui/macro' 5 6 import {useLingui} from '@lingui/react' ··· 18 19 import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 19 20 import * as toast from '#/components/Toast' 20 21 import {IS_NATIVE, IS_WEB} from '#/env' 22 + import {isAnimatedGif} from './videos/isAnimatedGif' 21 23 22 24 export type SelectMediaButtonProps = { 23 25 disabled?: boolean ··· 128 130 * `mimeType`. If `mimeType` is not available, we try to infer it through 129 131 * various means. 130 132 */ 131 - function classifyImagePickerAsset(asset: ImagePickerAsset): 133 + async function classifyImagePickerAsset(asset: ImagePickerAsset): Promise< 132 134 | { 133 135 success: true 134 136 type: AssetType ··· 138 140 success: false 139 141 type: undefined 140 142 mimeType: undefined 141 - } { 143 + } 144 + > { 142 145 /* 143 146 * Try to use the `mimeType` reported by `expo-image-picker` first. 144 147 */ ··· 178 181 */ 179 182 let type: AssetType | undefined 180 183 if (mimeType === 'image/gif') { 181 - type = 'gif' 184 + let bytes: ArrayBuffer | undefined 185 + if (IS_WEB) { 186 + bytes = await asset.file?.arrayBuffer() 187 + } else { 188 + const file = new File(asset.uri) 189 + if (file.exists) { 190 + bytes = await file.arrayBuffer() 191 + } 192 + } 193 + if (bytes) { 194 + const {isAnimated} = isAnimatedGif(bytes) 195 + type = isAnimated ? 'gif' : 'image' 196 + } else { 197 + // If we can't read the file, assume it's animated 198 + type = 'gif' 199 + } 182 200 } else if (mimeType?.startsWith('video/')) { 183 201 type = 'video' 184 202 } else if (mimeType?.startsWith('image/')) { ··· 236 254 let supportedAssets: ValidatedImagePickerAsset[] = [] 237 255 238 256 for (const asset of assets) { 239 - const {success, type, mimeType} = classifyImagePickerAsset(asset) 257 + const {success, type, mimeType} = await classifyImagePickerAsset(asset) 240 258 241 259 if (!success) { 242 260 errors.add(SelectedAssetError.Unsupported) ··· 469 487 useEffect(() => { 470 488 if (autoOpen && !hasAutoOpened.current && !disabled) { 471 489 hasAutoOpened.current = true 472 - onPressSelectMedia() 490 + void onPressSelectMedia() 473 491 } 474 492 }, [autoOpen, disabled, onPressSelectMedia]) 475 493
+59
src/view/com/composer/videos/isAnimatedGif.ts
··· 1 + /** 2 + * Checks if a GIF is animated. Cooked up by Claude, validated with some examples. 3 + * @param bytes - The GIF bytes, as a Uint8Array. 4 + * @returns An object with properties isGif, isAnimated, and frames. 5 + */ 6 + export function isAnimatedGif(buffer: ArrayBuffer): { 7 + isGif: boolean 8 + isAnimated: boolean 9 + frames: number 10 + } { 11 + const bytes = new Uint8Array(buffer) 12 + // Verify GIF signature 13 + const sig = String.fromCharCode(...bytes.slice(0, 6)) 14 + if (!sig.startsWith('GIF')) 15 + return {isGif: false, isAnimated: false, frames: 0} 16 + 17 + let i = 13 // Skip header + logical screen descriptor 18 + 19 + // Skip global color table if present 20 + if (bytes[10] & 0x80) { 21 + const gctSize = 3 * (1 << ((bytes[10] & 0x07) + 1)) 22 + i += gctSize 23 + } 24 + 25 + let frames = 0 26 + 27 + while (i < bytes.length) { 28 + const block = bytes[i++] 29 + 30 + if (block === 0x2c) { 31 + // Image descriptor 32 + frames++ 33 + 34 + // Skip image descriptor fields 35 + i += 8 36 + // Skip local color table if present 37 + if (bytes[i] & 0x80) { 38 + const lctSize = 3 * (1 << ((bytes[i] & 0x07) + 1)) 39 + i += lctSize + 1 40 + } else { 41 + i++ 42 + } 43 + // Skip image data blocks 44 + i++ // LZW minimum code size 45 + while (bytes[i]) i += bytes[i] + 1 46 + i++ 47 + } else if (block === 0x21) { 48 + // Extension 49 + i++ // Extension type 50 + while (bytes[i]) i += bytes[i] + 1 51 + i++ 52 + } else if (block === 0x3b) { 53 + // Trailer 54 + break 55 + } 56 + } 57 + 58 + return {isGif: true, isAnimated: frames > 1, frames} 59 + }