feat(intake): talk to the rembg sidecar, always naming u2net (#281)
Adds the client that will let the intake path remove backgrounds from submitted photos via the rembg sidecar over HTTP. isRembgConfigured() reports whether REMBG_URL is set (unconfigured is a normal, working state, not a failure), and removeBackground() posts a file to /api/remove and resolves with the PNG bytes it gets back, rejecting on every failure — unconfigured, unreachable, a non-2xx response, or a body that fails the same magic-byte PNG check the upload path already uses. The one hard rule: every request names model=u2net explicitly and this is never configurable. The sidecar's default model, reached simply by omitting the parameter, is bria-rmbg, which is licensed non-commercial — a licensing problem that a shop cannot silently ship, and one that would produce a perfectly good image with nothing in it to reveal the mistake. The test that posts against a real stub HTTP server and asserts model=u2net appears on the wire is the only thing guarding against that regressing. Wires REMBG_URL into both docker-compose.qa.yml and docker-compose.prod.yml as an optional variable, right after ANTHROPIC_WORKSPACE_ID, following the existing style in each file's environment block and header comment. It is deliberately left out of envValidation.ts's ALWAYS_REQUIRED — requiring it would make an environment with no sidecar refuse to boot, which is exactly the failure mode this feature is designed to avoid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { trimTrailingSlashes } from '../utils';
|
||||
import { SIGNATURE_BYTES, signatureMatches } from '../uploadTypes';
|
||||
|
||||
/**
|
||||
* The one place that talks to the background-removal sidecar.
|
||||
*
|
||||
* A sidecar rather than in-process inference: the application runs in a
|
||||
* container, and putting Python and ONNX into the image would add roughly
|
||||
* 300 MB to one already over a gigabyte. See
|
||||
* docs/ops/image-background-removal-stack.md for the measurements.
|
||||
*/
|
||||
|
||||
/**
|
||||
* NEVER remove this, and never make it configurable.
|
||||
*
|
||||
* The sidecar's default model is `bria-rmbg`, and BRIA's RMBG models are
|
||||
* licensed for NON-COMMERCIAL use. This is a shop. The default is reached by
|
||||
* simply not naming a model, so it is a licensing problem that happens
|
||||
* silently and produces a perfectly good image — there is nothing in the
|
||||
* output that could reveal it.
|
||||
*
|
||||
* `u2net` is Apache-2.0, and also ten times faster (1.1–2.3 s against
|
||||
* 14–20 s) at a sixth the size, so nothing is being traded away for it.
|
||||
*/
|
||||
const MODEL = 'u2net';
|
||||
|
||||
/**
|
||||
* Generous on purpose. The sidecar takes about 40 seconds to answer after a
|
||||
* container start and its first call per model downloads 168 MB, so a tight
|
||||
* timeout would turn an ordinary cold start into a failure. Nobody is waiting
|
||||
* on this in the worker's path, and an admin who clicked a button would rather
|
||||
* wait than be told it did not work.
|
||||
*/
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
/** The configured base URL, or null when there is none. */
|
||||
function baseUrl(): string | null {
|
||||
const raw = process.env.REMBG_URL;
|
||||
if (raw === undefined || raw.trim() === '') return null;
|
||||
return trimTrailingSlashes(raw.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the feature exists in this environment.
|
||||
*
|
||||
* Unconfigured is not a failure. It means the submitter sees no checkbox, the
|
||||
* admin sees no control and the worker skips the step — an unconfigured
|
||||
* environment must be a working one, which is the same rule
|
||||
* `getAnthropicClient` follows by returning null rather than throwing.
|
||||
*/
|
||||
export function isRembgConfigured(): boolean {
|
||||
return baseUrl() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cut-out, as PNG bytes.
|
||||
*
|
||||
* Rejects on every failure — unconfigured, unreachable, a non-2xx answer, or a
|
||||
* body that is not actually a PNG. Every caller catches, and none of them lets
|
||||
* the rejection reach a submission or a draft.
|
||||
*/
|
||||
export async function removeBackground(bytes: Buffer, mediaType: string): Promise<Buffer> {
|
||||
const base = baseUrl();
|
||||
if (base === null) {
|
||||
throw new Error('REMBG_URL is not set');
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
// A copy through Uint8Array because Buffer is not a BlobPart. The filename is
|
||||
// a constant: the sidecar does not use it, and passing the stored name would
|
||||
// put a value from the uploads volume into an outbound request for nothing.
|
||||
body.append('file', new Blob([new Uint8Array(bytes)], { type: mediaType }), 'photo');
|
||||
body.append('model', MODEL);
|
||||
|
||||
const res = await fetch(`${base}/api/remove`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`rembg answered ${res.status}`);
|
||||
}
|
||||
|
||||
const out = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
// The bytes, not the Content-Type header. A proxy error page served as
|
||||
// image/png would otherwise be written over a photograph — the same reason
|
||||
// uploads are checked by signature rather than by what the caller declared.
|
||||
if (!signatureMatches('image/png', out.subarray(0, SIGNATURE_BYTES))) {
|
||||
throw new Error('rembg response is not a PNG');
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user