feat(intake): swap a photo for a cut-out, keeping the original (#281)

Adds backend/src/intake/backgroundRemoval.ts, the shared module the drafting worker and the admin endpoints both call so a cut-out obtained either way is undoable the same way.

cutoutPathFor is pure and writes a new file beside the original rather than overwriting it, which is what keeps the original restorable and makes the JPEG-to-PNG change free. removeImageBackground only points the row at the new file after it is already on disk, and is idempotent via the original_image_path IS NOT NULL check — load-bearing twice, since it also stops a second pass from recording the cut-out as the original and losing the real one for good. restoreImageOriginal swaps the paths back and deliberately leaves the cut-out file on disk.

Extends the Task 1 integration test file with a stub sidecar bound to an ephemeral port and covers the no-op-on-repeat case plus three failure modes (500, non-image body, unreachable), asserting the row is left untouched in every failure case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:34:56 -05:00
co-authored by Claude Opus 5
parent 61e9c239d3
commit 8d12cb2f2d
3 changed files with 348 additions and 0 deletions
@@ -0,0 +1,22 @@
import { cutoutPathFor } from '../../src/intake/backgroundRemoval';
describe('where a cut-out is written', () => {
// A new file rather than a rewrite of the original, which is what makes the
// original restorable at all — and what makes the JPEG-to-PNG change free,
// since no existing path is renamed.
it('sits beside the original with a -cutout suffix and a .png extension', () => {
expect(cutoutPathFor('/uploads/abc-123.jpg')).toBe('/uploads/abc-123-cutout.png');
});
// image_path is a contract, not a string: #103 made the stored value the
// path uploadUrl joins an origin onto.
it('keeps the /uploads/ prefix', () => {
expect(cutoutPathFor('/uploads/x.webp')).toBe('/uploads/x-cutout.png');
});
// The extension is replaced rather than appended, so a second pass cannot
// produce `.png.png`.
it('replaces the extension rather than appending to it', () => {
expect(cutoutPathFor('/uploads/x.png')).toBe('/uploads/x-cutout.png');
});
});