Skip to content

Compress images on web by decoding once - #12365

Merged
mozzius merged 4 commits into
samuel/app-3190-native-image-compressorfrom
samuel/app-3190-web-image-compressor
Nov 9, 2026
Merged

mozzius merged 4 commits into
samuel/app-3190-native-image-compressorfrom
samuel/app-3190-web-image-compressor

Conversation

@mozzius

@mozzius mozzius commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Stacked on #12363 (compress images natively, decoding each image once).

Summary

On web, images are compressed with the same expo-image-manipulator binary search that native used before #12363. Each image takes up to 7 passes, and each pass:

  1. Decodes the image into a full-size canvas.
  2. Resizes it with the manipulator's JavaScript resampler. That loops over every pixel on the main thread, even when the size doesn't change.
  3. Encodes the whole image as a PNG. The manipulator does this every time it renders.
  4. Encodes the JPEG, then fetches it back to measure it.

It also leaves a blob URL behind for every losing pass. And because JPEG has no transparency, transparent areas come out black.

This does what #12363 does on native, using the browser:

  • It decodes once with createImageBitmap, which applies the EXIF orientation.
    • It doesn't use an <img>, because iOS Safari draws large ones from a lower-resolution copy and the output came out blurry.
    • The output size now comes from the decoded image rather than the picker's metadata.
  • It draws once at the target size onto white. When shrinking by more than half, it halves the image in steps first, because Firefox turns fine detail into speckles when one draw shrinks that far.
  • It tries qualities in memory with canvas.toBlob, using a TypeScript port of QualitySearch, and creates a URL only for the winner.
  • It shrinks by 20% if nothing fits, redrawing from the decoded image, up to 4 sizes, the same as native.

QualitySearch now exists in Swift, Kotlin and TypeScript, and the comments in each point to the others. The TypeScript copy has the unit tests (qualitySearch.test.ts).

Feature gate

This is behind image_uploads:web_compression:enable in GrowthBook, in the App project.

  • The gate exists but is switched off in production and has no rules.
  • Until it's turned on, web keeps using the manipulator loop (compressWithManipulator.ts), including the black transparency.
  • It's separate from the native gate in Compress images natively, decoding each image once #12363, so web can roll out on a web deploy without waiting for an app build.

Results

A static test page ran this code and the current code side by side. The current code is the real expo-image-manipulator web implementation, bundled with small shims for expo. Both used the same 10 images as #12363:

  • 8 grainy 12 MP JPEGs;
  • a 12 MP JPEG with EXIF orientation 6;
  • a 2048×2048 PNG with transparency.

They ran two at a time, as the app does. "Frozen" adds up every gap longer than 50 ms in a 16 ms timer, so it measures how long the page couldn't respond.

Post settings (4000px, 2 MB), 10 images

This PR Current
Chrome, desktop 2.3 s (0.6 s frozen) 36–43 s (17–24 s frozen)
Firefox, desktop 1.4 s (0.7 s frozen) 38–50 s (31–41 s frozen)
Safari, iPhone 14 4.4 s (longest freeze 0.5 s) 17 min (longest freeze 34 s)
Chrome, Galaxy A10 44 s (longest freeze 1.7 s) 9.6 min (longest freeze 10.5 s)

Avatar settings (2000px, 1 MB), 10 images

This PR Current
Chrome, desktop 1.4 s 15.2 s
Firefox, desktop 0.8 s 24.4 s

The desktop runs were headless Chrome 157 and Firefox 159 on an M-series Mac. The current code's desktop ranges cover two runs.

Output quality. I compared each output with an ideal downscale of the source (Lanczos, in PIL). The numbers are the mean difference per channel, out of 255; lower is better.

