Skip to content

Commit 5813ea3

Browse files
mozziusclaude
andcommitted
compress images with the native module on ios and android
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f383b76 commit 5813ea3

3 files changed

Lines changed: 148 additions & 111 deletions

File tree

‎src/lib/media/image/compress.ts‎

Lines changed: 5 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
1-
import {SaveFormat} from 'expo-image-manipulator'
2-
31
import {createLimit} from '#/lib/async/limit'
4-
import {moveIfNecessary} from '#/lib/media/image/cache'
5-
import {renderImage} from '#/lib/media/image-manipulator'
2+
import {compressToFit} from '#/lib/media/image/compressToFit'
63
import {type PickerImage} from '#/lib/media/picker.shared'
7-
import {getUriSize} from '#/lib/media/uriSize'
84
import {type ComposerImage} from '#/state/gallery'
95

106
/*
11-
* Each image being compressed holds full-size decoded bitmaps while it renders
12-
* (about 48 MB each for a 12 MP photo on iOS), and iOS starts decoding as
13-
* soon as a manipulation is created. Compressing a 10-image gallery all at
14-
* once is enough to get the app killed for memory (APP-3190). iOS encodes on a
15-
* single serial queue anyway, so running more than two at once buys little.
7+
* Each image being compressed holds at least one full-size decoded bitmap
8+
* (about 48 MB for a 12 MP photo), so compressing a 10-image gallery all at
9+
* once is enough to get the app killed for memory (APP-3190).
1610
*/
1711
const MAX_CONCURRENT_COMPRESSIONS = 2
1812
const limit = createLimit(MAX_CONCURRENT_COMPRESSIONS)
@@ -26,105 +20,5 @@ export function compressImage(
2620
img: ComposerImage,
2721
opts: {maxDimension: number; maxSize: number},
2822
): Promise<PickerImage> {
29-
return limit(() => compressImageNow(img, opts))
30-
}
31-
32-
async function compressImageNow(
33-
img: ComposerImage,
34-
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
35-
): Promise<PickerImage> {
36-
const source = img.transformed || img.source
37-
38-
let attempts = 0
39-
/*
40-
* Seeded from `maxDimension` but shrunk per attempt below, so keep the
41-
* passed-in value pristine.
42-
*/
43-
let currentDimension = maxDimension
44-
const maxBytes = maxSize
45-
46-
let minQualityPercentage = 0
47-
let maxQualityPercentage = 101 // exclusive
48-
let newDataUri
49-
50-
while (maxQualityPercentage - minQualityPercentage > 1) {
51-
if (attempts >= 4) break
52-
53-
const [w, h] = containImageRes(
54-
source.width,
55-
source.height,
56-
currentDimension,
57-
)
58-
const qualityPercentage = Math.round(
59-
(maxQualityPercentage + minQualityPercentage) / 2,
60-
)
61-
62-
/*
63-
* In the event the image doesn't compress well, we want to avoid
64-
* unecessary iterations. In this case, binary search will check 51, 26,
65-
* 13(rounded). We don't want to go below 25, so if we've halved to 13,
66-
* reset the loop and reduce the image dimensions instead.
67-
*/
68-
if (qualityPercentage <= 13) {
69-
minQualityPercentage = 0
70-
maxQualityPercentage = 101
71-
attempts++
72-
/*
73-
* max.width -> 0.8x -> 0.64x -> 0.512x -> ~0.41x
74-
* e.g. 4000px -> 3200px -> 2560px -> 2048px -> ~1638px
75-
*/
76-
currentDimension = Math.floor(currentDimension * 0.8)
77-
continue
78-
}
79-
80-
const res = await renderImage(
81-
source.path,
82-
context => context.resize({width: w, height: h}),
83-
{
84-
compress: qualityPercentage / 100,
85-
format: SaveFormat.JPEG,
86-
},
87-
)
88-
89-
/*
90-
* Measure the written file rather than requesting base64, which builds a
91-
* base64 copy of every pass and sends it to JS (Android even encodes the
92-
* JPEG a second time to do it).
93-
*/
94-
const size = await getUriSize(res.uri)
95-
if (size <= maxBytes) {
96-
minQualityPercentage = qualityPercentage
97-
newDataUri = {
98-
path: await moveIfNecessary(res.uri),
99-
width: res.width,
100-
height: res.height,
101-
mime: 'image/jpeg',
102-
size,
103-
}
104-
} else {
105-
maxQualityPercentage = qualityPercentage
106-
}
107-
}
108-
109-
if (newDataUri) {
110-
return newDataUri
111-
}
112-
113-
throw new Error(`Unable to compress image`)
114-
}
115-
116-
function containImageRes(
117-
w: number,
118-
h: number,
119-
max: number,
120-
): [width: number, height: number] {
121-
let scale = 1
122-
123-
if (w > max || h > max) {
124-
scale = w > h ? max / w : max / h
125-
w = Math.floor(w * scale)
126-
h = Math.floor(h * scale)
127-
}
128-
129-
return [w, h]
23+
return limit(() => compressToFit(img.transformed || img.source, opts))
13024
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {compressImage as compressNatively} from '@bsky.app/expo-bluesky-image-compressor'
2+
3+
import {moveIfNecessary} from '#/lib/media/image/cache'
4+
import {type PickerImage} from '#/lib/media/picker.shared'
5+
import {logger} from '#/logger'
6+
import {type ImageMeta} from '#/state/gallery'
7+
8+
/**
9+
* Re-encodes the image as a JPEG that fits `maxDimension` and `maxSize`. The
10+
* native module decodes the image once and tries each quality in memory.
11+
*/
12+
export async function compressToFit(
13+
source: ImageMeta,
14+
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
15+
): Promise<PickerImage> {
16+
const res = await compressNatively(source.path, {
17+
maxDimension,
18+
maxBytes: maxSize,
19+
})
20+
logger.debug('Compressed image', {
21+
width: res.width,
22+
height: res.height,
23+
size: res.size,
24+
quality: res.quality,
25+
})
26+
return {
27+
path: await moveIfNecessary(res.uri),
28+
width: res.width,
29+
height: res.height,
30+
mime: 'image/jpeg',
31+
size: res.size,
32+
}
33+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {SaveFormat} from 'expo-image-manipulator'
2+
3+
import {moveIfNecessary} from '#/lib/media/image/cache'
4+
import {renderImage} from '#/lib/media/image-manipulator'
5+
import {type PickerImage} from '#/lib/media/picker.shared'
6+
import {getUriSize} from '#/lib/media/uriSize'
7+
import {type ImageMeta} from '#/state/gallery'
8+
9+
/**
10+
* Re-encodes the image as a JPEG that fits `maxDimension` and `maxSize`, by
11+
* binary searching the quality with a manipulator pass per attempt. Native
12+
* uses expo-bluesky-image-compressor instead, which decodes only once.
13+
*/
14+
export async function compressToFit(
15+
source: ImageMeta,
16+
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
17+
): Promise<PickerImage> {
18+
let attempts = 0
19+
/*
20+
* Seeded from `maxDimension` but shrunk per attempt below, so keep the
21+
* passed-in value pristine.
22+
*/
23+
let currentDimension = maxDimension
24+
const maxBytes = maxSize
25+
26+
let minQualityPercentage = 0
27+
let maxQualityPercentage = 101 // exclusive
28+
let newDataUri
29+
30+
while (maxQualityPercentage - minQualityPercentage > 1) {
31+
if (attempts >= 4) break
32+
33+
const [w, h] = containImageRes(
34+
source.width,
35+
source.height,
36+
currentDimension,
37+
)
38+
const qualityPercentage = Math.round(
39+
(maxQualityPercentage + minQualityPercentage) / 2,
40+
)
41+
42+
/*
43+
* In the event the image doesn't compress well, we want to avoid
44+
* unecessary iterations. In this case, binary search will check 51, 26,
45+
* 13(rounded). We don't want to go below 25, so if we've halved to 13,
46+
* reset the loop and reduce the image dimensions instead.
47+
*/
48+
if (qualityPercentage <= 13) {
49+
minQualityPercentage = 0
50+
maxQualityPercentage = 101
51+
attempts++
52+
/*
53+
* max.width -> 0.8x -> 0.64x -> 0.512x -> ~0.41x
54+
* e.g. 4000px -> 3200px -> 2560px -> 2048px -> ~1638px
55+
*/
56+
currentDimension = Math.floor(currentDimension * 0.8)
57+
continue
58+
}
59+
60+
const res = await renderImage(
61+
source.path,
62+
context => context.resize({width: w, height: h}),
63+
{
64+
compress: qualityPercentage / 100,
65+
format: SaveFormat.JPEG,
66+
},
67+
)
68+
69+
/*
70+
* Measure the written file rather than requesting base64, which builds a
71+
* base64 copy of every pass and sends it to JS (Android even encodes the
72+
* JPEG a second time to do it).
73+
*/
74+
const size = await getUriSize(res.uri)
75+
if (size <= maxBytes) {
76+
minQualityPercentage = qualityPercentage
77+
newDataUri = {
78+
path: await moveIfNecessary(res.uri),
79+
width: res.width,
80+
height: res.height,
81+
mime: 'image/jpeg',
82+
size,
83+
}
84+
} else {
85+
maxQualityPercentage = qualityPercentage
86+
}
87+
}
88+
89+
if (newDataUri) {
90+
return newDataUri
91+
}
92+
93+
throw new Error(`Unable to compress image`)
94+
}
95+
96+
function containImageRes(
97+
w: number,
98+
h: number,
99+
max: number,
100+
): [width: number, height: number] {
101+
let scale = 1
102+
103+
if (w > max || h > max) {
104+
scale = w > h ? max / w : max / h
105+
w = Math.floor(w * scale)
106+
h = Math.floor(h * scale)
107+
}
108+
109+
return [w, h]
110+
}

0 commit comments

Comments
 (0)