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
+139
View File
@@ -0,0 +1,139 @@
import { promises as fs } from 'fs';
import path from 'path';
import { pool } from '../db';
import { typeForExtension } from '../uploadTypes';
import { isRembgConfigured, removeBackground } from './rembgClient';
/**
* Swapping a photo for a cut-out of itself, and swapping it back.
*
* One module rather than two, because the worker's path and the admin's path
* must produce identical results: a cut-out obtained either way has to be
* undoable the same way. A near-copy that drifted would mean a photo the
* Restore button could not restore.
*
* Nothing here deletes anything. The original file stays on disk and so does
* every cut-out ever made, because the submitter's photos are often the only
* copy of an item no longer in their hands — the same rule Discard follows in
* the review queue.
*/
interface ImageRow {
image_path: string;
original_image_path: string | null;
}
/**
* The path a cut-out of `imagePath` is written to.
*
* Pure, so the naming rule can be checked without a database or a sidecar.
* Always `.png` because the result is transparent, and the storefront's dark
* theme would show a flat white background as a bright box behind every
* product.
*/
export function cutoutPathFor(imagePath: string): string {
const base = path.basename(imagePath, path.extname(imagePath));
return `/uploads/${base}-cutout.png`;
}
function uploadsDir(): string {
return process.env.UPLOADS_DIR ?? '';
}
/**
* Replaces one image with a cut-out, keeping the original.
*
* Idempotent by way of the `original_image_path IS NOT NULL` check rather than
* a separate flag. That guard is load-bearing twice over: it makes a repeat
* call a no-op, and it stops a second pass from recording the *cut-out* as the
* original and losing the real one for good.
*
* Throws on every failure. Nothing is written to the row unless the file is
* already on disk, so a caller that catches and moves on leaves the photo
* exactly as it was.
*/
export async function removeImageBackground(imageId: number): Promise<void> {
const { rows } = await pool.query<ImageRow>(
`SELECT image_path, original_image_path FROM item_images WHERE id = $1`,
[imageId]
);
const row = rows[0];
if (!row) {
throw new Error(`no image ${imageId}`);
}
if (row.original_image_path !== null) {
// Already cut out. Doing it again would overwrite the record of where the
// real original went.
return;
}
// basename only: image_path is stored as '/uploads/<name>' and the directory
// it lives in is a server constant. Same rule readPhotos follows in the
// drafting worker.
const sourceName = path.basename(row.image_path);
const mediaType = typeForExtension(path.extname(sourceName));
if (mediaType === null) {
throw new Error(`cannot read ${sourceName}: unrecognised extension`);
}
const cutout = await removeBackground(
await fs.readFile(path.join(uploadsDir(), sourceName)),
mediaType
);
const cutoutPath = cutoutPathFor(row.image_path);
await fs.writeFile(path.join(uploadsDir(), path.basename(cutoutPath)), cutout);
// The row is pointed at the new file only after the file exists. The other
// order would leave a window in which the storefront rendered a broken image.
//
// `original_image_path = image_path` reads the pre-update value, which is how
// Postgres evaluates an UPDATE's right-hand side — so this records where the
// photo came from in the same statement that moves it.
await pool.query(
`UPDATE item_images
SET image_path = $2, original_image_path = image_path
WHERE id = $1 AND original_image_path IS NULL`,
[imageId, cutoutPath]
);
}
/**
* Puts the original back.
*
* The cut-out file is left on disk deliberately. Removing a background is
* exactly the operation that produces an occasional bad result on an unusual
* object, so somebody restoring one is quite likely to try again — and this
* module deletes nothing in any case.
*/
export async function restoreImageOriginal(imageId: number): Promise<void> {
const { rowCount } = await pool.query(
`UPDATE item_images
SET image_path = original_image_path, original_image_path = NULL
WHERE id = $1 AND original_image_path IS NOT NULL`,
[imageId]
);
if (rowCount === 0) {
throw new Error(`image ${imageId} has no original to restore`);
}
}
/**
* Every photo of one item, in order.
*
* Sequential rather than parallel: the sidecar is assumed to handle one
* request at a time, and the worker it runs inside is not in a hurry. A
* failure on one photo stops the rest, and the caller logs it — the item keeps
* whatever was already done, and nothing is left half-written.
*/
export async function removeBackgroundsForItem(itemId: number): Promise<void> {
if (!isRembgConfigured()) return;
const { rows } = await pool.query<{ id: number }>(
`SELECT id FROM item_images WHERE item_id = $1 ORDER BY sort_order`,
[itemId]
);
for (const row of rows) {
await removeImageBackground(row.id);
}
}