diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index afe47c2793..6723caa5e3 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -4,8 +4,8 @@ import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' 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 +41,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 +58,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 +73,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, } diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index d0600b2eb8..d9d7d326cb 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -257,9 +257,7 @@ export async function imageToThumb( try { const img = await downloadAndResize({ uri: imageUri, - width: POST_IMG_MAX.width, - height: POST_IMG_MAX.height, - mode: 'contain', + maxDimension: POST_IMG_MAX.width, maxSize: POST_IMG_MAX.size, timeout: 15e3, }) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 95eb57fd54..5d79eb62c1 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -102,6 +102,12 @@ export const POST_IMG_MAX = { size: 1000000, } +export const POST_IMG_MAX_HIGH_RES = { + width: 4000, + height: 4000, + size: 2000000, +} + export const STAGING_LINK_META_PROXY = 'https://cardyb.staging.bsky.dev/v1/extract?url=' diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 2be799e261..0a6f6402e1 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -21,7 +21,7 @@ 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, @@ -31,9 +31,7 @@ export async function compressIfNeeded( return img } const resizedImage = await doResize(normalizePath(img.path), { - width: img.width, - height: img.height, - mode: 'stretch', + maxDimension: POST_IMG_MAX.width, maxSize, }) const finalImageMovedPath = await moveToPermanentPath( @@ -49,9 +47,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 +63,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 +187,7 @@ export function getImageDim(path: string): Promise { // = interface DoResizeOpts { - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' + maxDimension: number maxSize: number } @@ -204,10 +201,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, + }, + {width: opts.maxDimension, height: opts.maxDimension}, + ) let minQualityPercentage = 0 let maxQualityPercentage = 101 // exclusive @@ -388,28 +388,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..948482fa6e 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,6 +1,12 @@ +import {POST_IMG_MAX} from '#/lib/constants' 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, @@ -10,18 +16,14 @@ export async function compressIfNeeded( return img } return await doResize(img.path, { - width: img.width, - height: img.height, - mode: 'stretch', + maxDimension: POST_IMG_MAX.width, maxSize, }) } export interface DownloadAndResizeOpts { uri: string - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' + maxDimension: number maxSize: number timeout: number } @@ -34,7 +36,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 +75,7 @@ export async function getImageDim(path: string): Promise { // = interface DoResizeOpts { - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' + maxDimension: number maxSize: number } @@ -80,6 +83,12 @@ async function doResize( dataUri: string, opts: DoResizeOpts, ): Promise { + const sourceDims = await getImageDim(dataUri) + const newDimensions = getResizedDimensions(sourceDims, { + width: opts.maxDimension, + height: opts.maxDimension, + }) + let newDataUri let minQualityPercentage = 0 @@ -90,10 +99,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 +120,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/util.ts b/src/lib/media/util.ts index da0b306d4f..ebe40034a6 100644 --- a/src/lib/media/util.ts +++ b/src/lib/media/util.ts @@ -1,7 +1,31 @@ +import {POST_IMG_MAX} from '#/lib/constants' + export function extractDataUriMime(uri: string): string { return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) } +export function getResizedDimensions( + originalDims: { + width: number + height: number + }, + max: {width: number; height: number} = POST_IMG_MAX, +) { + if (originalDims.width <= max.width && originalDims.height <= max.height) { + return originalDims + } + + const ratio = Math.min( + max.width / originalDims.width, + max.height / 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/state/gallery.ts b/src/state/gallery.ts index e1c1541bc3..fff0b686f5 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -13,6 +13,7 @@ import { } from 'expo-image-manipulator' import {nanoid} from 'nanoid/non-secure' +import {POST_IMG_MAX_HIGH_RES} from '#/lib/constants' import {getImageDim} from '#/lib/media/manip' import {openCropper} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' @@ -201,12 +202,19 @@ export function resetImageManipulation( return img } -export async function compressImage(img: ComposerImage): Promise { +export async function compressImage( + img: ComposerImage, + { + maxDimension = POST_IMG_MAX_HIGH_RES.width, + maxBytes = POST_IMG_MAX_HIGH_RES.size, + }: {maxDimension?: number; maxBytes?: 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 param + // itself pristine. + let currentDimension = maxDimension let minQualityPercentage = 0 let maxQualityPercentage = 101 // exclusive @@ -215,7 +223,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, ) @@ -231,7 +243,7 @@ export async function compressImage(img: ComposerImage): Promise { maxQualityPercentage = 101 attempts++ // 4000px → 3200px → 2560px → 2048px → ~1638px - maxDimension = Math.floor(maxDimension * 0.8) + currentDimension = Math.floor(currentDimension * 0.8) continue } diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 6806f037bf..958d57a55d 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 {POST_IMG_MAX_HIGH_RES} 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,8 @@ 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, + maxDimension: POST_IMG_MAX_HIGH_RES.width, + maxSize: POST_IMG_MAX_HIGH_RES.size, timeout: 15e3, })