bound image compression memory usage

This commit is contained in:
Samuel Newman
2026-09-07 14:56:33 +03:00
parent cc8667b33c
commit a22e96c56a
15 changed files with 602 additions and 233 deletions
@@ -5,6 +5,7 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {revokeObjectUrl} from '#/lib/media/image-manipulator'
import {cleanError} from '#/lib/strings/errors'
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
@@ -187,6 +188,13 @@ function DialogInner({
ImageMeta | undefined | null
>()
useEffect(
() => () => {
revokeObjectUrl(newListAvatar?.path)
},
[newListAvatar],
)
// When creating with pre-filled values (from Starter Pack), consider dirty
// immediately so the Save button is enabled
const hasInitialValuesForCreate = !list && initialValues != null
+46 -14
View File
@@ -1,3 +1,4 @@
import * as Device from 'expo-device'
import {TID} from '@atproto/common-web'
import {type $Typed, type Client} from '@atproto/lex'
import {
@@ -12,6 +13,8 @@ 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 {revokeObjectUrl} from '#/lib/media/image-manipulator'
import {getImageCompressionConcurrency} from '#/lib/media/util'
import {isNetworkError} from '#/lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {logger} from '#/logger'
@@ -29,6 +32,7 @@ import {
type PostDraft,
type ThreadDraft,
} from '#/view/com/composer/state/composer'
import {IS_ANDROID} from '#/env'
import {app, chat, com} from '#/lexicons'
import * as bsky from '#/types/bsky'
import {createGIFDescription} from '../gif-alt-text'
@@ -340,11 +344,15 @@ async function resolveMedia(
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: imagesDraft[i].alt,
aspectRatio: {width, height},
try {
const res = await uploadBlob(pdsClient, path, mime)
return {
image: res.blob,
alt: imagesDraft[i].alt,
aspectRatio: {width, height},
}
} finally {
revokeObjectUrl(path)
}
}),
)
@@ -364,12 +372,16 @@ async function resolveMedia(
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: imagesDraft[i].alt,
aspectRatio: {width, height},
try {
const res = await uploadBlob(pdsClient, path, mime)
return {
$type: 'app.bsky.embed.gallery#image' as const,
image: res.blob,
alt: imagesDraft[i].alt,
aspectRatio: {width, height},
}
} finally {
revokeObjectUrl(path)
}
}),
)
@@ -490,15 +502,35 @@ type CompressedImage = Awaited<ReturnType<typeof compressImage>>
async function compressImages(
images: ComposerImage[],
): Promise<CompressedImage[]> {
const totalMemoryBytes = Device.totalMemory
const concurrency = getImageCompressionConcurrency({
isAndroid: IS_ANDROID,
totalMemoryBytes,
})
logger.debug('Compressing images', {
concurrency,
totalMemoryBytes,
})
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})
{
concurrency,
shouldRetry: isBitmapLoadingError,
onRetry(_error, i) {
logger.info(`Retrying image compression serially`, {
index: i,
concurrency,
totalMemoryBytes,
})
},
onDiscard(image) {
revokeObjectUrl(image.path)
},
},
)
}
+60 -4
View File
@@ -25,7 +25,10 @@ describe('mapWithSerialRetry', () => {
return value * 2
},
error => String(error).includes('retryable'),
{
concurrency: 3,
shouldRetry: error => String(error).includes('retryable'),
},
)
expect(result).toEqual([2, 4, 6])
@@ -43,10 +46,63 @@ describe('mapWithSerialRetry', () => {
const mapper = jest.fn((value: string) => Promise.reject(new Error(value)))
await expect(
mapWithSerialRetry(['retryable', 'permanent'], mapper, error =>
String(error).includes('retryable'),
),
mapWithSerialRetry(['retryable', 'permanent'], mapper, {
concurrency: 2,
shouldRetry: error => String(error).includes('retryable'),
}),
).rejects.toThrow('permanent')
expect(mapper).toHaveBeenCalledTimes(2)
})
it('limits the concurrent attempts', async () => {
let inFlight = 0
let maxInFlight = 0
let release: (() => void) | undefined
const blocked = new Promise<void>(resolve => {
release = resolve
})
const resultPromise = mapWithSerialRetry(
[1, 2, 3, 4],
async value => {
inFlight++
maxInFlight = Math.max(maxInFlight, inFlight)
await blocked
inFlight--
return value
},
{
concurrency: 2,
shouldRetry: () => false,
},
)
await Promise.resolve()
expect(maxInFlight).toBe(2)
release?.()
await expect(resultPromise).resolves.toEqual([1, 2, 3, 4])
})
it('discards successful results when the batch fails', async () => {
const onDiscard = jest.fn()
await expect(
mapWithSerialRetry(
[1, 2, 3],
value =>
value === 2
? Promise.reject(new Error('permanent'))
: Promise.resolve(value * 2),
{
concurrency: 2,
shouldRetry: () => false,
onDiscard,
},
),
).rejects.toThrow('permanent')
expect(onDiscard.mock.calls).toEqual([
[2, 0],
[6, 2],
])
})
})
+65 -13
View File
@@ -1,31 +1,83 @@
/**
* Maps all values concurrently, then retries eligible failures one at a time.
* Successful results from the concurrent attempt are reused.
* Maps values with bounded concurrency, 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,
{
concurrency,
shouldRetry,
onRetry,
onDiscard,
}: {
concurrency: number
shouldRetry: (error: unknown) => boolean
onRetry?: (error: unknown, index: number) => void
onDiscard?: (value: U, index: number) => void
},
): Promise<U[]> {
const settled = await Promise.allSettled(values.map(mapper))
const settled = new Array<PromiseSettledResult<U>>(values.length)
let nextIndex = 0
async function worker() {
while (nextIndex < values.length) {
const index = nextIndex++
try {
settled[index] = {
status: 'fulfilled',
value: await mapper(values[index], index),
}
} catch (reason) {
settled[index] = {status: 'rejected', reason}
}
}
}
const workerCount = Math.min(
values.length,
Math.max(1, Math.floor(concurrency)),
)
await Promise.all(Array.from({length: workerCount}, worker))
const completed: {value: U; index: number}[] = []
for (let i = 0; i < settled.length; i++) {
const result = settled[i]
if (result.status === 'fulfilled') {
completed.push({value: result.value, index: i})
}
}
const discardCompleted = () => {
for (const result of completed) {
onDiscard?.(result.value, result.index)
}
}
const nonRetryableFailure = settled.find(
result => result.status === 'rejected' && !shouldRetry(result.reason),
)
if (nonRetryableFailure?.status === 'rejected') {
discardCompleted()
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))
const results = new Array<U>(values.length)
try {
for (let i = 0; i < settled.length; i++) {
const result = settled[i]
if (result.status === 'fulfilled') {
results[i] = result.value
} else {
onRetry?.(result.reason, i)
const value = await mapper(values[i], i)
results[i] = value
completed.push({value, index: i})
}
}
} catch (error) {
discardCompleted()
throw error
}
return results
+47
View File
@@ -5,6 +5,8 @@ import {
type SaveOptions,
} from 'expo-image-manipulator'
import {IS_WEB} from '#/env'
export async function renderImage(
source: string,
manipulate?: (context: ImageManipulatorContext) => void,
@@ -19,9 +21,54 @@ export async function renderImage(
try {
return await image.saveAsync(saveOptions)
} finally {
releaseWebImageResources(image)
image.release()
}
} finally {
await releaseWebContextResources(context)
context.release()
}
}
export function revokeObjectUrl(uri: string | undefined): void {
if (IS_WEB && uri?.startsWith('blob:')) {
URL.revokeObjectURL(uri)
}
}
function releaseWebImageResources(image: unknown): void {
if (!IS_WEB) {
return
}
const webImage = image as {
uri?: string
canvas?: {width: number; height: number}
}
revokeObjectUrl(webImage.uri)
if (webImage.canvas) {
webImage.canvas.width = 0
webImage.canvas.height = 0
}
}
async function releaseWebContextResources(context: unknown): Promise<void> {
if (!IS_WEB) {
return
}
try {
const currentTask = (
context as {
currentTask?: Promise<{width: number; height: number}>
}
).currentTask
const canvas = await currentTask
if (canvas) {
canvas.width = 0
canvas.height = 0
}
} catch {
// The original render error is more useful than a cleanup error.
}
}
+9 -4
View File
@@ -1,3 +1,4 @@
import {revokeObjectUrl} from './image-manipulator'
import {type PickerImage} from './picker.shared'
import {type Dimensions} from './types'
import {
@@ -14,10 +15,14 @@ export async function compressIfNeeded(
if (img.size < maxSize) {
return img
}
return await doResize(img.path, {
maxDimension,
maxSize,
})
try {
return await doResize(img.path, {
maxDimension,
maxSize,
})
} finally {
revokeObjectUrl(img.path)
}
}
export interface DownloadAndResizeOpts {
+27
View File
@@ -27,6 +27,33 @@ export function getResizedDimensions(
}
}
/**
* Select a compression limit that balances throughput with decoded bitmap
* memory. Android benefits from more parallel work on higher-memory devices;
* web and iOS plateau at two concurrent images.
*/
export function getImageCompressionConcurrency({
isAndroid,
totalMemoryBytes,
}: {
isAndroid: boolean
totalMemoryBytes: number | null
}): number {
if (!isAndroid) {
return 2
}
if (totalMemoryBytes === null) {
return 1
}
if (totalMemoryBytes >= 6_000_000_000) {
return 5
}
if (totalMemoryBytes >= 3_000_000_000) {
return 3
}
return 1
}
// Fairly accurate estimate that is more performant
// than decoding and checking length of URI
export function getDataUriSize(uri: string): number {
+34 -29
View File
@@ -16,6 +16,7 @@ import {
TIMELINE_SAVED_FEED,
VIDEO_SAVED_FEED,
} from '#/lib/constants'
import {revokeObjectUrl} from '#/lib/media/image-manipulator'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {logger} from '#/logger'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
@@ -148,43 +149,47 @@ export function StepFinished() {
})(),
(async () => {
const {imageUri, imageMime} = profileStepResults
const blobPromise =
imageUri && imageMime
? uploadBlob(pdsClient, imageUri, imageMime)
: undefined
try {
const blobPromise =
imageUri && imageMime
? uploadBlob(pdsClient, imageUri, imageMime)
: undefined
await pdsClient.call(upsertProfile, async existing => {
let next: Un$Typed<app.bsky.actor.profile.Main> = existing ?? {}
await pdsClient.call(upsertProfile, async existing => {
let next: Un$Typed<app.bsky.actor.profile.Main> = existing ?? {}
if (blobPromise) {
const res = await blobPromise
if (res.blob) {
next.avatar = res.blob
if (blobPromise) {
const res = await blobPromise
if (res.blob) {
next.avatar = res.blob
}
}
}
if (starterPack) {
next.joinedViaStarterPack = {
uri: starterPack.uri,
cid: starterPack.cid,
if (starterPack) {
next.joinedViaStarterPack = {
uri: starterPack.uri,
cid: starterPack.cid,
}
}
}
next.displayName = ''
next.displayName = ''
if (!next.createdAt) {
next.createdAt = toDatetimeString(new Date())
}
return next
})
if (!next.createdAt) {
next.createdAt = toDatetimeString(new Date())
}
return next
})
ax.metric('onboarding:finished:avatarResult', {
avatarResult: profileStepResults.isCreatedAvatar
? 'created'
: profileStepResults.image
? 'uploaded'
: 'default',
})
ax.metric('onboarding:finished:avatarResult', {
avatarResult: profileStepResults.isCreatedAvatar
? 'created'
: profileStepResults.image
? 'uploaded'
: 'default',
})
} finally {
revokeObjectUrl(imageUri)
}
})(),
requestNotificationsPermission('AfterOnboarding'),
])
+10 -7
View File
@@ -9,7 +9,7 @@ import {
} from 'react'
import {View} from 'react-native'
import {Image as ExpoImage} from 'expo-image'
import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'
import {SaveFormat} from 'expo-image-manipulator'
import {
type ImagePickerOptions,
launchImageLibraryAsync,
@@ -20,6 +20,7 @@ import {Trans} from '@lingui/react/macro'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
import {renderImage, revokeObjectUrl} from '#/lib/media/image-manipulator'
import {compressIfNeeded} from '#/lib/media/manip'
import {openCropper} from '#/lib/media/picker'
import {getUriSize} from '#/lib/media/uriSize'
@@ -120,18 +121,17 @@ export function StepProfile() {
const asset = (response.assets ?? [])[0]
if (!asset) return []
let result: Awaited<ReturnType<typeof renderImage>> | undefined
try {
const context = ImageManipulator.manipulate(asset.uri)
const rendered = await context.renderAsync()
const result = await rendered.saveAsync({
result = await renderImage(asset.uri, undefined, {
format: SaveFormat.JPEG,
compress: 1.0,
compress: 1,
})
return [
{
mime: 'image/jpeg',
height: rendered.height,
width: rendered.width,
height: result.height,
width: result.width,
path: result.uri,
size: await getUriSize(result.uri),
},
@@ -140,7 +140,10 @@ export function StepProfile() {
setError(
l`This image could not be used. Try a different format like .jpg or .png.`,
)
revokeObjectUrl(result?.uri)
return []
} finally {
revokeObjectUrl(asset.uri)
}
},
[l, setError, sheetWrapper],
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {MAX_DESCRIPTION, MAX_DISPLAY_NAME, urls} from '#/lib/constants'
import {revokeObjectUrl} from '#/lib/media/image-manipulator'
import {cleanError} from '#/lib/strings/errors'
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
import {logger} from '#/logger'
@@ -124,6 +125,19 @@ function DialogInner({
ImageMeta | undefined | null
>()
useEffect(
() => () => {
revokeObjectUrl(newUserBanner?.path)
},
[newUserBanner],
)
useEffect(
() => () => {
revokeObjectUrl(newUserAvatar?.path)
},
[newUserAvatar],
)
const dirty =
displayName !== initialDisplayName ||
description !== initialDescription ||
+78 -26
View File
@@ -8,7 +8,7 @@ import {
import {type ImageManipulatorContext, SaveFormat} from 'expo-image-manipulator'
import {nanoid} from 'nanoid/non-secure'
import {renderImage} from '#/lib/media/image-manipulator'
import {renderImage, revokeObjectUrl} 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'
@@ -162,6 +162,7 @@ export async function manipulateImage(
return img
}
revokeObjectUrl(img.transformed.path)
return {alt: img.alt, source: img.source}
}
@@ -169,12 +170,20 @@ export async function manipulateImage(
const result = await renderImage(source.path, context => context.crop(crop), {
format: SaveFormat.PNG,
})
let path: string
try {
path = await moveIfNecessary(result.uri)
} catch (error) {
await deleteTemporaryImage(result.uri)
throw error
}
await deleteTemporaryImage(img.transformed?.path)
return {
alt: img.alt,
source: img.source,
transformed: {
path: await moveIfNecessary(result.uri),
path,
width: result.width,
height: result.height,
mime: 'image/png',
@@ -187,12 +196,19 @@ export function resetImageManipulation(
img: ComposerImage,
): ComposerImageWithoutTransformation {
if (img.transformed !== undefined) {
revokeObjectUrl(img.transformed.path)
return {alt: img.alt, source: img.source}
}
return img
}
/** Release browser resources owned by an image removed from the composer. */
export function releaseComposerImage(img: ComposerImage): void {
revokeObjectUrl(img.source.path)
revokeObjectUrl(img.transformed?.path)
}
export async function compressImage(
img: ComposerImage,
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
@@ -207,7 +223,7 @@ export async function compressImage(
let minQualityPercentage = 0
let maxQualityPercentage = 101 // exclusive
let newDataUri
let newDataUri: PickerImage | undefined
while (maxQualityPercentage - minQualityPercentage > 1) {
if (attempts >= 4) break
@@ -237,30 +253,43 @@ export async function compressImage(
continue
}
const res = await renderImage(
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,
},
)
let res: Awaited<ReturnType<typeof renderImage>> | undefined
try {
res = await renderImage(
source.path,
context => context.resize({width: w, height: h}),
{
/*
* Leave this off in production. On Android, requesting base64 encodes
* the bitmap a second time and retains the bytes and string in memory.
*/
base64: false,
compress: qualityPercentage / 100,
format: SaveFormat.JPEG,
},
)
const size = await getUriSize(res.uri)
if (size <= maxBytes) {
minQualityPercentage = qualityPercentage
newDataUri = {
path: await moveIfNecessary(res.uri),
width: res.width,
height: res.height,
mime: 'image/jpeg',
size,
const size = await getUriSize(res.uri)
if (size <= maxBytes) {
minQualityPercentage = qualityPercentage
const previousPath = newDataUri?.path
const path = await moveIfNecessary(res.uri)
await deleteTemporaryImage(previousPath)
newDataUri = {
path,
width: res.width,
height: res.height,
mime: 'image/jpeg',
size,
}
} else {
await deleteTemporaryImage(res.uri)
maxQualityPercentage = qualityPercentage
}
} else {
maxQualityPercentage = qualityPercentage
} catch (error) {
await deleteTemporaryImage(res?.uri)
await deleteTemporaryImage(newDataUri?.path)
throw error
}
}
@@ -271,6 +300,27 @@ export async function compressImage(
throw new Error(`Unable to compress image`)
}
async function deleteTemporaryImage(path: string | undefined): Promise<void> {
if (!path) {
return
}
if (IS_WEB) {
revokeObjectUrl(path)
return
}
if (!IS_NATIVE) {
return
}
try {
await deleteAsync(path, {idempotent: true})
} catch (error) {
logger.info('Failed to delete temporary compressed image', {
safeMessage: error,
})
}
}
async function moveIfNecessary(from: string) {
const cacheDir = IS_NATIVE && getImageCacheDirectory()
@@ -305,7 +355,9 @@ async function copyToCache(from: string): Promise<string> {
try {
const response = await fetch(from)
const blob = await response.blob()
return await blobToDataUri(blob)
const dataUri = await blobToDataUri(blob)
revokeObjectUrl(from)
return dataUri
} catch (e) {
// Blob URL was likely revoked, return as-is for downstream error handling
return from
+25 -6
View File
@@ -65,6 +65,7 @@ import {
VIDEO_MAX_DURATION_MS,
} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {revokeObjectUrl} from '#/lib/media/image-manipulator'
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
import {mimeToExt} from '#/lib/media/video/util'
import {useCallOnce} from '#/lib/once'
@@ -79,6 +80,7 @@ import {
type ComposerImage,
createComposerImage,
pasteImage,
releaseComposerImage,
} from '#/state/gallery'
import {useRequireAltTextEnabled} from '#/state/preferences'
import {
@@ -157,7 +159,7 @@ import {
useSaveDraftMutation,
} from './drafts/state/queries'
import {type DraftSummary} from './drafts/state/schema'
import {revokeAllMediaUrls} from './drafts/state/storage'
import {revokeAllMediaUrls, revokeMediaUrl} from './drafts/state/storage'
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
import {
type AssetType,
@@ -189,6 +191,14 @@ type CancelRef = {
onPressCancel: () => void
}
function releaseEmbedMedia(media: EmbedDraft['media']): void {
if (media?.type === 'images' || media?.type === 'gallery') {
media.images.forEach(releaseComposerImage)
} else if (media?.type === 'video') {
revokeObjectUrl(media.video.asset?.uri)
}
}
function applyGalleryCap(
currentCount: number,
incoming: ComposerImage[],
@@ -503,6 +513,10 @@ export const ComposePost = ({
const clearVideo = useCallback(
(postId: string) => {
const post = thread.posts.find(item => item.id === postId)
if (post?.embed.media?.type === 'video') {
revokeObjectUrl(post.embed.media.video.asset?.uri)
}
composerDispatch({
type: 'update_post',
postId: postId,
@@ -511,7 +525,7 @@ export const ComposePost = ({
},
})
},
[composerDispatch],
[composerDispatch, thread.posts],
)
const restoreVideo = useCallback(
@@ -527,9 +541,10 @@ export const ComposePost = ({
let asset: ImagePickerAsset
if (IS_WEB) {
// Web: Convert blob URL to a File, then get video metadata (returns data URL)
// Convert the saved draft URL to a File and release the old URL.
const response = await fetch(videoInfo.uri)
const blob = await response.blob()
revokeMediaUrl(videoInfo.uri)
const file = new File([blob], 'restored-video', {
type: videoInfo.mimeType,
})
@@ -683,6 +698,7 @@ export const ComposePost = ({
)
// Dispatch restore action (this also sets draftId in state)
thread.posts.forEach(post => releaseEmbedMedia(post.embed.media))
composerDispatch({
type: 'restore_from_draft',
draftId: draftSummary.id,
@@ -715,7 +731,7 @@ export const ComposePost = ({
void restoreVideo(postId, videoInfo)
}
},
[composerDispatch, restoreVideo, ax],
[composerDispatch, restoreVideo, ax, thread.posts],
)
const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -726,10 +742,11 @@ export const ComposePost = ({
if (IS_ANDROID) {
Keyboard.dismiss()
}
thread.posts.forEach(post => releaseEmbedMedia(post.embed.media))
closeComposer()
clearThumbnailCache(queryClient)
revokeAllMediaUrls()
}, [closeComposer, queryClient])
}, [closeComposer, queryClient, thread.posts])
const getDraftSaveError = useCallback(
(e: unknown): string => {
@@ -866,11 +883,12 @@ export const ComposePost = ({
// Clear the composer (discard current content)
const handleClearComposer = useCallback(() => {
thread.posts.forEach(post => releaseEmbedMedia(post.embed.media))
composerDispatch({
type: 'clear',
initInteractionSettings: preferences?.postInteractionSettings,
})
}, [composerDispatch, preferences?.postInteractionSettings])
}, [composerDispatch, preferences?.postInteractionSettings, thread.posts])
const insets = useSafeAreaInsets()
const viewStyles = useMemo(
@@ -1762,6 +1780,7 @@ let ComposerPost = memo(function ComposerPost({
title={l`Discard post?`}
description={l`Are you sure you'd like to discard this post?`}
onConfirm={() => {
releaseEmbedMedia(post.embed.media)
dispatch({
type: 'remove_post',
postId: post.id,
+138 -119
View File
@@ -14,6 +14,7 @@ import {
usePhotoLibraryPermission,
useVideoLibraryPermission,
} from '#/lib/hooks/usePermissions'
import {revokeObjectUrl} from '#/lib/media/image-manipulator'
import {openUnifiedPicker} from '#/lib/media/picker'
import {blobToDataUri, extractDataUriMime} from '#/lib/media/util'
import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
@@ -260,133 +261,151 @@ async function processImagePickerAssets(
*/
let supportedAssets: ValidatedImagePickerAsset[] = []
for (const asset of assets) {
const {success, type, mimeType} = await classifyImagePickerAsset(asset)
if (!success) {
errors.add(SelectedAssetError.Unsupported)
continue
}
/*
* If we have an `allowedAssetTypes` prop, constrain to that. Otherwise,
* set this to the first valid asset type we see, and then use that to
* constrain all remaining selected assets.
*/
selectableAssetType = allowedAssetTypes || selectableAssetType || type
// ignore mixed types
if (type !== selectableAssetType) {
errors.add(SelectedAssetError.MixedTypes)
continue
}
if (type === 'video') {
/**
* We don't care too much about mimeType at this point on native,
* since the `processVideo` step later on will convert to `.mp4`.
*/
if (IS_WEB && !isSupportedVideoMimeType(mimeType)) {
errors.add(SelectedAssetError.Unsupported)
continue
}
/*
* Filesize appears to be stable across all platforms, so we can use it
* to filter out large files on web. On native, we compress these anyway,
* so we only check on web. On web, we can reject early if the browser
* doesn't support WebCodecs.
*/
if (
IS_WEB &&
!hasWebCodecs() &&
asset.fileSize &&
asset.fileSize > VIDEO_MAX_SIZE
) {
errors.add(SelectedAssetError.FileTooBig)
continue
}
}
if (type === 'image') {
if (!isSupportedImageMimeType(mimeType)) {
errors.add(SelectedAssetError.Unsupported)
continue
}
}
if (type === 'gif') {
/*
* Filesize appears to be stable across all platforms, so we can use it
* to filter out large files. We can't compress GIFs on either platform.
*/
if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
errors.add(SelectedAssetError.FileTooBig)
continue
}
}
/*
* All validations passed, we have an asset!
*/
let uri = asset.uri
if (IS_WEB && type === 'image' && asset.file) {
uri = await blobToDataUri(asset.file)
}
supportedAssets.push({
mimeType,
...asset,
/*
* In `expo-image-picker` >= v17, `uri` is now a `blob:` URL, not a
* data-uri. Our handling elsewhere in the app (for web) relies on the
* data-uri, so read images only after their type has been validated.
* Videos retain their File/blob URL and avoid an expensive base64 read.
*/
uri,
})
const pendingAssetUrls = new Set(assets.map(asset => asset.uri))
const limitSupportedAssets = (limit: number) => {
supportedAssets.slice(limit).forEach(asset => revokeObjectUrl(asset.uri))
supportedAssets = supportedAssets.slice(0, limit)
}
if (supportedAssets.length > 0) {
if (selectableAssetType === 'image') {
if (supportedAssets.length > selectionCountRemaining) {
errors.add(SelectedAssetError.MaxImages)
supportedAssets = supportedAssets.slice(0, selectionCountRemaining)
}
} else if (selectableAssetType === 'video') {
if (supportedAssets.length > 1) {
errors.add(SelectedAssetError.MaxVideos)
supportedAssets = supportedAssets.slice(0, 1)
}
try {
for (const asset of assets) {
const {success, type, mimeType} = await classifyImagePickerAsset(asset)
if (supportedAssets[0].duration) {
if (IS_WEB) {
/*
* Web reports duration as seconds
*/
supportedAssets[0].duration = supportedAssets[0].duration * 1000
}
if (supportedAssets[0].duration > videoMaxDurationMs) {
errors.add(SelectedAssetError.VideoTooLong)
supportedAssets = []
}
} else {
if (!success) {
errors.add(SelectedAssetError.Unsupported)
supportedAssets = []
continue
}
} else if (selectableAssetType === 'gif') {
if (supportedAssets.length > 1) {
errors.add(SelectedAssetError.MaxGIFs)
supportedAssets = supportedAssets.slice(0, 1)
/*
* If we have an `allowedAssetTypes` prop, constrain to that. Otherwise,
* set this to the first valid asset type we see, and then use that to
* constrain all remaining selected assets.
*/
selectableAssetType = allowedAssetTypes || selectableAssetType || type
// ignore mixed types
if (type !== selectableAssetType) {
errors.add(SelectedAssetError.MixedTypes)
continue
}
if (type === 'video') {
/**
* We don't care too much about mimeType at this point on native,
* since the `processVideo` step later on will convert to `.mp4`.
*/
if (IS_WEB && !isSupportedVideoMimeType(mimeType)) {
errors.add(SelectedAssetError.Unsupported)
continue
}
/*
* Filesize appears to be stable across all platforms, so we can use it
* to filter out large files on web. On native, we compress these anyway,
* so we only check on web. On web, we can reject early if the browser
* doesn't support WebCodecs.
*/
if (
IS_WEB &&
!hasWebCodecs() &&
asset.fileSize &&
asset.fileSize > VIDEO_MAX_SIZE
) {
errors.add(SelectedAssetError.FileTooBig)
continue
}
}
if (type === 'image') {
if (!isSupportedImageMimeType(mimeType)) {
errors.add(SelectedAssetError.Unsupported)
continue
}
}
if (type === 'gif') {
/*
* Filesize appears to be stable across all platforms, so we can use it
* to filter out large files. We can't compress GIFs on either platform.
*/
if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
errors.add(SelectedAssetError.FileTooBig)
continue
}
}
/*
* All validations passed, we have an asset!
*/
let uri = asset.uri
if (IS_WEB && type === 'image' && asset.file) {
try {
uri = await blobToDataUri(asset.file)
} finally {
revokeObjectUrl(asset.uri)
}
}
supportedAssets.push({
mimeType,
...asset,
/*
* In `expo-image-picker` >= v17, `uri` is now a `blob:` URL, not a
* data-uri. Our handling elsewhere in the app (for web) relies on the
* data-uri, so read images only after their type has been validated.
* Videos retain their File/blob URL and avoid an expensive base64 read.
*/
uri,
})
pendingAssetUrls.delete(asset.uri)
}
if (supportedAssets.length > 0) {
if (selectableAssetType === 'image') {
if (supportedAssets.length > selectionCountRemaining) {
errors.add(SelectedAssetError.MaxImages)
limitSupportedAssets(selectionCountRemaining)
}
} else if (selectableAssetType === 'video') {
if (supportedAssets.length > 1) {
errors.add(SelectedAssetError.MaxVideos)
limitSupportedAssets(1)
}
if (supportedAssets[0].duration) {
if (IS_WEB) {
/*
* Web reports duration as seconds
*/
supportedAssets[0].duration = supportedAssets[0].duration * 1000
}
if (supportedAssets[0].duration > videoMaxDurationMs) {
errors.add(SelectedAssetError.VideoTooLong)
limitSupportedAssets(0)
}
} else {
errors.add(SelectedAssetError.Unsupported)
limitSupportedAssets(0)
}
} else if (selectableAssetType === 'gif') {
if (supportedAssets.length > 1) {
errors.add(SelectedAssetError.MaxGIFs)
limitSupportedAssets(1)
}
}
}
}
return {
type: selectableAssetType!, // set above
assets: supportedAssets,
errors,
return {
type: selectableAssetType!, // set above
assets: supportedAssets,
errors,
}
} catch (error) {
supportedAssets.forEach(asset => revokeObjectUrl(asset.uri))
throw error
} finally {
pendingAssetUrls.forEach(revokeObjectUrl)
}
}
+35 -10
View File
@@ -283,6 +283,18 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
const [videoThumbnail, setVideoThumbnail] = useState<string | undefined>()
useEffect(() => {
let cancelled = false
const ownedUrls = new Set<string>()
const ownUrl = (url: string): boolean => {
if (cancelled) {
storage.revokeMediaUrl(url)
return false
}
ownedUrls.add(url)
return true
}
async function loadMedia() {
if (post.images && post.images.length > 0) {
const loaded: LoadedImage[] = []
@@ -290,36 +302,49 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
const alt = image.altText || ''
try {
const url = await storage.loadMediaFromLocal(image.localPath)
loaded.push({url, alt})
if (ownUrl(url)) {
loaded.push({url, alt})
}
} catch (e) {
// Image doesn't exist locally, skip it
}
}
setLoadedImages(loaded)
if (!cancelled) {
setLoadedImages(loaded)
}
}
if (post.video?.exists && post.video.localPath) {
try {
const url = await storage.loadMediaFromLocal(post.video.localPath)
if (IS_WEB) {
// can't generate thumbnails on web
if (IS_WEB) {
if (!cancelled) {
// We cannot generate draft video thumbnails on web.
setVideoThumbnail("yep, there's a video")
} else {
}
} else {
try {
const url = await storage.loadMediaFromLocal(post.video.localPath)
if (!ownUrl(url)) return
logger.debug('generating thumbnail of ', {url})
const thumbnail = await VideoThumbnails.getThumbnailAsync(url, {
time: 0,
quality: 0.2,
})
logger.debug('thumbnail generated', {thumbnail})
setVideoThumbnail(thumbnail.uri)
if (!cancelled) {
setVideoThumbnail(thumbnail.uri)
}
} catch (e) {
// Video doesn't exist locally
}
} catch (e) {
// Video doesn't exist locally
}
}
}
void loadMedia()
return () => {
cancelled = true
ownedUrls.forEach(storage.revokeMediaUrl)
}
}, [post.images, post.video])
// Nothing to show
+6 -1
View File
@@ -18,7 +18,11 @@ import {Trans} from '@lingui/react/macro'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {type Dimensions} from '#/lib/media/types'
import {colors} from '#/lib/styles'
import {type ComposerImage, cropImage} from '#/state/gallery'
import {
type ComposerImage,
cropImage,
releaseComposerImage,
} from '#/state/gallery'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import * as Dialog from '#/components/Dialog'
@@ -119,6 +123,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
dispatch({type: 'embed_update_image', image: next})
}}
onRemove={() => {
releaseComposerImage(image)
dispatch({type: 'embed_remove_image', image})
}}
/>