Skip to content

Compress images natively, decoding each image once - #12363

Merged
mozzius merged 3 commits into
mainfrom
samuel/app-3190-native-image-compressor
Nov 9, 2026
Merged

mozzius merged 3 commits into
mainfrom
samuel/app-3190-native-image-compressor

Conversation

@mozzius

@mozzius mozzius commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Builds on #12362 (compress at most two images at once), which has merged.

Summary

On iOS and Android, compressing an image for upload used to take one expo-image-manipulator pass for every quality it tried. Each pass decoded the full image from disk again, resized it, encoded a JPEG and wrote it to a file. JS then measured the file and picked the next quality. The binary search keeps going until the quality is pinned down to 1 point, so it takes up to 7 passes per image, and more when it has to shrink the image.

This adds a local module, expo-bluesky-image-compressor, that does the whole job in one native call:

  • It decodes once, downsampled straight to the target size: CGImageSourceCreateThumbnailAtIndex on iOS, inSampleSize plus one scaling draw on Android. The EXIF orientation is applied at the same time.
  • It tries qualities in memory and writes only the winner to disk.
  • It needs fewer tries. It starts at 90 and checks the floor (25) if that's too big. Then it interpolates on log(size) and stops once the result fits and uses 90% of the budget or more. You can't see the difference between quality 86 and 88, so there's no point pinning it down further.
  • It shrinks by 20% if nothing fits at the floor, up to 4 sizes, as before. It now shrinks from the image's actual size rather than maxDimension, so a 1000px image no longer wastes attempts on "sizes" of 4000, 3200 and 2560px.

The output has no metadata (so no location) and keeps the colour profile, so Display P3 photos stay P3. Transparency is flattened onto white on both platforms. On iOS the module runs on its own queue, because by default every Expo module's async functions share one serial queue and a long compression would hold up the others.

Web is unchanged. The manipulator loop moved to compressWithManipulator.ts, which web uses and native falls back to when the gate is off (see below). The cap of two concurrent compressions from #12362 stays, because a decoded 12 MP photo is still ~48 MB.

QualitySearch is duplicated in Swift and Kotlin, and the module README describes the strategy once. I kept the search in native code rather than driving it from JS, so each image costs one bridge call instead of one per quality tried.

Feature gate

The native compressor is behind image_uploads:native_compression:enable in GrowthBook, in the App project.

  • The gate exists but is switched off in production and has no rules, so merging this changes nothing until it's turned on.
  • While it's off, iOS and Android use the manipulator loop as before.
  • It only has an effect in builds that include the module.
  • The cap of two from Compress at most two images at once when posting #12362 applies either way.

Results

On device

I compressed 10 images with the post settings (4000px, 2 MB) on an iPhone 14 and a Galaxy A10 (2 GB RAM, 32-bit):

  • 8 grainy 12 MP JPEGs (4.6–5.7 MB each);
  • a 12 MP JPEG with EXIF orientation 6;
  • a 2048×2048 PNG with transparency.

"Manipulator loop" is what #12362 runs on native, now in compressToFit.web.ts. Both use a cap of two, as the app does. There are 3 runs of each on each phone.

iPhone 14

