diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx index b6440685ef..91d8d11952 100644 --- a/src/view/com/composer/SelectMediaButton.tsx +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -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]) diff --git a/src/view/com/composer/videos/isAnimatedGif.ts b/src/view/com/composer/videos/isAnimatedGif.ts new file mode 100644 index 0000000000..7a808a4b6b --- /dev/null +++ b/src/view/com/composer/videos/isAnimatedGif.ts @@ -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} +}