Add local background removal and mask tools

This commit is contained in:
achraf
2026-06-07 23:32:26 +02:00
parent f5364bf7a8
commit e29dc6fd6d
23 changed files with 2508 additions and 21 deletions

View File

@@ -1,16 +1,38 @@
import { Link } from "react-router-dom";
import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context";
import { runtimeConfigValue } from "../../core/config/runtime-config";
export function Footer() {
const { choice, openSettings } = useAnalyticsConsent();
const repoUrl = runtimeConfigValue(
"VITE_PLIMI_REPO_URL",
"https://github.com/achrafachkari/plimi"
);
return (
<footer className="border-t border-[var(--p-border)] px-6 py-6 md:px-14">
<div className="mx-auto flex max-w-[1200px] flex-col gap-4 text-sm text-[var(--p-muted)] sm:flex-row sm:items-center sm:justify-between">
<span>
© {new Date().getFullYear()} Achraf Achkari. Plimi processes tool inputs locally in your browser.
© {new Date().getFullYear()} Achraf Achkari. Plimi is free software under the{" "}
<a
href={repoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--p-muted)] underline hover:text-[var(--p-text)]"
>
AGPL-3.0
</a>
.
</span>
<nav className="flex flex-wrap items-center gap-x-5 gap-y-2">
<a
href={repoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--p-muted)] no-underline hover:text-[var(--p-text)]"
>
Source code
</a>
<Link
to="/privacy"
className="text-[var(--p-muted)] no-underline hover:text-[var(--p-text)]"

View File

