fix(uploads): the EXIF strip rotates every photo that was taken in portrait #300

Closed
opened 2026-09-04 12:57:58 -05:00 by bermudalamb · 0 comments
Owner

Photos arrive in the review queue rotated, in an orientation the sender never saw. We are doing it to them.

What happens

A phone camera does not rotate its sensor data. It writes the pixels in whatever orientation the sensor read them and sets an EXIF Orientation tag — commonly 6, meaning "rotate this 90° clockwise to display it". Every viewer honours that tag, which is why the photo looks upright to the person who took it and to the person who attached it.

reencodeInPlace in backend/src/imageProcessing.ts rebuilds the file from decoded pixels and deliberately drops all metadata, which is the whole point of #226 — a product photo that carries the coordinates it was taken at is a privacy problem, and re-encoding is what makes that impossible rather than a matter of remembering to delete the right tags.

But it never applies the orientation before discarding it:

sharp(filePath, mimetype === 'image/webp' ? { animated: true } : {}).resize({
  width: MAX_DIMENSION,
  height: MAX_DIMENSION,
  fit: 'inside',
  withoutEnlargement: true
})

There is no .rotate() in that file at all. So the sideways pixels are preserved exactly, and the one piece of information that explained them is thrown away. The result is a photo that is genuinely rotated, not merely tagged as such.

The fix, for photos not yet uploaded

sharp's .rotate() with no arguments applies the EXIF orientation and normalises it. It has to come before .resize(): the resize bounds are width and height, and for a portrait photo those are the wrong way round until the rotation has happened.

That is a one-line change, and it belongs with a test that a photo carrying Orientation: 6 comes out with its width and height swapped.

The part that cannot be fixed that way

Photos already uploaded are not recoverable automatically. Their EXIF is gone — we deleted it — so nothing records which way up they were meant to be. Re-running npm run backfill:images will not help, and needsProcessing would skip them anyway, since a file with no EXIF that is already within bounds is by that function's definition already in its final state.

For #281's cut-outs, item_images.original_image_path keeps the file the cut-out came from — but that original was itself re-encoded on upload, so it is mis-oriented too.

So every photo taken in portrait since #226 landed needs a person to look at it and turn it. That is a manual rotation control in the review queue and the inventory editor, and it is filed separately.

Worth deciding

Whether to keep the rotation control once this is fixed. It is needed either way for the existing photos, and there is an argument for keeping it afterwards: EXIF is not always right, some cameras write nothing, and a photograph taken at an angle on a table is not something orientation metadata can describe. But that is a different question from this bug.

How this got missed

#226's tests assert that metadata is gone and that dimensions are bounded. Neither notices that the pixels are now oriented differently from what the uploader saw, because a test fixture generated by sharp has no orientation tag to lose. It took a real phone photograph to show it.

Photos arrive in the review queue rotated, in an orientation the sender never saw. We are doing it to them. ## What happens A phone camera does not rotate its sensor data. It writes the pixels in whatever orientation the sensor read them and sets an EXIF `Orientation` tag — commonly 6, meaning "rotate this 90° clockwise to display it". Every viewer honours that tag, which is why the photo looks upright to the person who took it and to the person who attached it. `reencodeInPlace` in `backend/src/imageProcessing.ts` rebuilds the file from decoded pixels and deliberately drops all metadata, which is the whole point of #226 — a product photo that carries the coordinates it was taken at is a privacy problem, and re-encoding is what makes that impossible rather than a matter of remembering to delete the right tags. But it never applies the orientation before discarding it: ```ts sharp(filePath, mimetype === 'image/webp' ? { animated: true } : {}).resize({ width: MAX_DIMENSION, height: MAX_DIMENSION, fit: 'inside', withoutEnlargement: true }) ``` There is no `.rotate()` in that file at all. So the sideways pixels are preserved exactly, and the one piece of information that explained them is thrown away. The result is a photo that is genuinely rotated, not merely tagged as such. ## The fix, for photos not yet uploaded `sharp`'s `.rotate()` with **no arguments** applies the EXIF orientation and normalises it. It has to come **before** `.resize()`: the resize bounds are width and height, and for a portrait photo those are the wrong way round until the rotation has happened. That is a one-line change, and it belongs with a test that a photo carrying `Orientation: 6` comes out with its width and height swapped. ## The part that cannot be fixed that way **Photos already uploaded are not recoverable automatically.** Their EXIF is gone — we deleted it — so nothing records which way up they were meant to be. Re-running `npm run backfill:images` will not help, and `needsProcessing` would skip them anyway, since a file with no EXIF that is already within bounds is by that function's definition already in its final state. For #281's cut-outs, `item_images.original_image_path` keeps the file the cut-out came from — but that original was itself re-encoded on upload, so it is mis-oriented too. So every photo taken in portrait since #226 landed needs a person to look at it and turn it. That is a manual rotation control in the review queue and the inventory editor, and it is filed separately. ## Worth deciding Whether to keep the rotation control once this is fixed. It is needed either way for the existing photos, and there is an argument for keeping it afterwards: EXIF is not always right, some cameras write nothing, and a photograph taken at an angle on a table is not something orientation metadata can describe. But that is a different question from this bug. ## How this got missed #226's tests assert that metadata is gone and that dimensions are bounded. Neither notices that the pixels are now oriented differently from what the uploader saw, because a test fixture generated by sharp has no orientation tag to lose. It took a real phone photograph to show it.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#300