background-removal / js /ml-client.js
sdragly's picture
Segmentation model
f952998
// Client for the server-side background removal endpoint. In-browser
// inference via transformers.js proved unreliable on iOS (see
// memory/project_in_browser_ml_dead_end.md), so the model lives on the
// HF Docker Space that also hosts this static frontend. Same-origin —
// no CORS, no absolute URL to keep in sync.
import { tagOp } from './diag.js';
// In production the frontend is served by the same FastAPI process as
// the API (HF Space), so a relative URL is same-origin. In local dev
// the frontend is served by a plain static server with no backend —
// fall back to the HF Space absolute URL so dev against prod Just Works.
const SPACE_ORIGIN = 'https://sdragly-background-removal.hf.space';
const API_BASE =
typeof location !== 'undefined' &&
(location.hostname === 'localhost' || location.hostname === '127.0.0.1')
? SPACE_ORIGIN
: '';
// 90s covers the worst-case cold-start (~15-30s container boot) plus
// a slow image upload on a weak cellular connection. If we ever need
// more, something has gone wrong upstream and we should surface the
// error rather than hang the UI.
const REQUEST_TIMEOUT_MS = 90_000;
// Show a "waking up" hint if the response doesn't come back quickly.
// The typical warm response is <3s; anything slower is either a cold
// start or a slow network, and the user deserves to know.
const COLD_START_HINT_MS = 3_000;
/**
* Remove background from an image blob via the HF Space.
* @param {Blob} blob JPEG/PNG image
* @param {(p: {status: string, message: string, progress?: number}) => void} [onProgress]
* @returns {Promise<Blob>} PNG with transparent background
*/
export async function removeBackground(blob, onProgress) {
const progress = (message) => {
if (onProgress) onProgress({ status: 'processing', message });
};
tagOp('[remove-bg] uploading');
progress('Sending to server...');
// If the server is cold, the first POST will hang for ~15-30s. Flip
// the message after a few seconds so the user knows nothing's broken.
const coldTimer = setTimeout(() => {
tagOp('[remove-bg] waking server');
progress('Waking up server (first request is slow)...');
}, COLD_START_HINT_MS);
const abort = new AbortController();
const timeoutTimer = setTimeout(() => abort.abort(), REQUEST_TIMEOUT_MS);
try {
const form = new FormData();
// Use a stable filename — server only cares about content, but
// FormData requires *something* here.
form.append('file', blob, 'input.png');
const res = await fetch(`${API_BASE}/remove-bg`, {
method: 'POST',
body: form,
signal: abort.signal,
});
clearTimeout(coldTimer);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`Server returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`,
);
}
tagOp('[remove-bg] receiving');
progress('Receiving result...');
const result = await res.blob();
tagOp('[remove-bg] done');
if (onProgress) onProgress({ status: 'done', message: 'Background removed' });
return result;
} catch (err) {
clearTimeout(coldTimer);
if (err.name === 'AbortError') {
throw new Error(
`Server request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`,
);
}
throw err;
} finally {
clearTimeout(timeoutTimer);
}
}
/**
* Auto-segment a full drawing into distinct parts via the HF Space
* SlimSAM endpoint.
*
* @param {Blob} blob background-removed PNG
* @param {(p: {status?: string, message: string, progress?: number}) => void} [onProgress]
* @returns {Promise<Array>} segments in the shape segment-review.js expects
*/
export async function autoSegment(blob, onProgress) {
const progress = (message, pct) => {
if (onProgress) onProgress({ status: 'processing', message, progress: pct });
};
tagOp('[auto-segment] uploading');
progress('Looking for parts...', 5);
// Auto-segment is heavier than bg removal (~5s warm, more on cold
// start). Keep the same ceiling as remove-bg for now — if SAM ever
// drifts past this we should investigate rather than paper over.
const coldTimer = setTimeout(() => {
tagOp('[auto-segment] waking server');
progress('Waking up server (first request is slow)...', 10);
}, COLD_START_HINT_MS);
const abort = new AbortController();
const timeoutTimer = setTimeout(() => abort.abort(), REQUEST_TIMEOUT_MS);
try {
const form = new FormData();
form.append('file', blob, 'input.png');
const res = await fetch(`${API_BASE}/auto-segment`, {
method: 'POST',
body: form,
signal: abort.signal,
});
clearTimeout(coldTimer);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`Server returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`,
);
}
progress('Decoding parts...', 85);
const json = await res.json();
const rawSegments = Array.isArray(json.segments) ? json.segments : [];
tagOp(`[auto-segment] ${rawSegments.length} segments`);
// The server hands back base64 for both the mask bytes and the
// cropped PNG. Decode once here so segment-review.js can work with
// plain Uint8Array + Blob like the old in-browser path did.
const segments = rawSegments.map((s) => ({
id: s.id,
score: s.score,
area: s.area,
bbox: s.bbox,
maskW: s.maskW,
maskH: s.maskH,
mask: base64ToUint8Array(s.mask),
croppedBlob: base64ToBlob(s.croppedPng, 'image/png'),
}));
if (onProgress) onProgress({ status: 'done', message: 'Parts found', progress: 100 });
return segments;
} catch (err) {
clearTimeout(coldTimer);
if (err.name === 'AbortError') {
throw new Error(
`Server request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`,
);
}
throw err;
} finally {
clearTimeout(timeoutTimer);
}
}
function base64ToUint8Array(b64) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function base64ToBlob(b64, type) {
return new Blob([base64ToUint8Array(b64)], { type });
}