This PR: Chrome / Firefox / iOS Safari Current
Posts (4000px, 12 MP sources) 2.69 / 2.77 / 2.69 2.71–2.75
Avatars (2000px) 1.88 / 2.03 / 1.89 1.55
Quarter size (1000px) 1.71 / 2.11 / 1.55 1.12
  • Posts: a 12 MP photo barely shrinks, so the output matches the current code.
  • Big shrinks: the manipulator's resampler, a proper area filter, stays a little sharper. Zoomed in 4×, this PR's output looks slightly softer on fine detail.
  • Without the halving step, Firefox scored 3.09 at quarter size, with visible speckling in foliage.
  • With an <img> instead of createImageBitmap, iOS Safari scored 3.36 at post size and looked blurry.

Every output is upright, including the EXIF-rotated photo. Transparency comes out white, matching native. The semi-transparent blue bar in the test image becomes (128,127,255), where it used to come out as black and dark navy (1,0,128).

Follow-up: Safari, and Chrome on low-end Android, still do most of this work on the main thread. Safari encodes JPEGs there, and the A10 also draws there. Moving it into a Worker with OffscreenCanvas would fix that. I left it out because Metro makes Workers awkward.

Test plan

  • Gate off (the default): posting on web still works, through the manipulator loop.
  • Gate on (dev override): post images on web in Chrome, Safari and Firefox, including a portrait photo with EXIF rotation and a transparent PNG.
  • Change an avatar and a banner on web.
  • Desktop Safari: I couldn't automate it, but iOS Safari ran the test page.
  • Ran the test page in Chrome and Firefox on desktop, Safari on an iPhone 14, and Chrome on a Galaxy A10.
  • 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 15:29
@linear-code

linear-code Bot commented Nov 9, 2026

Copy link
Copy Markdown

APP-3190

@mozzius mozzius changed the title samuel/app 3190 web image compressor Compress images on web by decoding once Nov 9, 2026
@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 +3.33 KB (0.03%)
Gzip 2.86 MB 2.86 MB +1.08 KB (0.04%)

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

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

I reviewed this PR and found no actionable defects.

Claude review, run

@mozzius
mozzius force-pushed the samuel/app-3190-web-image-compressor branch from 4e6cd53 to f0173a9 Compare November 9, 2026 16:56
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

I found no 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

mozzius and others added 3 commits November 9, 2026 20:44
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mozzius
mozzius force-pushed the samuel/app-3190-web-image-compressor branch from f0173a9 to 0b7a7a7 Compare November 9, 2026 18:45
Comment on lines +138 to +141
'image/jpeg',
quality / 100,
)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

non-blocking: SVG images can no longer be posted on web while the gate is on

The web composer accepts image/svg+xml (SUPPORTED_IMAGE_MIME_TYPES in src/view/com/composer/SelectMediaButton.tsx:98, plus the svg extension fallback). If the user doesn't crop, compressImage passes img.source (the SVG blob/data URI) straight to this function. createImageBitmap doesn't decode vector blobs. Chromium rejects an image/svg+xml Blob with "The source image could not be decoded", and Firefox rejects it too. So compressToFit throws and the post fails. The manipulator path this replaces loaded the source through an <img>, which renders SVGs, so this worked before.

One fix: when blob.type === 'image/svg+xml', rasterize it through an HTMLImageElement first. SVGs are small, so the iOS Safari downsampling problem doesn't apply. Another option is to send SVGs to compressWithManipulator.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in ff5fe16: SVGs are now drawn with an <img> first, at the size it gives them, then go through the same white-background draw and encode as everything else. Checked in Chrome and Firefox. Both SVGs (with and without an intrinsic size) compress again, and transparent areas now come out white rather than black. GIF and WebP are unchanged.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

One non-blocking finding, posted inline on src/lib/media/image/compressToFit.web.ts:138-141: with image_uploads:web_compression:enable on, SVG images can no longer be posted on web.

The web composer still accepts image/svg+xml. If the user doesn't crop, the SVG goes straight to the new decoder. That decoder uses createImageBitmap, which rejects SVG blobs, so compressToFit throws and the post fails. The expo-image-manipulator path this replaces loaded images through an <img>, which renders SVGs, so this worked before.

Claude review, run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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 456d588 into main Nov 9, 2026
33 checks passed
@mozzius
mozzius deleted the samuel/app-3190-web-image-compressor branch November 9, 2026 21:21
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.

2 participants