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

* only treat *animated* gifs as videos

* fix export
This commit is contained in:
Samuel Newman
2026-02-04 21:00:29 +02:00
committed by GitHub
parent 6f01503eac
commit da5c356fc7
2 changed files with 82 additions and 5 deletions
+23 -5
View File
@@ -1,5 +1,6 @@
import {useCallback, useEffect, useRef} from 'react'
import {Keyboard} from 'react-native'
import {File} from 'expo-file-system'
import {type ImagePickerAsset} from 'expo-image-picker'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -18,6 +19,7 @@ import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
import * as toast from '#/components/Toast'
import {IS_NATIVE, IS_WEB} from '#/env'
import {isAnimatedGif} from './videos/isAnimatedGif'
export type SelectMediaButtonProps = {
disabled?: boolean
@@ -128,7 +130,7 @@ const extensionToMimeType: Record<
* `mimeType`. If `mimeType` is not available, we try to infer it through
* various means.
*/
function classifyImagePickerAsset(asset: ImagePickerAsset):
async function classifyImagePickerAsset(asset: ImagePickerAsset): Promise<
| {
success: true
type: AssetType
@@ -138,7 +140,8 @@ function classifyImagePickerAsset(asset: ImagePickerAsset):
success: false
type: undefined
mimeType: undefined
} {
}
> {
/*
* Try to use the `mimeType` reported by `expo-image-picker` first.
*/
@@ -178,7 +181,22 @@ function classifyImagePickerAsset(asset: ImagePickerAsset):
*/
let type: AssetType | undefined
if (mimeType === 'image/gif') {
type = 'gif'
let bytes: ArrayBuffer | undefined
if (IS_WEB) {
bytes = await asset.file?.arrayBuffer()
} else {
const file = new File(asset.uri)
if (file.exists) {
bytes = await file.arrayBuffer()
}
}
if (bytes) {
const {isAnimated} = isAnimatedGif(bytes)
type = isAnimated ? 'gif' : 'image'
} else {
// If we can't read the file, assume it's animated
type = 'gif'
}
} else if (mimeType?.startsWith('video/')) {
type = 'video'
} else if (mimeType?.startsWith('image/')) {
@@ -236,7 +254,7 @@ async function processImagePickerAssets(
let supportedAssets: ValidatedImagePickerAsset[] = []
for (const asset of assets) {
const {success, type, mimeType} = classifyImagePickerAsset(asset)
const {success, type, mimeType} = await classifyImagePickerAsset(asset)
if (!success) {
errors.add(SelectedAssetError.Unsupported)
@@ -469,7 +487,7 @@ export function SelectMediaButton({
useEffect(() => {
if (autoOpen && !hasAutoOpened.current && !disabled) {
hasAutoOpened.current = true
onPressSelectMedia()
void onPressSelectMedia()
}
}, [autoOpen, disabled, onPressSelectMedia])
@@ -0,0 +1,59 @@
/**
* Checks if a GIF is animated. Cooked up by Claude, validated with some examples.
* @param bytes - The GIF bytes, as a Uint8Array.
* @returns An object with properties isGif, isAnimated, and frames.
*/
export function isAnimatedGif(buffer: ArrayBuffer): {
isGif: boolean
isAnimated: boolean
frames: number
} {
const bytes = new Uint8Array(buffer)
// Verify GIF signature
const sig = String.fromCharCode(...bytes.slice(0, 6))
if (!sig.startsWith('GIF'))
return {isGif: false, isAnimated: false, frames: 0}
let i = 13 // Skip header + logical screen descriptor
// Skip global color table if present
if (bytes[10] & 0x80) {
const gctSize = 3 * (1 << ((bytes[10] & 0x07) + 1))
i += gctSize
}
let frames = 0
while (i < bytes.length) {
const block = bytes[i++]
if (block === 0x2c) {
// Image descriptor
frames++
// Skip image descriptor fields
i += 8
// Skip local color table if present
if (bytes[i] & 0x80) {
const lctSize = 3 * (1 << ((bytes[i] & 0x07) + 1))
i += lctSize + 1
} else {
i++
}
// Skip image data blocks
i++ // LZW minimum code size
while (bytes[i]) i += bytes[i] + 1
i++
} else if (block === 0x21) {
// Extension
i++ // Extension type
while (bytes[i]) i += bytes[i] + 1
i++
} else if (block === 0x3b) {
// Trailer
break
}
}
return {isGif: true, isAnimated: frames > 1, frames}
}