fix: keep photo quality for storage, reduce only for AI/API requests - #58
Merged
Conversation
Photos are stored at capture quality, but sending those bytes verbatim to Claude is wasteful and past a point broken: base64 inflates a request by ~33%, Anthropic rejects images over 5MB, and it downscales anything above ~1.15MP internally anyway. Re-encoding to JPEG here also makes the hardcoded image/jpeg media type honest for HEIC and PNG gallery picks. The sizing math lives in commonMain so Android and iOS cannot drift, and so it is testable without Robolectric. Both actuals decode through an API that applies EXIF orientation, since re-encoding without it would drop the tag and hand the model a sideways photo. Nothing calls this yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Requests now carry a bounded copy of the photo instead of the stored bytes: 1568px for Claude analysis, 2048px for YouCam try-on, which generates an image and so loses visibly more to a small input. Storage is untouched. Downscaling belongs here rather than in ClaudeApiClient, which is a thin HTTP wrapper, or in the UI, which would have to remember to do it at every call site. Also normalises HEIC and PNG picks to real JPEG, which both clients already declared but never enforced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The editor decoded a ~2048px preview and cropped that, so cropping a 12MP photo permanently threw away most of its resolution. cropToJpeg now re-reads the original bytes and maps the selection against the full-size bitmap's own dimensions, which also removes any chance of a rect measured against one image being applied to another. Both decodes move to ImageDecoder, which applies EXIF orientation. BitmapFactory ignores it, so a camera photo showed sideways in the editor while the grid — which goes through Coil — showed it upright, and the crop landed on the wrong axis. Stored JPEG quality goes 90 -> 95; the reduction now happens on the request path instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TakePicturePreview returns the camera app's thumbnail extra — a few hundred pixels — so every photo taken in-app was stored at a fraction of the sensor's resolution. TakePicture writes the real frame into a URI we supply instead, which needs a FileProvider over a cache scratch file. The captured bytes are stored as-is, EXIF and all: no re-encode, so nothing is lost between the sensor and disk. That makes the two JPEG_QUALITY constants that re-compressed the thumbnail redundant. Photos are now large enough that decoding one for a preview matters, so the remaining main-thread full-resolution BitmapFactory decodes in AddItemSheet move to the shared, downsampling decodePreviewImage. androidx.core is declared explicitly rather than leaned on transitively, since FileProvider now comes from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
prepareForEditing downsampled to 2048px and the crop ran on that, so cropping a 12MP photo permanently threw away most of its resolution. The downsample is now preview-only: crop re-reads the original data and maps the selection against the full-size buffer's own dimensions. Camera captures — the one place the app encodes a stored photo itself, since UIImagePickerController hands back a UIImage rather than file data — go through PhotoEncoding at 0.95 instead of three drifting inline 0.9 literals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Photos now keep their capture resolution, which makes the read side matter. AsyncImage decodes the full bitmap regardless of the frame it renders into, so a wardrobe grid would decode ~48MB per visible cell. StoredPhotoImage goes through CGImageSourceCreateThumbnailAtIndex at the laid-out size instead, and honours the EXIF transform while it is there. It also drops the per-cell FileManager.fileExists check, which was a blocking stat on the main thread for every visible card — Android's ClothingPhoto already avoids that deliberately. A failed load falls back to the same placeholder the check was guarding. Background removal preserves resolution, so its re-encode is a storage one: 90 -> 95 on both platforms, matching the crop path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An array-of-one worked but read as a puzzle. Nothing renders the URI, so a change should not recompose — which is why it is not mutableStateOf. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #57.
The photo pipeline was degrading quality in exactly the wrong places: storage lost resolution it should have kept, while outbound API requests carried the full, unbounded bytes.
Storage was losing quality
ActivityResultContracts.TakePicturePreview, which returns the camera app's thumbnail extra (a few hundred pixels), not the real frame.AI/API requests had no reduction at all
ClaudeApiClientbase64-encoded the stored bytes verbatim. Base64 inflates ~33%, so a phone photo can exceed Anthropic's 5MB per-image limit and surface as a bare 400 — while anything above ~1.15MP is downscaled server-side anyway, so the extra pixels only bought image tokens.media_typewas hardcodedimage/jpegwhile gallery and share picks passed through raw, so an iOS HEIC pick was sent mislabelled.Photos are now stored at capture quality, and a single shared downscale step runs on the way out to any AI/API call. Re-encoding at that step also makes the declared
image/jpeghonest.Changes
ImageDownscaler(expect/actual) indata/source/image/, a sibling ofBackgroundRemover. The sizing math lives incommonMain(ImageScaling) so Android and iOS cannot drift, and so it is testable without Robolectric. Both actuals decode through an API that applies EXIF orientation —BitmapFactoryignores it, so re-encoding through that path would have dropped the tag and handed the model a sideways photo.ClaudeApiClient(a thin HTTP wrapper) or the UI: 1568px for Claude analysis, 2048px for YouCam try-on, which generates an image and so loses visibly more to a small input.TakePicturewriting into aFileProviderURI over a cache scratch file. The bytes are stored as-is, EXIF and all — no re-encode, which makes the twoJPEG_QUALITY = 90constants that recompressed the thumbnail redundant.PhotoEncodinginstead of three drifting inline0.9literals.decodePreviewImageinstead of main-thread full-resolutionBitmapFactorycalls; iOS getsStoredPhotoImage, which decodes viaCGImageSourceCreateThumbnailAtIndexat the laid-out size. That also drops the per-cellFileManager.fileExistsblockingstaton the main thread — something Android'sClothingPhotoalready avoids deliberately.androidx.core:core:1.19.0declared explicitly rather than leaned on transitively, sinceFileProvidernow comes from it. (core-ktxis an empty artifact now — the Kotlin extensions merged intocore.)Test plan
Verified on an emulator (API 36,
sdk_gphone64_x86_64), with the test items and pushed fixtures cleaned up afterwards.Camera full resolution
TakePicturePreview)TakePicture+ FileProvider)8× linear, 64× the pixels. The camera launched, wrote to our URI, and returned with no
SecurityExceptionorIllegalArgumentException.EXIF orientation — needed a dedicated fixture, because the emulator camera writes
orientation=1and so never exercises the rotation path. Fed a JPEG stored sideways on disk (1200×900) taggedorientation=6, with unmistakable landmarks (red band = top, blue = bottom, triangle pointing up):The old
BitmapFactorypath would have shown that landscape with the red band down the left edge.Crop geometry with EXIF in play — selected the top-left quadrant; the saved result was exactly that region (red band on top, black left border, the triangle's left edge in the bottom-right).
Crop preserves resolution — picked a 4608×3456 (16MP) image and cropped the full frame: stored 4608×3456. The old power-of-two subsample would have stored 2304×1728.
No crashes, no OOM, no exceptions in logcat across the 16MP decode → crop → save → grid render.
Automated —
./gradlew :shared:allTests(172 Android host tests, 0 failures, including 9 newImageScalingTestcases),:composeApp:assembleDebug,:shared:compileKotlinIosSimulatorArm64,./gradlew detekt.Not verified — needs a reviewer's machine
ImageDownscalerat runtime. No Claude API key was configured, so Try It sits behind the "Connect Claude" CTA andanalyzeProspectiveItemis never reached (analyzeAndTagis unreachable from the UI regardless). Its sizing math is unit-tested, but theImageDecoder/Bitmapglue in the Android actual has not executed. Worth a pass with a key set: pick a large photo in Try It and confirm the request succeeds where an oversized HEIC/PNG previously 400'd.StoredPhotoImageis the riskiest piece: a newGeometryReader+task(id:)view used at five call sites. Please buildiosApp/and check a wardrobe grid renders and scrolls, plus the crop and camera flows.Expected consequence — photos already stored stay as they are. Anything captured before this change remains a thumbnail; only new captures benefit.
Checklist
./gradlew detektpasses🤖 Generated with Claude Code