diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index afe47c2793..f0f948365c 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -1,11 +1,12 @@ import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy' import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' +import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants' import { downloadAndResize, type DownloadAndResizeOpts, - getResizedDimensions, } from '../../src/lib/media/manip' +import {getResizedDimensions} from '../../src/lib/media/util' const mockResizedImage = { path: 'file://resized-image.jpg', @@ -41,10 +42,8 @@ describe('downloadAndResize', () => { const opts: DownloadAndResizeOpts = { uri: 'https://example.com/image.jpg', - width: 100, - height: 100, + maxDimension: 2000, maxSize: 500000, - mode: 'cover', timeout: 10000, } @@ -60,9 +59,11 @@ describe('downloadAndResize', () => { // First time it gets called is to get dimensions expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {}) + // The mocked source image is 100x100, below maxDimension, so it is not + // downsized. expect(manipulateAsync).toHaveBeenCalledWith( expect.any(String), - [{resize: {height: opts.height, width: opts.width}}], + [{resize: {height: 100, width: 100}}], {format: SaveFormat.JPEG, compress: 1.0}, ) expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), { @@ -73,10 +74,8 @@ describe('downloadAndResize', () => { it('should return undefined for invalid URI', async () => { const opts: DownloadAndResizeOpts = { uri: 'invalid-uri', - width: 100, - height: 100, + maxDimension: 2000, maxSize: 500000, - mode: 'cover', timeout: 10000, } @@ -90,13 +89,19 @@ describe('downloadAndResize', () => { width: 1200, height: 1000, } - const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) + const resizedDimensionsOne = getResizedDimensions( + initialDimensionsOne, + IMAGE_SIZE_CONFIG_2K_1MB.maxDimension, + ) const initialDimensionsTwo = { width: 1000, height: 1200, } - const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) + const resizedDimensionsTwo = getResizedDimensions( + initialDimensionsTwo, + IMAGE_SIZE_CONFIG_2K_1MB.maxDimension, + ) expect(resizedDimensionsOne).toEqual(initialDimensionsOne) expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo) @@ -107,13 +112,19 @@ describe('downloadAndResize', () => { width: 3000, height: 1500, } - const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) + const resizedDimensionsOne = getResizedDimensions( + initialDimensionsOne, + IMAGE_SIZE_CONFIG_2K_1MB.maxDimension, + ) const initialDimensionsTwo = { width: 2000, height: 4000, } - const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) + const resizedDimensionsTwo = getResizedDimensions( + initialDimensionsTwo, + IMAGE_SIZE_CONFIG_2K_1MB.maxDimension, + ) expect(resizedDimensionsOne).toEqual({ width: 2000, diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index c5f6cc6eb5..7de4e13e56 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -21,6 +21,7 @@ import {sha256} from 'js-sha256' import {CID} from 'multiformats/cid' import * as Hasher from 'multiformats/hashes/hasher' +import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants' import {isNetworkError} from '#/lib/strings/errors' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {logger} from '#/logger' @@ -324,7 +325,10 @@ async function resolveMedia( const images: AppBskyEmbedImages.Image[] = await Promise.all( imagesDraft.map(async (image, i) => { logger.debug(`Compressing image #${i}`) - const {path, width, height, mime} = await compressImage(image) + const {path, width, height, mime} = await compressImage( + image, + IMAGE_SIZE_CONFIG_POSTS, + ) logger.debug(`Uploading image #${i}`) const res = await uploadBlob(agent, path, mime) return { diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index d411bd8204..ca0b342045 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -7,7 +7,7 @@ import { } from '@atproto/api' import {AtUri} from '@atproto/api' -import {DM_SERVICE_HEADERS, POST_IMG_MAX} from '#/lib/constants' +import {DM_SERVICE_HEADERS, IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' import {downloadAndResize} from '#/lib/media/manip' @@ -284,10 +284,7 @@ export async function imageToThumb( try { const img = await downloadAndResize({ uri: imageUri, - width: POST_IMG_MAX.width, - height: POST_IMG_MAX.height, - mode: 'contain', - maxSize: POST_IMG_MAX.size, + ...IMAGE_SIZE_CONFIG_2K_1MB, timeout: 15e3, }) if (img) { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a22700f880..13ae162a85 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -97,10 +97,14 @@ export const STAGING_FEEDS = [ `feedgen|${STAGING_DEFAULT_FEED('thevids')}`, ] -export const POST_IMG_MAX = { - width: 2000, - height: 2000, - size: 1000000, +export const IMAGE_SIZE_CONFIG_POSTS = { + maxDimension: 4000, + maxSize: 2000000, +} + +export const IMAGE_SIZE_CONFIG_2K_1MB = { + maxDimension: 2000, + maxSize: 1000000, } export const STAGING_LINK_META_PROXY = diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 2be799e261..84ab65af92 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -16,24 +16,21 @@ import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' import * as MediaLibrary from 'expo-media-library' import * as Sharing from 'expo-sharing' -import {POST_IMG_MAX} from '#/lib/constants' import {logger} from '#/logger' import {IS_ANDROID, IS_IOS} from '#/env' import {type PickerImage} from './picker.shared' import {type Dimensions} from './types' -import {convertCdnPreset} from './util' +import {convertCdnPreset, getResizedDimensions} from './util' export async function compressIfNeeded( img: PickerImage, - maxSize: number = POST_IMG_MAX.size, + {maxDimension, maxSize}: {maxDimension: number; maxSize: number}, ): Promise { if (img.size < maxSize) { return img } const resizedImage = await doResize(normalizePath(img.path), { - width: img.width, - height: img.height, - mode: 'stretch', + maxDimension, maxSize, }) const finalImageMovedPath = await moveToPermanentPath( @@ -49,9 +46,7 @@ export async function compressIfNeeded( export interface DownloadAndResizeOpts { uri: string - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' + maxDimension: number maxSize: number timeout: number } @@ -67,7 +62,10 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout) try { - return await doResize(path, opts) + return await doResize(path, { + maxDimension: opts.maxDimension, + maxSize: opts.maxSize, + }) } finally { void safeDeleteAsync(path) } @@ -188,9 +186,7 @@ export function getImageDim(path: string): Promise { // = interface DoResizeOpts { - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' + maxDimension: number maxSize: number } @@ -204,10 +200,13 @@ async function doResize( // Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize() // does not work for local files... const imageRes = await manipulateAsync(localUri, [], {}) - const newDimensions = getResizedDimensions({ - width: imageRes.width, - height: imageRes.height, - }) + const newDimensions = getResizedDimensions( + { + width: imageRes.width, + height: imageRes.height, + }, + opts.maxDimension, + ) let minQualityPercentage = 0 let maxQualityPercentage = 101 // exclusive @@ -388,28 +387,6 @@ async function withTempFile( } } -export function getResizedDimensions(originalDims: { - width: number - height: number -}) { - if ( - originalDims.width <= POST_IMG_MAX.width && - originalDims.height <= POST_IMG_MAX.height - ) { - return originalDims - } - - const ratio = Math.min( - POST_IMG_MAX.width / originalDims.width, - POST_IMG_MAX.height / originalDims.height, - ) - - return { - width: Math.round(originalDims.width * ratio), - height: Math.round(originalDims.height * ratio), - } -} - async function downloadImage(uri: string, destName: string, timeout: number) { // Download to a temp path first, then rename with the correct extension // based on the response's mimeType. diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 8fa2b6a4fa..94732687ef 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,27 +1,28 @@ import {type PickerImage} from './picker.shared' import {type Dimensions} from './types' -import {blobToDataUri, convertCdnPreset, getDataUriSize} from './util' +import { + blobToDataUri, + convertCdnPreset, + getDataUriSize, + getResizedDimensions, +} from './util' export async function compressIfNeeded( img: PickerImage, - maxSize: number, + {maxDimension, maxSize}: {maxDimension: number; maxSize: number}, ): Promise { if (img.size < maxSize) { return img } return await doResize(img.path, { - width: img.width, - height: img.height, - mode: 'stretch', + maxDimension, maxSize, }) } export interface DownloadAndResizeOpts { uri: string - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' + maxDimension: number maxSize: number timeout: number } @@ -34,7 +35,10 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { clearTimeout(to) const dataUri = await blobToDataUri(resBody) - return await doResize(dataUri, opts) + return await doResize(dataUri, { + maxDimension: opts.maxDimension, + maxSize: opts.maxSize, + }) } export async function shareImageModal(_opts: {uri: string}) { @@ -70,9 +74,7 @@ export async function getImageDim(path: string): Promise { // = interface DoResizeOpts { - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' + maxDimension: number maxSize: number } @@ -80,6 +82,9 @@ async function doResize( dataUri: string, opts: DoResizeOpts, ): Promise { + const sourceDims = await getImageDim(dataUri) + const newDimensions = getResizedDimensions(sourceDims, opts.maxDimension) + let newDataUri let minQualityPercentage = 0 @@ -90,10 +95,10 @@ async function doResize( (maxQualityPercentage + minQualityPercentage) / 2, ) const tempDataUri = await createResizedImage(dataUri, { - width: opts.width, - height: opts.height, + width: newDimensions.width, + height: newDimensions.height, quality: qualityPercentage / 100, - mode: opts.mode, + mode: 'contain', }) if (getDataUriSize(tempDataUri) < opts.maxSize) { @@ -111,8 +116,8 @@ async function doResize( path: newDataUri, mime: 'image/jpeg', size: getDataUriSize(newDataUri), - width: opts.width, - height: opts.height, + width: newDimensions.width, + height: newDimensions.height, } } diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index f01217d2a5..7aaa69c47d 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -8,6 +8,7 @@ import ExpoImageCropTool, { type OpenCropperOptions, } from '@bsky.app/expo-image-crop-tool' +import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {compressIfNeeded} from './manip' import {type PickerImage} from './picker.shared' @@ -28,13 +29,16 @@ async function getFile() { throw new Error('Failed to get file info') } - return await compressIfNeeded({ - path: file, - mime: 'image/jpeg', - size: fileInfo.size, - width: 4288, - height: 2848, - }) + return await compressIfNeeded( + { + path: file, + mime: 'image/jpeg', + size: fileInfo.size, + width: 4288, + height: 2848, + }, + IMAGE_SIZE_CONFIG_2K_1MB, + ) } export async function openPicker(): Promise { diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts index da0b306d4f..8db418f045 100644 --- a/src/lib/media/util.ts +++ b/src/lib/media/util.ts @@ -2,6 +2,31 @@ export function extractDataUriMime(uri: string): string { return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) } +export function getResizedDimensions( + originalDims: { + width: number + height: number + }, + maxDimension: number, +) { + if ( + originalDims.width <= maxDimension && + originalDims.height <= maxDimension + ) { + return originalDims + } + + const ratio = Math.min( + maxDimension / originalDims.width, + maxDimension / originalDims.height, + ) + + return { + width: Math.round(originalDims.width * ratio), + height: Math.round(originalDims.height * ratio), + } +} + // Fairly accurate estimate that is more performant // than decoding and checking length of URI export function getDataUriSize(uri: string): number { diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 6c84ec4c86..85ae4acc68 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -19,6 +19,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' import {compressIfNeeded} from '#/lib/media/manip' import {openCropper} from '#/lib/media/picker' @@ -212,7 +213,7 @@ export function StepProfile() { } } } - image = await compressIfNeeded(image, 1000000) + image = await compressIfNeeded(image, IMAGE_SIZE_CONFIG_2K_1MB) // If we are on mobile, prefetching the image will load the image into memory before we try and display it, // stopping any brief flickers. diff --git a/src/state/gallery.ts b/src/state/gallery.ts index e1c1541bc3..e6c5b50d36 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -201,12 +201,17 @@ export function resetImageManipulation( return img } -export async function compressImage(img: ComposerImage): Promise { +export async function compressImage( + img: ComposerImage, + {maxDimension, maxSize}: {maxDimension: number; maxSize: number}, +): Promise { const source = img.transformed || img.source let attempts = 0 - let maxDimension = 4000 - let maxBytes = 2000000 + // Seeded from `maxDimension` but shrunk per attempt below, so keep the + // passed-in value pristine. + let currentDimension = maxDimension + const maxBytes = maxSize let minQualityPercentage = 0 let maxQualityPercentage = 101 // exclusive @@ -215,7 +220,11 @@ export async function compressImage(img: ComposerImage): Promise { while (maxQualityPercentage - minQualityPercentage > 1) { if (attempts >= 4) break - const [w, h] = containImageRes(source.width, source.height, maxDimension) + const [w, h] = containImageRes( + source.width, + source.height, + currentDimension, + ) const qualityPercentage = Math.round( (maxQualityPercentage + minQualityPercentage) / 2, ) @@ -230,8 +239,9 @@ export async function compressImage(img: ComposerImage): Promise { minQualityPercentage = 0 maxQualityPercentage = 101 attempts++ - // 4000px → 3200px → 2560px → 2048px → ~1638px - maxDimension = Math.floor(maxDimension * 0.8) + // max.width → 0.8× → 0.64× → 0.512× → ~0.41× + // e.g. 4000px → 3200px → 2560px → 2048px → ~1638px + currentDimension = Math.floor(currentDimension * 0.8) continue } diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 2df285f7ae..320a1b7d03 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -3,7 +3,6 @@ import * as MediaLibrary from 'expo-media-library' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' -import {POST_IMG_MAX} from '#/lib/constants' import {useCameraPermission} from '#/lib/hooks/usePermissions' import {openCamera} from '#/lib/media/picker' import {logger} from '#/logger' @@ -35,7 +34,7 @@ export function OpenCameraBtn({disabled, onAdd}: Props) { } const img = await openCamera({ - aspect: [POST_IMG_MAX.width, POST_IMG_MAX.height], + aspect: [1, 1], }) // If we don't have permissions it's fine, we just wont save it. The post itself will still have access to diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 6806f037bf..313f0becbf 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -16,7 +16,7 @@ import {type PasteEventPayload, TextInputWrapper} from 'expo-paste-input' import {AppBskyRichtextFacet, RichText} from '@atproto/api' import {useLingui} from '@lingui/react/macro' -import {POST_IMG_MAX} from '#/lib/constants' +import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants' import {downloadAndResize} from '#/lib/media/manip' import {isUriImage} from '#/lib/media/util' import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip' @@ -93,10 +93,7 @@ export function TextInput({ if (isUriImage(feature.uri)) { const res = await downloadAndResize({ uri: feature.uri, - width: POST_IMG_MAX.width, - height: POST_IMG_MAX.height, - mode: 'contain', - maxSize: POST_IMG_MAX.size, + ...IMAGE_SIZE_CONFIG_POSTS, timeout: 15e3, }) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 9db4914b21..f139542857 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -17,6 +17,7 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' +import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import { useCameraPermission, @@ -395,6 +396,7 @@ let EditableUserAvatar = ({ await openCamera({ aspect: [1, 1], }), + IMAGE_SIZE_CONFIG_2K_1MB, ), ) }, [onSelectNewAvatar, requestCameraAccessIfNeeded]) @@ -423,6 +425,7 @@ let EditableUserAvatar = ({ shape: circular ? 'circle' : 'rectangle', aspectRatio: 1, }), + IMAGE_SIZE_CONFIG_2K_1MB, ), ) } else { @@ -449,7 +452,7 @@ let EditableUserAvatar = ({ const onChangeEditImage = useCallback( async (image: ComposerImage) => { - const compressed = await compressImage(image) + const compressed = await compressImage(image, IMAGE_SIZE_CONFIG_2K_1MB) onSelectNewAvatar(compressed) }, [onSelectNewAvatar], diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 033e8c14fe..ff4b6631bd 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -6,6 +6,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import { useCameraPermission, usePhotoLibraryPermission, @@ -62,6 +63,7 @@ export function UserBanner({ await openCamera({ aspect: [3, 1], }), + IMAGE_SIZE_CONFIG_2K_1MB, ), ) }, [onSelectNewBanner, requestCameraAccessIfNeeded]) @@ -83,6 +85,7 @@ export function UserBanner({ imageUri: items[0].path, aspectRatio: 3 / 1, }), + IMAGE_SIZE_CONFIG_2K_1MB, ), ) } else { @@ -108,7 +111,7 @@ export function UserBanner({ const onChangeEditImage = useCallback( async (image: ComposerImage) => { - const compressed = await compressImage(image) + const compressed = await compressImage(image, IMAGE_SIZE_CONFIG_2K_1MB) onSelectNewBanner?.(compressed) }, [onSelectNewBanner],