Two at a time Native (this PR) Manipulator loop (#12362)
Total for 10 images 1.7–1.8 s 7.5–7.6 s
Per photo (median) 337–364 ms 1.57–1.60 s
Transparent PNG 37–39 ms 230–241 ms
Peak memory above baseline +409–427 MB +751–764 MB
Runs completed 3/3 3/3

Galaxy A10

Two at a time Native (this PR) Manipulator loop (#12362)
Total for 10 images 13.9–14.2 s 65.2–65.6 s
Per photo (median) 2.7–2.8 s 11.6–12.5 s
Transparent PNG 329–338 ms 2.05–2.19 s
Peak memory above baseline +133–144 MB +172–182 MB
Runs completed 3/3 2/3

The failed manipulator run was killed by Android's low-memory killer at 928 MB, about 4 s in, before any image finished. On an earlier try with a development JS bundle, which starts from a higher baseline, native ×2 finished both runs and the manipulator loop was killed both times. So #12362 on its own leaves 2 GB Android phones right at the edge.

Without the cap:

iPhone 14 Galaxy A10
Native, all 10 at once 1.0 s, +1169 MB killed
Manipulator loop, all 10 at once (before #12362) 5.5 s, +1691 MB (2.6 GB in total) killed
Manipulator loop, one at a time 9.8 s, +609 MB 93.7 s, +246 MB

Native with all 10 at once still gets killed on the A10, so the cap from #12362 is still needed.

Output. I pulled the files off both phones and checked them:

  • They're 1.88–1.98 MB on iOS and 1.81–1.94 MB on Android, against 1.89–2.00 MB from the manipulator loop.
  • The EXIF-rotated photo comes out upright and matches the source as viewers display it.
  • No orientation tag remains, and every file is sRGB.
  • The transparent PNG is flattened onto white, pixel for pixel the same on both platforms.
  • The iOS files only carry the colour space and pixel size that ImageIO writes itself, plus an empty IPTC block.
  • The chosen qualities are 52–66 on iOS and 80–91 on Android for the same file sizes, because ImageIO's quality scale isn't libjpeg's.

Memory per image. Each compression on iOS briefly holds about 130 MB, measured with the same code on macOS. ImageIO decodes a 12 MP photo at full size and then scales it to 4000px, and the encoder keeps its own copy. That memory comes back after every image. Encoding straight from the file could lower it, but that would be a separate change.

Method. A temporary overlay in the app, not committed, downloaded the test images into the cache and timed each compression. The app ran a production JS bundle (expo start --no-dev --minify) and was relaunched before every run. Memory was sampled every 50 ms: phys_footprint on iOS, which is what the iOS out-of-memory killer (jetsam) counts, and RSS on Android. Treat the A10 memory figures as rough, because under pressure it swaps pages to compressed RAM (zram). The kills are the clearer signal there.

macOS harness

ImageCompressor.swift only depends on ImageIO, so I also compiled it on macOS and ran it on a few more inputs at the same settings:

Input Output Quality Encodes
12 MP photo-like JPEG with EXIF rotation and GPS 3000×4000, 1.91 MB, upright, no GPS 47 5
12 MP very noisy JPEG 4000×3000, 1.92 MB 25 2
6016×6016 HEIC (macOS wallpaper) 4000×4000, 1.95 MB 71 4
800×600 JPEG, also as a data URI 800×600, 0.50 MB 100 2
PNG with transparency transparent area is white 100 2

Test plan

This adds a native module, so it needs a new build and can't go out as an OTA update.

  • Gate off (the default): posting still works, through the manipulator loop.
  • Gate on (dev override): posting uses the module. The System Log shows Compressed image with a quality.
  • iOS: post a 10-image gallery of camera photos. Each image is under 2 MB, upright, and the colours look right.
  • Android: same, including a portrait photo with EXIF rotation and a screenshot with transparency.
  • Avatar and banner uploads, which use the 2000px / 1 MB settings.
  • Share an image into the app (share extension on iOS, share intent on Android) and post it.
  • Web: post a few images (code moved, no change in behaviour).
  • Benchmarked on an iPhone 14 and a Galaxy A10, and checked the output files from both (see Results).
  • Swift core compiled and run on macOS against JPEG, HEIC, PNG and data URI inputs.
  • ./gradlew :bsky-app-expo-bluesky-image-compressor:compileDebugKotlin
  • xcodebuild -scheme ExpoBlueskyImageCompressor (simulator)
  • pnpm typecheck, pnpm lint, pnpm test

Part of APP-3190.

🤖 Generated with Claude Code

@mozzius
mozzius added this pull request to stack #12364 November 9, 2026 09:52
@mozzius mozzius changed the title samuel/app 3190 native image compressor Compress images natively, decoding each image once Nov 9, 2026
@linear-code

linear-code Bot commented Nov 9, 2026

Copy link
Copy Markdown

APP-3190

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

The Pull Request introduced native fingerprint changes against the base commit.


Generated by PR labeler 🤖

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor
Size Old size New size Diff
Uncompressed 9.77 MB 9.77 MB +478 B (0.00%)
Gzip 2.86 MB 2.86 MB +130 B (0.00%)

@mozzius
mozzius marked this pull request as ready for review November 9, 2026 14:31
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

No actionable defects found.

Claude review, run

@mozzius mozzius mentioned this pull request Nov 9, 2026
2 of 6 tasks
Base automatically changed from samuel/app-3190-limit-image-compression-concurrency to main November 9, 2026 16:56
mozzius and others added 2 commits November 9, 2026 18:56
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

I didn't find any actionable defects in this PR.

Claude review, run

@estrattonbailey estrattonbailey left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's feature flag this and give it a shot

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@arcalinea
arcalinea temporarily deployed to samuel/app-3190-native-image-compressor - social-app PR #12363 November 9, 2026 18:45 — with Render Destroyed
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

No actionable defects found.

Claude review, run

@mozzius
mozzius merged commit 5a28cb6 into main Nov 9, 2026
30 checks passed

This branch was successfully deployed

No deployments
samuel/app-3190-native-image-compressor - social-app PR #12363 — 60fa1c6c Deployed Nov 9, 2026 by arcalinea
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants