reduce image compression memory pressure

This commit is contained in:
Samuel Newman
2026-09-07 12:19:04 +03:00
parent 08069e2877
commit cc8667b33c
4 changed files with 122 additions and 20 deletions
+32 -15
View File
@@ -10,11 +10,12 @@ import {t} from '@lingui/core/macro'
import {type QueryClient} from '@tanstack/react-query'
import {type LinkResolvers} from '#/lib/api/resolve'
import {mapWithSerialRetry} from '#/lib/async/map-with-serial-retry'
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'
import {compressImage} from '#/state/gallery'
import {type ComposerImage, compressImage} from '#/state/gallery'
import {
fetchResolveGifQuery,
fetchResolveLinkQuery,
@@ -334,18 +335,15 @@ async function resolveMedia(
count: imagesDraft.length,
})
onStateChange?.(t`Uploading images...`)
const compressedImages = await compressImages(imagesDraft)
const images: app.bsky.embed.images.Image[] = await Promise.all(
imagesDraft.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(
image,
IMAGE_SIZE_CONFIG_POSTS,
)
compressedImages.map(async (compressedImage, i) => {
const {path, width, height, mime} = compressedImage
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(pdsClient, path, mime)
return {
image: res.blob,
alt: image.alt,
alt: imagesDraft[i].alt,
aspectRatio: {width, height},
}
}),
@@ -361,19 +359,16 @@ async function resolveMedia(
count: imagesDraft.length,
})
onStateChange?.(t`Uploading images...`)
const compressedImages = await compressImages(imagesDraft)
const items: $Typed<app.bsky.embed.gallery.Image>[] = await Promise.all(
imagesDraft.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(
image,
IMAGE_SIZE_CONFIG_POSTS,
)
compressedImages.map(async (compressedImage, i) => {
const {path, width, height, mime} = compressedImage
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(pdsClient, path, mime)
return {
$type: 'app.bsky.embed.gallery#image' as const,
image: res.blob,
alt: image.alt,
alt: imagesDraft[i].alt,
aspectRatio: {width, height},
}
}),
@@ -490,6 +485,28 @@ async function resolveMedia(
return undefined
}
type CompressedImage = Awaited<ReturnType<typeof compressImage>>
async function compressImages(
images: ComposerImage[],
): Promise<CompressedImage[]> {
return mapWithSerialRetry(
images,
(image, i) => {
logger.debug(`Compressing image #${i}`)
return compressImage(image, IMAGE_SIZE_CONFIG_POSTS)
},
isBitmapLoadingError,
(_error, i) => {
logger.info(`Retrying image compression serially`, {index: i})
},
)
}
function isBitmapLoadingError(error: unknown): boolean {
return String(error).includes('Loading bitmap failed')
}
async function resolveRecord(
clients: LinkResolvers,
queryClient: QueryClient,
@@ -0,0 +1,52 @@
import {mapWithSerialRetry} from './map-with-serial-retry'
describe('mapWithSerialRetry', () => {
it('reuses successes and retries failures serially', async () => {
const attempts = new Map<number, number>()
let retriesInFlight = 0
let maxRetriesInFlight = 0
const result = await mapWithSerialRetry(
[1, 2, 3],
async value => {
const attempt = (attempts.get(value) ?? 0) + 1
attempts.set(value, attempt)
if (value !== 1 && attempt === 1) {
throw new Error('retryable')
}
if (attempt > 1) {
retriesInFlight++
maxRetriesInFlight = Math.max(maxRetriesInFlight, retriesInFlight)
await Promise.resolve()
retriesInFlight--
}
return value * 2
},
error => String(error).includes('retryable'),
)
expect(result).toEqual([2, 4, 6])
expect(attempts).toEqual(
new Map([
[1, 1],
[2, 2],
[3, 2],
]),
)
expect(maxRetriesInFlight).toBe(1)
})
it('does not retry when any concurrent attempt fails permanently', async () => {
const mapper = jest.fn((value: string) => Promise.reject(new Error(value)))
await expect(
mapWithSerialRetry(['retryable', 'permanent'], mapper, error =>
String(error).includes('retryable'),
),
).rejects.toThrow('permanent')
expect(mapper).toHaveBeenCalledTimes(2)
})
})
+32
View File
@@ -0,0 +1,32 @@
/**
* Maps all values concurrently, then retries eligible failures one at a time.
* Successful results from the concurrent attempt are reused.
*/
export async function mapWithSerialRetry<T, U>(
values: T[],
mapper: (value: T, index: number) => Promise<U>,
shouldRetry: (error: unknown) => boolean,
onRetry?: (error: unknown, index: number) => void,
): Promise<U[]> {
const settled = await Promise.allSettled(values.map(mapper))
const nonRetryableFailure = settled.find(
result => result.status === 'rejected' && !shouldRetry(result.reason),
)
if (nonRetryableFailure?.status === 'rejected') {
throw nonRetryableFailure.reason
}
const results: U[] = []
for (let i = 0; i < settled.length; i++) {
const result = settled[i]
if (result.status === 'fulfilled') {
results.push(result.value)
} else {
onRetry?.(result.reason, i)
results.push(await mapper(values[i], i))
}
}
return results
}
+6 -5
View File
@@ -12,7 +12,7 @@ import {renderImage} from '#/lib/media/image-manipulator'
import {getImageDim} from '#/lib/media/manip'
import {openCropper} from '#/lib/media/picker'
import {type PickerImage} from '#/lib/media/picker.shared'
import {getDataUriSize} from '#/lib/media/util'
import {getUriSize} from '#/lib/media/uriSize'
import {isCancelledError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {IS_NATIVE, IS_WEB} from '#/env'
@@ -241,15 +241,16 @@ export async function compressImage(
source.path,
context => context.resize({width: w, height: h}),
{
// Requesting base64 makes Android encode the bitmap a second time and
// retain the encoded bytes and string in memory. Measure the URI instead.
base64: false,
compress: qualityPercentage / 100,
format: SaveFormat.JPEG,
base64: true,
},
)
const base64 = res.base64
const size = base64 ? getDataUriSize(base64) : 0
if (base64 && size <= maxBytes) {
const size = await getUriSize(res.uri)
if (size <= maxBytes) {
minQualityPercentage = qualityPercentage
newDataUri = {
path: await moveIfNecessary(res.uri),