Bluesky app fork with some witchin' additions 💫

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

* only treat *animated* gifs as videos

* fix export

authored by samuel.fm 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 + }