feat(erase-object): add freeform lasso selection mode (#503)

Adds a Brush | Lasso toggle to the object eraser. Lasso lets the user drag a freeform loop that auto-closes and fills into the mask, so they select around a subject instead of painting every pixel. Frontend-only; the mask contract is unchanged. Also un-skips the erase-object e2e suite via a shared mockAiFeaturesInstalled helper (7 tests now run; 2 multi-file tests fixme'd for a pre-existing tool-page remount bug). Closes #492.
This commit is contained in:
SnapOtter
2026-07-11 22:27:51 +08:00
committed by GitHub
parent 380419dd06
commit 601557edae
27 changed files with 408 additions and 124 deletions
@@ -1,4 +1,4 @@
import { Download, Redo, Trash2 } from "lucide-react";
import { Download, Lasso, Paintbrush, Redo, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
@@ -117,6 +117,8 @@ interface EraseObjectSettingsProps {
hasStrokes: boolean;
brushSize: number;
onBrushSizeChange: (size: number) => void;
mode: "brush" | "lasso";
onModeChange: (mode: "brush" | "lasso") => void;
onMaskCenter?: (centerPct: number) => void;
maskedFileCount: number;
}
@@ -126,6 +128,8 @@ export function EraseObjectSettings({
hasStrokes,
brushSize,
onBrushSizeChange: setBrushSize,
mode,
onModeChange,
onMaskCenter,
maskedFileCount,
}: EraseObjectSettingsProps) {
@@ -439,29 +443,63 @@ export function EraseObjectSettings({
return (
<div className="space-y-4">
{/* Brush size */}
<div>
<div className="flex justify-between items-center">
<label htmlFor="eraser-brush-size" className="text-xs text-muted-foreground">
{t.toolSettings["erase-object"].brushSize}
</label>
<span className="text-xs font-mono text-foreground">{brushSize}px</span>
</div>
<input
id="eraser-brush-size"
type="range"
min={5}
max={100}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>{t.toolSettings["erase-object"].fine}</span>
<span>{t.toolSettings["erase-object"].wide}</span>
</div>
{/* Mode: brush vs lasso */}
<div className="flex gap-1 rounded-lg bg-muted p-1">
<button
type="button"
data-testid="eraser-mode-brush"
aria-pressed={mode === "brush"}
onClick={() => onModeChange("brush")}
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
mode === "brush"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Paintbrush className="h-3.5 w-3.5" />
{t.toolSettings["erase-object"].brushMode}
</button>
<button
type="button"
data-testid="eraser-mode-lasso"
aria-pressed={mode === "lasso"}
onClick={() => onModeChange("lasso")}
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
mode === "lasso"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Lasso className="h-3.5 w-3.5" />
{t.toolSettings["erase-object"].lassoMode}
</button>
</div>
{/* Brush size (brush mode only) */}
{mode === "brush" && (
<div>
<div className="flex justify-between items-center">
<label htmlFor="eraser-brush-size" className="text-xs text-muted-foreground">
{t.toolSettings["erase-object"].brushSize}
</label>
<span className="text-xs font-mono text-foreground">{brushSize}px</span>
</div>
<input
id="eraser-brush-size"
type="range"
min={5}
max={100}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>{t.toolSettings["erase-object"].fine}</span>
<span>{t.toolSettings["erase-object"].wide}</span>
</div>
</div>
)}
{/* Clear / Undo */}
{hasStrokes && (
<div className="flex gap-2">
@@ -528,7 +566,9 @@ export function EraseObjectSettings({
{/* Hint */}
{hasFile && !hasStrokes && (
<p className="text-[10px] text-muted-foreground">
Paint over the objects you want to remove. Use Ctrl+Z to undo.
{mode === "lasso"
? t.toolSettings["erase-object"].lassoHint
: t.toolSettings["erase-object"].paintHint}
</p>
)}
+107 -25
View File
@@ -4,13 +4,29 @@ import { useZoomPan } from "@/hooks/use-zoom-pan";
import { renderSize } from "@/hooks/zoom-pan-math";
type Point = { x: number; y: number };
type Stroke = { points: Point[]; size: number };
type StrokeKind = "brush" | "lasso";
type Stroke = { points: Point[]; size: number; kind: StrokeKind };
type ImageStrokeData = {
strokes: Stroke[];
canvasSize: { w: number; h: number };
naturalSize: { w: number; h: number };
};
// A lasso must enclose at least this many points and this much fitted-px area to
// count, so an accidental tap or tiny drag never leaves a stray filled region.
const MIN_LASSO_POINTS = 3;
const MIN_LASSO_AREA = 100;
// Shoelace area of a polygon in fitted canvas coordinates.
function lassoArea(points: Point[]): number {
let area = 0;
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length;
area += points[i].x * points[j].y - points[j].x * points[i].y;
}
return Math.abs(area) / 2;
}
export interface EraserCanvasRef {
exportMask: () => Promise<Blob | null>;
exportAllMasks: () => Promise<Map<string, Blob>>;
@@ -23,12 +39,13 @@ export interface EraserCanvasRef {
interface EraserCanvasProps {
imageSrc: string;
brushSize: number;
mode: StrokeKind;
onStrokeChange: (hasStrokes: boolean) => void;
onMaskedCountChange?: (count: number) => void;
}
export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(function EraserCanvas(
{ imageSrc, brushSize, onStrokeChange, onMaskedCountChange },
{ imageSrc, brushSize, mode, onStrokeChange, onMaskedCountChange },
ref,
) {
const wrapperRef = useRef<HTMLDivElement>(null);
@@ -120,6 +137,19 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (stroke.kind === "lasso") {
if (stroke.points.length < MIN_LASSO_POINTS) return;
ctx.beginPath();
ctx.fillStyle = "rgba(255, 60, 60, 0.4)";
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
ctx.closePath();
ctx.fill();
return;
}
if (stroke.points.length === 1) {
ctx.beginPath();
ctx.fillStyle = "rgba(255, 60, 60, 0.4)";
@@ -198,16 +228,19 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
drawingRef.current = true;
currentPointsRef.current = [pt];
// Immediate dot
const ctx = prepCtx();
if (ctx) {
ctx.beginPath();
ctx.fillStyle = "rgba(255, 60, 60, 0.4)";
ctx.arc(pt.x, pt.y, brushSize / 2, 0, Math.PI * 2);
ctx.fill();
// Brush: drop an immediate dot so a single tap erases. Lasso: draw nothing
// until the loop takes shape on move.
if (mode === "brush") {
const ctx = prepCtx();
if (ctx) {
ctx.beginPath();
ctx.fillStyle = "rgba(255, 60, 60, 0.4)";
ctx.arc(pt.x, pt.y, brushSize / 2, 0, Math.PI * 2);
ctx.fill();
}
}
},
[getPoint, brushSize, isPanMode, beginPan, prepCtx],
[getPoint, brushSize, mode, isPanMode, beginPan, prepCtx],
);
const handleMove = useCallback(
@@ -224,9 +257,10 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
return;
}
// Update cursor position for brush preview
// Brush shows a round cursor preview; lasso uses a crosshair (no preview dot).
const pt = getPoint(e);
if (pt) setCursorPos(pt);
if (pt && mode === "brush") setCursorPos(pt);
else setCursorPos(null);
if (!drawingRef.current) return;
if ("touches" in e) e.preventDefault();
@@ -237,8 +271,27 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
const ctx = prepCtx();
if (!ctx) return;
const pts = currentPointsRef.current;
if (pts.length < 2) return;
if (mode === "lasso") {
// The filled shape changes as points are added, so repaint the committed
// strokes then overlay the in-progress loop as a translucent preview.
redraw();
if (pts.length >= 2) {
ctx.beginPath();
ctx.fillStyle = "rgba(255, 60, 60, 0.25)";
ctx.strokeStyle = "rgba(255, 60, 60, 0.8)";
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
return;
}
if (pts.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = "rgba(255, 60, 60, 0.4)";
ctx.lineWidth = brushSize;
@@ -247,7 +300,7 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
ctx.lineTo(pt.x, pt.y);
ctx.stroke();
},
[getPoint, brushSize, isPanMode, movePan, prepCtx],
[getPoint, brushSize, mode, isPanMode, movePan, prepCtx, redraw],
);
const handleUp = useCallback(() => {
@@ -255,18 +308,23 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
if (!drawingRef.current) return;
drawingRef.current = false;
if (currentPointsRef.current.length > 0) {
strokesRef.current.push({
points: [...currentPointsRef.current],
size: brushSize,
});
currentPointsRef.current = [];
onStrokeChange(true);
const pts = currentPointsRef.current;
currentPointsRef.current = [];
if (pts.length > 0) {
if (mode === "lasso") {
// Auto-close the loop into a filled region; ignore accidental taps / tiny loops.
if (pts.length >= MIN_LASSO_POINTS && lassoArea(pts) >= MIN_LASSO_AREA) {
strokesRef.current.push({ points: [...pts], size: 0, kind: "lasso" });
}
} else {
strokesRef.current.push({ points: [...pts], size: brushSize, kind: "brush" });
}
onStrokeChange(strokesRef.current.length > 0);
redraw();
persistStrokes();
onMaskedCountChange?.(allStrokesRef.current.size);
}
}, [brushSize, onStrokeChange, onMaskedCountChange, redraw, persistStrokes, endPan]);
}, [brushSize, mode, onStrokeChange, onMaskedCountChange, redraw, persistStrokes, endPan]);
const handleLeave = useCallback(() => {
setCursorPos(null);
@@ -299,6 +357,18 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
ctx.lineJoin = "round";
for (const stroke of strokesRef.current) {
if (stroke.kind === "lasso") {
if (stroke.points.length < MIN_LASSO_POINTS) continue;
ctx.beginPath();
ctx.moveTo(stroke.points[0].x * sx, stroke.points[0].y * sy);
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x * sx, stroke.points[i].y * sy);
}
ctx.closePath();
ctx.fill();
continue;
}
const scaledSize = stroke.size * Math.max(sx, sy);
if (stroke.points.length === 1) {
@@ -345,6 +415,18 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const stroke of data.strokes) {
if (stroke.kind === "lasso") {
if (stroke.points.length < MIN_LASSO_POINTS) continue;
ctx.beginPath();
ctx.moveTo(stroke.points[0].x * sx, stroke.points[0].y * sy);
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x * sx, stroke.points[i].y * sy);
}
ctx.closePath();
ctx.fill();
continue;
}
const scaledSize = stroke.size * Math.max(sx, sy);
if (stroke.points.length === 1) {
ctx.beginPath();
@@ -463,7 +545,7 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
style={{
width: canvasSize.w,
height: canvasSize.h,
cursor: isPanMode ? "grab" : "none",
cursor: isPanMode ? "grab" : mode === "lasso" ? "crosshair" : "none",
}}
onMouseDown={handleDown}
onMouseMove={handleMove}
@@ -473,8 +555,8 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
onTouchMove={handleMove}
onTouchEnd={handleUp}
/>
{/* Brush cursor preview */}
{cursorPos && !isPanMode && (
{/* Brush cursor preview (brush mode only) */}
{cursorPos && !isPanMode && mode === "brush" && (
<div
className="pointer-events-none absolute rounded-full border-2 border-white/80"
style={{
+2
View File
@@ -49,6 +49,8 @@ export interface EraserProps {
hasStrokes: boolean;
brushSize: number;
onBrushSizeChange: (size: number) => void;
mode: "brush" | "lasso";
onModeChange: (mode: "brush" | "lasso") => void;
onMaskCenter?: (centerPct: number) => void;
maskedFileCount: number;
}
+4
View File
@@ -361,6 +361,7 @@ export function ToolPage() {
const eraserRef = useRef<EraserCanvasRef | null>(null);
const [eraserHasStrokes, setEraserHasStrokes] = useState(false);
const [eraserBrushSize, setEraserBrushSize] = useState(30);
const [eraserMode, setEraserMode] = useState<"brush" | "lasso">("brush");
const [eraserMaskedCount, setEraserMaskedCount] = useState(0);
// Center of the painted mask as a 0-100 percentage, used to init the slider at the erased spot
const [eraserSliderInitPos, setEraserSliderInitPos] = useState<number | null>(null);
@@ -660,6 +661,8 @@ export function ToolPage() {
hasStrokes: eraserHasStrokes,
brushSize: eraserBrushSize,
onBrushSizeChange: setEraserBrushSize,
mode: eraserMode,
onModeChange: setEraserMode,
onMaskCenter: setEraserSliderInitPos,
maskedFileCount: eraserMaskedCount,
}
@@ -851,6 +854,7 @@ export function ToolPage() {
ref={eraserRef}
imageSrc={originalBlobUrl}
brushSize={eraserBrushSize}
mode={eraserMode}
onStrokeChange={setEraserHasStrokes}
onMaskedCountChange={setEraserMaskedCount}
/>
+3
View File
@@ -1555,6 +1555,8 @@ export const ar: TranslationKeys = {
progressLabelBatch: "جاري تكبير {count} صورة",
},
"erase-object": {
brushMode: "فرشاة",
lassoMode: "لاسو",
brushSize: "حجم الفرشاة",
fine: "دقيق",
wide: "عريض",
@@ -1563,6 +1565,7 @@ export const ar: TranslationKeys = {
outputFormat: "صيغة الإخراج",
quality: "الجودة",
paintHint: "ارسم فوق الكائنات التي تريد إزالتها. استخدم Ctrl+Z للتراجع.",
lassoHint: "ارسم حلقة حول الكائن الذي تريد إزالته. استخدم Ctrl+Z للتراجع.",
submit: "مسح الكائن",
submitBatch: "مسح الكل ({count})",
progressLabel: "جاري مسح الكائن",
+4
View File
@@ -1572,6 +1572,8 @@ export const de: TranslationKeys = {
progressLabelBatch: "{count} Bilder werden hochskaliert",
},
"erase-object": {
brushMode: "Pinsel",
lassoMode: "Lasso",
brushSize: "Pinselgröße",
fine: "Fein",
wide: "Breit",
@@ -1581,6 +1583,8 @@ export const de: TranslationKeys = {
quality: "Qualität",
paintHint:
"Malen Sie über die Objekte, die Sie entfernen möchten. Strg+Z zum Rückgängigmachen.",
lassoHint:
"Ziehen Sie eine Schlaufe um das Objekt, das Sie entfernen möchten. Strg+Z zum Rückgängigmachen.",
submit: "Objekt entfernen",
submitBatch: "Alle entfernen ({count})",
progressLabel: "Objekt wird entfernt",
+3
View File
@@ -1519,6 +1519,8 @@ export const en = {
progressLabelBatch: "Upscaling {count} images",
},
"erase-object": {
brushMode: "Brush",
lassoMode: "Lasso",
brushSize: "Brush Size",
fine: "Fine",
wide: "Wide",
@@ -1527,6 +1529,7 @@ export const en = {
outputFormat: "Output Format",
quality: "Quality",
paintHint: "Paint over the objects you want to remove. Use Ctrl+Z to undo.",
lassoHint: "Draw a loop around the object you want to remove. Use Ctrl+Z to undo.",
submit: "Erase Object",
submitBatch: "Erase All ({count})",
progressLabel: "Erasing object",
+4
View File
@@ -1555,6 +1555,8 @@ export const es: TranslationKeys = {
progressLabelBatch: "Escalando {count} imágenes",
},
"erase-object": {
brushMode: "Pincel",
lassoMode: "Lazo",
brushSize: "Tamaño del pincel",
fine: "Fino",
wide: "Ancho",
@@ -1563,6 +1565,8 @@ export const es: TranslationKeys = {
outputFormat: "Formato de salida",
quality: "Calidad",
paintHint: "Pinta sobre los objetos que quieras eliminar. Usa Ctrl+Z para deshacer.",
lassoHint:
"Dibuja un lazo alrededor del objeto que quieras eliminar. Usa Ctrl+Z para deshacer.",
submit: "Borrar objeto",
submitBatch: "Borrar todos ({count})",
progressLabel: "Borrando objeto",
+4
View File
@@ -1579,6 +1579,8 @@ export const fr: TranslationKeys = {
progressLabelBatch: "Agrandissement de {count} images",
},
"erase-object": {
brushMode: "Pinceau",
lassoMode: "Lasso",
brushSize: "Taille du pinceau",
fine: "Fin",
wide: "Large",
@@ -1588,6 +1590,8 @@ export const fr: TranslationKeys = {
quality: "Qualité",
paintHint:
"Peignez sur les objets que vous souhaitez supprimer. Utilisez Ctrl+Z pour annuler.",
lassoHint:
"Tracez une boucle autour de l'objet que vous souhaitez supprimer. Utilisez Ctrl+Z pour annuler.",
submit: "Effacer l'objet",
submitBatch: "Tout effacer ({count})",
progressLabel: "Effacement de l'objet",
+3
View File
@@ -1385,6 +1385,8 @@ export const hi: TranslationKeys = {
progressLabelBatch: "{count} इमेज अपस्केल हो रही हैं",
},
"erase-object": {
brushMode: "ब्रश",
lassoMode: "लासो",
brushSize: "ब्रश साइज़",
fine: "महीन",
wide: "चौड़ा",
@@ -1393,6 +1395,7 @@ export const hi: TranslationKeys = {
outputFormat: "आउटपुट फॉर्मेट",
quality: "क्वालिटी",
paintHint: "जिन वस्तुओं को हटाना है उन पर पेंट करें। पूर्ववत के लिए Ctrl+Z दबाएं।",
lassoHint: "जिस वस्तु को हटाना है उसके चारों ओर लूप बनाएं। पूर्ववत के लिए Ctrl+Z दबाएं।",
submit: "ऑब्जेक्ट मिटाएं",
submitBatch: "सभी मिटाएं ({count})",
progressLabel: "ऑब्जेक्ट मिटाया जा रहा है",
+4
View File
@@ -1565,6 +1565,8 @@ export const id: TranslationKeys = {
progressLabelBatch: "Memperbesar {count} gambar",
},
"erase-object": {
brushMode: "Kuas",
lassoMode: "Laso",
brushSize: "Ukuran Kuas",
fine: "Halus",
wide: "Lebar",
@@ -1573,6 +1575,8 @@ export const id: TranslationKeys = {
outputFormat: "Format Output",
quality: "Kualitas",
paintHint: "Cat di atas objek yang ingin Anda hapus. Gunakan Ctrl+Z untuk undo.",
lassoHint:
"Gambar lingkaran di sekeliling objek yang ingin Anda hapus. Gunakan Ctrl+Z untuk undo.",
submit: "Hapus Objek",
submitBatch: "Hapus Semua ({count})",
progressLabel: "Menghapus objek",
+4
View File
@@ -1570,6 +1570,8 @@ export const it: TranslationKeys = {
progressLabelBatch: "Ingrandimento di {count} immagini",
},
"erase-object": {
brushMode: "Pennello",
lassoMode: "Lazo",
brushSize: "Dimensione pennello",
fine: "Sottile",
wide: "Largo",
@@ -1578,6 +1580,8 @@ export const it: TranslationKeys = {
outputFormat: "Formato di output",
quality: "Qualità",
paintHint: "Dipingi sopra gli oggetti che vuoi rimuovere. Usa Ctrl+Z per annullare.",
lassoHint:
"Traccia un anello attorno all'oggetto che vuoi rimuovere. Usa Ctrl+Z per annullare.",
submit: "Cancella oggetto",
submitBatch: "Cancella tutti ({count})",
progressLabel: "Cancellazione oggetto",
+3
View File
@@ -1527,6 +1527,8 @@ export const ja: TranslationKeys = {
progressLabelBatch: "{count}枚の画像をアップスケール中",
},
"erase-object": {
brushMode: "ブラシ",
lassoMode: "投げ縄",
brushSize: "ブラシサイズ",
fine: "細い",
wide: "太い",
@@ -1535,6 +1537,7 @@ export const ja: TranslationKeys = {
outputFormat: "出力フォーマット",
quality: "品質",
paintHint: "除去したいオブジェクトの上を塗ってください。Ctrl+Zで元に戻せます。",
lassoHint: "除去したいオブジェクトを囲むように輪を描いてください。Ctrl+Zで元に戻せます。",
submit: "消去",
submitBatch: "すべて消去({count}",
progressLabel: "オブジェクトを消去中",
+3
View File
@@ -1509,6 +1509,8 @@ export const ko: TranslationKeys = {
progressLabelBatch: "{count}개 이미지 업스케일 중",
},
"erase-object": {
brushMode: "브러시",
lassoMode: "올가미",
brushSize: "브러시 크기",
fine: "가늘게",
wide: "굵게",
@@ -1517,6 +1519,7 @@ export const ko: TranslationKeys = {
outputFormat: "출력 포맷",
quality: "품질",
paintHint: "제거할 객체 위를 칠하세요. Ctrl+Z로 실행 취소할 수 있습니다.",
lassoHint: "제거할 객체 주위에 올가미를 그리세요. Ctrl+Z로 실행 취소할 수 있습니다.",
submit: "객체 지우기",
submitBatch: "모두 지우기 ({count})",
progressLabel: "객체 지우는 중",
+4
View File
@@ -1569,6 +1569,8 @@ export const nl: TranslationKeys = {
progressLabelBatch: "{count} afbeeldingen opschalen",
},
"erase-object": {
brushMode: "Penseel",
lassoMode: "Lasso",
brushSize: "Penseelgrootte",
fine: "Fijn",
wide: "Breed",
@@ -1578,6 +1580,8 @@ export const nl: TranslationKeys = {
quality: "Kwaliteit",
paintHint:
"Verf over de objecten die je wilt verwijderen. Gebruik Ctrl+Z om ongedaan te maken.",
lassoHint:
"Teken een lus rond het object dat je wilt verwijderen. Gebruik Ctrl+Z om ongedaan te maken.",
submit: "Object verwijderen",
submitBatch: "Alles verwijderen ({count})",
progressLabel: "Object verwijderen",
+3
View File
@@ -1570,6 +1570,8 @@ export const pl: TranslationKeys = {
progressLabelBatch: "Powiększanie {count} obrazów",
},
"erase-object": {
brushMode: "Pędzel",
lassoMode: "Lasso",
brushSize: "Rozmiar pędzla",
fine: "Cienki",
wide: "Szeroki",
@@ -1578,6 +1580,7 @@ export const pl: TranslationKeys = {
outputFormat: "Format wyjściowy",
quality: "Jakość",
paintHint: "Zamaluj obiekty, które chcesz usunąć. Naciśnij Ctrl+Z, aby cofnąć.",
lassoHint: "Narysuj pętlę wokół obiektu, który chcesz usunąć. Naciśnij Ctrl+Z, aby cofnąć.",
submit: "Usuń obiekt",
submitBatch: "Usuń wszystko ({count})",
progressLabel: "Usuwanie obiektu",
+3
View File
@@ -1568,6 +1568,8 @@ export const ptBR: TranslationKeys = {
progressLabelBatch: "Ampliando {count} imagens",
},
"erase-object": {
brushMode: "Pincel",
lassoMode: "Laço",
brushSize: "Tamanho do pincel",
fine: "Fino",
wide: "Largo",
@@ -1576,6 +1578,7 @@ export const ptBR: TranslationKeys = {
outputFormat: "Formato de saída",
quality: "Qualidade",
paintHint: "Pinte sobre os objetos que deseja remover. Use Ctrl+Z para desfazer.",
lassoHint: "Desenhe um laço ao redor do objeto que deseja remover. Use Ctrl+Z para desfazer.",
submit: "Apagar objeto",
submitBatch: "Apagar todos ({count})",
progressLabel: "Apagando objeto",
+3
View File
@@ -1565,6 +1565,8 @@ export const ru: TranslationKeys = {
progressLabelBatch: "Увеличение {count} изображений",
},
"erase-object": {
brushMode: "Кисть",
lassoMode: "Лассо",
brushSize: "Размер кисти",
fine: "Тонкая",
wide: "Широкая",
@@ -1573,6 +1575,7 @@ export const ru: TranslationKeys = {
outputFormat: "Формат вывода",
quality: "Качество",
paintHint: "Закрасьте объекты, которые хотите удалить. Нажмите Ctrl+Z для отмены.",
lassoHint: "Обведите петлёй объект, который хотите удалить. Нажмите Ctrl+Z для отмены.",
submit: "Удалить объект",
submitBatch: "Удалить всё ({count})",
progressLabel: "Удаление объекта",
+3
View File
@@ -1565,6 +1565,8 @@ export const sv: TranslationKeys = {
progressLabelBatch: "Uppskalar {count} bilder",
},
"erase-object": {
brushMode: "Pensel",
lassoMode: "Lasso",
brushSize: "Penselstorlek",
fine: "Fin",
wide: "Bred",
@@ -1573,6 +1575,7 @@ export const sv: TranslationKeys = {
outputFormat: "Utdataformat",
quality: "Kvalitet",
paintHint: "Måla över objekten du vill ta bort. Använd Ctrl+Z för att ångra.",
lassoHint: "Rita en ögla runt objektet du vill ta bort. Använd Ctrl+Z för att ångra.",
submit: "Radera objekt",
submitBatch: "Radera alla ({count})",
progressLabel: "Raderar objekt",
+3
View File
@@ -1545,6 +1545,8 @@ export const th: TranslationKeys = {
progressLabelBatch: "กำลังขยาย {count} ภาพ",
},
"erase-object": {
brushMode: "แปรง",
lassoMode: "ลาสโซ",
brushSize: "ขนาดแปรง",
fine: "ละเอียด",
wide: "กว้าง",
@@ -1553,6 +1555,7 @@ export const th: TranslationKeys = {
outputFormat: "รูปแบบเอาต์พุต",
quality: "คุณภาพ",
paintHint: "ระบายทับวัตถุที่ต้องการลบ กด Ctrl+Z เพื่อเลิกทำ",
lassoHint: "วาดวงรอบวัตถุที่ต้องการลบ กด Ctrl+Z เพื่อเลิกทำ",
submit: "ลบวัตถุ",
submitBatch: "ลบทั้งหมด ({count})",
progressLabel: "กำลังลบวัตถุ",
+4
View File
@@ -1568,6 +1568,8 @@ export const tr: TranslationKeys = {
progressLabelBatch: "{count} görüntü büyütülüyor",
},
"erase-object": {
brushMode: "Fırça",
lassoMode: "Kement",
brushSize: "Fırça Boyutu",
fine: "İnce",
wide: "Geniş",
@@ -1577,6 +1579,8 @@ export const tr: TranslationKeys = {
quality: "Kalite",
paintHint:
"Kaldırmak istediğiniz nesnelerin üzerini boyayın. Geri almak için Ctrl+Z kullanın.",
lassoHint:
"Kaldırmak istediğiniz nesnenin etrafına bir ilmek çizin. Geri almak için Ctrl+Z kullanın.",
submit: "Nesneyi Sil",
submitBatch: "Tümünü Sil ({count})",
progressLabel: "Nesne siliniyor",
+3
View File
@@ -1568,6 +1568,8 @@ export const uk: TranslationKeys = {
progressLabelBatch: "Збільшення {count} зображень",
},
"erase-object": {
brushMode: "Пензель",
lassoMode: "Ласо",
brushSize: "Розмір пензля",
fine: "Тонкий",
wide: "Широкий",
@@ -1576,6 +1578,7 @@ export const uk: TranslationKeys = {
outputFormat: "Формат виводу",
quality: "Якість",
paintHint: "Замалюйте об'єкти, які хочете видалити. Натисніть Ctrl+Z для скасування.",
lassoHint: "Обведіть петлею об'єкт, який хочете видалити. Натисніть Ctrl+Z для скасування.",
submit: "Видалити об'єкт",
submitBatch: "Видалити все ({count})",
progressLabel: "Видалення об'єкта",
+3
View File
@@ -1568,6 +1568,8 @@ export const vi: TranslationKeys = {
progressLabelBatch: "Đang phóng to {count} ảnh",
},
"erase-object": {
brushMode: "Cọ",
lassoMode: "Lasso",
brushSize: "Kích thước cọ",
fine: "Mảnh",
wide: "Rộng",
@@ -1576,6 +1578,7 @@ export const vi: TranslationKeys = {
outputFormat: "Định dạng đầu ra",
quality: "Chất lượng",
paintHint: "Tô lên các đối tượng bạn muốn xóa. Nhấn Ctrl+Z để hoàn tác.",
lassoHint: "Vẽ một vòng quanh đối tượng bạn muốn xóa. Nhấn Ctrl+Z để hoàn tác.",
submit: "Xóa đối tượng",
submitBatch: "Xóa tất cả ({count})",
progressLabel: "Đang xóa đối tượng",
+3
View File
@@ -1333,6 +1333,8 @@ export const zhCN: TranslationKeys = {
progressLabelBatch: "正在放大 {count} 张图片",
},
"erase-object": {
brushMode: "画笔",
lassoMode: "套索",
brushSize: "画笔大小",
fine: "细",
wide: "粗",
@@ -1341,6 +1343,7 @@ export const zhCN: TranslationKeys = {
outputFormat: "输出格式",
quality: "质量",
paintHint: "涂抹要移除的物体。按 Ctrl+Z 撤销。",
lassoHint: "在要移除的物体周围画一个圈。按 Ctrl+Z 撤销。",
submit: "擦除物体",
submitBatch: "全部擦除({count}",
progressLabel: "正在擦除物体",
+3
View File
@@ -1333,6 +1333,8 @@ export const zhTW: TranslationKeys = {
progressLabelBatch: "正在放大{count}張影像",
},
"erase-object": {
brushMode: "筆刷",
lassoMode: "套索",
brushSize: "筆刷大小",
fine: "精細",
wide: "寬大",
@@ -1341,6 +1343,7 @@ export const zhTW: TranslationKeys = {
outputFormat: "輸出格式",
quality: "品質",
paintHint: "在要移除的物件上塗抹。按Ctrl+Z復原。",
lassoHint: "在要移除的物件周圍畫一個圈。按Ctrl+Z復原。",
submit: "擦除物件",
submitBatch: "全部擦除({count}",
progressLabel: "正在擦除物件",
+125 -76
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import { expect, test } from "./helpers";
import { expect, mockAiFeaturesInstalled, test } from "./helpers";
function fixturePath(name: string): string {
return path.join(process.cwd(), "tests", "fixtures", name);
@@ -15,61 +15,68 @@ async function uploadFile(page: import("@playwright/test").Page, filePath: strin
}
test.describe("Erase Object tool", () => {
async function skipIfFeatureNotInstalled(page: import("@playwright/test").Page) {
// The eraser is an AI tool: without its bundle it renders a FeatureInstallPrompt
// instead of the tool, which made this whole suite silently skip in CI and on
// any box without the bundle. Mock the feature as installed so the UI actually
// renders and these tests run. The settings panel (brush size, mode toggle,
// submit) only mounts once a file is loaded, so tests that touch it upload first.
async function gotoEraser(page: import("@playwright/test").Page) {
await mockAiFeaturesInstalled(page, [
{
id: "object-eraser-colorize",
name: "Object Eraser",
enablesTools: ["erase-object", "colorize"],
},
]);
await page.goto("/image/erase-object");
// Guard against route rot: a wrong/404 route must fail loudly, not silently skip
// (skipping on a missing submit button previously masked the route being broken).
// A wrong/404 route must fail loudly, not silently pass.
await expect(page.getByRole("heading", { name: "404" })).toHaveCount(0);
try {
await page.getByTestId("erase-object-submit").waitFor({ state: "visible", timeout: 15_000 });
} catch {
test.skip(true, "object-eraser-colorize feature bundle not installed");
}
// Installed => the tool renders its dropzone (not the install prompt). Failing
// here means the feature gate wasn't bypassed, not that a bundle is missing.
await page
.getByRole("button", { name: /upload from computer/i })
.waitFor({ state: "visible", timeout: 15_000 });
}
test("page loads with correct UI controls", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
// Brush size slider
await expect(page.getByText("Brush Size")).toBeVisible();
await expect(page.locator("#eraser-brush-size")).toBeVisible();
// Output format dropdown
await expect(page.locator("#eraser-format")).toBeVisible();
// Submit button is disabled with no file
await expect(page.getByTestId("erase-object-submit")).toBeDisabled();
});
test("submit button disabled without file", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
await expect(page.getByTestId("erase-object-submit")).toBeDisabled();
});
test("submit button remains disabled with file but no strokes", async ({
test("shows the tool (not an install prompt) when the feature is available", async ({
loggedInPage: page,
}) => {
await skipIfFeatureNotInstalled(page);
await gotoEraser(page);
// Dropzone is shown; the settings panel and submit only appear after a file.
await expect(page.getByRole("button", { name: /upload from computer/i })).toBeVisible();
await expect(page.getByTestId("erase-object-submit")).toHaveCount(0);
});
test("loads settings controls once a file is added", async ({ loggedInPage: page }) => {
await gotoEraser(page);
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
// Submit should still be disabled because no strokes have been painted
await expect(page.getByText("Brush Size")).toBeVisible();
await expect(page.locator("#eraser-brush-size")).toBeVisible();
await expect(page.locator("#eraser-format")).toBeVisible();
// No strokes yet -> submit disabled.
await expect(page.getByTestId("erase-object-submit")).toBeDisabled();
});
test("submit stays disabled with a file but no strokes", async ({ loggedInPage: page }) => {
await gotoEraser(page);
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
await expect(page.getByTestId("erase-object-submit")).toBeDisabled();
});
test("brush size slider is interactive", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
await gotoEraser(page);
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
const slider = page.locator("#eraser-brush-size");
await expect(slider).toBeVisible();
// Change slider value
await slider.fill("75");
await expect(page.getByText("75px")).toBeVisible();
});
test("quality slider shows for lossy formats only", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
await gotoEraser(page);
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
const qualitySlider = page.locator("#eraser-quality");
const formatSelect = page.locator("#eraser-format");
@@ -90,8 +97,15 @@ test.describe("Erase Object tool", () => {
await expect(qualitySlider).not.toBeVisible();
});
test("strokes persist when switching between files", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
// FIXME(pre-existing): cross-file stroke persistence is broken. tool-page renders
// the image area under `key={`pending-${selectedIndex}`}`, so switching files
// REMOUNTS EraserCanvas and wipes its in-component `allStrokesRef` (the per-image
// stroke cache the multi-file design relies on): draw on A, switch to B and back,
// and A's strokes are gone. The key predates the lasso work; the fix belongs in
// tool-page's key logic. Un-fixme once EraserCanvas survives file switches. This
// suite previously skipped entirely (feature gate), so it never caught this.
test.fixme("strokes persist when switching between files", async ({ loggedInPage: page }) => {
await gotoEraser(page);
// Upload first file
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
@@ -110,32 +124,72 @@ test.describe("Erase Object tool", () => {
// Undo/Clear buttons should appear
await expect(page.getByRole("button", { name: "Undo" })).toBeVisible();
// Upload second file via the "+ Add more" button
// Add a DISTINCT second file so the file entries are unambiguous.
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByRole("button", { name: /Add more/i }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(fixturePath("image/valid/test-200x150.png"));
await fileChooser.setFiles(fixturePath("image/valid/test-100x100.jpg"));
await page.waitForTimeout(500);
// Switch to second file (click thumbnail or file entry)
// The file list shows buttons with the filename - click the second one
const fileEntries = page.locator("button").filter({ hasText: "test-200x150.png" });
const count = await fileEntries.count();
if (count >= 2) {
await fileEntries.nth(1).click();
await page.waitForTimeout(300);
// Switch to the second file, then back to the first (select by unique name).
await page.locator("button").filter({ hasText: "test-100x100.jpg" }).first().click();
await page.waitForTimeout(300);
await page.locator("button").filter({ hasText: "test-200x150.png" }).first().click();
await page.waitForTimeout(300);
// Switch back to first file
await fileEntries.first().click();
await page.waitForTimeout(300);
// Undo button should still be visible (strokes were preserved)
await expect(page.getByRole("button", { name: "Undo" })).toBeVisible();
}
// The first file's stroke was preserved -> Undo is still available.
await expect(page.getByRole("button", { name: "Undo" })).toBeVisible();
});
test("shows Erase All button when multiple files have masks", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
test("lasso mode toggle switches modes and hides the brush size", async ({
loggedInPage: page,
}) => {
await gotoEraser(page);
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
// Brush is the default mode: the brush-size slider is shown.
await expect(page.locator("#eraser-brush-size")).toBeVisible();
// Switch to lasso: the brush-size slider is hidden.
await page.getByTestId("eraser-mode-lasso").click();
await expect(page.locator("#eraser-brush-size")).not.toBeVisible();
// Switch back to brush: the slider returns.
await page.getByTestId("eraser-mode-brush").click();
await expect(page.locator("#eraser-brush-size")).toBeVisible();
});
test("drawing a lasso loop enables submit", async ({ loggedInPage: page }) => {
await gotoEraser(page);
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
await page.getByTestId("eraser-mode-lasso").click();
const canvas = page.locator("canvas");
await canvas.waitFor({ state: "visible", timeout: 5_000 });
const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found");
// Drag a closed quad around the middle of the canvas (>= 3 points, real area).
await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.3);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.7, box.y + box.height * 0.5);
await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.7);
await page.mouse.move(box.x + box.width * 0.3, box.y + box.height * 0.5);
await page.mouse.up();
// A lasso region counts as a stroke: Undo appears and submit is enabled.
await expect(page.getByRole("button", { name: "Undo" })).toBeVisible();
await expect(page.getByTestId("erase-object-submit")).toBeEnabled();
});
// FIXME(pre-existing): needs per-file masks to persist across file switches,
// which is broken by the EraserCanvas remount on switch (see the strokes-persist
// fixme above). Un-fixme once that's fixed.
test.fixme("shows Erase All button when multiple files have masks", async ({
loggedInPage: page,
}) => {
await gotoEraser(page);
// Upload first file
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
@@ -154,32 +208,27 @@ test.describe("Erase Object tool", () => {
// Button should say "Erase Object" (only one file has mask)
await expect(page.getByTestId("erase-object-submit")).toHaveText("Erase Object");
// Upload second file
// Add a DISTINCT second file and paint on it too.
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByRole("button", { name: /Add more/i }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(fixturePath("image/valid/test-200x150.png"));
await fileChooser.setFiles(fixturePath("image/valid/test-100x100.jpg"));
await page.waitForTimeout(500);
// Switch to second file and paint
const fileEntries = page.locator("button").filter({ hasText: "test-200x150.png" });
const count = await fileEntries.count();
if (count >= 2) {
await fileEntries.nth(1).click();
await page.waitForTimeout(500);
await page.locator("button").filter({ hasText: "test-100x100.jpg" }).first().click();
await page.waitForTimeout(500);
const canvas2 = page.locator("canvas");
await canvas2.waitFor({ state: "visible", timeout: 5_000 });
box = await canvas2.boundingBox();
if (!box) throw new Error("Canvas not found");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 20, box.y + box.height / 2);
await page.mouse.up();
await page.waitForTimeout(200);
const canvas2 = page.locator("canvas");
await canvas2.waitFor({ state: "visible", timeout: 5_000 });
box = await canvas2.boundingBox();
if (!box) throw new Error("Canvas not found");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 20, box.y + box.height / 2);
await page.mouse.up();
await page.waitForTimeout(200);
// Now button should say "Erase All (2)"
await expect(page.getByTestId("erase-object-submit")).toHaveText("Erase All (2)");
}
// Both files now have masks -> batch submit button.
await expect(page.getByTestId("erase-object-submit")).toHaveText("Erase All (2)");
});
});
+37
View File
@@ -128,6 +128,43 @@ export async function uploadTestImage(page: Page): Promise<void> {
await page.waitForTimeout(500);
}
// ---------------------------------------------------------------------------
// mockAiFeaturesInstalled() — make AI-tool bundles report as installed.
//
// AI tools (erase-object, upscale, ...) render a FeatureInstallPrompt instead of
// the tool UI when their bundle is missing, so their e2e specs otherwise skip in
// CI and on any box without the bundle. Mock the feature-status endpoint so the
// tool UI renders. Call BEFORE navigating to the tool page, then goto (a full
// load resets the in-memory features store, which re-fetches through this mock).
// These specs exercise client-side UI up to mask generation, not the real AI
// backend / processing.
// ---------------------------------------------------------------------------
export async function mockAiFeaturesInstalled(
page: Page,
bundles: Array<{ id: string; enablesTools: string[]; name?: string }>,
): Promise<void> {
await page.route("**/api/v1/features", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
bundles: bundles.map((b) => ({
id: b.id,
name: b.name ?? b.id,
description: "",
status: "installed",
installedVersion: "1.0.0",
estimatedSize: "",
downloadBytes: null,
installedBytes: null,
enablesTools: b.enablesTools,
progress: null,
})),
}),
}),
);
}
// ---------------------------------------------------------------------------
// waitForProcessing() — wait for processing to complete
// ---------------------------------------------------------------------------