@@ -5,6 +5,7 @@ import type {
UnknownPlimiPlugin,
} from "../../core/plugins/plugin-types";
import { describeToolPermissions } from "../../core/plugins/plugin-permissions";
import { normalizeToolOptions } from "../../core/plugins/plugin-validation";
import { ToolInputPanel } from "./ToolInputPanel";
import { ToolOptionsPanel } from "./ToolOptionsPanel";
import { ToolResultPanel } from "./ToolResultPanel";
@@ -87,6 +88,7 @@ export function ToolShell({
}) {
const [input, setInput] = useState<ToolInput>({});
const [options, setOptions] = useState<ToolOptionsValue>({});
const [runWarning, setRunWarning] = useState<string | null>(null);
const { run, cancel, result, isExecuting, progress, error } =
useToolExecution(plugin);
@@ -96,8 +98,22 @@ export function ToolShell({
const primaryExample = examples[0];
const handleRun = useCallback(async () => {
await run(input, options);
}, [input, options, run]);
const normalizedOptions = normalizeToolOptions(plugin.optionsSchema, options);
if (plugin.getRunWarning) {
const warning = await plugin.getRunWarning(input, normalizedOptions);
if (warning) {
setRunWarning(warning);
return;
}
}
await run(input, normalizedOptions);
}, [input, options, run, plugin]);
const handleConfirmRun = useCallback(async () => {
setRunWarning(null);
const normalizedOptions = normalizeToolOptions(plugin.optionsSchema, options);
await run(input, normalizedOptions);
}, [input, options, run, plugin]);
const handleApplyExample = useCallback(() => {
if (!primaryExample) return;
@@ -232,6 +248,48 @@ export function ToolShell({
</button>
</div>
</div>
{runWarning && (
<div
role="presentation"
onClick={() => setRunWarning(null)}
className="fixed inset-0 z-[60] flex items-center justify-center p-4 animate-plimi-fade"
style={{
background: "color-mix(in oklab, var(--p-bg) 60%, transparent)",
backdropFilter: "blur(4px)",
}}
>
<div
role="alertdialog"
aria-modal="true"
aria-label="Confirm download"
onClick={(e) => e.stopPropagation()}
className="w-full max-w-[420px] rounded-[18px] border-[1.5px] border-[var(--p-border)] bg-[var(--p-surface)] p-[22px] animate-plimi-slide"
style={{ boxShadow: "0 40px 80px -30px var(--p-shadow-soft)" }}
>
<div className="font-sans text-[16px] font-semibold tracking-tight text-[var(--p-text)]">
One-time download
</div>
<p className="mt-2 mb-5 font-sans text-[13px] leading-relaxed text-[var(--p-muted)]">
{runWarning}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setRunWarning(null)}
className="cursor-pointer rounded-[10px] bg-[var(--p-chip)] px-[16px] py-[9px] font-sans text-[13px] font-medium text-[var(--p-muted)]"
>
Cancel
</button>
<button
onClick={handleConfirmRun}
className="cursor-pointer rounded-[10px] bg-[var(--p-accent)] px-[16px] py-[9px] font-sans text-[13px] font-semibold text-[var(--p-accent-ink)]"
>
Download &amp; run
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -27,6 +27,7 @@ import { qrCodeGeneratorPlugin } from "../../tools/qr-code-generator";
import { passwordGeneratorPlugin } from "../../tools/password-generator";
import { fileChecksumVerifierPlugin } from "../../tools/file-checksum-verifier";
import { svgViewerPlugin } from "../../tools/svg-viewer";
import { backgroundRemoverPlugin } from "../../tools/background-remover";
function erasePlugin<TOptions>(plugin: PlimiPlugin<TOptions>): UnknownPlimiPlugin {
return plugin as unknown as UnknownPlimiPlugin;
@@ -61,6 +62,7 @@ export const pluginRegistry: UnknownPlimiPlugin[] = [
erasePlugin(passwordGeneratorPlugin),
erasePlugin(fileChecksumVerifierPlugin),
erasePlugin(svgViewerPlugin),
erasePlugin(backgroundRemoverPlugin),
];
export function getAllPlugins(): UnknownPlimiPlugin[] {

View File

@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ToolResult } from "../io/output-types";
import type { PlimiPlugin, ToolContext } from "./plugin-types";
import { disposePersistentWorker, runPlugin } from "./plugin-runner";
class FakeWorker extends EventTarget {
terminate = vi.fn();
postMessage = vi.fn((request: { id: string }) => {
queueMicrotask(() => {
const result: ToolResult = { type: "text", value: "done" };
this.dispatchEvent(
new MessageEvent("message", {
data: { type: "success", id: request.id, result },
})
);
});
});
}
function makePlugin(
id: string,
persistentWorker: boolean,
workerFactory: () => Worker
): PlimiPlugin<Record<string, never>> {
return {
manifest: {
id,
name: id,
description: id,
category: "image",
version: "1.0.0",
input: { type: "text" },
output: { type: "text" },
offlineReady: true,
},
capabilities: { worker: true, persistentWorker },
worker: workerFactory,
};
}
function makeContext(): ToolContext {
return {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
}
describe("runPlugin worker lifecycle", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("reuses a persistent worker between runs", async () => {
const worker = new FakeWorker();
const factory = vi.fn(() => worker as unknown as Worker);
const plugin = makePlugin("persistent-test", true, factory);
await runPlugin(plugin, { text: "one" }, {}, makeContext());
await runPlugin(plugin, { text: "two" }, {}, makeContext());
expect(factory).toHaveBeenCalledTimes(1);
expect(worker.postMessage).toHaveBeenCalledTimes(2);
expect(worker.terminate).not.toHaveBeenCalled();
expect(disposePersistentWorker("persistent-test")).toBe(true);
expect(worker.terminate).toHaveBeenCalledOnce();
await runPlugin(plugin, { text: "three" }, {}, makeContext());
expect(factory).toHaveBeenCalledTimes(2);
disposePersistentWorker("persistent-test");
});
it("terminates a non-persistent worker after success", async () => {
const worker = new FakeWorker();
const plugin = makePlugin(
"temporary-test",
false,
() => worker as unknown as Worker
);
await runPlugin(plugin, { text: "one" }, {}, makeContext());
expect(worker.terminate).toHaveBeenCalledOnce();
});
});

View File

@@ -4,6 +4,44 @@ import type { ToolResult } from "../io/output-types";
import type { ToolWorkerRequest, ToolWorkerResponse } from "./worker-protocol";
import { normalizeToolOptions, validateToolInput } from "./plugin-validation";
const persistentWorkers = new Map<string, Worker>();
export function disposePersistentWorker(pluginId: string): boolean {
const worker = persistentWorkers.get(pluginId);
if (!worker) return false;
persistentWorkers.delete(pluginId);
worker.terminate();
return true;
}
function createPluginWorker<TOptions>(plugin: PlimiPlugin<TOptions>): Worker {
if (!plugin.worker) {
throw new Error("Worker is not defined for this plugin");
}
if (!plugin.capabilities?.persistentWorker) {
return plugin.worker();
}
const pluginId = plugin.manifest.id;
const existing = persistentWorkers.get(pluginId);
if (existing) return existing;
const worker = plugin.worker();
persistentWorkers.set(pluginId, worker);
return worker;
}
function discardPluginWorker<TOptions>(
plugin: PlimiPlugin<TOptions>,
worker: Worker
): void {
if (persistentWorkers.get(plugin.manifest.id) === worker) {
persistentWorkers.delete(plugin.manifest.id);
}
worker.terminate();
}
export async function runPlugin<TOptions>(
plugin: PlimiPlugin<TOptions>,
input: ToolInput,
@@ -33,21 +71,28 @@ function runPluginInWorker<TOptions>(
context: ToolContext
): Promise<ToolResult> {
return new Promise((resolve, reject) => {
if (!plugin.worker) {
return reject(new Error("Worker is not defined for this plugin"));
let worker: Worker;
try {
worker = createPluginWorker(plugin);
} catch (error) {
reject(error);
return;
}
const worker = plugin.worker();
const jobId = Math.random().toString(36).substring(7);
// Handle cancellation
const cleanup = () => {
context.signal.removeEventListener("abort", onAbort);
worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
};
const onAbort = () => {
worker.terminate();
cleanup();
discardPluginWorker(plugin, worker);
reject(new DOMException("Operation cancelled by user.", "AbortError"));
};
context.signal.addEventListener("abort", onAbort);
worker.onmessage = (e: MessageEvent<ToolWorkerResponse>) => {
const onMessage = (e: MessageEvent<ToolWorkerResponse>) => {
const { type, id } = e.data;
if (id !== jobId) return;
@@ -55,22 +100,28 @@ function runPluginInWorker<TOptions>(
if (type === "progress") {
context.reportProgress(e.data.progress);
} else if (type === "success") {
context.signal.removeEventListener("abort", onAbort);
worker.terminate();
cleanup();
if (!plugin.capabilities?.persistentWorker) {
worker.terminate();
}
resolve(e.data.result);
} else if (type === "error") {
context.signal.removeEventListener("abort", onAbort);
worker.terminate();
cleanup();
discardPluginWorker(plugin, worker);
reject(new Error(e.data.error));
}
};
worker.onerror = (e) => {
context.signal.removeEventListener("abort", onAbort);
worker.terminate();
const onError = (e: ErrorEvent) => {
cleanup();
discardPluginWorker(plugin, worker);
reject(new Error("Worker error: " + e.message));
};
context.signal.addEventListener("abort", onAbort);
worker.addEventListener("message", onMessage);
worker.addEventListener("error", onError);
// Serialize input payload (can't send DOM elements or complex functions)
const request: ToolWorkerRequest<TOptions> = {
type: "run",

View File

@@ -80,6 +80,8 @@ export type ToolOptionsValue = Record<string, ToolOptionValue>;
export interface ToolCapabilities {
batch?: boolean;
worker?: boolean;
/** Keep the worker alive between runs so it can retain expensive state. */
persistentWorker?: boolean;
wasm?: boolean;
preview?: boolean;
streaming?: boolean;
@@ -138,6 +140,16 @@ export interface PlimiPlugin<TOptions = unknown> {
worker?: () => Worker;
/**
* Optional pre-run check. Return a message to surface a confirmation dialog
* before the tool runs (e.g. "this will download 44 MB"); return null to run
* immediately. Runs on the main thread, so it can inspect caches/storage.
*/
getRunWarning?: (
input: ToolInput,
options: TOptions
) => Promise<string | null> | string | null;
customUi?: React.LazyExoticComponent<
React.ComponentType<ToolUiProps<TOptions>>
>;
@@ -145,10 +157,14 @@ export interface PlimiPlugin<TOptions = unknown> {
export type UnknownPlimiPlugin = Omit<
PlimiPlugin<never>,
"run" | "customUi"
"run" | "customUi" | "getRunWarning"
> & {
run?: ToolRunner<ToolOptionsValue>;
examples?: ToolExample<ToolOptionsValue>[];
getRunWarning?: (
input: ToolInput,
options: ToolOptionsValue
) => Promise<string | null> | string | null;
customUi?: React.LazyExoticComponent<
React.ComponentType<ToolUiProps<ToolOptionsValue>>
>;

View File

@@ -134,6 +134,14 @@ export function PrivacyPage() {
settings. Plimi does not use analytics cookies, advertising cookies,
or marketing pixels.
</p>
<p className="m-0">
If you use automatic background removal, Plimi stores the selected AI
model and its ONNX runtime files in your browser&apos;s Cache Storage.
This avoids downloading the same large files for every run. The cache
contains application assets only, not your uploaded images, masks, or
generated results. You can delete it at any time with the
&quot;Clear models&quot; button in the Background Remover.
</p>
</Section>
<Section title="Recipients and transfers">

View File

@@ -0,0 +1,757 @@
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import type { ToolUiProps } from "../../core/plugins/plugin-types";
import { Button } from "../../components/ui/Button";
import { Card } from "../../components/ui/Card";
import { Dropzone } from "../../components/ui/Dropzone";
import { Select } from "../../components/ui/Select";
import { Slider } from "../../components/ui/Slider";
import { ToolProgress } from "../../components/tool/ToolProgress";
import { useToolExecution } from "../../components/tool/useToolExecution";
import { disposePersistentWorker } from "../../core/plugins/plugin-runner";
import { clearImglyCache } from "./imgly-cache";
import type { BackgroundRemoverOptions } from "./remove";
type EditMode = "automatic" | "select-area";
type EditorAction = "sample" | "remove" | "restore";
interface Point {
x: number;
y: number;
}
interface MaskStroke {
type: "stroke";
action: Exclude<EditorAction, "sample">;
points: Point[];
size: number;
}
interface SampleSelection {
type: "sample";
point: Point;
tolerance: number;
}
type MaskOperation = MaskStroke | SampleSelection;
function drawStrokePath(
context: CanvasRenderingContext2D,
stroke: MaskStroke,
width: number,
height: number
): void {
const points = stroke.points;
if (points.length === 0) return;
const scale = Math.min(width, height);
context.lineWidth = stroke.size * scale;
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
context.moveTo(points[0].x * width, points[0].y * height);
if (points.length === 1) {
context.lineTo(points[0].x * width + 0.01, points[0].y * height);
} else {
for (const point of points.slice(1)) {
context.lineTo(point.x * width, point.y * height);
}
}
context.stroke();
}
function applyStroke(
output: HTMLCanvasElement,
source: HTMLCanvasElement,
scratch: HTMLCanvasElement,
stroke: MaskStroke
): void {
const outputContext = output.getContext("2d");
const scratchContext = scratch.getContext("2d");
if (!outputContext || !scratchContext) return;
if (stroke.action === "remove") {
outputContext.save();
outputContext.globalCompositeOperation = "destination-out";
outputContext.strokeStyle = "#000";
drawStrokePath(outputContext, stroke, output.width, output.height);
outputContext.restore();
return;
}
scratchContext.clearRect(0, 0, scratch.width, scratch.height);
scratchContext.globalCompositeOperation = "source-over";
scratchContext.drawImage(source, 0, 0);
scratchContext.globalCompositeOperation = "destination-in";
scratchContext.strokeStyle = "#000";
drawStrokePath(scratchContext, stroke, scratch.width, scratch.height);
scratchContext.globalCompositeOperation = "source-over";
outputContext.drawImage(scratch, 0, 0);
}
function applySampleSelection(
output: HTMLCanvasElement,
source: HTMLCanvasElement,
selection: SampleSelection
): void {
const outputContext = output.getContext("2d", { willReadFrequently: true });
const sourceContext = source.getContext("2d", { willReadFrequently: true });
if (!outputContext || !sourceContext) return;
const { width, height } = output;
const sourceData = sourceContext.getImageData(0, 0, width, height);
const outputData = outputContext.getImageData(0, 0, width, height);
const startX = Math.min(width - 1, Math.floor(selection.point.x * width));
const startY = Math.min(height - 1, Math.floor(selection.point.y * height));
const startIndex = startY * width + startX;
const startOffset = startIndex * 4;
const targetR = sourceData.data[startOffset];
const targetG = sourceData.data[startOffset + 1];
const targetB = sourceData.data[startOffset + 2];
const threshold = (selection.tolerance / 100) * Math.sqrt(3 * 255 * 255);
const thresholdSquared = threshold * threshold;
const visited = new Uint8Array(width * height);
const stack = new Uint32Array(width * height);
let stackSize = 0;
visited[startIndex] = 1;
stack[stackSize++] = startIndex;
while (stackSize > 0) {
const index = stack[--stackSize];
const offset = index * 4;
const deltaR = sourceData.data[offset] - targetR;
const deltaG = sourceData.data[offset + 1] - targetG;
const deltaB = sourceData.data[offset + 2] - targetB;
const distanceSquared =
deltaR * deltaR + deltaG * deltaG + deltaB * deltaB;
if (distanceSquared > thresholdSquared) continue;
outputData.data[offset + 3] = 0;
const x = index % width;
const y = Math.floor(index / width);
const neighbors = [
x > 0 ? index - 1 : -1,
x + 1 < width ? index + 1 : -1,
y > 0 ? index - width : -1,
y + 1 < height ? index + width : -1,
];
for (const neighbor of neighbors) {
if (neighbor >= 0 && !visited[neighbor]) {
visited[neighbor] = 1;
stack[stackSize++] = neighbor;
}
}
}
outputContext.putImageData(outputData, 0, 0);
}
function downloadBlob(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
export default function BackgroundRemoverUi({
plugin,
}: ToolUiProps<BackgroundRemoverOptions>) {
const [files, setFiles] = useState<File[]>([]);
const [sourceUrl, setSourceUrl] = useState("");
const [resultUrl, setResultUrl] = useState("");
const [editMode, setEditMode] = useState<EditMode>("automatic");
const [quality, setQuality] =
useState<BackgroundRemoverOptions["quality"]>("small");
const [useGpu, setUseGpu] = useState(true);
const [editorAction, setEditorAction] = useState<EditorAction>("sample");
const [tolerance, setTolerance] = useState(14);
const [brushSize, setBrushSize] = useState(4);
const [operations, setOperations] = useState<MaskOperation[]>([]);
const [isDrawing, setIsDrawing] = useState(false);
const [warning, setWarning] = useState<string | null>(null);
const [pendingOptions, setPendingOptions] =
useState<BackgroundRemoverOptions | null>(null);
const [cacheMessage, setCacheMessage] = useState("");
const [isClearingCache, setIsClearingCache] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const sourceCanvasRef = useRef<HTMLCanvasElement | null>(null);
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
const scratchCanvasRef = useRef<HTMLCanvasElement | null>(null);
const activeStrokeRef = useRef<MaskStroke | null>(null);
const resultUrlRef = useRef("");
const { run, result, isExecuting, progress, error, reset } =
useToolExecution(plugin);
const resultFile =
result?.type === "files" && result.files.length > 0
? result.files[0]
: null;
useEffect(() => {
return () => {
if (sourceUrl) URL.revokeObjectURL(sourceUrl);
};
}, [sourceUrl]);
useEffect(
() => () => {
if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current);
},
[]
);
const renderOperations = useCallback((nextOperations: MaskOperation[]) => {
const output = canvasRef.current;
const base = baseCanvasRef.current;
const source = sourceCanvasRef.current;
const scratch = scratchCanvasRef.current;
if (!output || !base || !source || !scratch) return;
const context = output.getContext("2d");
if (!context) return;
context.clearRect(0, 0, output.width, output.height);
context.drawImage(base, 0, 0);
for (const operation of nextOperations) {
if (operation.type === "sample") {
applySampleSelection(output, source, operation);
} else {
applyStroke(output, source, scratch, operation);
}
}
}, []);
useEffect(() => {
if (editMode !== "select-area" || files.length === 0) return;
let cancelled = false;
const initialize = async () => {
const sourceBitmap = await createImageBitmap(files[0]);
if (cancelled) {
sourceBitmap.close();
return;
}
const width = sourceBitmap.width;
const height = sourceBitmap.height;
const sourceCanvas = document.createElement("canvas");
const baseCanvas = document.createElement("canvas");
const scratchCanvas = document.createElement("canvas");
for (const canvas of [sourceCanvas, baseCanvas, scratchCanvas]) {
canvas.width = width;
canvas.height = height;
}
sourceCanvas.getContext("2d")?.drawImage(sourceBitmap, 0, 0, width, height);
baseCanvas.getContext("2d")?.drawImage(sourceBitmap, 0, 0, width, height);
sourceCanvasRef.current = sourceCanvas;
baseCanvasRef.current = baseCanvas;
scratchCanvasRef.current = scratchCanvas;
const output = canvasRef.current;
if (output) {
output.width = width;
output.height = height;
output.getContext("2d")?.drawImage(baseCanvas, 0, 0);
}
sourceBitmap.close();
setOperations([]);
};
void initialize();
return () => {
cancelled = true;
};
}, [editMode, files]);
const clearResult = useCallback(() => {
if (resultUrlRef.current) {
URL.revokeObjectURL(resultUrlRef.current);
resultUrlRef.current = "";
}
setResultUrl("");
setOperations([]);
activeStrokeRef.current = null;
reset();
}, [reset]);
const handleFilesDrop = useCallback(
(nextFiles: File[]) => {
setFiles(nextFiles);
setSourceUrl(
nextFiles.length > 0 ? URL.createObjectURL(nextFiles[0]) : ""
);
clearResult();
},
[clearResult]
);
const execute = useCallback(
async (options: BackgroundRemoverOptions) => {
if (files.length === 0) return;
const nextResult = await run({ files }, options);
if (nextResult?.type === "files" && nextResult.files.length > 0) {
if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current);
const url = URL.createObjectURL(nextResult.files[0].blob);
resultUrlRef.current = url;
setResultUrl(url);
}
},
[files, run]
);
const handleRun = useCallback(async () => {
const options = { quality, useGpu };
if (plugin.getRunWarning) {
const nextWarning = await plugin.getRunWarning({ files }, options);
if (nextWarning) {
setPendingOptions(options);
setWarning(nextWarning);
return;
}
}
await execute(options);
}, [execute, files, plugin, quality, useGpu]);
const pointFromEvent = (
event: ReactPointerEvent<HTMLCanvasElement>
): Point => {
const rect = event.currentTarget.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)),
};
};
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!canvasRef.current || !sourceCanvasRef.current || !scratchCanvasRef.current) {
return;
}
event.preventDefault();
if (editorAction === "sample") {
const selection: SampleSelection = {
type: "sample",
point: pointFromEvent(event),
tolerance,
};
applySampleSelection(
canvasRef.current,
sourceCanvasRef.current,
selection
);
setOperations((current) => [...current, selection]);
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
const stroke: MaskStroke = {
type: "stroke",
action: editorAction,
points: [pointFromEvent(event)],
size: brushSize / 100,
};
activeStrokeRef.current = stroke;
setIsDrawing(true);
applyStroke(
canvasRef.current,
sourceCanvasRef.current,
scratchCanvasRef.current,
stroke
);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const active = activeStrokeRef.current;
const output = canvasRef.current;
const source = sourceCanvasRef.current;
const scratch = scratchCanvasRef.current;
if (!isDrawing || !active || !output || !source || !scratch) return;
const point = pointFromEvent(event);
const segment: MaskStroke = {
...active,
points: [active.points.at(-1) ?? point, point],
};
active.points.push(point);
applyStroke(output, source, scratch, segment);
};
const finishStroke = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const active = activeStrokeRef.current;
if (!active) return;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
activeStrokeRef.current = null;
setIsDrawing(false);
setOperations((current) => [...current, active]);
};
const handleUndo = () => {
setOperations((current) => {
const next = current.slice(0, -1);
renderOperations(next);
return next;
});
};
const handleResetMask = () => {
setOperations([]);
renderOperations([]);
};
const handleDownloadEdited = async () => {
const canvas = canvasRef.current;
if (!canvas || files.length === 0) return;
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png")
);
if (blob) {
const name =
files[0].name.replace(/\.[^./\\]+$/, "") + "_manual-no-bg.png";
downloadBlob(blob, name);
}
};
const handleModeChange = (nextMode: EditMode) => {
setEditMode(nextMode);
clearResult();
};
const handleClearModels = async () => {
setIsClearingCache(true);
setCacheMessage("");
try {
const deleted = await clearImglyCache();
const released = disposePersistentWorker(plugin.manifest.id);
setCacheMessage(
deleted || released
? "Cached models cleared. Automatic mode will download them again."
: "No cached models were found."
);
} catch {
setCacheMessage("Could not clear cached models.");
} finally {
setIsClearingCache(false);
}
};
return (
<div className="mx-auto max-w-6xl p-[18px] md:p-[28px]">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="space-y-6">
{files.length === 0 ? (
<Card title="Upload Image">
<Dropzone
accept="image/jpeg,image/png,image/webp"
multiple={false}
maxSizeMb={25}
onFilesDrop={handleFilesDrop}
/>
</Card>
) : (
<Card
title={
editMode === "select-area"
? "Select Background"
: resultFile
? "Result"
: "Source Image"
}
>
<div
className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-xl border border-[var(--p-border)]"
style={{
backgroundColor: "#fff",
backgroundImage:
"linear-gradient(45deg,#ddd 25%,transparent 25%),linear-gradient(-45deg,#ddd 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ddd 75%),linear-gradient(-45deg,transparent 75%,#ddd 75%)",
backgroundPosition: "0 0,0 8px,8px -8px,-8px 0",
backgroundSize: "16px 16px",
}}
>
{editMode === "select-area" ? (
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishStroke}
onPointerCancel={finishStroke}
className="max-h-[65vh] max-w-full cursor-crosshair object-contain"
style={{ touchAction: "none" }}
aria-label="Background mask editor"
/>
) : (
<img
src={resultUrl || sourceUrl}
alt={resultFile ? "Background removed" : "Source"}
className="max-h-[65vh] max-w-full object-contain"
/>
)}
</div>
{editMode === "select-area" && (
<p className="mb-0 mt-3 text-sm text-[var(--p-muted)]">
Choose Select background and click a representative point.
Only the connected region with a similar color is removed.
</p>
)}
</Card>
)}
{isExecuting && (
<ToolProgress
percentage={progress.percentage ?? 0}
message={progress.message}
/>
)}
{error && (
<Card className="border-red-300">
<p className="m-0 text-sm text-red-700">{error}</p>
</Card>
)}
</div>
<div className="space-y-6">
<Card title="Mode">
<div className="grid grid-cols-2 gap-2">
<Button
variant={editMode === "automatic" ? "primary" : "secondary"}
onClick={() => handleModeChange("automatic")}
disabled={isExecuting}
>
Automatic
</Button>
<Button
variant={editMode === "select-area" ? "primary" : "secondary"}
onClick={() => handleModeChange("select-area")}
disabled={isExecuting}
>
Select area
</Button>
</div>
<p className="mb-0 mt-3 text-xs leading-relaxed text-[var(--p-muted)]">
Select area uses a sampled background color and does not run the
AI model.
</p>
</Card>
<Card title="Settings">
<div className="space-y-5">
{editMode === "automatic" ? (
<>
<Select
label="Model"
value={quality}
disabled={isExecuting}
onChange={(event) => {
setQuality(
event.target.value as BackgroundRemoverOptions["quality"]
);
clearResult();
}}
options={[
{ label: "Smallest download (quantized)", value: "small" },
{ label: "Balanced (FP16)", value: "medium" },
{ label: "Best quality (FP32)", value: "large" },
]}
/>
<label className="flex items-center gap-2 text-sm text-[var(--p-text)]">
<input
type="checkbox"
checked={useGpu}
disabled={isExecuting}
onChange={(event) => {
setUseGpu(event.target.checked);
clearResult();
}}
className="h-4 w-4 accent-[var(--p-accent)]"
/>
Use GPU acceleration
</label>
</>
) : (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-2">
<Button
variant={editorAction === "sample" ? "primary" : "secondary"}
onClick={() => setEditorAction("sample")}
>
Select
</Button>
<Button
variant={editorAction === "remove" ? "primary" : "secondary"}
onClick={() => setEditorAction("remove")}
>
Remove
</Button>
<Button
variant={editorAction === "restore" ? "primary" : "secondary"}
onClick={() => setEditorAction("restore")}
>
Restore
</Button>
</div>
{editorAction === "sample" ? (
<Slider
label={`Color tolerance: ${tolerance}%`}
min={1}
max={40}
value={tolerance}
onChange={(event) =>
setTolerance(Number(event.target.value))
}
/>
) : (
<Slider
label={`Brush size: ${brushSize}%`}
min={1}
max={20}
value={brushSize}
onChange={(event) =>
setBrushSize(Number(event.target.value))
}
/>
)}
<div className="grid grid-cols-2 gap-2">
<Button
variant="secondary"
disabled={operations.length === 0}
onClick={handleUndo}
>
Undo
</Button>
<Button
variant="secondary"
disabled={operations.length === 0}
onClick={handleResetMask}
>
Reset
</Button>
</div>
</div>
)}
<div className="space-y-2 border-t border-[var(--p-border)] pt-4">
{editMode === "select-area" ? (
<Button
className="w-full"
disabled={files.length === 0}
onClick={handleDownloadEdited}
>
Download selected result
</Button>
) : !resultFile ? (
<Button
className="w-full"
disabled={files.length === 0 || isExecuting}
onClick={handleRun}
>
{isExecuting ? "Processing..." : "Remove background"}
</Button>
) : (
<Button
className="w-full"
onClick={() => downloadBlob(resultFile.blob, resultFile.name)}
>
Download PNG
</Button>
)}
{files.length > 0 && (
<Button
variant="secondary"
className="w-full"
disabled={isExecuting}
onClick={() => {
setFiles([]);
setSourceUrl("");
clearResult();
}}
>
Choose another image
</Button>
)}
</div>
<div className="space-y-2 border-t border-[var(--p-border)] pt-4">
<Button
variant="danger"
className="w-full"
disabled={isExecuting || isClearingCache}
onClick={handleClearModels}
>
{isClearingCache ? "Clearing..." : "Clear models"}
</Button>
<p className="m-0 text-xs leading-relaxed text-[var(--p-muted)]">
Removes downloaded AI models and ONNX runtime files from this
browser.
</p>
{cacheMessage && (
<p
role="status"
className="m-0 text-xs font-medium text-[var(--p-text)]"
>
{cacheMessage}
</p>
)}
</div>
</div>
</Card>
</div>
</div>
{warning && pendingOptions && (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/50 p-4">
<div
role="alertdialog"
aria-modal="true"
className="w-full max-w-md rounded-2xl border border-[var(--p-border)] bg-[var(--p-surface)] p-6"
>
<h2 className="m-0 text-lg font-semibold text-[var(--p-text)]">
One-time download
</h2>
<p className="my-4 text-sm leading-relaxed text-[var(--p-muted)]">
{warning}
</p>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => {
setWarning(null);
setPendingOptions(null);
}}
>
Cancel
</Button>
<Button
onClick={() => {
const options = pendingOptions;
setWarning(null);
setPendingOptions(null);
void execute(options);
}}
>
Download &amp; run
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,91 @@
/**
* Persistent caching for the self-hosted @imgly model + wasm assets.
*
* @imgly/background-removal fetches its assets with plain `fetch`, so whether
* they persist between runs is at the mercy of the server's Cache-Control
* headers. The Vite dev server (and some hosts) send `no-store`/`no-cache`,
* which makes the model re-download on every run.
*
* To make caching deterministic and host-independent, we intercept same-origin
* `/imgly/` requests and back them with the Cache Storage API, which we control
* explicitly. CacheStorage is shared across the origin, so an asset cached by
* the worker (during a run) is visible to the main thread (for the pre-run
* download estimate) and vice-versa.
*/
export const IMGLY_CACHE_NAME = "plimi-imgly-v1";
type FetchInput = Parameters<typeof fetch>[0];
function urlOf(input: FetchInput): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}
function isImglyUrl(url: string): boolean {
return url.includes("/imgly/");
}
let installed = false;
/** Route `/imgly/` fetches through Cache Storage. Safe to call multiple times. */
export function installImglyCache(): void {
if (installed) return;
installed = true;
const scope = self as unknown as {
fetch: typeof fetch;
caches?: CacheStorage;
};
if (!scope.caches || typeof scope.fetch !== "function") return;
const original = scope.fetch.bind(scope);
scope.fetch = async (input: FetchInput, init?: RequestInit) => {
try {
const url = urlOf(input);
if (isImglyUrl(url)) {
const cache = await scope.caches!.open(IMGLY_CACHE_NAME);
const hit = await cache.match(url);
if (hit) return hit;
const res = await original(input, init);
if (res.ok) {
try {
await cache.put(url, res.clone());
} catch {
// Quota or opaque response — fall through with the live response.
}
}
return res;
}
} catch {
// Any cache failure: fall back to the network below.
}
return original(input, init);
};
}
/** Whether an `/imgly/` asset is already stored locally (no download needed). */
export async function isImglyAssetCached(url: string): Promise<boolean> {
const scope = self as unknown as { caches?: CacheStorage };
if (scope.caches) {
try {
const cache = await scope.caches.open(IMGLY_CACHE_NAME);
return Boolean(await cache.match(url));
} catch {
// fall through to HTTP-cache probe
}
}
try {
const res = await fetch(url, { cache: "only-if-cached", mode: "same-origin" });
return res.ok;
} catch {
return false;
}
}
/** Delete all persisted IMG.LY model and runtime assets for this origin. */
export async function clearImglyCache(): Promise<boolean> {
const scope = globalThis as typeof globalThis & { caches?: CacheStorage };
if (!scope.caches) return false;
return scope.caches.delete(IMGLY_CACHE_NAME);
}

View File

@@ -0,0 +1,86 @@
import { lazy } from "react";
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runBackgroundRemover } from "./run";
import {
estimateUncachedDownloadBytes,
type BackgroundRemoverOptions,
} from "./remove";
import BgWorker from "./worker?worker";
export type { BackgroundRemoverOptions } from "./remove";
const BackgroundRemoverUi = lazy(() => import("./BackgroundRemoverUi"));
function formatMb(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
}
export const backgroundRemoverPlugin: PlimiPlugin<BackgroundRemoverOptions> = {
manifest: {
id: "background-remover",
name: "Background Remover",
description:
"Remove the background from any image with AI, entirely in your browser. Your photo is never uploaded.",
category: "image",
version: "1.0.0",
tags: ["image", "background", "remove", "ai", "transparent", "cutout"],
input: {
type: "files",
accept: ["image/png", "image/jpeg", "image/webp"],
multiple: false,
},
output: { type: "files" },
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "quality",
label: "Quality",
defaultValue: "small",
options: [
{ label: "Smallest download (quantized)", value: "small" },
{ label: "Balanced (FP16)", value: "medium" },
{ label: "Best quality (FP32)", value: "large" },
],
},
{
type: "boolean",
key: "useGpu",
label: "Use GPU (WebGPU) when available",
defaultValue: true,
},
],
},
capabilities: {
cancelable: false,
worker: true,
persistentWorker: true,
wasm: true,
customUi: true,
preview: true,
},
getRunWarning: async (_input, options) => {
const bytes = await estimateUncachedDownloadBytes(options, self.location.origin);
if (bytes <= 0) return null;
const label =
options.quality === "small"
? "Smallest download"
: options.quality === "medium"
? "Balanced"
: "Best quality";
return (
`Running the “${label}” model needs a one-time download of about ` +
`${formatMb(bytes)} to your device. It is cached afterwards, so future ` +
`runs are instant and fully offline. Continue?`
);
},
run: runBackgroundRemover,
worker: () => new BgWorker(),
customUi: BackgroundRemoverUi,
};

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { createBackgroundRemovalProgress } from "./remove";
describe("background remover progress", () => {
it("aggregates resource downloads without resetting the percentage", () => {
const report = vi.fn();
const progress = createBackgroundRemovalProgress(
new Map([
["/models/isnet_quint8", 80],
["/onnxruntime-web/ort.wasm", 20],
]),
report
);
progress("fetch:/models/isnet_quint8", 40, 80);
progress("fetch:/models/isnet_quint8", 80, 80);
progress("fetch:/onnxruntime-web/ort.wasm", 10, 20);
progress("compute:decode", 0, 4);
progress("compute:inference", 1, 4);
progress("compute:encode", 4, 4);
const percentages = report.mock.calls.map(
([value]) => value.percentage as number
);
expect(percentages).toEqual([...percentages].sort((a, b) => a - b));
expect(percentages.at(-1)).toBe(100);
});
it("uses the full progress range when the model session is already loaded", () => {
const report = vi.fn();
const progress = createBackgroundRemovalProgress(new Map(), report);
progress("compute:decode", 0, 4);
progress("compute:inference", 1, 4);
progress("compute:mask", 2, 4);
progress("compute:encode", 4, 4);
expect(report.mock.calls.map(([value]) => value.percentage)).toEqual([
0, 25, 50, 100,
]);
});
});

View File

@@ -0,0 +1,194 @@
import { removeBackground } from "@imgly/background-removal";
import type { ToolResult } from "../../core/io/output-types";
import type { ToolProgress } from "../../core/plugins/plugin-types";
import { installImglyCache, isImglyAssetCached } from "./imgly-cache";
export interface BackgroundRemoverOptions {
/** Quality/size trade-off: small=quint8, medium=fp16, large=fp32. */
quality: "small" | "medium" | "large";
/** Use WebGPU when available (falls back to wasm automatically). */
useGpu: boolean;
}
/**
* Self-hosted asset location. The @imgly model + onnxruntime-web wasm files are
* mirrored into `public/imgly/` (see scripts/fetch-imgly-assets.mjs) so nothing
* is fetched from a third party — the image never leaves the browser.
*/
function publicPath(origin: string): string {
return new URL("/imgly/", origin).toString();
}
const MODEL_BY_QUALITY: Record<
BackgroundRemoverOptions["quality"],
"isnet" | "isnet_fp16" | "isnet_quint8"
> = {
small: "isnet_quint8",
medium: "isnet_fp16",
large: "isnet",
};
interface ResourceEntry {
size: number;
chunks: Array<{ name: string }>;
}
type ResourceMap = Record<string, ResourceEntry>;
function resourceKeys(options: BackgroundRemoverOptions): string[] {
const wasmBase = options.useGpu
? "/onnxruntime-web/ort-wasm-simd-threaded.jsep"
: "/onnxruntime-web/ort-wasm-simd-threaded";
return [
`/models/${MODEL_BY_QUALITY[options.quality]}`,
`${wasmBase}.wasm`,
`${wasmBase}.mjs`,
];
}
async function loadResourceMap(base: string): Promise<ResourceMap | null> {
try {
const res = await fetch(new URL("resources.json", base).toString());
if (!res.ok) return null;
return (await res.json()) as ResourceMap;
} catch {
return null;
}
}
export function createBackgroundRemovalProgress(
expectedSizes: ReadonlyMap<string, number>,
reportProgress?: (progress: ToolProgress) => void
): (key: string, current: number, total: number) => void {
const fetchedByKey = new Map<string, number>();
const expectedTotal = [...expectedSizes.values()].reduce(
(sum, size) => sum + size,
0
);
let sawFetch = false;
let lastPercentage = 0;
return (key, current, total) => {
let percentage: number;
let message: string;
if (key.startsWith("fetch:")) {
sawFetch = true;
const resourceKey = key.slice("fetch:".length);
fetchedByKey.set(resourceKey, Math.min(current, total));
const fetched = [...fetchedByKey.values()].reduce(
(sum, size) => sum + size,
0
);
const knownTotal =
expectedTotal > 0
? expectedTotal
: [...fetchedByKey.keys()].reduce(
(sum, knownKey) =>
sum + (knownKey === resourceKey ? total : fetchedByKey.get(knownKey) ?? 0),
0
);
percentage = knownTotal > 0 ? (fetched / knownTotal) * 65 : lastPercentage;
message = "Loading AI model...";
} else if (key.startsWith("compute:")) {
const computePercentage = total > 0 ? current / total : 0;
percentage = sawFetch
? 65 + computePercentage * 35
: computePercentage * 100;
message =
current === 0 ? "Preparing image..." : "Removing background...";
} else {
percentage = lastPercentage;
message = "Processing...";
}
lastPercentage = Math.max(lastPercentage, Math.min(100, percentage));
reportProgress?.({ percentage: lastPercentage, message });
};
}
/**
* Estimate how many bytes still need downloading before the tool can run with
* the given options. Returns 0 when everything is already cached locally.
*/
export async function estimateUncachedDownloadBytes(
options: BackgroundRemoverOptions,
origin: string
): Promise<number> {
const base = publicPath(origin);
const resources = await loadResourceMap(base);
if (!resources) return 0;
let bytes = 0;
for (const key of resourceKeys(options)) {
const entry = resources[key];
if (!entry?.chunks?.length) continue;
// Chunks of a resource are fetched together, so the first chunk is a
// reliable proxy for whether the whole resource is cached.
const firstChunkUrl = new URL(entry.chunks[0].name, base).toString();
if (!(await isImglyAssetCached(firstChunkUrl))) {
bytes += entry.size;
}
}
return bytes;
}
export async function removeImageBackground(
fileName: string,
imageData: Blob | ArrayBuffer | Uint8Array,
options: BackgroundRemoverOptions,
origin: string,
reportProgress?: (progress: ToolProgress) => void
): Promise<ToolResult> {
// Back /imgly/ fetches with Cache Storage so the model persists between runs
// regardless of server cache headers.
installImglyCache();
const base = publicPath(origin);
const resources = await loadResourceMap(base);
const expectedSizes = new Map(
resourceKeys(options).flatMap((key) => {
const size = resources?.[key]?.size;
return size === undefined ? [] : [[key, size] as const];
})
);
const progress = createBackgroundRemovalProgress(
expectedSizes,
reportProgress
);
const source =
imageData instanceof Blob
? imageData
: new Blob([
(imageData instanceof Uint8Array
? imageData
: new Uint8Array(imageData)) as unknown as BlobPart,
]);
const resultBlob = await removeBackground(source, {
publicPath: base,
model: MODEL_BY_QUALITY[options.quality],
device: options.useGpu ? "gpu" : "cpu",
// We already run inside a Web Worker; avoid nested worker proxying.
proxyToWorker: false,
output: { format: "image/png" },
progress,
});
const outName = fileName.replace(/\.[^./\\]+$/, "") + "_no-bg.png";
return {
type: "files",
files: [
{
name: outName,
mimeType: "image/png",
blob: resultBlob,
sizeAfter: resultBlob.size,
},
],
};
}

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { backgroundRemoverPlugin } from "./index";
import { runBackgroundRemover } from "./run";
import type { BackgroundRemoverOptions } from "./remove";
import type { ToolContext } from "../../core/plugins/plugin-types";
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
const options: BackgroundRemoverOptions = { quality: "small", useGpu: true };
describe("Background Remover Plugin", () => {
it("should have correct manifest", () => {
expect(backgroundRemoverPlugin.manifest.id).toBe("background-remover");
expect(backgroundRemoverPlugin.manifest.category).toBe("image");
expect(backgroundRemoverPlugin.manifest.input.type).toBe("files");
expect(backgroundRemoverPlugin.manifest.output.type).toBe("files");
});
it("should run in a worker with wasm capability", () => {
expect(backgroundRemoverPlugin.capabilities?.worker).toBe(true);
expect(backgroundRemoverPlugin.capabilities?.persistentWorker).toBe(true);
expect(backgroundRemoverPlugin.capabilities?.wasm).toBe(true);
expect(backgroundRemoverPlugin.capabilities?.customUi).toBe(true);
expect(backgroundRemoverPlugin.worker).toBeDefined();
expect(backgroundRemoverPlugin.customUi).toBeDefined();
});
it("should throw if no image is provided", async () => {
await expect(
runBackgroundRemover({ files: [] }, options, mockContext)
).rejects.toThrow("No image provided.");
});
});

View File

@@ -0,0 +1,26 @@
import type { ToolInput } from "../../core/io/input-types";
import type { ToolResult } from "../../core/io/output-types";
import type { ToolContext } from "../../core/plugins/plugin-types";
import { removeImageBackground, type BackgroundRemoverOptions } from "./remove";
export async function runBackgroundRemover(
input: ToolInput,
options: BackgroundRemoverOptions,
context: ToolContext
): Promise<ToolResult> {
const files = input.files;
if (!files || !Array.isArray(files) || files.length === 0) {
throw new Error("No image provided.");
}
const file = files[0];
return removeImageBackground(
file.name,
file,
options,
self.location.origin,
context.reportProgress
);
}

View File

@@ -0,0 +1,44 @@
import type {
ToolWorkerRequest,
ToolWorkerResponse,
} from "../../core/plugins/worker-protocol";
import { removeImageBackground, type BackgroundRemoverOptions } from "./remove";
function post(response: ToolWorkerResponse) {
self.postMessage(response);
}
self.addEventListener(
"message",
async (e: MessageEvent<ToolWorkerRequest<BackgroundRemoverOptions>>) => {
const { id, input, options } = e.data;
const files = input.files;
if (!files || !Array.isArray(files) || files.length === 0) {
post({ type: "error", id, error: "No image provided." });
return;
}
try {
const file = files[0];
const result = await removeImageBackground(
file.name,
file,
options,
self.location.origin,
(progress) => post({ type: "progress", id, progress })
);
post({ type: "success", id, result });
} catch (error) {
post({
type: "error",
id,
error:
error instanceof Error
? error.message
: "Unknown error occurred while removing the background.",
});
}
}
);