feat(erase-object): replace mask upload with in-browser brush painting

Replace the external mask file upload workflow with an interactive
canvas-based brush tool. Users now paint directly on the image to
mark areas for erasure. Adds EraserCanvas component with adjustable
brush size, undo/clear, and mask export. Switch Python inpainting
from broken lama-cleaner to OpenCV cv2.inpaint (Telea algorithm).
Add before/after comparison slider after processing.
This commit is contained in:
Siddharth Kumar Sah
2026-03-26 17:33:17 +08:00
parent 563892774d
commit 40656a0452
4 changed files with 421 additions and 87 deletions
+58 -3
View File
@@ -1,7 +1,7 @@
import { TOOLS } from "@stirling-image/shared";
import * as icons from "lucide-react";
import { CheckCircle2, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Crop } from "react-image-crop";
import { useParams } from "react-router-dom";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
@@ -27,6 +27,8 @@ import { ConvertSettings } from "@/components/tools/convert-settings";
import { CropCanvas } from "@/components/tools/crop-canvas";
import { CropSettings } from "@/components/tools/crop-settings";
import { EraseObjectSettings } from "@/components/tools/erase-object-settings";
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
import { EraserCanvas } from "@/components/tools/eraser-canvas";
import { FaviconSettings } from "@/components/tools/favicon-settings";
import { FindDuplicatesSettings } from "@/components/tools/find-duplicates-settings";
import { GifToolsSettings } from "@/components/tools/gif-tools-settings";
@@ -66,7 +68,7 @@ const COLOR_TOOL_IDS = new Set([
// Tools that don't need a file dropzone (they generate content or have custom UI)
const NO_DROPZONE_TOOLS = new Set(["qr-generate"]);
const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop", "rotate"]);
const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop", "rotate", "erase-object"]);
const LIVE_PREVIEW_TOOLS = new Set([
"rotate",
"brightness-contrast",
@@ -76,12 +78,14 @@ const LIVE_PREVIEW_TOOLS = new Set([
]);
const NO_COMPARISON_TOOLS = new Set(["strip-metadata", "convert"]);
const INTERACTIVE_CROP_TOOLS = new Set(["crop"]);
const INTERACTIVE_ERASER_TOOLS = new Set(["erase-object"]);
function ToolSettingsPanel({
toolId,
onPreviewTransform,
onPreviewFilter,
cropProps,
eraserProps,
}: {
toolId: string;
onPreviewTransform?: (t: PreviewTransform) => void;
@@ -97,6 +101,12 @@ function ToolSettingsPanel({
onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void;
};
eraserProps?: {
eraserRef: React.RefObject<EraserCanvasRef | null>;
hasStrokes: boolean;
brushSize: number;
onBrushSizeChange: (size: number) => void;
};
}) {
// Phase 2: Core tools
if (toolId === "resize") return <ResizeSettings />;
@@ -138,7 +148,7 @@ function ToolSettingsPanel({
if (toolId === "upscale") return <UpscaleSettings />;
if (toolId === "ocr") return <OcrSettings />;
if (toolId === "blur-faces") return <BlurFacesSettings />;
if (toolId === "erase-object") return <EraseObjectSettings />;
if (toolId === "erase-object" && eraserProps) return <EraseObjectSettings {...eraserProps} />;
if (toolId === "smart-crop") return <SmartCropSettings />;
return (
@@ -264,6 +274,11 @@ export function ToolPage() {
[cropCrop, cropAspect, cropShowGrid, cropImgDimensions],
);
// Eraser state
const eraserRef = useRef<EraserCanvasRef | null>(null);
const [eraserHasStrokes, setEraserHasStrokes] = useState(false);
const [eraserBrushSize, setEraserBrushSize] = useState(30);
// Reset crop state when the image changes
useEffect(() => {
setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 });
@@ -385,6 +400,16 @@ export function ToolPage() {
}
: undefined
}
eraserProps={
INTERACTIVE_ERASER_TOOLS.has(tool.id)
? {
eraserRef,
hasStrokes: eraserHasStrokes,
brushSize: eraserBrushSize,
onBrushSizeChange: setEraserBrushSize,
}
: undefined
}
/>
</div>
@@ -438,6 +463,16 @@ export function ToolPage() {
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
) : INTERACTIVE_ERASER_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<EraserCanvas
ref={eraserRef}
imageSrc={originalBlobUrl}
brushSize={eraserBrushSize}
onStrokeChange={setEraserHasStrokes}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}
@@ -558,6 +593,16 @@ export function ToolPage() {
}
: undefined
}
eraserProps={
INTERACTIVE_ERASER_TOOLS.has(tool.id)
? {
eraserRef,
hasStrokes: eraserHasStrokes,
brushSize: eraserBrushSize,
onBrushSizeChange: setEraserBrushSize,
}
: undefined
}
/>
</div>
@@ -625,6 +670,16 @@ export function ToolPage() {
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
) : INTERACTIVE_ERASER_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<EraserCanvas
ref={eraserRef}
imageSrc={originalBlobUrl}
brushSize={eraserBrushSize}
onStrokeChange={setEraserHasStrokes}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}