Feature/301 rotate photos #303

Merged
bermudalamb merged 7 commits from feature/301-rotate-photos into main 2026-09-04 14:17:13 -05:00
Owner
No description provided.
bermudalamb added 7 commits 2026-09-04 14:16:30 -05:00
The remedy for what #300 could only stop. Fixing the EXIF strip means new uploads arrive the way the sender saw them; it cannot repair what is already stored, because that metadata is gone and the originals kept for #281's cut-outs were re-encoded on the way in too. Every portrait photo since #226 needs a person to look at it and turn it.

Rotation rewrites the file rather than recording an angle. Storing an angle keeps the bytes pristine and makes undo exact, but it puts an obligation on every consumer — the storefront, both admin screens, the drafting worker's photo reader, and the rembg sidecar — and any one that forgets shows the photo sideways. The sidecar in particular is not ours to teach. Rewriting means nothing else in the system has to know rotation exists, and the cost is bounded: one rotation is a second generation at quality 82, which is why the control offers both directions rather than making somebody press one button three times to undo.

Per photo, and that is deliberately the opposite of what #293 decided for background removal. The reason there does not carry: three photos of a vase can each be wrong in a different direction, so turning them together would fix one and break two.

A cut-out and its original turn together. An image that has been through #281 has two files, and rotating only the displayed one would leave them disagreeing — Restore original would then silently un-rotate the photo, turning the undo of one feature into a regression of another.

The endpoints go on the item rather than the draft, so the inventory editor needs no backend work at all when it follows: an image belongs to an item whether or not a draft row exists, and the second screen is then the same call from a different place.

Two things pinned so they are not settled by a coin-flip while implementing. Left is anticlockwise and right is clockwise, which is sharp.rotate(-90) and sharp.rotate(90) — sharp reads a positive angle as clockwise, so the sign is the whole mapping and reversing it produces a control that works and does the opposite of its label. And the displayed image has to be forced to reload: rotation does not change image_path, so the img src is identical afterwards and the browser keeps what it has. express.static is mounted with no maxAge and would serve the new bytes on a page reload, but nothing in a session asks it to, so the src gets a cache-busting parameter after a successful turn.

One honest asymmetry recorded rather than engineered around: rotation is not idempotent the way background removal is, so a retry after a half-done failure turns the displayed file twice. That needs the disk to break between two writes, and the remedy is one press in the other direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tasks, each with its own test cycle: the file operation, the endpoints and the module behind them, then the control in the review queue.

Two things were probed rather than assumed while writing it, and both changed the design. sharp reads a positive angle as clockwise, so left is rotate(-90) and right is rotate(90) — and the direction test asserts a pixel rather than a dimension, because a rectangle's dimensions swap whichever way the turn goes and a reversed sign would pass every size assertion while shipping a control that does the opposite of its label. And a quarter turn of an animated WebP is refused by sharp itself, which is what makes passing the same animated flag reencodeInPlace passes the safe choice: omitting it would read the first frame alone and write a still back over someone's animation while reporting success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file half of the remedy for what #300 could only stop. Fixing the EXIF strip means new uploads arrive the way the sender saw them; it cannot repair what is already stored, because the tag that said which way up the pixels went is gone. Those photos need a person to look at each one and turn it.

Rewrites the pixels rather than recording an angle, because an angle obliges every consumer to honour it — the storefront, both admin screens, the drafting worker's photo reader, and the rembg sidecar — and any one that forgets shows the photo sideways. The sidecar is not ours to teach.

Left is anticlockwise and right is clockwise, which is rotate(-90) and rotate(90); sharp reads a positive angle as clockwise. The direction test asserts a pixel rather than a dimension, because dimensions swap whichever way the turn goes — a reversed sign would pass every size assertion and ship a control that does the opposite of its label.

The animated WebP case is the one that could destroy someone's file quietly. Reading such a file without the animated flag succeeds and hands back the first frame alone, so a rotation that omitted it would write a still back over the animation and report success. Passing the same flag reencodeInPlace passes makes sharp refuse instead — multi-page images turn only by 180° — which is the honest answer and leaves the file untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
libvips caches input file mappings in memory after a pipeline finishes. On Windows, this keeps an open handle on the input file, and the OS refuses to delete a file with an open handle. The animated-WebP test triggers a pipeline rejection (correctly refusing a multi-page rotation), so the mapping stays in cache and afterEach cannot remove the test directory.

Disabling the cache costs these tests nothing: each file is read exactly once during its test, so there is no reuse to cache. With caching disabled, Windows can delete the input files and afterEach succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two POST routes and the module behind them. They live on the item rather than on the draft, and that is the decision that makes the inventory editor free when it follows: an image belongs to an item whether or not a draft row exists, so the second screen to want this is the same call from a different place with no new backend at all.

A cut-out and its pristine original turn together. An image that has been through #281 has two files, and rotating only the displayed one would leave them disagreeing — Restore original would then quietly un-rotate the photo, so the undo of one feature becomes a regression of another.

204 rather than 200. Rotation changes no column: the paths are identical afterwards and only the bytes differ, so there is no row worth returning, which is the same reason deleting an image is already a 204.

Only "not on this item" is a 404, and it is indistinguishable from an absent id on purpose, because an image id is a serial and this endpoint should not confirm which ones exist. Everything else stays loud as a 500, and the file is untouched in every one of those cases — rotateInPlace renames over the original only once the new file has been written.

One asymmetry is recorded rather than engineered around: rotation is not idempotent the way background removal is, so a retry after a failure between the two files turns the displayed one twice. That needs the disk to break between two writes, and the remedy is one press in the other direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two icon buttons under every thumbnail, and the client for the item-scoped endpoints behind them. Icon-only with an aria-label rather than visible text, because three labelled buttons under a 120px thumbnail is more furniture than the photo — and a button with no text has no accessible name at all without one.

They are not gated on the background-removal flag. That flag is about the sidecar, and rotation has nothing to do with it: turning a photo is a local file operation that works in every environment, including one where REMBG_URL was never set.

The cache-busting src is the part most likely to have shipped broken. Rotation does not change image_path, so after a successful turn the src is byte-for-byte the string the browser already holds a copy for, and the photo would appear not to have moved. express.static is mounted with no maxAge and would serve the new bytes on a full page reload, but nothing in a session asks it to. A version held in component state is what makes the button visibly do something, and it needs no column and no server change, because the file's identity has not changed — only this page's need to see it again.

The client lives in its own module rather than in draftsApi, whose send() hardcodes the item-drafts prefix these routes deliberately do not use. The inventory editor imports this same module unchanged when it follows, which is the whole reason the endpoints went on the item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The turn handler only bumped the cache-busting version on the success path, so a failure between the backend's two writes (displayed file rotated, then pristine original rotated) left the admin looking at an error toast next to a photo whose src string had not changed and whose bytes the browser still served from cache — even though the displayed file on disk had already turned. The design's stated mitigation for this failure, that the admin can see the photo moved and press back once, depended on the browser re-requesting the file regardless of outcome. Moving setVersion(Date.now()) into the finally block, alongside setTurning(false), makes that re-request happen on both the success and failure paths, so the failure is now visible and recoverable the way the design intends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bermudalamb merged commit e437584d7e into main 2026-09-04 14:17:13 -05:00
bermudalamb deleted branch feature/301-rotate-photos 2026-09-04 14:17:32 -05:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#303