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