bound image compression memory usage
This commit is contained in:
@@ -5,6 +5,7 @@ import {msg} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Plural, Trans} from '@lingui/react/macro'
|
import {Plural, Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {revokeObjectUrl} from '#/lib/media/image-manipulator'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
|
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
@@ -187,6 +188,13 @@ function DialogInner({
|
|||||||
ImageMeta | undefined | null
|
ImageMeta | undefined | null
|
||||||
>()
|
>()
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
revokeObjectUrl(newListAvatar?.path)
|
||||||
|
},
|
||||||
|
[newListAvatar],
|
||||||
|
)
|
||||||
|
|
||||||
// When creating with pre-filled values (from Starter Pack), consider dirty
|
// When creating with pre-filled values (from Starter Pack), consider dirty
|
||||||
// immediately so the Save button is enabled
|
// immediately so the Save button is enabled
|
||||||
const hasInitialValuesForCreate = !list && initialValues != null
|
const hasInitialValuesForCreate = !list && initialValues != null
|
||||||
|
|||||||
+46
-14
@@ -1,3 +1,4 @@
|
|||||||
|
import * as Device from 'expo-device'
|
||||||
import {TID} from '@atproto/common-web'
|
import {TID} from '@atproto/common-web'
|
||||||
import {type $Typed, type Client} from '@atproto/lex'
|
import {type $Typed, type Client} from '@atproto/lex'
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,8 @@ 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 {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 {revokeObjectUrl} from '#/lib/media/image-manipulator'
|
||||||
|
import {getImageCompressionConcurrency} from '#/lib/media/util'
|
||||||
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'
|
||||||
@@ -29,6 +32,7 @@ import {
|
|||||||
type PostDraft,
|
type PostDraft,
|
||||||
type ThreadDraft,
|
type ThreadDraft,
|
||||||
} from '#/view/com/composer/state/composer'
|
} from '#/view/com/composer/state/composer'
|
||||||
|
import {IS_ANDROID} from '#/env'
|
||||||
import {app, chat, com} from '#/lexicons'
|
import {app, chat, com} from '#/lexicons'
|
||||||
import * as bsky from '#/types/bsky'
|
import * as bsky from '#/types/bsky'
|
||||||
import {createGIFDescription} from '../gif-alt-text'
|
import {createGIFDescription} from '../gif-alt-text'
|
||||||
@@ -340,11 +344,15 @@ async function resolveMedia(
|
|||||||
compressedImages.map(async (compressedImage, i) => {
|
compressedImages.map(async (compressedImage, i) => {
|
||||||
const {path, width, height, mime} = compressedImage
|
const {path, width, height, mime} = compressedImage
|
||||||
logger.debug(`Uploading image #${i}`)
|
logger.debug(`Uploading image #${i}`)
|
||||||
const res = await uploadBlob(pdsClient, path, mime)
|
try {
|
||||||
return {
|
const res = await uploadBlob(pdsClient, path, mime)
|
||||||
image: res.blob,
|
return {
|
||||||
alt: imagesDraft[i].alt,
|
image: res.blob,
|
||||||
aspectRatio: {width, height},
|
alt: imagesDraft[i].alt,
|
||||||
|
aspectRatio: {width, height},
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
revokeObjectUrl(path)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -364,12 +372,16 @@ async function resolveMedia(
|
|||||||
compressedImages.map(async (compressedImage, i) => {
|
compressedImages.map(async (compressedImage, i) => {
|
||||||
const {path, width, height, mime} = compressedImage
|
const {path, width, height, mime} = compressedImage
|
||||||
logger.debug(`Uploading image #${i}`)
|
logger.debug(`Uploading image #${i}`)
|
||||||
const res = await uploadBlob(pdsClient, path, mime)
|
try {
|
||||||
return {
|
const res = await uploadBlob(pdsClient, path, mime)
|
||||||
$type: 'app.bsky.embed.gallery#image' as const,
|
return {
|
||||||
image: res.blob,
|
$type: 'app.bsky.embed.gallery#image' as const,
|
||||||
alt: imagesDraft[i].alt,
|
image: res.blob,
|
||||||
aspectRatio: {width, height},
|
alt: imagesDraft[i].alt,
|
||||||
|
aspectRatio: {width, height},
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
revokeObjectUrl(path)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -490,15 +502,35 @@ type CompressedImage = Awaited<ReturnType<typeof compressImage>>
|
|||||||
async function compressImages(
|
async function compressImages(
|
||||||
images: ComposerImage[],
|
images: ComposerImage[],
|
||||||
): Promise<CompressedImage[]> {
|
): Promise<CompressedImage[]> {
|
||||||
|
const totalMemoryBytes = Device.totalMemory
|
||||||
|
const concurrency = getImageCompressionConcurrency({
|
||||||
|
isAndroid: IS_ANDROID,
|
||||||
|
totalMemoryBytes,
|
||||||
|
})
|
||||||
|
logger.debug('Compressing images', {
|
||||||
|
concurrency,
|
||||||
|
totalMemoryBytes,
|
||||||
|
})
|
||||||
|
|
||||||
return mapWithSerialRetry(
|
return mapWithSerialRetry(
|
||||||
images,
|
images,
|
||||||
(image, i) => {
|
(image, i) => {
|
||||||
logger.debug(`Compressing image #${i}`)
|
logger.debug(`Compressing image #${i}`)
|
||||||
return compressImage(image, IMAGE_SIZE_CONFIG_POSTS)
|
return compressImage(image, IMAGE_SIZE_CONFIG_POSTS)
|
||||||
},
|
},
|
||||||
isBitmapLoadingError,
|
{
|
||||||
(_error, i) => {
|
concurrency,
|
||||||
logger.info(`Retrying image compression serially`, {index: i})
|
shouldRetry: isBitmapLoadingError,
|
||||||
|
onRetry(_error, i) {
|
||||||
|
logger.info(`Retrying image compression serially`, {
|
||||||
|
index: i,
|
||||||
|
concurrency,
|
||||||
|
totalMemoryBytes,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDiscard(image) {
|
||||||
|
revokeObjectUrl(image.path)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ describe('mapWithSerialRetry', () => {
|
|||||||
|
|
||||||
return value * 2
|
return value * 2
|
||||||
},
|
},
|
||||||
error => String(error).includes('retryable'),
|
{
|
||||||
|
concurrency: 3,
|
||||||
|
shouldRetry: error => String(error).includes('retryable'),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toEqual([2, 4, 6])
|
expect(result).toEqual([2, 4, 6])
|
||||||
@@ -43,10 +46,63 @@ describe('mapWithSerialRetry', () => {
|
|||||||
const mapper = jest.fn((value: string) => Promise.reject(new Error(value)))
|
const mapper = jest.fn((value: string) => Promise.reject(new Error(value)))
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
mapWithSerialRetry(['retryable', 'permanent'], mapper, error =>
|
mapWithSerialRetry(['retryable', 'permanent'], mapper, {
|
||||||
String(error).includes('retryable'),
|
concurrency: 2,
|
||||||
),
|
shouldRetry: error => String(error).includes('retryable'),
|
||||||
|
}),
|
||||||
).rejects.toThrow('permanent')
|
).rejects.toThrow('permanent')
|
||||||
expect(mapper).toHaveBeenCalledTimes(2)
|
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],
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,31 +1,83 @@
|
|||||||
/**
|
/**
|
||||||
* Maps all values concurrently, then retries eligible failures one at a time.
|
* Maps values with bounded concurrency, then retries eligible failures one at
|
||||||
* Successful results from the concurrent attempt are reused.
|
* a time. Successful results from the concurrent attempt are reused.
|
||||||
*/
|
*/
|
||||||
export async function mapWithSerialRetry<T, U>(
|
export async function mapWithSerialRetry<T, U>(
|
||||||
values: T[],
|
values: T[],
|
||||||
mapper: (value: T, index: number) => Promise<U>,
|
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[]> {
|
): 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(
|
const nonRetryableFailure = settled.find(
|
||||||
result => result.status === 'rejected' && !shouldRetry(result.reason),
|
result => result.status === 'rejected' && !shouldRetry(result.reason),
|
||||||
)
|
)
|
||||||
if (nonRetryableFailure?.status === 'rejected') {
|
if (nonRetryableFailure?.status === 'rejected') {
|
||||||
|
discardCompleted()
|
||||||
throw nonRetryableFailure.reason
|
throw nonRetryableFailure.reason
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: U[] = []
|
const results = new Array<U>(values.length)
|
||||||
for (let i = 0; i < settled.length; i++) {
|
try {
|
||||||
const result = settled[i]
|
for (let i = 0; i < settled.length; i++) {
|
||||||
if (result.status === 'fulfilled') {
|
const result = settled[i]
|
||||||
results.push(result.value)
|
if (result.status === 'fulfilled') {
|
||||||
} else {
|
results[i] = result.value
|
||||||
onRetry?.(result.reason, i)
|
} else {
|
||||||
results.push(await mapper(values[i], i))
|
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
|
return results
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
type SaveOptions,
|
type SaveOptions,
|
||||||
} from 'expo-image-manipulator'
|
} from 'expo-image-manipulator'
|
||||||
|
|
||||||
|
import {IS_WEB} from '#/env'
|
||||||
|
|
||||||
export async function renderImage(
|
export async function renderImage(
|
||||||
source: string,
|
source: string,
|
||||||
manipulate?: (context: ImageManipulatorContext) => void,
|
manipulate?: (context: ImageManipulatorContext) => void,
|
||||||
@@ -19,9 +21,54 @@ export async function renderImage(
|
|||||||
try {
|
try {
|
||||||
return await image.saveAsync(saveOptions)
|
return await image.saveAsync(saveOptions)
|
||||||
} finally {
|
} finally {
|
||||||
|
releaseWebImageResources(image)
|
||||||
image.release()
|
image.release()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
await releaseWebContextResources(context)
|
||||||
context.release()
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {revokeObjectUrl} from './image-manipulator'
|
||||||
import {type PickerImage} from './picker.shared'
|
import {type PickerImage} from './picker.shared'
|
||||||
import {type Dimensions} from './types'
|
import {type Dimensions} from './types'
|
||||||
import {
|
import {
|
||||||
@@ -14,10 +15,14 @@ export async function compressIfNeeded(
|
|||||||
if (img.size < maxSize) {
|
if (img.size < maxSize) {
|
||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
return await doResize(img.path, {
|
try {
|
||||||
maxDimension,
|
return await doResize(img.path, {
|
||||||
maxSize,
|
maxDimension,
|
||||||
})
|
maxSize,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
revokeObjectUrl(img.path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadAndResizeOpts {
|
export interface DownloadAndResizeOpts {
|
||||||
|
|||||||
@@ -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
|
// Fairly accurate estimate that is more performant
|
||||||
// than decoding and checking length of URI
|
// than decoding and checking length of URI
|
||||||
export function getDataUriSize(uri: string): number {
|
export function getDataUriSize(uri: string): number {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
TIMELINE_SAVED_FEED,
|
TIMELINE_SAVED_FEED,
|
||||||
VIDEO_SAVED_FEED,
|
VIDEO_SAVED_FEED,
|
||||||
} from '#/lib/constants'
|
} from '#/lib/constants'
|
||||||
|
import {revokeObjectUrl} from '#/lib/media/image-manipulator'
|
||||||
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
||||||
@@ -148,43 +149,47 @@ export function StepFinished() {
|
|||||||
})(),
|
})(),
|
||||||
(async () => {
|
(async () => {
|
||||||
const {imageUri, imageMime} = profileStepResults
|
const {imageUri, imageMime} = profileStepResults
|
||||||
const blobPromise =
|
try {
|
||||||
imageUri && imageMime
|
const blobPromise =
|
||||||
? uploadBlob(pdsClient, imageUri, imageMime)
|
imageUri && imageMime
|
||||||
: undefined
|
? uploadBlob(pdsClient, imageUri, imageMime)
|
||||||
|
: undefined
|
||||||
|
|
||||||
await pdsClient.call(upsertProfile, async existing => {
|
await pdsClient.call(upsertProfile, async existing => {
|
||||||
let next: Un$Typed<app.bsky.actor.profile.Main> = existing ?? {}
|
let next: Un$Typed<app.bsky.actor.profile.Main> = existing ?? {}
|
||||||
|
|
||||||
if (blobPromise) {
|
if (blobPromise) {
|
||||||
const res = await blobPromise
|
const res = await blobPromise
|
||||||
if (res.blob) {
|
if (res.blob) {
|
||||||
next.avatar = res.blob
|
next.avatar = res.blob
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (starterPack) {
|
if (starterPack) {
|
||||||
next.joinedViaStarterPack = {
|
next.joinedViaStarterPack = {
|
||||||
uri: starterPack.uri,
|
uri: starterPack.uri,
|
||||||
cid: starterPack.cid,
|
cid: starterPack.cid,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
next.displayName = ''
|
next.displayName = ''
|
||||||
|
|
||||||
if (!next.createdAt) {
|
if (!next.createdAt) {
|
||||||
next.createdAt = toDatetimeString(new Date())
|
next.createdAt = toDatetimeString(new Date())
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
ax.metric('onboarding:finished:avatarResult', {
|
ax.metric('onboarding:finished:avatarResult', {
|
||||||
avatarResult: profileStepResults.isCreatedAvatar
|
avatarResult: profileStepResults.isCreatedAvatar
|
||||||
? 'created'
|
? 'created'
|
||||||
: profileStepResults.image
|
: profileStepResults.image
|
||||||
? 'uploaded'
|
? 'uploaded'
|
||||||
: 'default',
|
: 'default',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
revokeObjectUrl(imageUri)
|
||||||
|
}
|
||||||
})(),
|
})(),
|
||||||
requestNotificationsPermission('AfterOnboarding'),
|
requestNotificationsPermission('AfterOnboarding'),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {Image as ExpoImage} from 'expo-image'
|
import {Image as ExpoImage} from 'expo-image'
|
||||||
import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'
|
import {SaveFormat} from 'expo-image-manipulator'
|
||||||
import {
|
import {
|
||||||
type ImagePickerOptions,
|
type ImagePickerOptions,
|
||||||
launchImageLibraryAsync,
|
launchImageLibraryAsync,
|
||||||
@@ -20,6 +20,7 @@ import {Trans} from '@lingui/react/macro'
|
|||||||
|
|
||||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
||||||
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||||
|
import {renderImage, revokeObjectUrl} from '#/lib/media/image-manipulator'
|
||||||
import {compressIfNeeded} from '#/lib/media/manip'
|
import {compressIfNeeded} from '#/lib/media/manip'
|
||||||
import {openCropper} from '#/lib/media/picker'
|
import {openCropper} from '#/lib/media/picker'
|
||||||
import {getUriSize} from '#/lib/media/uriSize'
|
import {getUriSize} from '#/lib/media/uriSize'
|
||||||
@@ -120,18 +121,17 @@ export function StepProfile() {
|
|||||||
const asset = (response.assets ?? [])[0]
|
const asset = (response.assets ?? [])[0]
|
||||||
if (!asset) return []
|
if (!asset) return []
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof renderImage>> | undefined
|
||||||
try {
|
try {
|
||||||
const context = ImageManipulator.manipulate(asset.uri)
|
result = await renderImage(asset.uri, undefined, {
|
||||||
const rendered = await context.renderAsync()
|
|
||||||
const result = await rendered.saveAsync({
|
|
||||||
format: SaveFormat.JPEG,
|
format: SaveFormat.JPEG,
|
||||||
compress: 1.0,
|
compress: 1,
|
||||||
})
|
})
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
mime: 'image/jpeg',
|
mime: 'image/jpeg',
|
||||||
height: rendered.height,
|
height: result.height,
|
||||||
width: rendered.width,
|
width: result.width,
|
||||||
path: result.uri,
|
path: result.uri,
|
||||||
size: await getUriSize(result.uri),
|
size: await getUriSize(result.uri),
|
||||||
},
|
},
|
||||||
@@ -140,7 +140,10 @@ export function StepProfile() {
|
|||||||
setError(
|
setError(
|
||||||
l`This image could not be used. Try a different format like .jpg or .png.`,
|
l`This image could not be used. Try a different format like .jpg or .png.`,
|
||||||
)
|
)
|
||||||
|
revokeObjectUrl(result?.uri)
|
||||||
return []
|
return []
|
||||||
|
} finally {
|
||||||
|
revokeObjectUrl(asset.uri)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[l, setError, sheetWrapper],
|
[l, setError, sheetWrapper],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {Plural, Trans} from '@lingui/react/macro'
|
import {Plural, Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {MAX_DESCRIPTION, MAX_DISPLAY_NAME, urls} from '#/lib/constants'
|
import {MAX_DESCRIPTION, MAX_DISPLAY_NAME, urls} from '#/lib/constants'
|
||||||
|
import {revokeObjectUrl} from '#/lib/media/image-manipulator'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
|
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
@@ -124,6 +125,19 @@ function DialogInner({
|
|||||||
ImageMeta | undefined | null
|
ImageMeta | undefined | null
|
||||||
>()
|
>()
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
revokeObjectUrl(newUserBanner?.path)
|
||||||
|
},
|
||||||
|
[newUserBanner],
|
||||||
|
)
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
revokeObjectUrl(newUserAvatar?.path)
|
||||||
|
},
|
||||||
|
[newUserAvatar],
|
||||||
|
)
|
||||||
|
|
||||||
const dirty =
|
const dirty =
|
||||||
displayName !== initialDisplayName ||
|
displayName !== initialDisplayName ||
|
||||||
description !== initialDescription ||
|
description !== initialDescription ||
|
||||||
|
|||||||
+78
-26
@@ -8,7 +8,7 @@ import {
|
|||||||
import {type ImageManipulatorContext, SaveFormat} from 'expo-image-manipulator'
|
import {type ImageManipulatorContext, SaveFormat} from 'expo-image-manipulator'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
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 {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'
|
||||||
@@ -162,6 +162,7 @@ export async function manipulateImage(
|
|||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
|
revokeObjectUrl(img.transformed.path)
|
||||||
return {alt: img.alt, source: img.source}
|
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), {
|
const result = await renderImage(source.path, context => context.crop(crop), {
|
||||||
format: SaveFormat.PNG,
|
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 {
|
return {
|
||||||
alt: img.alt,
|
alt: img.alt,
|
||||||
source: img.source,
|
source: img.source,
|
||||||
transformed: {
|
transformed: {
|
||||||
path: await moveIfNecessary(result.uri),
|
path,
|
||||||
width: result.width,
|
width: result.width,
|
||||||
height: result.height,
|
height: result.height,
|
||||||
mime: 'image/png',
|
mime: 'image/png',
|
||||||
@@ -187,12 +196,19 @@ export function resetImageManipulation(
|
|||||||
img: ComposerImage,
|
img: ComposerImage,
|
||||||
): ComposerImageWithoutTransformation {
|
): ComposerImageWithoutTransformation {
|
||||||
if (img.transformed !== undefined) {
|
if (img.transformed !== undefined) {
|
||||||
|
revokeObjectUrl(img.transformed.path)
|
||||||
return {alt: img.alt, source: img.source}
|
return {alt: img.alt, source: img.source}
|
||||||
}
|
}
|
||||||
|
|
||||||
return img
|
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(
|
export async function compressImage(
|
||||||
img: ComposerImage,
|
img: ComposerImage,
|
||||||
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
|
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
|
||||||
@@ -207,7 +223,7 @@ export async function compressImage(
|
|||||||
|
|
||||||
let minQualityPercentage = 0
|
let minQualityPercentage = 0
|
||||||
let maxQualityPercentage = 101 // exclusive
|
let maxQualityPercentage = 101 // exclusive
|
||||||
let newDataUri
|
let newDataUri: PickerImage | undefined
|
||||||
|
|
||||||
while (maxQualityPercentage - minQualityPercentage > 1) {
|
while (maxQualityPercentage - minQualityPercentage > 1) {
|
||||||
if (attempts >= 4) break
|
if (attempts >= 4) break
|
||||||
@@ -237,30 +253,43 @@ export async function compressImage(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await renderImage(
|
let res: Awaited<ReturnType<typeof renderImage>> | undefined
|
||||||
source.path,
|
try {
|
||||||
context => context.resize({width: w, height: h}),
|
res = await renderImage(
|
||||||
{
|
source.path,
|
||||||
// Requesting base64 makes Android encode the bitmap a second time and
|
context => context.resize({width: w, height: h}),
|
||||||
// retain the encoded bytes and string in memory. Measure the URI instead.
|
{
|
||||||
base64: false,
|
/*
|
||||||
compress: qualityPercentage / 100,
|
* Leave this off in production. On Android, requesting base64 encodes
|
||||||
format: SaveFormat.JPEG,
|
* 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)
|
const size = await getUriSize(res.uri)
|
||||||
if (size <= maxBytes) {
|
if (size <= maxBytes) {
|
||||||
minQualityPercentage = qualityPercentage
|
minQualityPercentage = qualityPercentage
|
||||||
newDataUri = {
|
const previousPath = newDataUri?.path
|
||||||
path: await moveIfNecessary(res.uri),
|
const path = await moveIfNecessary(res.uri)
|
||||||
width: res.width,
|
await deleteTemporaryImage(previousPath)
|
||||||
height: res.height,
|
newDataUri = {
|
||||||
mime: 'image/jpeg',
|
path,
|
||||||
size,
|
width: res.width,
|
||||||
|
height: res.height,
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await deleteTemporaryImage(res.uri)
|
||||||
|
maxQualityPercentage = qualityPercentage
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
maxQualityPercentage = qualityPercentage
|
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`)
|
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) {
|
async function moveIfNecessary(from: string) {
|
||||||
const cacheDir = IS_NATIVE && getImageCacheDirectory()
|
const cacheDir = IS_NATIVE && getImageCacheDirectory()
|
||||||
|
|
||||||
@@ -305,7 +355,9 @@ async function copyToCache(from: string): Promise<string> {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(from)
|
const response = await fetch(from)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
return await blobToDataUri(blob)
|
const dataUri = await blobToDataUri(blob)
|
||||||
|
revokeObjectUrl(from)
|
||||||
|
return dataUri
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Blob URL was likely revoked, return as-is for downstream error handling
|
// Blob URL was likely revoked, return as-is for downstream error handling
|
||||||
return from
|
return from
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
VIDEO_MAX_DURATION_MS,
|
VIDEO_MAX_DURATION_MS,
|
||||||
} from '#/lib/constants'
|
} from '#/lib/constants'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {revokeObjectUrl} from '#/lib/media/image-manipulator'
|
||||||
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
|
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
|
||||||
import {mimeToExt} from '#/lib/media/video/util'
|
import {mimeToExt} from '#/lib/media/video/util'
|
||||||
import {useCallOnce} from '#/lib/once'
|
import {useCallOnce} from '#/lib/once'
|
||||||
@@ -79,6 +80,7 @@ import {
|
|||||||
type ComposerImage,
|
type ComposerImage,
|
||||||
createComposerImage,
|
createComposerImage,
|
||||||
pasteImage,
|
pasteImage,
|
||||||
|
releaseComposerImage,
|
||||||
} from '#/state/gallery'
|
} from '#/state/gallery'
|
||||||
import {useRequireAltTextEnabled} from '#/state/preferences'
|
import {useRequireAltTextEnabled} from '#/state/preferences'
|
||||||
import {
|
import {
|
||||||
@@ -157,7 +159,7 @@ import {
|
|||||||
useSaveDraftMutation,
|
useSaveDraftMutation,
|
||||||
} from './drafts/state/queries'
|
} from './drafts/state/queries'
|
||||||
import {type DraftSummary} from './drafts/state/schema'
|
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 {PostLanguageSelect} from './select-language/PostLanguageSelect'
|
||||||
import {
|
import {
|
||||||
type AssetType,
|
type AssetType,
|
||||||
@@ -189,6 +191,14 @@ type CancelRef = {
|
|||||||
onPressCancel: () => void
|
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(
|
function applyGalleryCap(
|
||||||
currentCount: number,
|
currentCount: number,
|
||||||
incoming: ComposerImage[],
|
incoming: ComposerImage[],
|
||||||
@@ -503,6 +513,10 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
const clearVideo = useCallback(
|
const clearVideo = useCallback(
|
||||||
(postId: string) => {
|
(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({
|
composerDispatch({
|
||||||
type: 'update_post',
|
type: 'update_post',
|
||||||
postId: postId,
|
postId: postId,
|
||||||
@@ -511,7 +525,7 @@ export const ComposePost = ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[composerDispatch],
|
[composerDispatch, thread.posts],
|
||||||
)
|
)
|
||||||
|
|
||||||
const restoreVideo = useCallback(
|
const restoreVideo = useCallback(
|
||||||
@@ -527,9 +541,10 @@ export const ComposePost = ({
|
|||||||
let asset: ImagePickerAsset
|
let asset: ImagePickerAsset
|
||||||
|
|
||||||
if (IS_WEB) {
|
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 response = await fetch(videoInfo.uri)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
|
revokeMediaUrl(videoInfo.uri)
|
||||||
const file = new File([blob], 'restored-video', {
|
const file = new File([blob], 'restored-video', {
|
||||||
type: videoInfo.mimeType,
|
type: videoInfo.mimeType,
|
||||||
})
|
})
|
||||||
@@ -683,6 +698,7 @@ export const ComposePost = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Dispatch restore action (this also sets draftId in state)
|
// Dispatch restore action (this also sets draftId in state)
|
||||||
|
thread.posts.forEach(post => releaseEmbedMedia(post.embed.media))
|
||||||
composerDispatch({
|
composerDispatch({
|
||||||
type: 'restore_from_draft',
|
type: 'restore_from_draft',
|
||||||
draftId: draftSummary.id,
|
draftId: draftSummary.id,
|
||||||
@@ -715,7 +731,7 @@ export const ComposePost = ({
|
|||||||
void restoreVideo(postId, videoInfo)
|
void restoreVideo(postId, videoInfo)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[composerDispatch, restoreVideo, ax],
|
[composerDispatch, restoreVideo, ax, thread.posts],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||||
@@ -726,10 +742,11 @@ export const ComposePost = ({
|
|||||||
if (IS_ANDROID) {
|
if (IS_ANDROID) {
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
}
|
}
|
||||||
|
thread.posts.forEach(post => releaseEmbedMedia(post.embed.media))
|
||||||
closeComposer()
|
closeComposer()
|
||||||
clearThumbnailCache(queryClient)
|
clearThumbnailCache(queryClient)
|
||||||
revokeAllMediaUrls()
|
revokeAllMediaUrls()
|
||||||
}, [closeComposer, queryClient])
|
}, [closeComposer, queryClient, thread.posts])
|
||||||
|
|
||||||
const getDraftSaveError = useCallback(
|
const getDraftSaveError = useCallback(
|
||||||
(e: unknown): string => {
|
(e: unknown): string => {
|
||||||
@@ -866,11 +883,12 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
// Clear the composer (discard current content)
|
// Clear the composer (discard current content)
|
||||||
const handleClearComposer = useCallback(() => {
|
const handleClearComposer = useCallback(() => {
|
||||||
|
thread.posts.forEach(post => releaseEmbedMedia(post.embed.media))
|
||||||
composerDispatch({
|
composerDispatch({
|
||||||
type: 'clear',
|
type: 'clear',
|
||||||
initInteractionSettings: preferences?.postInteractionSettings,
|
initInteractionSettings: preferences?.postInteractionSettings,
|
||||||
})
|
})
|
||||||
}, [composerDispatch, preferences?.postInteractionSettings])
|
}, [composerDispatch, preferences?.postInteractionSettings, thread.posts])
|
||||||
|
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const viewStyles = useMemo(
|
const viewStyles = useMemo(
|
||||||
@@ -1762,6 +1780,7 @@ let ComposerPost = memo(function ComposerPost({
|
|||||||
title={l`Discard post?`}
|
title={l`Discard post?`}
|
||||||
description={l`Are you sure you'd like to discard this post?`}
|
description={l`Are you sure you'd like to discard this post?`}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
|
releaseEmbedMedia(post.embed.media)
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'remove_post',
|
type: 'remove_post',
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
usePhotoLibraryPermission,
|
usePhotoLibraryPermission,
|
||||||
useVideoLibraryPermission,
|
useVideoLibraryPermission,
|
||||||
} from '#/lib/hooks/usePermissions'
|
} from '#/lib/hooks/usePermissions'
|
||||||
|
import {revokeObjectUrl} from '#/lib/media/image-manipulator'
|
||||||
import {openUnifiedPicker} from '#/lib/media/picker'
|
import {openUnifiedPicker} from '#/lib/media/picker'
|
||||||
import {blobToDataUri, extractDataUriMime} from '#/lib/media/util'
|
import {blobToDataUri, extractDataUriMime} from '#/lib/media/util'
|
||||||
import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
|
import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
|
||||||
@@ -260,133 +261,151 @@ async function processImagePickerAssets(
|
|||||||
*/
|
*/
|
||||||
let supportedAssets: ValidatedImagePickerAsset[] = []
|
let supportedAssets: ValidatedImagePickerAsset[] = []
|
||||||
|
|
||||||
for (const asset of assets) {
|
const pendingAssetUrls = new Set(assets.map(asset => asset.uri))
|
||||||
const {success, type, mimeType} = await classifyImagePickerAsset(asset)
|
const limitSupportedAssets = (limit: number) => {
|
||||||
|
supportedAssets.slice(limit).forEach(asset => revokeObjectUrl(asset.uri))
|
||||||
if (!success) {
|
supportedAssets = supportedAssets.slice(0, limit)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportedAssets.length > 0) {
|
try {
|
||||||
if (selectableAssetType === 'image') {
|
for (const asset of assets) {
|
||||||
if (supportedAssets.length > selectionCountRemaining) {
|
const {success, type, mimeType} = await classifyImagePickerAsset(asset)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supportedAssets[0].duration) {
|
if (!success) {
|
||||||
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 {
|
|
||||||
errors.add(SelectedAssetError.Unsupported)
|
errors.add(SelectedAssetError.Unsupported)
|
||||||
supportedAssets = []
|
continue
|
||||||
}
|
}
|
||||||
} else if (selectableAssetType === 'gif') {
|
|
||||||
if (supportedAssets.length > 1) {
|
/*
|
||||||
errors.add(SelectedAssetError.MaxGIFs)
|
* If we have an `allowedAssetTypes` prop, constrain to that. Otherwise,
|
||||||
supportedAssets = supportedAssets.slice(0, 1)
|
* 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 {
|
return {
|
||||||
type: selectableAssetType!, // set above
|
type: selectableAssetType!, // set above
|
||||||
assets: supportedAssets,
|
assets: supportedAssets,
|
||||||
errors,
|
errors,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
supportedAssets.forEach(asset => revokeObjectUrl(asset.uri))
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
pendingAssetUrls.forEach(revokeObjectUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -283,6 +283,18 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
|
|||||||
const [videoThumbnail, setVideoThumbnail] = useState<string | undefined>()
|
const [videoThumbnail, setVideoThumbnail] = useState<string | undefined>()
|
||||||
|
|
||||||
useEffect(() => {
|
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() {
|
async function loadMedia() {
|
||||||
if (post.images && post.images.length > 0) {
|
if (post.images && post.images.length > 0) {
|
||||||
const loaded: LoadedImage[] = []
|
const loaded: LoadedImage[] = []
|
||||||
@@ -290,36 +302,49 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
|
|||||||
const alt = image.altText || ''
|
const alt = image.altText || ''
|
||||||
try {
|
try {
|
||||||
const url = await storage.loadMediaFromLocal(image.localPath)
|
const url = await storage.loadMediaFromLocal(image.localPath)
|
||||||
loaded.push({url, alt})
|
if (ownUrl(url)) {
|
||||||
|
loaded.push({url, alt})
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Image doesn't exist locally, skip it
|
// Image doesn't exist locally, skip it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setLoadedImages(loaded)
|
if (!cancelled) {
|
||||||
|
setLoadedImages(loaded)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (post.video?.exists && post.video.localPath) {
|
if (post.video?.exists && post.video.localPath) {
|
||||||
try {
|
if (IS_WEB) {
|
||||||
const url = await storage.loadMediaFromLocal(post.video.localPath)
|
if (!cancelled) {
|
||||||
if (IS_WEB) {
|
// We cannot generate draft video thumbnails on web.
|
||||||
// can't generate thumbnails on web
|
|
||||||
setVideoThumbnail("yep, there's a video")
|
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})
|
logger.debug('generating thumbnail of ', {url})
|
||||||
const thumbnail = await VideoThumbnails.getThumbnailAsync(url, {
|
const thumbnail = await VideoThumbnails.getThumbnailAsync(url, {
|
||||||
time: 0,
|
time: 0,
|
||||||
quality: 0.2,
|
quality: 0.2,
|
||||||
})
|
})
|
||||||
logger.debug('thumbnail generated', {thumbnail})
|
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()
|
void loadMedia()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
ownedUrls.forEach(storage.revokeMediaUrl)
|
||||||
|
}
|
||||||
}, [post.images, post.video])
|
}, [post.images, post.video])
|
||||||
|
|
||||||
// Nothing to show
|
// Nothing to show
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import {Trans} from '@lingui/react/macro'
|
|||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {type Dimensions} from '#/lib/media/types'
|
import {type Dimensions} from '#/lib/media/types'
|
||||||
import {colors} from '#/lib/styles'
|
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 {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
@@ -119,6 +123,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
|||||||
dispatch({type: 'embed_update_image', image: next})
|
dispatch({type: 'embed_update_image', image: next})
|
||||||
}}
|
}}
|
||||||
onRemove={() => {
|
onRemove={() => {
|
||||||
|
releaseComposerImage(image)
|
||||||
dispatch({type: 'embed_remove_image', image})
|
dispatch({type: 'embed_remove_image', image})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user