reduce image compression memory pressure
This commit is contained in:
+32
-15
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user