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 QueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {type LinkResolvers} from '#/lib/api/resolve'
|
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 {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {compressImage} from '#/state/gallery'
|
import {type ComposerImage, compressImage} from '#/state/gallery'
|
||||||
import {
|
import {
|
||||||
fetchResolveGifQuery,
|
fetchResolveGifQuery,
|
||||||
fetchResolveLinkQuery,
|
fetchResolveLinkQuery,
|
||||||
@@ -334,18 +335,15 @@ async function resolveMedia(
|
|||||||
count: imagesDraft.length,
|
count: imagesDraft.length,
|
||||||
})
|
})
|
||||||
onStateChange?.(t`Uploading images...`)
|
onStateChange?.(t`Uploading images...`)
|
||||||
|
const compressedImages = await compressImages(imagesDraft)
|
||||||
const images: app.bsky.embed.images.Image[] = await Promise.all(
|
const images: app.bsky.embed.images.Image[] = await Promise.all(
|
||||||
imagesDraft.map(async (image, i) => {
|
compressedImages.map(async (compressedImage, i) => {
|
||||||
logger.debug(`Compressing image #${i}`)
|
const {path, width, height, mime} = compressedImage
|
||||||
const {path, width, height, mime} = await compressImage(
|
|
||||||
image,
|
|
||||||
IMAGE_SIZE_CONFIG_POSTS,
|
|
||||||
)
|
|
||||||
logger.debug(`Uploading image #${i}`)
|
logger.debug(`Uploading image #${i}`)
|
||||||
const res = await uploadBlob(pdsClient, path, mime)
|
const res = await uploadBlob(pdsClient, path, mime)
|
||||||
return {
|
return {
|
||||||
image: res.blob,
|
image: res.blob,
|
||||||
alt: image.alt,
|
alt: imagesDraft[i].alt,
|
||||||
aspectRatio: {width, height},
|
aspectRatio: {width, height},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -361,19 +359,16 @@ async function resolveMedia(
|
|||||||
count: imagesDraft.length,
|
count: imagesDraft.length,
|
||||||
})
|
})
|
||||||
onStateChange?.(t`Uploading images...`)
|
onStateChange?.(t`Uploading images...`)
|
||||||
|
const compressedImages = await compressImages(imagesDraft)
|
||||||
const items: $Typed<app.bsky.embed.gallery.Image>[] = await Promise.all(
|
const items: $Typed<app.bsky.embed.gallery.Image>[] = await Promise.all(
|
||||||
imagesDraft.map(async (image, i) => {
|
compressedImages.map(async (compressedImage, i) => {
|
||||||
logger.debug(`Compressing image #${i}`)
|
const {path, width, height, mime} = compressedImage
|
||||||
const {path, width, height, mime} = await compressImage(
|
|
||||||
image,
|
|
||||||
IMAGE_SIZE_CONFIG_POSTS,
|
|
||||||
)
|
|
||||||
logger.debug(`Uploading image #${i}`)
|
logger.debug(`Uploading image #${i}`)
|
||||||
const res = await uploadBlob(pdsClient, path, mime)
|
const res = await uploadBlob(pdsClient, path, mime)
|
||||||
return {
|
return {
|
||||||
$type: 'app.bsky.embed.gallery#image' as const,
|
$type: 'app.bsky.embed.gallery#image' as const,
|
||||||
image: res.blob,
|
image: res.blob,
|
||||||
alt: image.alt,
|
alt: imagesDraft[i].alt,
|
||||||
aspectRatio: {width, height},
|
aspectRatio: {width, height},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -490,6 +485,28 @@ async function resolveMedia(
|
|||||||
return undefined
|
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(
|
async function resolveRecord(
|
||||||
clients: LinkResolvers,
|
clients: LinkResolvers,
|
||||||
queryClient: QueryClient,
|
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 {getImageDim} from '#/lib/media/manip'
|
||||||
import {openCropper} from '#/lib/media/picker'
|
import {openCropper} from '#/lib/media/picker'
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
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 {isCancelledError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
@@ -241,15 +241,16 @@ export async function compressImage(
|
|||||||
source.path,
|
source.path,
|
||||||
context => context.resize({width: w, height: h}),
|
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,
|
compress: qualityPercentage / 100,
|
||||||
format: SaveFormat.JPEG,
|
format: SaveFormat.JPEG,
|
||||||
base64: true,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const base64 = res.base64
|
const size = await getUriSize(res.uri)
|
||||||
const size = base64 ? getDataUriSize(base64) : 0
|
if (size <= maxBytes) {
|
||||||
if (base64 && size <= maxBytes) {
|
|
||||||
minQualityPercentage = qualityPercentage
|
minQualityPercentage = qualityPercentage
|
||||||
newDataUri = {
|
newDataUri = {
|
||||||
path: await moveIfNecessary(res.uri),
|
path: await moveIfNecessary(res.uri),
|
||||||
|
|||||||
Reference in New Issue
Block a user