diff --git a/web/index.html b/web/index.html index d65d293e..11148bef 100644 --- a/web/index.html +++ b/web/index.html @@ -8,7 +8,7 @@ void; + onCancel: () => void; + disabledTools?: ToolName[]; +} + +const TOP_BTN: React.CSSProperties = { + background: "transparent", + border: "none", + color: T.textDim, + padding: "6px 8px", + cursor: "pointer", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", +}; + +export default function MaskEditor({ + open = false, + imageUrl, + imageWidth, + imageHeight, + candidateMask, + onCommit, + onCancel, +}: MaskEditorProps) { + const [state, dispatch] = useReducer(maskEditorReducer, INITIAL_STATE); + const maskCanvasRef = useRef(null); + const overlayCanvasRef = useRef(null); + const hintsCanvasRef = useRef(null); + const srcCanvasRef = useRef(null); + + // Undo/redo stacks — snapshots of mask canvas + hints canvas + polygon vertices + type UndoEntry = { mask: ImageData; hints: ImageData | null; vertices: Point[]; polygonClosed: boolean }; + const undoCanvasStack = useRef([]); + const redoCanvasStack = useRef([]); + + const undoCount = state.undoStack; + const redoCount = state.redoStack; + + // Stable ref so event handlers always call the latest logic without re-registering. + // Synced via useEffect (not during render) to satisfy react-hooks/refs. + const pushUndoRef = useRef<() => void>(() => {}); + useEffect(() => { + pushUndoRef.current = () => { + const mask = maskCanvasRef.current; + const hints = hintsCanvasRef.current; + if (!mask) return; + const mCtx = mask.getContext("2d")!; + const entry: UndoEntry = { + mask: mCtx.getImageData(0, 0, mask.width, mask.height), + hints: hints ? hints.getContext("2d")!.getImageData(0, 0, hints.width, hints.height) : null, + vertices: [...state.polygonVertices], + polygonClosed: state.polygonClosed, + }; + undoCanvasStack.current = [...undoCanvasStack.current, entry].slice(-20); + redoCanvasStack.current = []; + dispatch({ type: "tool_applied" }); + }; + }); + // Stable callback passed to children — never changes reference + const handlePushUndo = useCallback(() => pushUndoRef.current(), []); + + const restoreEntry = useCallback((entry: UndoEntry) => { + const mask = maskCanvasRef.current; + const hints = hintsCanvasRef.current; + if (!mask) return; + mask.getContext("2d")!.putImageData(entry.mask, 0, 0); + if (hints) { + if (entry.hints) hints.getContext("2d")!.putImageData(entry.hints, 0, 0); + else hints.getContext("2d")!.clearRect(0, 0, hints.width, hints.height); + } + dispatch({ type: "polygon_state_restored", vertices: entry.vertices, closed: entry.polygonClosed }); + }, []); + + const captureCurrentEntry = useCallback((): UndoEntry | null => { + const mask = maskCanvasRef.current; + const hints = hintsCanvasRef.current; + if (!mask) return null; + return { + mask: mask.getContext("2d")!.getImageData(0, 0, mask.width, mask.height), + hints: hints ? hints.getContext("2d")!.getImageData(0, 0, hints.width, hints.height) : null, + vertices: [...state.polygonVertices], + polygonClosed: state.polygonClosed, + }; + }, [state.polygonVertices, state.polygonClosed]); + + const handleUndo = useCallback(() => { + if (undoCanvasStack.current.length === 0) return; + const current = captureCurrentEntry(); + if (!current) return; + redoCanvasStack.current = [current, ...redoCanvasStack.current].slice(0, 20); + const prev = undoCanvasStack.current[undoCanvasStack.current.length - 1]; + undoCanvasStack.current = undoCanvasStack.current.slice(0, -1); + restoreEntry(prev); + dispatch({ type: "undo" }); + }, [captureCurrentEntry, restoreEntry]); + + const handleRedo = useCallback(() => { + if (redoCanvasStack.current.length === 0) return; + const current = captureCurrentEntry(); + if (!current) return; + undoCanvasStack.current = [...undoCanvasStack.current, current].slice(-20); + const next = redoCanvasStack.current[0]; + redoCanvasStack.current = redoCanvasStack.current.slice(1); + restoreEntry(next); + dispatch({ type: "redo" }); + }, [captureCurrentEntry, restoreEntry]); + + const handleCommit = useCallback(() => { + const canvas = maskCanvasRef.current; + if (!canvas) return; + // Output: RGB zeroed, alpha = foreground mask + const ctx = canvas.getContext("2d")!; + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + for (let i = 0; i < imageData.data.length; i += 4) { + imageData.data[i] = 0; + imageData.data[i + 1] = 0; + imageData.data[i + 2] = 0; + } + const tmp = document.createElement("canvas"); + tmp.width = canvas.width; + tmp.height = canvas.height; + tmp.getContext("2d")!.putImageData(imageData, 0, 0); + tmp.toBlob((blob) => { + if (blob) onCommit(blob); + }, "image/png"); + }, [onCommit]); + + const handleRunGrabCut = useCallback(() => { + const mask = maskCanvasRef.current; + const src = srcCanvasRef.current; + if (!mask || !src || !state.grabcutRect) return; + const t0 = performance.now(); + handlePushUndo(); + dispatch({ type: "assist_started" }); + runGrabCut(src, mask, hintsCanvasRef.current, state.grabcutRect, state.grabcutIterations) + .then(() => { + dispatch({ type: "assist_succeeded", ms: Math.round(performance.now() - t0) }); + }) + .catch((err: unknown) => { + dispatch({ type: "assist_failed", error: String(err) }); + }); + }, [state.grabcutRect, state.grabcutIterations, handlePushUndo]); + + const handleSnapVertices = useCallback((vertexIndices: number[]) => { + const src = srcCanvasRef.current; + if (!src || state.polygonVertices.length === 0) return; + const targets = vertexIndices.map((i) => state.polygonVertices[i]); + handlePushUndo(); + dispatch({ type: "assist_started" }); + snapVerticesToEdges(src, targets, state.snapRadius, state.snapEdgeThreshold, state.snapEdgeOperator) + .then((snapped) => { + const newVerts = [...state.polygonVertices]; + vertexIndices.forEach((vi, si) => { newVerts[vi] = snapped[si]; }); + dispatch({ type: "polygon_vertices_set", vertices: newVerts }); + dispatch({ type: "assist_succeeded", ms: 0 }); + }) + .catch((err: unknown) => { + dispatch({ type: "assist_failed", error: String(err) }); + }); + }, [state.polygonVertices, state.snapRadius, state.snapEdgeThreshold, state.snapEdgeOperator, handlePushUndo]); + + const handleSnapVertex = useCallback(() => { + if (state.selectedVertex == null) return; + handleSnapVertices([state.selectedVertex]); + }, [state.selectedVertex, handleSnapVertices]); + + const handleSnapAll = useCallback(() => { + handleSnapVertices(state.polygonVertices.map((_, i) => i)); + }, [state.polygonVertices, handleSnapVertices]); + + // ---- Polygon operations ---- + + const handleClosePolygon = useCallback(() => { + if (state.polygonVertices.length < 3) return; + dispatch({ type: "polygon_closed" }); + }, [state.polygonVertices.length]); + + const handleApplyPolygon = useCallback(() => { + const canvas = maskCanvasRef.current; + if (!canvas || state.polygonVertices.length < 3) return; + handlePushUndo(); + fillPolygon(canvas, state.polygonVertices, state.floodMode); + dispatch({ type: "tool_applied" }); + dispatch({ type: "polygon_vertices_set", vertices: [] }); + }, [state.polygonVertices, state.floodMode, handlePushUndo]); + + const handleInsertVertex = useCallback(() => { + const verts = state.polygonVertices; + const sel = state.selectedVertex; + if (verts.length < 2 || sel == null) return; + handlePushUndo(); + const next = (sel + 1) % verts.length; + const mid = { + x: (verts[sel].x + verts[next].x) / 2, + y: (verts[sel].y + verts[next].y) / 2, + }; + const newVerts = [...verts.slice(0, sel + 1), mid, ...verts.slice(sel + 1)]; + dispatch({ type: "polygon_vertices_set", vertices: newVerts }); + dispatch({ type: "polygon_vertex_selected", index: sel + 1 }); + }, [state.polygonVertices, state.selectedVertex, handlePushUndo]); + + const handleSmoothPolygon = useCallback(() => { + if (state.polygonVertices.length < 3) return; + handlePushUndo(); + dispatch({ type: "polygon_vertices_set", vertices: smoothPolygon(state.polygonVertices) }); + }, [state.polygonVertices, handlePushUndo]); + + const handleSimplifyPolygon = useCallback(() => { + if (state.polygonVertices.length < 3) return; + handlePushUndo(); + dispatch({ type: "polygon_vertices_set", vertices: simplifyPolygon(state.polygonVertices, state.polygonSimplifyEps) }); + }, [state.polygonVertices, state.polygonSimplifyEps, handlePushUndo]); + + // ---- Flood commit (fills the whole current mask region — no-op, just shows user can re-click) ---- + // "Commit fill" in the inspector confirms the last flood op, which is already live on canvas. + // We just dispatch tool_applied to mark dirty if not already marked. + const handleFloodCommit = useCallback(() => { + dispatch({ type: "tool_applied" }); + }, []); + + // ---- Global keyboard shortcuts ---- + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return; + + const mod = e.metaKey || e.ctrlKey; + const tool = state.activeTool; + + if (mod) { + if (e.key === "z" && !e.shiftKey) { e.preventDefault(); handleUndo(); } + else if ((e.key === "z" && e.shiftKey) || e.key === "y") { e.preventDefault(); handleRedo(); } + else if (e.key === "Enter" && tool === "grabcut") { e.preventDefault(); handleRunGrabCut(); } + return; + } + + switch (e.key) { + case "p": case "P": dispatch({ type: "set_tool", tool: "prefill" }); break; + case "e": case "E": dispatch({ type: "set_tool", tool: "eraser" }); break; + case "g": case "G": + if (tool === "grabcut") dispatch({ type: "set_grabcut_hint_mode", mode: "background" }); + else dispatch({ type: "set_tool", tool: "polygon" }); + break; + case "f": case "F": + if (tool === "grabcut") dispatch({ type: "set_grabcut_hint_mode", mode: "foreground" }); + else dispatch({ type: "set_tool", tool: "flood" }); + break; + case "c": case "C": dispatch({ type: "set_tool", tool: "grabcut" }); break; + case "s": case "S": + if (tool === "snap") { + if (e.shiftKey) handleSnapAll(); + else handleSnapVertex(); + } else { + dispatch({ type: "set_tool", tool: "snap" }); + } + break; + case "r": case "R": + if (tool === "grabcut") dispatch({ type: "set_grabcut_rect", rect: null }); + break; + case "Enter": + // Enter only closes the path. Apply requires the "Apply to mask" button. + if (tool === "polygon" && !state.polygonClosed) { + e.preventDefault(); + handleClosePolygon(); + } + break; + case "Backspace": case "Delete": + if (tool === "polygon" && state.selectedVertex != null) { + e.preventDefault(); + dispatch({ type: "polygon_vertex_deleted", index: state.selectedVertex }); + } + break; + case "i": case "I": + if (tool === "polygon") handleInsertVertex(); + break; + case "[": + if (tool === "snap") dispatch({ type: "set_snap_radius", radius: Math.max(2, state.snapRadius - 4) }); + else if (tool === "eraser") dispatch({ type: "set_eraser_radius", radius: Math.max(2, state.eraserRadius - 4) }); + break; + case "]": + if (tool === "snap") dispatch({ type: "set_snap_radius", radius: Math.min(64, state.snapRadius + 4) }); + else if (tool === "eraser") dispatch({ type: "set_eraser_radius", radius: Math.min(120, state.eraserRadius + 4) }); + break; + case "ArrowLeft": case "ArrowUp": + if (tool === "snap" && state.polygonVertices.length > 0) { + e.preventDefault(); + const n = state.polygonVertices.length; + const cur = state.selectedVertex ?? 0; + dispatch({ type: "polygon_vertex_selected", index: (cur - 1 + n) % n }); + } + break; + case "ArrowRight": case "ArrowDown": + if (tool === "snap" && state.polygonVertices.length > 0) { + e.preventDefault(); + const n = state.polygonVertices.length; + const cur = state.selectedVertex ?? -1; + dispatch({ type: "polygon_vertex_selected", index: (cur + 1) % n }); + } + break; + } + }; + + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [ + state.activeTool, + state.selectedVertex, + state.snapRadius, + state.eraserRadius, + state.polygonVertices, + state.polygonClosed, + handleUndo, + handleRedo, + handleRunGrabCut, + handleSnapAll, + handleSnapVertex, + handleClosePolygon, + handleApplyPolygon, + handleInsertVertex, + ]); + + return ( + + {/* Top bar */} +
+ {/* Left: cancel + title */} +
+ +
+
+ Edit mask +
+
+ candidate IoU — +
+
+
+ + {/* Right: undo/redo + view + commit */} +
+
+ + +
+
+ {undoCount} / 20 +
+ + + + + + + + + + {state.assistStatus === "loading" && ( + running… + )} + + +
+
+ + {/* Body: tool rail + canvas + inspector */} +
+ + + + + dispatch({ type: "polygon_reopened" })} + onInsertVertex={handleInsertVertex} + onSmoothPolygon={handleSmoothPolygon} + onSimplifyPolygon={handleSimplifyPolygon} + onFloodCommit={handleFloodCommit} + onRunGrabCut={handleRunGrabCut} + onSnapVertex={handleSnapVertex} + onSnapAll={handleSnapAll} + /> +
+ + +
+ ); +} diff --git a/web/src/components/MaskEditorCanvas.tsx b/web/src/components/MaskEditorCanvas.tsx new file mode 100644 index 00000000..01c8d5e7 --- /dev/null +++ b/web/src/components/MaskEditorCanvas.tsx @@ -0,0 +1,797 @@ +import { useRef, useEffect, useState } from "react"; +import type { Dispatch } from "react"; +import { Pill, Spinner } from "./MaskEditorShared"; +import { T } from "./maskEditorTokens"; +import type { MaskEditorState, MaskEditorAction, ToolName, Point } from "./maskEditorState"; +import { floodFill } from "./maskEditorCanvasOps"; +import { HINT_FG_COLOR, HINT_BG_COLOR, isCvReady, getCv } from "./maskEditorCv"; + +// ---- Coordinate helpers ---- + +function canvasPoint( + canvas: HTMLCanvasElement, + e: PointerEvent | MouseEvent, +): Point { + const rect = canvas.getBoundingClientRect(); + return { + x: ((e.clientX - rect.left) * canvas.width) / rect.width, + y: ((e.clientY - rect.top) * canvas.height) / rect.height, + }; +} + +// ---- Overlay canvas drawing ---- + +function drawPolygonOverlay( + ctx: CanvasRenderingContext2D, + vertices: Point[], + selectedVertex: number | null, + hoverPoint: Point | null, + closed = false, +) { + const W = ctx.canvas.width; + const H = ctx.canvas.height; + ctx.clearRect(0, 0, W, H); + if (vertices.length === 0) return; + + ctx.save(); + + // Fill preview (subtle tint) + if (vertices.length >= 3) { + ctx.beginPath(); + ctx.moveTo(vertices[0].x, vertices[0].y); + for (let i = 1; i < vertices.length; i++) ctx.lineTo(vertices[i].x, vertices[i].y); + ctx.closePath(); + ctx.fillStyle = T.accentTint; + ctx.fill(); + } + + // Helper: draw edge path + const drawEdgePath = () => { + ctx.beginPath(); + ctx.moveTo(vertices[0].x, vertices[0].y); + for (let i = 1; i < vertices.length; i++) ctx.lineTo(vertices[i].x, vertices[i].y); + if (closed && vertices.length >= 3) ctx.closePath(); + }; + + // Two-pass edges: dark shadow stroke then colored stroke for contrast on any background + ctx.setLineDash([]); + drawEdgePath(); + ctx.strokeStyle = "rgba(0,0,0,0.55)"; + ctx.lineWidth = 3.5; + ctx.stroke(); + drawEdgePath(); + ctx.strokeStyle = T.accent; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Preview line to cursor (open mode only) + if (hoverPoint && vertices.length > 0) { + const last = vertices[vertices.length - 1]; + ctx.beginPath(); + ctx.moveTo(last.x, last.y); + ctx.lineTo(hoverPoint.x, hoverPoint.y); + ctx.strokeStyle = "rgba(0,0,0,0.45)"; + ctx.lineWidth = 2.5; + ctx.setLineDash([4, 4]); + ctx.stroke(); + ctx.strokeStyle = T.accent; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.setLineDash([]); + } + + // Vertex handles — white fill with colored outline for max contrast + for (let i = 0; i < vertices.length; i++) { + const v = vertices[i]; + const isSel = i === selectedVertex; + const r = isSel ? 6 : 4; + // Shadow + ctx.beginPath(); + ctx.arc(v.x, v.y, r + 1.5, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,0.5)"; + ctx.fill(); + // Fill + ctx.beginPath(); + ctx.arc(v.x, v.y, r, 0, Math.PI * 2); + ctx.fillStyle = isSel ? T.accent : "white"; + ctx.fill(); + // Ring + ctx.strokeStyle = isSel ? "white" : T.accent; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + ctx.restore(); +} + +function drawGrabCutRect( + ctx: CanvasRenderingContext2D, + rect: { x: number; y: number; w: number; h: number } | null, + dragging: { x: number; y: number; w: number; h: number } | null, +) { + const r = dragging ?? rect; + if (!r || r.w === 0 || r.h === 0) return; + + const { x, y, w, h } = r; + ctx.save(); + ctx.strokeStyle = T.text; + ctx.lineWidth = 1.5; + ctx.setLineDash([5, 4]); + ctx.strokeRect(x, y, w, h); + ctx.setLineDash([]); + + // Corner handles + for (const [cx, cy] of [[x, y], [x + w, y], [x, y + h], [x + w, y + h]] as [number, number][]) { + ctx.fillStyle = T.text; + ctx.fillRect(cx - 3, cy - 3, 6, 6); + } + + // Size label + ctx.fillStyle = T.bg2; + ctx.fillRect(x, y - 18, 80, 16); + ctx.fillStyle = T.textDim; + ctx.font = `9px ${T.fontMono}`; + ctx.fillText(`${Math.round(Math.abs(w))}×${Math.round(Math.abs(h))}`, x + 4, y - 6); + ctx.restore(); +} + +// ---- Context strip ---- + +function ContextStrip({ + tool, + cursor, + eraserRadius, + floodTolerance, + floodMode, + floodConnectivity, + polygonVertices, + polygonSimplifyEps, + grabcutRect, + grabcutIterations, + snapEdgeOperator, + snapRadius, + snapEdgeThreshold, +}: { + tool: ToolName; + cursor: Point | null; + eraserRadius: number; + floodTolerance: number; + floodMode: "add" | "subtract"; + floodConnectivity: "4" | "8"; + polygonVertices: Point[]; + polygonSimplifyEps: number; + grabcutRect: { x: number; y: number; w: number; h: number } | null; + grabcutIterations: number; + snapEdgeOperator: string; + snapRadius: number; + snapEdgeThreshold: number; +}) { + const cx = cursor ? Math.round(cursor.x) : 0; + const cy = cursor ? Math.round(cursor.y) : 0; + + const leftText: Record = { + prefill: "PRE-FILL · applying candidate mask", + polygon: `POLY · ${polygonVertices.length} vertices · ε ${polygonSimplifyEps}`, + flood: `FLOOD · ${floodMode} · tol ${floodTolerance} · ${floodConnectivity}-way`, + eraser: `ERASER · r ${eraserRadius} px`, + grabcut: grabcutRect + ? `GRABCUT · rect ${Math.round(grabcutRect.w)}×${Math.round(grabcutRect.h)} · iter ${grabcutIterations}` + : "GRABCUT · drag to set rect", + snap: `SNAP · ${snapEdgeOperator} · r ${snapRadius} · τ ${snapEdgeThreshold}`, + }; + + return ( +
+ {leftText[tool]} + {cursor ? `cursor ${cx}, ${cy}` : ""} +
+ ); +} + +// ---- Tool label ---- +const TOOL_LABELS: Record = { + prefill: "PRE-FILL", + polygon: "POLYGON", + flood: "FLOOD FILL", + eraser: "ERASER", + grabcut: "GRABCUT", + snap: "CONTOUR SNAP", +}; + +// ---- Main component ---- + +interface MaskEditorCanvasProps { + state: MaskEditorState; + dispatch: Dispatch; + maskCanvasRef: React.RefObject; + overlayCanvasRef: React.RefObject; + hintsCanvasRef: React.RefObject; + srcCanvasRef: React.MutableRefObject; + imageUrl: string; + imageWidth: number; + imageHeight: number; + candidateMask?: string | null; + onPushUndo: () => void; + onClosePolygon: () => void; +} + +export default function MaskEditorCanvas({ + state, + dispatch, + maskCanvasRef, + overlayCanvasRef, + hintsCanvasRef, + srcCanvasRef, + imageUrl, + imageWidth, + imageHeight, + candidateMask, + onPushUndo, + onClosePolygon, +}: MaskEditorCanvasProps) { + const tool = state.activeTool; + const dragRect = useRef<{ startX: number; startY: number; x: number; y: number; w: number; h: number } | null>(null); + const draggingVertexIdx = useRef(null); + const hintPainting = useRef(false); + const [cursor, setCursor] = useState(null); + const [cvLoaded, setCvLoaded] = useState(isCvReady); + + // Track WASM ready state so the UI can show a loading indicator + useEffect(() => { + if (cvLoaded) return; + getCv().then(() => setCvLoaded(true)); + }, [cvLoaded]); + + // ---- Initialize canvas dimensions ---- + useEffect(() => { + const mask = maskCanvasRef.current; + const overlay = overlayCanvasRef.current; + const hints = hintsCanvasRef.current; + if (!mask || !overlay) return; + mask.width = imageWidth; + mask.height = imageHeight; + overlay.width = imageWidth; + overlay.height = imageHeight; + if (hints) { hints.width = imageWidth; hints.height = imageHeight; } + }, [imageWidth, imageHeight, maskCanvasRef, overlayCanvasRef, hintsCanvasRef]); + + // ---- Load source image into offscreen canvas for flood fill pixel access ---- + useEffect(() => { + if (!imageUrl) return; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const c = document.createElement("canvas"); + c.width = imageWidth; + c.height = imageHeight; + c.getContext("2d")!.drawImage(img, 0, 0, imageWidth, imageHeight); + srcCanvasRef.current = c; + }; + img.src = imageUrl; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [imageUrl, imageWidth, imageHeight]); // srcCanvasRef is a stable mutable ref + + // ---- Load candidateMask onto maskCanvas ---- + useEffect(() => { + const mask = maskCanvasRef.current; + if (!mask || !candidateMask) return; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const ctx = mask.getContext("2d")!; + ctx.clearRect(0, 0, mask.width, mask.height); + // Draw mask — threshold alpha > 128 → paint foreground color + const tmp = document.createElement("canvas"); + tmp.width = mask.width; + tmp.height = mask.height; + const tmpCtx = tmp.getContext("2d")!; + tmpCtx.drawImage(img, 0, 0, mask.width, mask.height); + const imgData = tmpCtx.getImageData(0, 0, mask.width, mask.height); + const outData = ctx.createImageData(mask.width, mask.height); + // Parse T.accent into RGBA for painting (approximate from oklch string — use warm orange) + for (let i = 0; i < imgData.data.length; i += 4) { + if (imgData.data[i + 3] > 128) { + outData.data[i] = 220; + outData.data[i + 1] = 90; + outData.data[i + 2] = 40; + outData.data[i + 3] = 255; + } + } + ctx.putImageData(outData, 0, 0); + dispatch({ type: "hydrate", hadMask: true }); + }; + img.src = candidateMask; + }, [candidateMask, maskCanvasRef, dispatch]); + + // ---- Composite overlay redraw ---- + // Polygon vertices persist across tool switches; grabcut rect composites on top when active. + useEffect(() => { + const overlay = overlayCanvasRef.current; + if (!overlay) return; + const ctx = overlay.getContext("2d")!; + ctx.clearRect(0, 0, overlay.width, overlay.height); + if (state.polygonVertices.length > 0) { + drawPolygonOverlay(ctx, state.polygonVertices, state.selectedVertex, null, state.polygonClosed); + } + if (tool === "grabcut") { + drawGrabCutRect(ctx, state.grabcutRect, null); + } + }, [tool, state.polygonVertices, state.polygonClosed, state.selectedVertex, state.grabcutRect, overlayCanvasRef]); + + // ---- Eraser (paint alpha=0 circles on maskCanvas) ---- + useEffect(() => { + const canvas = maskCanvasRef.current; + if (!canvas || tool !== "eraser") return; + + const eraserPainting = { active: false }; + + const paint = (e: PointerEvent) => { + const p = canvasPoint(canvas, e); + const ctx = canvas.getContext("2d")!; + ctx.save(); + ctx.globalCompositeOperation = "destination-out"; + ctx.beginPath(); + ctx.arc(p.x, p.y, state.eraserRadius, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,1)"; + ctx.fill(); + ctx.restore(); + }; + + const onDown = (e: PointerEvent) => { + onPushUndo(); + eraserPainting.active = true; + canvas.setPointerCapture(e.pointerId); + paint(e); + }; + const onMove = (e: PointerEvent) => { + const p = canvasPoint(canvas, e); + setCursor(p); + if (eraserPainting.active) paint(e); + }; + const onUp = () => { eraserPainting.active = false; }; + const onLeave = () => setCursor(null); + + canvas.addEventListener("pointerdown", onDown); + canvas.addEventListener("pointermove", onMove); + canvas.addEventListener("pointerup", onUp); + canvas.addEventListener("pointerleave", onLeave); + return () => { + canvas.removeEventListener("pointerdown", onDown); + canvas.removeEventListener("pointermove", onMove); + canvas.removeEventListener("pointerup", onUp); + canvas.removeEventListener("pointerleave", onLeave); + }; + }, [tool, state.eraserRadius, maskCanvasRef, onPushUndo]); + + // ---- Flood fill click (on maskCanvas) ---- + useEffect(() => { + const canvas = maskCanvasRef.current; + if (!canvas) return; + if (tool !== "flood") return; + + const onClick = (e: MouseEvent) => { + const src = srcCanvasRef.current; + if (!src) return; + const p = canvasPoint(canvas, e); + onPushUndo(); + floodFill(canvas, src, p.x, p.y, state.floodTolerance, state.floodConnectivity, state.floodMode); + dispatch({ type: "tool_applied" }); + }; + + canvas.addEventListener("click", onClick); + return () => canvas.removeEventListener("click", onClick); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tool, state.floodTolerance, state.floodConnectivity, state.floodMode, maskCanvasRef, onPushUndo, dispatch]); // srcCanvasRef is a stable mutable ref + + // ---- Polygon pointer events (on overlayCanvas) ---- + useEffect(() => { + const overlay = overlayCanvasRef.current; + const mask = maskCanvasRef.current; + if (!overlay || !mask) return; + if (tool !== "polygon") return; + + const oCtx = overlay.getContext("2d")!; + + const HANDLE_HIT = 8; + + const findVertex = (p: Point): number | null => { + for (let i = 0; i < state.polygonVertices.length; i++) { + const v = state.polygonVertices[i]; + if (Math.abs(v.x - p.x) <= HANDLE_HIT && Math.abs(v.y - p.y) <= HANDLE_HIT) return i; + } + return null; + }; + + const onDown = (e: PointerEvent) => { + const p = canvasPoint(overlay, e); + const hit = findVertex(p); + if (hit !== null) { + onPushUndo(); // snapshot before drag starts + draggingVertexIdx.current = hit; + overlay.setPointerCapture(e.pointerId); + dispatch({ type: "polygon_vertex_selected", index: hit }); + } + }; + + const onMove = (e: PointerEvent) => { + const p = canvasPoint(overlay, e); + setCursor(p); + if (draggingVertexIdx.current !== null) { + dispatch({ type: "polygon_vertex_moved", index: draggingVertexIdx.current, point: p }); + } else if (!state.polygonClosed) { + // Preview edge to cursor only when open (adding vertices) + drawPolygonOverlay(oCtx, state.polygonVertices, state.selectedVertex, p, false); + } + }; + + const onUp = () => { + draggingVertexIdx.current = null; + }; + + const onClick = (e: MouseEvent) => { + const p = canvasPoint(overlay, e); + const hit = findVertex(p); + if (hit !== null) { + dispatch({ type: "polygon_vertex_selected", index: hit }); + return; + } + if (state.polygonClosed) return; // closed: no new vertices on click + onPushUndo(); // snapshot before adding vertex + dispatch({ type: "polygon_vertex_added", point: p }); + }; + + const onDblClick = (e: MouseEvent) => { + e.preventDefault(); + onClosePolygon(); + }; + + const onContextMenu = (e: MouseEvent) => { + e.preventDefault(); + const p = canvasPoint(overlay, e); + const hit = findVertex(p); + if (hit !== null) { + onPushUndo(); // snapshot before deleting vertex + dispatch({ type: "polygon_vertex_deleted", index: hit }); + } + }; + + const onLeave = () => { + setCursor(null); + // Redraw without hover preview line + oCtx.clearRect(0, 0, overlay.width, overlay.height); + drawPolygonOverlay(oCtx, state.polygonVertices, state.selectedVertex, null, state.polygonClosed); + }; + + overlay.addEventListener("pointerdown", onDown); + overlay.addEventListener("pointermove", onMove); + overlay.addEventListener("pointerup", onUp); + overlay.addEventListener("click", onClick); + overlay.addEventListener("dblclick", onDblClick); + overlay.addEventListener("contextmenu", onContextMenu); + overlay.addEventListener("pointerleave", onLeave); + return () => { + overlay.removeEventListener("pointerdown", onDown); + overlay.removeEventListener("pointermove", onMove); + overlay.removeEventListener("pointerup", onUp); + overlay.removeEventListener("click", onClick); + overlay.removeEventListener("dblclick", onDblClick); + overlay.removeEventListener("contextmenu", onContextMenu); + overlay.removeEventListener("pointerleave", onLeave); + }; + }, [ + tool, + state.polygonVertices, + state.polygonClosed, + state.selectedVertex, + overlayCanvasRef, + maskCanvasRef, + onClosePolygon, + onPushUndo, + dispatch, + ]); + + // ---- GrabCut hint painting (FG/BG scribbles on hintsCanvas) ---- + useEffect(() => { + const hints = hintsCanvasRef.current; + if (!hints || tool !== "grabcut") return; + const hintMode = state.grabcutHintMode; + if (hintMode === "rect") return; // rect drag is handled by overlay, not hints + + const ctx = hints.getContext("2d")!; + const color = hintMode === "foreground" ? HINT_FG_COLOR : HINT_BG_COLOR; + const radius = state.grabcutBrushRadius; + + const onDown = (e: PointerEvent) => { + onPushUndo(); // snapshot before first stroke pixel + hintPainting.current = true; + hints.setPointerCapture(e.pointerId); + const p = canvasPoint(hints, e); + ctx.beginPath(); + ctx.fillStyle = color; + ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); + ctx.fill(); + }; + const onMove = (e: PointerEvent) => { + const p = canvasPoint(hints, e); + setCursor(p); + if (!hintPainting.current) return; + ctx.beginPath(); + ctx.fillStyle = color; + ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); + ctx.fill(); + }; + const onUp = () => { hintPainting.current = false; }; + const onLeave = () => setCursor(null); + + hints.addEventListener("pointerdown", onDown); + hints.addEventListener("pointermove", onMove); + hints.addEventListener("pointerup", onUp); + hints.addEventListener("pointerleave", onLeave); + return () => { + hints.removeEventListener("pointerdown", onDown); + hints.removeEventListener("pointermove", onMove); + hints.removeEventListener("pointerup", onUp); + hints.removeEventListener("pointerleave", onLeave); + }; + }, [tool, state.grabcutHintMode, state.grabcutBrushRadius, hintsCanvasRef, onPushUndo]); + + // ---- GrabCut rect drag (on overlayCanvas) ---- + useEffect(() => { + const overlay = overlayCanvasRef.current; + if (!overlay) return; + if (tool !== "grabcut") return; + + const oCtx = overlay.getContext("2d")!; + + const onDown = (e: PointerEvent) => { + const p = canvasPoint(overlay, e); + overlay.setPointerCapture(e.pointerId); + dragRect.current = { startX: p.x, startY: p.y, x: p.x, y: p.y, w: 0, h: 0 }; + }; + + const onMove = (e: PointerEvent) => { + const p = canvasPoint(overlay, e); + setCursor(p); + if (!dragRect.current) return; + const r = dragRect.current; + r.x = Math.min(p.x, r.startX); + r.y = Math.min(p.y, r.startY); + r.w = Math.abs(p.x - r.startX); + r.h = Math.abs(p.y - r.startY); + // Composite: clear, polygon, then live drag rect + oCtx.clearRect(0, 0, overlay.width, overlay.height); + if (state.polygonVertices.length > 0) { + drawPolygonOverlay(oCtx, state.polygonVertices, state.selectedVertex, null, state.polygonClosed); + } + drawGrabCutRect(oCtx, state.grabcutRect, r); + }; + + const onUp = () => { + if (!dragRect.current) return; + const r = dragRect.current; + if (r.w > 4 && r.h > 4) { + dispatch({ type: "set_grabcut_rect", rect: { x: r.x, y: r.y, w: r.w, h: r.h } }); + } + dragRect.current = null; + }; + + const onLeave = () => setCursor(null); + + overlay.addEventListener("pointerdown", onDown); + overlay.addEventListener("pointermove", onMove); + overlay.addEventListener("pointerup", onUp); + overlay.addEventListener("pointerleave", onLeave); + return () => { + overlay.removeEventListener("pointerdown", onDown); + overlay.removeEventListener("pointermove", onMove); + overlay.removeEventListener("pointerup", onUp); + overlay.removeEventListener("pointerleave", onLeave); + }; + }, [tool, state.grabcutRect, state.polygonVertices, state.polygonClosed, state.selectedVertex, overlayCanvasRef, dispatch]); + + // ---- Snap mode: click to select vertex ---- + useEffect(() => { + const overlay = overlayCanvasRef.current; + if (!overlay || tool !== "snap") return; + + const HANDLE_HIT = 10; + const vertices = state.polygonVertices; + + const findVertex = (p: Point): number | null => { + let best: number | null = null; + let bestDist = HANDLE_HIT; + for (let i = 0; i < vertices.length; i++) { + const dx = vertices[i].x - p.x; + const dy = vertices[i].y - p.y; + const d = Math.sqrt(dx * dx + dy * dy); + if (d < bestDist) { bestDist = d; best = i; } + } + return best; + }; + + const onMove = (e: PointerEvent) => setCursor(canvasPoint(overlay, e)); + const onClick = (e: MouseEvent) => { + const p = canvasPoint(overlay, e); + const hit = findVertex(p); + if (hit !== null) dispatch({ type: "polygon_vertex_selected", index: hit }); + }; + const onLeave = () => setCursor(null); + + overlay.addEventListener("pointermove", onMove); + overlay.addEventListener("click", onClick); + overlay.addEventListener("pointerleave", onLeave); + return () => { + overlay.removeEventListener("pointermove", onMove); + overlay.removeEventListener("click", onClick); + overlay.removeEventListener("pointerleave", onLeave); + }; + }, [tool, state.polygonVertices, overlayCanvasRef, dispatch]); + + // ---- Canvas pointer events for cursor tracking (prefill) ---- + useEffect(() => { + const overlay = overlayCanvasRef.current; + if (!overlay) return; + if (tool !== "prefill") return; + const onMove = (e: PointerEvent) => setCursor(canvasPoint(overlay, e)); + const onLeave = () => setCursor(null); + overlay.addEventListener("pointermove", onMove); + overlay.addEventListener("pointerleave", onLeave); + return () => { + overlay.removeEventListener("pointermove", onMove); + overlay.removeEventListener("pointerleave", onLeave); + }; + }, [tool, overlayCanvasRef]); + + // Cursor style per tool + const cursorStyle: Record = { + prefill: "default", + polygon: "crosshair", + flood: "crosshair", + eraser: "cell", + grabcut: "crosshair", + snap: "default", + }; + + return ( +
+ {/* Canvas viewport */} +
+ {/* Checker background for transparency */} +
+ + {/* Source image */} + + + {/* Mask canvas — alpha channel = foreground */} + + + {/* Overlay canvas — cursor, polygon, grabcut rect (only rect mode) */} + + + {/* Hints canvas — FG/BG scribbles for GrabCut */} + + + {/* Corner badges */} +
+ {TOOL_LABELS[tool]} + {imageWidth} × {imageHeight} + {(tool === "grabcut" || tool === "snap") && !cvLoaded && ( + + + opencv.js loading… + + )} +
+
+ {state.dirty && ● unsaved} +
+
+ + +
+ ); +} + +export type MaskCanvasRef = React.RefObject; diff --git a/web/src/components/MaskEditorFooter.tsx b/web/src/components/MaskEditorFooter.tsx new file mode 100644 index 00000000..70f373a5 --- /dev/null +++ b/web/src/components/MaskEditorFooter.tsx @@ -0,0 +1,60 @@ +import { Pill } from "./MaskEditorShared"; +import { T } from "./maskEditorTokens"; +import type { MaskEditorState, ToolName } from "./maskEditorState"; + +const TOOL_PERF: Record = { + prefill: "— · candidate load", + polygon: "— · canvas-only", + flood: "~38 ms · canvas", + eraser: "— · canvas-only", + grabcut: "482 ms · last", + snap: "24 ms · last", +}; + +interface MaskEditorFooterProps { + state: MaskEditorState; +} + +export default function MaskEditorFooter({ state }: MaskEditorFooterProps) { + const usesWasm = state.activeTool === "grabcut" || state.activeTool === "snap"; + const perf = TOOL_PERF[state.activeTool]; + const undoCount = state.undoStack; + + return ( +
+
+ Mask · 640 × 480 · RGBA + FG: 18,420 px (5.99%) + Edits: {undoCount} +
+
+ + + {usesWasm ? "opencv.js · wasm" : "canvas · local"} + + {perf} +
+
+ ); +} diff --git a/web/src/components/MaskEditorIcons.tsx b/web/src/components/MaskEditorIcons.tsx new file mode 100644 index 00000000..b0c69ff4 --- /dev/null +++ b/web/src/components/MaskEditorIcons.tsx @@ -0,0 +1,145 @@ +import type { CSSProperties } from "react"; + +interface MEIconProps { + name: keyof typeof PATHS; + size?: number; + style?: CSSProperties; +} + +const SVG_PROPS = { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.6, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, +}; + +const PATHS = { + layers: ( + <> + + + + + ), + brush: ( + <> + + + + ), + polygon: ( + <> + + + + + + + + ), + flood: ( + <> + + + + ), + eraser: ( + <> + + + + ), + grabcut: ( + <> + + + + ), + snap: ( + <> + + + + + + + + + + + ), + undo: ( + <> + + + + ), + redo: ( + <> + + + + ), + eye: ( + <> + + + + ), + grid: ( + <> + + + + + + ), + zoom: ( + <> + + + + + + ), + x: ( + <> + + + + ), + check: , + info: ( + <> + + + + + ), + play: , + keyboard: ( + <> + + + + + + + + ), +}; + +export default function MEIcon({ name, size = 18, style }: MEIconProps) { + return ( + + {PATHS[name]} + + ); +} diff --git a/web/src/components/MaskEditorInspector.tsx b/web/src/components/MaskEditorInspector.tsx new file mode 100644 index 00000000..d0d999c4 --- /dev/null +++ b/web/src/components/MaskEditorInspector.tsx @@ -0,0 +1,969 @@ +import type { Dispatch } from "react"; +import MEIcon from "./MaskEditorIcons"; +import { + ActionButton, + InspectorShell, + MESlider, + Pill, + Section, + Segmented, + ShortcutRow, + Stat, + Stepper, + Toggle, +} from "./MaskEditorShared"; +import { T } from "./maskEditorTokens"; +import type { + EdgeOperator, + FloodConnectivity, + FloodMode, + GrabCutHintMode, + MaskEditorAction, + MaskEditorState, + SnapTarget, + ToolName, +} from "./maskEditorState"; + +// ---- Polygon inspector ---- +interface PolygonInspectorProps { + state: MaskEditorState; + dispatch: Dispatch; + onClosePolygon?: () => void; + onApplyPolygon?: () => void; + onReopenPolygon?: () => void; + onInsertVertex?: () => void; + onSmoothPolygon?: () => void; + onSimplifyPolygon?: () => void; +} + +function PolygonInspector({ state, dispatch, onClosePolygon, onApplyPolygon, onReopenPolygon, onInsertVertex, onSmoothPolygon, onSimplifyPolygon }: PolygonInspectorProps) { + const nVerts = state.polygonVertices.length; + const sel = state.selectedVertex; + const closed = state.polygonClosed; + return ( + +
v{sel} : undefined} + > +
+ +
+
+ +
+
+ } + onClick={() => + sel != null && + dispatch({ type: "polygon_vertex_deleted", index: sel }) + } + disabled={sel == null} + > + Delete v{sel ?? "—"} + + + Insert here · I + + + Smooth ×3 + + {!closed ? ( + + Close path · ⏎ + + ) : ( + + Reopen path + + )} +
+ {closed && ( + } onClick={onApplyPolygon} disabled={nVerts < 3}> + Apply to mask + + )} +
+ +
+ + dispatch({ type: "set_polygon_simplify_eps", eps: v }) + } + /> +
+ + {nVerts} → {Math.max(3, nVerts - 5)} vertices + + Δ IoU −0.4% +
+
+ + Apply simplify + +
+
+ +
+ dispatch({ type: "set_polygon_live_snap", on })} + /> + + dispatch({ type: "set_polygon_snap_radius", radius: v }) + } + /> +
+ +
+ + + + + +
+
+ ); +} + +// ---- Eraser inspector ---- +function EraserInspector({ state, dispatch }: { state: MaskEditorState; dispatch: Dispatch }) { + return ( + +
+ dispatch({ type: "set_eraser_radius", radius: v })} + /> +
+
+ + +
+
+ ); +} + +// ---- Tone histogram ---- +function ToneHistogram() { + const bars = Array.from({ length: 32 }, (_, i) => { + const t = i / 31; + const a = Math.exp(-Math.pow((t - 0.22) / 0.08, 2)); + const b = Math.exp(-Math.pow((t - 0.62) / 0.14, 2)) * 0.7; + return a + b; + }); + const max = Math.max(...bars); + return ( +
+
+ {bars.map((v, i) => ( +
+ ))} +
+
+ ); +} + +// ---- Flood inspector ---- +interface FloodInspectorProps { + state: MaskEditorState; + dispatch: Dispatch; + onFloodCommit?: () => void; +} + +function FloodInspector({ state, dispatch, onFloodCommit }: FloodInspectorProps) { + return ( + +
+ + dispatch({ type: "set_flood_mode", mode: v as FloodMode }) + } + /> +
+ +
+ dispatch({ type: "set_flood_tolerance", tolerance: v })} + /> + +
+ ← darker + lighter → +
+
+ +
+ + dispatch({ + type: "set_flood_sample_size", + size: Math.max(1, state.floodSampleSize - 2), + }) + } + onIncrement={() => + dispatch({ + type: "set_flood_sample_size", + size: Math.min(9, state.floodSampleSize + 2), + }) + } + /> +
+
+
+
rgb(98, 52, 38)
+
Lab(28, 18, 14)
+
+
+
+ +
+ + dispatch({ + type: "set_flood_connectivity", + connectivity: v as FloodConnectivity, + }) + } + /> +
+ dispatch({ type: "set_flood_contiguous", contiguous: on })} + /> + dispatch({ type: "set_flood_anti_alias", antiAlias: on })} + /> +
+ +
+
+ + 4,218 px + +1.4% FG +
+
+ } onClick={onFloodCommit}> + Commit fill + +
+
+ ); +} + +// ---- GrabCut inspector ---- +function HintModeRow({ + active, + label, + sub, + swatch, + stroke, + onClick, +}: { + active: boolean; + label: string; + sub: string; + swatch: string; + stroke?: boolean; + onClick?: () => void; +}) { + return ( +
+
+
+
+ {label} +
+
+ {sub} +
+
+ {active ? active : null} +
+ ); +} + +function TopologyChip({ on, label, sub }: { on: boolean; label: string; sub: string }) { + return ( +
+
+ {label} +
+
+ {sub} +
+
+ ); +} + +interface GrabCutInspectorProps { + state: MaskEditorState; + dispatch: Dispatch; + onRunGrabCut?: () => void; +} + +function GrabCutInspector({ state, dispatch, onRunGrabCut }: GrabCutInspectorProps) { + const isLoading = state.assistStatus === "loading"; + return ( + +
+
+ {( + [ + { id: "rect" as GrabCutHintMode, label: "Bounding rect", sub: "defines the search region", swatch: T.text, stroke: true }, + { id: "foreground" as GrabCutHintMode, label: "Foreground", sub: "scribble what is part of the piece", swatch: T.accent, stroke: false }, + { id: "background" as GrabCutHintMode, label: "Background", sub: "scribble what should be excluded", swatch: T.slate, stroke: false }, + ] + ).map((row) => ( + + dispatch({ type: "set_grabcut_hint_mode", mode: row.id }) + } + /> + ))} +
+
+ +
+ + dispatch({ type: "set_grabcut_brush_radius", radius: v }) + } + /> + + dispatch({ + type: "set_grabcut_iterations", + iterations: Math.max(1, state.grabcutIterations - 1), + }) + } + onIncrement={() => + dispatch({ + type: "set_grabcut_iterations", + iterations: Math.min(20, state.grabcutIterations + 1), + }) + } + /> +
+ +
ready}> + } + loading={isLoading} + onClick={onRunGrabCut} + disabled={!state.grabcutRect} + > + {isLoading ? "Running…" : "Refine mask · ⌘↵"} + + {state.lastAssistMs != null && ( +
+
+ Last run +
+
+ + + + +
+
+ )} + {state.assistError && ( +
+ {state.assistError} +
+ )} +
+ +
+
+ + +
+
+ Last 10 runs: median 462 ms · p95 712 ms +
+
+ +
+ + + + +
+
+ ); +} + +// ---- Gradient preview (contour snap) ---- +function GradientPreview() { + return ( +
+ + + + + + + + + {Array.from({ length: 60 }).map((_, i) => { + const x = i * 4; + const e1 = Math.exp(-Math.pow((x - 80) / 6, 2)); + const e2 = Math.exp(-Math.pow((x - 160) / 7, 2)) * 0.85; + const intensity = e1 + e2 + ((i * 17) % 5) / 40; + return ( + + ); + })} + + edge + 0 + +
+ ); +} + +// ---- Contour snap inspector ---- +interface SnapInspectorProps { + state: MaskEditorState; + dispatch: Dispatch; + onSnapVertex?: () => void; + onSnapAll?: () => void; +} + +function SnapInspector({ state, dispatch, onSnapVertex, onSnapAll }: SnapInspectorProps) { + const nVerts = state.polygonVertices.length; + const sel = state.selectedVertex; + const hasVerts = nVerts > 0; + return ( + + {!hasVerts && ( +
+ Draw a polygon path first, then switch to Contour snap to pull vertices to image edges. +
+ )} +
+ + dispatch({ type: "set_snap_target", target: v as SnapTarget }) + } + /> +
+ +
+ dispatch({ type: "set_snap_radius", radius: v })} + /> + + dispatch({ type: "set_snap_edge_threshold", threshold: v }) + } + /> +
+ +
+ + dispatch({ + type: "set_snap_edge_operator", + operator: v as EdgeOperator, + }) + } + /> +
+ +
+ ‖∇I‖ preview + blur σ = 1.2 +
+
+ +
v{sel ?? "—"} / {nVerts} : undefined} + > +
+ + Snap v{sel ?? "—"} · S + + + Snap all ({nVerts}) · ⇧S + +
+ {hasVerts && ( +
+ Click a vertex to select · ←/→ cycle through vertices +
+ )} +
+ +
+ dispatch({ type: "set_snap_reject_below", on })} + /> + + dispatch({ type: "set_snap_across_discontinuities", on }) + } + /> +
+ +
+ + + + + +
+
+ ); +} + +// ---- Placeholder inspectors for pre-fill and brush ---- +function PlaceholderInspector({ title }: { title: string }) { + return ( + +
+ Controls are baked into the underlying component. +
+
+ ); +} + +// ---- Dispatcher ---- +interface MaskEditorInspectorProps { + state: MaskEditorState; + dispatch: Dispatch; + activeTool: ToolName; + onClosePolygon?: () => void; + onApplyPolygon?: () => void; + onReopenPolygon?: () => void; + onInsertVertex?: () => void; + onSmoothPolygon?: () => void; + onSimplifyPolygon?: () => void; + onFloodCommit?: () => void; + onRunGrabCut?: () => void; + onSnapVertex?: () => void; + onSnapAll?: () => void; +} + +export default function MaskEditorInspector({ + state, + dispatch, + activeTool, + onClosePolygon, + onApplyPolygon, + onReopenPolygon, + onInsertVertex, + onSmoothPolygon, + onSimplifyPolygon, + onFloodCommit, + onRunGrabCut, + onSnapVertex, + onSnapAll, +}: MaskEditorInspectorProps) { + switch (activeTool) { + case "prefill": + return ; + case "polygon": + return ( + + ); + case "flood": + return ; + case "eraser": + return ; + case "grabcut": + return ( + + ); + case "snap": + return ( + + ); + } +} diff --git a/web/src/components/MaskEditorShared.tsx b/web/src/components/MaskEditorShared.tsx new file mode 100644 index 00000000..48d7815f --- /dev/null +++ b/web/src/components/MaskEditorShared.tsx @@ -0,0 +1,661 @@ +import type { CSSProperties, ReactNode } from "react"; +import { T } from "./maskEditorTokens"; + +// ---- Kbd ---- +export function Kbd({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +// ---- Pill ---- +type PillKind = "default" | "accent" | "ok" | "slate" | "warn"; + +const PILL_KINDS: Record = { + default: { bg: T.bg2, color: T.textDim, border: T.line }, + accent: { + bg: T.accentSoft, + color: T.accent, + border: "oklch(0.70 0.12 40 / 0.4)", + }, + ok: { + bg: T.okSoft, + color: T.ok, + border: "oklch(0.72 0.13 145 / 0.4)", + }, + slate: { + bg: T.slateSoft, + color: T.slate, + border: "oklch(0.72 0.04 230 / 0.4)", + }, + warn: { + bg: "oklch(0.75 0.13 50 / 0.16)", + color: T.warn, + border: "oklch(0.75 0.13 50 / 0.4)", + }, +}; + +interface PillProps { + children: ReactNode; + kind?: PillKind; + mono?: boolean; +} + +export function Pill({ children, kind = "default", mono = true }: PillProps) { + const c = PILL_KINDS[kind]; + return ( + + {children} + + ); +} + +// ---- Section ---- +interface SectionProps { + title: string; + hint?: string; + children: ReactNode; + action?: ReactNode; +} + +export function Section({ title, hint, children, action }: SectionProps) { + return ( +
+
+
+
+ {title} +
+ {hint ? ( +
+ {hint} +
+ ) : null} +
+ {action} +
+ {children} +
+ ); +} + +// ---- Slider ---- +interface SliderProps { + label: string; + value: number; + min: number; + max: number; + step?: number; + unit?: string; + tickAt?: number; + onChange?: (value: number) => void; +} + +export function MESlider({ + label, + value, + min, + max, + step = 1, + unit = "", + tickAt, + onChange, +}: SliderProps) { + const pct = ((value - min) / (max - min)) * 100; + const tickPct = tickAt != null ? ((tickAt - min) / (max - min)) * 100 : null; + return ( +
+
+ {label} + + {value} + {unit} + +
+
+
+
+ {tickPct != null && ( +
+ )} +
+ onChange?.(parseFloat(e.target.value))} + style={{ + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + opacity: 0, + cursor: "pointer", + margin: 0, + }} + /> +
+
+ ); +} + +// ---- Stepper ---- +interface StepperProps { + label: string; + value: string | number; + sub?: string; + onDecrement?: () => void; + onIncrement?: () => void; +} + +const STEP_BTN: CSSProperties = { + background: "transparent", + border: "none", + color: T.textDim, + padding: "4px 10px", + cursor: "pointer", + fontSize: 14, + lineHeight: 1, +}; + +export function Stepper({ + label, + value, + sub, + onDecrement, + onIncrement, +}: StepperProps) { + return ( +
+
+
{label}
+ {sub ? ( +
+ {sub} +
+ ) : null} +
+
+ +
+ {value} +
+ +
+
+ ); +} + +// ---- Segmented ---- +interface SegmentedOption { + value: string; + label: ReactNode; + dot?: string; +} + +interface SegmentedProps { + options: SegmentedOption[]; + value: string; + onChange?: (value: string) => void; +} + +export function Segmented({ options, value, onChange }: SegmentedProps) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +// ---- Toggle ---- +interface ToggleProps { + on: boolean; + label: string; + onChange?: (on: boolean) => void; +} + +export function Toggle({ on, label, onChange }: ToggleProps) { + return ( + + ); +} + +// ---- Stat ---- +interface StatProps { + label: string; + value: ReactNode; + mono?: boolean; + pos?: boolean; +} + +export function Stat({ label, value, mono = true, pos }: StatProps) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +// ---- ShortcutRow ---- +interface ShortcutRowProps { + keys: string[]; + label: string; +} + +export function ShortcutRow({ keys, label }: ShortcutRowProps) { + return ( +
+ {label} + + {keys.map((k, i) => ( + {k} + ))} + +
+ ); +} + +// ---- Spinner ---- +export function Spinner({ size = 12 }: { size?: number }) { + return ( + + ); +} + +// Inject keyframe once — safe to call multiple times +if (typeof document !== "undefined" && !document.getElementById("me-keyframes")) { + const style = document.createElement("style"); + style.id = "me-keyframes"; + style.textContent = `@keyframes me-spin { to { transform: rotate(360deg); } }`; + document.head.appendChild(style); +} + +// ---- ActionButton ---- +interface ActionButtonProps { + children: ReactNode; + primary?: boolean; + dim?: boolean; + full?: boolean; + icon?: ReactNode; + loading?: boolean; + onClick?: () => void; + disabled?: boolean; +} + +export function ActionButton({ + children, + primary, + dim, + full, + icon, + loading, + onClick, + disabled, +}: ActionButtonProps) { + const isDisabled = disabled || loading; + return ( + + ); +} + +// ---- InspectorShell ---- +interface InspectorShellProps { + title: string; + sub?: string; + children: ReactNode; +} + +export function InspectorShell({ title, sub, children }: InspectorShellProps) { + return ( +
+
+
+ {title} +
+ {sub ? ( +
+ {sub} +
+ ) : null} +
+
{children}
+
+ ); +} diff --git a/web/src/components/MaskEditorToolbar.tsx b/web/src/components/MaskEditorToolbar.tsx new file mode 100644 index 00000000..143f4e62 --- /dev/null +++ b/web/src/components/MaskEditorToolbar.tsx @@ -0,0 +1,98 @@ +import type { Dispatch } from "react"; +import MEIcon from "./MaskEditorIcons"; +import { T } from "./maskEditorTokens"; +import type { MaskEditorAction, ToolName } from "./maskEditorState"; + +const TOOLS: { id: ToolName; label: string; icon: Parameters[0]["name"]; kbd: string }[] = [ + { id: "prefill", label: "Pre-fill", icon: "layers", kbd: "P" }, + { id: "polygon", label: "Polygon edit", icon: "polygon", kbd: "G" }, + { id: "flood", label: "Flood fill", icon: "flood", kbd: "F" }, + { id: "eraser", label: "Eraser", icon: "eraser", kbd: "E" }, + { id: "grabcut", label: "GrabCut", icon: "grabcut", kbd: "C" }, + { id: "snap", label: "Contour snap", icon: "snap", kbd: "S" }, +]; + +interface MaskEditorToolbarProps { + active: ToolName; + dispatch: Dispatch; +} + +export default function MaskEditorToolbar({ + active, + dispatch, +}: MaskEditorToolbarProps) { + return ( +
+ {TOOLS.map((t) => { + const on = t.id === active; + return ( + + ); + })} +
+ +
+ ); +} diff --git a/web/src/components/maskEditorCanvasOps.ts b/web/src/components/maskEditorCanvasOps.ts new file mode 100644 index 00000000..4c317fee --- /dev/null +++ b/web/src/components/maskEditorCanvasOps.ts @@ -0,0 +1,168 @@ +import type { Point } from "./maskEditorState"; + +// ---- Brush painting ---- + +export function paintCircle( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + radius: number, + erase: boolean, + color = "oklch(0.62 0.14 40)", +) { + ctx.save(); + if (erase) { + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "rgba(0,0,0,1)"; + } else { + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = color; + } + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +// ---- Polygon fill ---- + +export function fillPolygon( + maskCanvas: HTMLCanvasElement, + vertices: Point[], + mode: "add" | "subtract", +) { + if (vertices.length < 3) return; + const ctx = maskCanvas.getContext("2d")!; + ctx.save(); + if (mode === "subtract") { + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "rgba(0,0,0,1)"; + } else { + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = "oklch(0.62 0.14 40)"; + } + ctx.beginPath(); + ctx.moveTo(vertices[0].x, vertices[0].y); + for (let i = 1; i < vertices.length; i++) ctx.lineTo(vertices[i].x, vertices[i].y); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} + +// ---- Polygon smooth (Laplacian, 3 passes) ---- + +export function smoothPolygon(vertices: Point[], passes = 3): Point[] { + let verts = [...vertices]; + for (let iter = 0; iter < passes; iter++) { + verts = verts.map((v, i) => { + const prev = verts[(i - 1 + verts.length) % verts.length]; + const next = verts[(i + 1) % verts.length]; + return { + x: 0.25 * prev.x + 0.5 * v.x + 0.25 * next.x, + y: 0.25 * prev.y + 0.5 * v.y + 0.25 * next.y, + }; + }); + } + return verts; +} + +// ---- Douglas-Peucker simplification ---- + +function perpDist(a: Point, b: Point, p: Point): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy); + if (len === 0) return Math.hypot(p.x - a.x, p.y - a.y); + return Math.abs(dx * (a.y - p.y) - (a.x - p.x) * dy) / len; +} + +export function douglasPeucker(pts: Point[], eps: number): Point[] { + if (pts.length <= 2) return pts; + let maxDist = 0; + let maxIdx = 0; + for (let i = 1; i < pts.length - 1; i++) { + const d = perpDist(pts[0], pts[pts.length - 1], pts[i]); + if (d > maxDist) { maxDist = d; maxIdx = i; } + } + if (maxDist > eps) { + return [ + ...douglasPeucker(pts.slice(0, maxIdx + 1), eps).slice(0, -1), + ...douglasPeucker(pts.slice(maxIdx), eps), + ]; + } + return [pts[0], pts[pts.length - 1]]; +} + +export function simplifyPolygon(vertices: Point[], eps: number): Point[] { + if (vertices.length <= 3) return vertices; + // Close the loop, simplify, reopen + const closed = [...vertices, vertices[0]]; + const simplified = douglasPeucker(closed, eps); + return simplified.slice(0, -1); // remove the repeated first point +} + +// ---- Flood fill ---- + +export function floodFill( + maskCanvas: HTMLCanvasElement, + srcCanvas: HTMLCanvasElement, + seedX: number, + seedY: number, + tolerance: number, + connectivity: "4" | "8", + mode: "add" | "subtract", +) { + const W = maskCanvas.width; + const H = maskCanvas.height; + const maskCtx = maskCanvas.getContext("2d")!; + const srcCtx = srcCanvas.getContext("2d")!; + const maskData = maskCtx.getImageData(0, 0, W, H); + const srcData = srcCtx.getImageData(0, 0, W, H); + + const sx = Math.round(Math.max(0, Math.min(W - 1, seedX))); + const sy = Math.round(Math.max(0, Math.min(H - 1, seedY))); + + const seedOff = (sy * W + sx) * 4; + const seedR = srcData.data[seedOff]; + const seedG = srcData.data[seedOff + 1]; + const seedB = srcData.data[seedOff + 2]; + + const paintAlpha = mode === "add" ? 255 : 0; + const tolSq = tolerance * tolerance * 3; + + const visited = new Uint8Array(W * H); + const queue: number[] = [sy * W + sx]; + visited[sy * W + sx] = 1; + + const DIRS4 = [-1, 1, -W, W]; + const DIRS8 = [-1, 1, -W, W, -W - 1, -W + 1, W - 1, W + 1]; + const dirs = connectivity === "4" ? DIRS4 : DIRS8; + + while (queue.length > 0) { + const pos = queue.pop()!; + const off = pos * 4; + + const dr = srcData.data[off] - seedR; + const dg = srcData.data[off + 1] - seedG; + const db = srcData.data[off + 2] - seedB; + if (dr * dr + dg * dg + db * db > tolSq) continue; + + maskData.data[off + 3] = paintAlpha; + + const px = pos % W; + const py = Math.floor(pos / W); + + for (const d of dirs) { + const npos = pos + d; + if (npos < 0 || npos >= W * H) continue; + const nx = npos % W; + const ny = Math.floor(npos / W); + if (Math.abs(nx - px) > 1 || Math.abs(ny - py) > 1) continue; + if (visited[npos]) continue; + visited[npos] = 1; + queue.push(npos); + } + } + + maskCtx.putImageData(maskData, 0, 0); +} diff --git a/web/src/components/maskEditorCv.ts b/web/src/components/maskEditorCv.ts new file mode 100644 index 00000000..e4630c5a --- /dev/null +++ b/web/src/components/maskEditorCv.ts @@ -0,0 +1,167 @@ +import { loadOpenCV, type OpenCV } from "@opencvjs/web"; + +// Singleton promise — WASM only loads once, subsequent calls return the same instance. +let cvPromise: Promise | null = null; +let cvReady = false; + +export function getCv(): Promise { + if (!cvPromise) { + cvPromise = loadOpenCV().then((cv) => { cvReady = true; return cv; }); + } + return cvPromise; +} + +export function isCvReady(): boolean { return cvReady; } + +// Kick off WASM download immediately so it's ready when the user needs it. +getCv(); + +// ---- Hint pixel encoding ---- +// Orange (R>160, B<80) = GC_FGD; slate-blue (R<80, B>120) = GC_BGD. +// These are visually distinct and detectable from an RGBA canvas read-back. +export const HINT_FG_COLOR = "oklch(0.62 0.14 40)"; +export const HINT_BG_COLOR = "oklch(0.55 0.06 240)"; + +function isHintFg(r: number, b: number): boolean { return r > 160 && b < 80; } +function isHintBg(r: number, b: number): boolean { return r < 80 && b > 120; } + +// ---- GrabCut ---- + +export async function runGrabCut( + srcCanvas: HTMLCanvasElement, + maskCanvas: HTMLCanvasElement, + hintsCanvas: HTMLCanvasElement | null, + rect: { x: number; y: number; w: number; h: number }, + iterations: number, +): Promise { + const cv = await getCv(); + + const W = srcCanvas.width; + const H = srcCanvas.height; + + const rgba = cv.matFromImageData(srcCanvas.getContext("2d")!.getImageData(0, 0, W, H)); + const rgb = new cv.Mat(); + cv.cvtColor(rgba, rgb, cv.COLOR_RGBA2RGB); + rgba.delete(); + + // Build GrabCut mask from existing mask canvas: treat current foreground as PR_FGD. + const maskData = maskCanvas.getContext("2d")!.getImageData(0, 0, W, H); + const gcMask = new cv.Mat(H, W, cv.CV_8UC1); + for (let i = 0; i < W * H; i++) { + gcMask.data[i] = maskData.data[i * 4 + 3] > 128 ? cv.GC_PR_FGD : cv.GC_PR_BGD; + } + + // Overlay hard hint strokes: orange = GC_FGD, slate-blue = GC_BGD. + let hasHints = false; + if (hintsCanvas) { + const hData = hintsCanvas.getContext("2d")!.getImageData(0, 0, W, H); + for (let i = 0; i < W * H; i++) { + if (hData.data[i * 4 + 3] < 128) continue; + const r = hData.data[i * 4]; + const b = hData.data[i * 4 + 2]; + if (isHintFg(r, b)) { gcMask.data[i] = cv.GC_FGD; hasHints = true; } + else if (isHintBg(r, b)) { gcMask.data[i] = cv.GC_BGD; hasHints = true; } + } + } + + const bgdModel = new cv.Mat(); + const fgdModel = new cv.Mat(); + const cvRect = new cv.Rect( + Math.round(rect.x), + Math.round(rect.y), + Math.round(rect.w), + Math.round(rect.h), + ); + + // First run (no hints yet) uses GC_INIT_WITH_RECT for a clean initialisation. + // Once hints are painted, switch to GC_EVAL so the hard pins are respected. + const mode = hasHints ? cv.GC_EVAL : cv.GC_INIT_WITH_RECT; + + cv.grabCut(rgb, gcMask, cvRect, bgdModel, fgdModel, iterations, mode); + + // Write result back: GC_FGD(1) and GC_PR_FGD(3) → foreground. + const maskCtx = maskCanvas.getContext("2d")!; + const outData = maskCtx.createImageData(W, H); + for (let i = 0; i < W * H; i++) { + const label = gcMask.data[i]; + const isFg = label === cv.GC_FGD || label === cv.GC_PR_FGD; + outData.data[i * 4] = 220; + outData.data[i * 4 + 1] = 90; + outData.data[i * 4 + 2] = 40; + outData.data[i * 4 + 3] = isFg ? 255 : 0; + } + maskCtx.putImageData(outData, 0, 0); + + rgb.delete(); + gcMask.delete(); + bgdModel.delete(); + fgdModel.delete(); +} + +// ---- Contour snap ---- + +export type SnapPoint = { x: number; y: number }; + +export async function snapVerticesToEdges( + srcCanvas: HTMLCanvasElement, + vertices: SnapPoint[], + snapRadius: number, + edgeThreshold: number, + operator: "sobel" | "scharr" | "canny", +): Promise { + const cv = await getCv(); + + const W = srcCanvas.width; + const H = srcCanvas.height; + + const rgba = cv.matFromImageData(srcCanvas.getContext("2d")!.getImageData(0, 0, W, H)); + const gray = new cv.Mat(); + cv.cvtColor(rgba, gray, cv.COLOR_RGBA2GRAY); + rgba.delete(); + + const edges = new cv.Mat(); + + if (operator === "canny") { + const lo = edgeThreshold * 128; + cv.Canny(gray, edges, lo, lo * 2); + } else { + const gx = new cv.Mat(); + const gy = new cv.Mat(); + const absGx = new cv.Mat(); + const absGy = new cv.Mat(); + if (operator === "scharr") { + cv.Sobel(gray, gx, cv.CV_16S, 1, 0, -1); + cv.Sobel(gray, gy, cv.CV_16S, 0, 1, -1); + } else { + cv.Sobel(gray, gx, cv.CV_16S, 1, 0, 3); + cv.Sobel(gray, gy, cv.CV_16S, 0, 1, 3); + } + cv.convertScaleAbs(gx, absGx); + cv.convertScaleAbs(gy, absGy); + cv.addWeighted(absGx, 0.5, absGy, 0.5, 0, edges); + gx.delete(); gy.delete(); absGx.delete(); absGy.delete(); + } + + gray.delete(); + + const minStrength = edgeThreshold * 255; + + const snapped = vertices.map((v) => { + const cx = Math.round(v.x); + const cy = Math.round(v.y); + const r = Math.round(snapRadius); + let bestX = v.x, bestY = v.y, bestStrength = minStrength; + + for (let py = Math.max(0, cy - r); py <= Math.min(H - 1, cy + r); py++) { + for (let px = Math.max(0, cx - r); px <= Math.min(W - 1, cx + r); px++) { + if ((px - cx) * (px - cx) + (py - cy) * (py - cy) > r * r) continue; + const s = edges.data[py * W + px]; + if (s > bestStrength) { bestStrength = s; bestX = px; bestY = py; } + } + } + return { x: bestX, y: bestY }; + }); + + edges.delete(); + return snapped; +} diff --git a/web/src/components/maskEditorState.ts b/web/src/components/maskEditorState.ts new file mode 100644 index 00000000..440be17b --- /dev/null +++ b/web/src/components/maskEditorState.ts @@ -0,0 +1,289 @@ +export type ToolName = + | "prefill" + | "polygon" + | "flood" + | "eraser" + | "grabcut" + | "snap"; + +export type GrabCutHintMode = "rect" | "foreground" | "background"; + +export type EdgeOperator = "sobel" | "scharr" | "canny"; + +export type FloodMode = "add" | "subtract"; + +export type FloodConnectivity = "4" | "8"; + +export type SnapTarget = "vertex" | "all"; + +export type AssistStatus = "idle" | "loading" | "error"; + +export type Rect = { x: number; y: number; w: number; h: number }; + +export type Point = { x: number; y: number }; + +export type MaskEditorState = { + activeTool: ToolName; + + // eraser params + eraserRadius: number; + + // flood fill params + floodMode: FloodMode; + floodTolerance: number; + floodSampleSize: number; + floodConnectivity: FloodConnectivity; + floodContiguous: boolean; + floodAntiAlias: boolean; + + // polygon params + polygonVertices: Point[]; + polygonClosed: boolean; // true = editing mode; click selects rather than adds + selectedVertex: number | null; + polygonSimplifyEps: number; + polygonLiveSnap: boolean; + polygonSnapRadius: number; + + // grabcut params + grabcutRect: Rect | null; + grabcutHintMode: GrabCutHintMode; + grabcutBrushRadius: number; + grabcutIterations: number; + + // contour snap params + snapTarget: SnapTarget; + snapRadius: number; + snapEdgeThreshold: number; + snapEdgeOperator: EdgeOperator; + snapRejectBelow: boolean; + snapAcrossDiscontinuities: boolean; + + // undo/redo counts — actual ImageData snapshots live in MaskEditor refs + undoStack: number; + redoStack: number; + + // assist operations + assistStatus: AssistStatus; + assistError: string | null; + lastAssistMs: number | null; + + dirty: boolean; +}; + +export type MaskEditorAction = + | { type: "hydrate"; hadMask: boolean } + | { type: "set_tool"; tool: ToolName } + // eraser + | { type: "set_eraser_radius"; radius: number } + // flood + | { type: "set_flood_mode"; mode: FloodMode } + | { type: "set_flood_tolerance"; tolerance: number } + | { type: "set_flood_sample_size"; size: number } + | { type: "set_flood_connectivity"; connectivity: FloodConnectivity } + | { type: "set_flood_contiguous"; contiguous: boolean } + | { type: "set_flood_anti_alias"; antiAlias: boolean } + // polygon + | { type: "polygon_vertex_added"; point: Point } + | { type: "polygon_vertex_moved"; index: number; point: Point } + | { type: "polygon_vertex_deleted"; index: number } + | { type: "polygon_vertex_selected"; index: number | null } + | { type: "set_polygon_simplify_eps"; eps: number } + | { type: "set_polygon_live_snap"; on: boolean } + | { type: "set_polygon_snap_radius"; radius: number } + | { type: "polygon_vertices_set"; vertices: Point[] } + | { type: "polygon_closed" } + | { type: "polygon_reopened" } + | { type: "polygon_state_restored"; vertices: Point[]; closed: boolean } + // grabcut + | { type: "set_grabcut_rect"; rect: Rect | null } + | { type: "set_grabcut_hint_mode"; mode: GrabCutHintMode } + | { type: "set_grabcut_brush_radius"; radius: number } + | { type: "set_grabcut_iterations"; iterations: number } + // snap + | { type: "set_snap_target"; target: SnapTarget } + | { type: "set_snap_radius"; radius: number } + | { type: "set_snap_edge_threshold"; threshold: number } + | { type: "set_snap_edge_operator"; operator: EdgeOperator } + | { type: "set_snap_reject_below"; on: boolean } + | { type: "set_snap_across_discontinuities"; on: boolean } + // canvas mutations — ImageData lives in component refs, state tracks counts + | { type: "tool_applied" } + | { type: "assist_started" } + | { type: "assist_succeeded"; ms: number } + | { type: "assist_failed"; error: string } + | { type: "undo" } + | { type: "redo" }; + +const MAX_UNDO = 20; + +export const INITIAL_STATE: MaskEditorState = { + activeTool: "polygon", + eraserRadius: 16, + floodMode: "add", + floodTolerance: 28, + floodSampleSize: 3, + floodConnectivity: "4", + floodContiguous: true, + floodAntiAlias: false, + polygonVertices: [], + polygonClosed: false, + selectedVertex: null, + polygonSimplifyEps: 1.4, + polygonLiveSnap: true, + polygonSnapRadius: 8, + grabcutRect: null, + grabcutHintMode: "rect", + grabcutBrushRadius: 6, + grabcutIterations: 5, + snapTarget: "vertex", + snapRadius: 22, + snapEdgeThreshold: 0.42, + snapEdgeOperator: "sobel", + snapRejectBelow: true, + snapAcrossDiscontinuities: false, + undoStack: 0, + redoStack: 0, + assistStatus: "idle", + assistError: null, + lastAssistMs: null, + dirty: false, +}; + +function pushUndo(state: MaskEditorState): MaskEditorState { + return { + ...state, + undoStack: Math.min(state.undoStack + 1, MAX_UNDO), + redoStack: 0, + dirty: true, + }; +} + +export function maskEditorReducer( + state: MaskEditorState, + action: MaskEditorAction, +): MaskEditorState { + switch (action.type) { + case "hydrate": + return { + ...INITIAL_STATE, + undoStack: action.hadMask ? 1 : 0, + }; + + case "set_tool": + return { ...state, activeTool: action.tool }; + + case "set_eraser_radius": + return { ...state, eraserRadius: action.radius }; + + case "set_flood_mode": + return { ...state, floodMode: action.mode }; + case "set_flood_tolerance": + return { ...state, floodTolerance: action.tolerance }; + case "set_flood_sample_size": + return { ...state, floodSampleSize: action.size }; + case "set_flood_connectivity": + return { ...state, floodConnectivity: action.connectivity }; + case "set_flood_contiguous": + return { ...state, floodContiguous: action.contiguous }; + case "set_flood_anti_alias": + return { ...state, floodAntiAlias: action.antiAlias }; + + case "polygon_vertex_added": + return { + ...state, + polygonVertices: [...state.polygonVertices, action.point], + selectedVertex: state.polygonVertices.length, + dirty: true, + }; + case "polygon_vertex_moved": { + const verts = [...state.polygonVertices]; + verts[action.index] = action.point; + return { ...state, polygonVertices: verts, dirty: true }; + } + case "polygon_vertex_deleted": { + const verts = state.polygonVertices.filter((_, i) => i !== action.index); + return { + ...state, + polygonVertices: verts, + selectedVertex: null, + dirty: true, + }; + } + case "polygon_vertex_selected": + return { ...state, selectedVertex: action.index }; + case "set_polygon_simplify_eps": + return { ...state, polygonSimplifyEps: action.eps }; + case "set_polygon_live_snap": + return { ...state, polygonLiveSnap: action.on }; + case "set_polygon_snap_radius": + return { ...state, polygonSnapRadius: action.radius }; + case "polygon_vertices_set": + return { ...state, polygonVertices: action.vertices, polygonClosed: false, selectedVertex: null, dirty: true }; + case "polygon_closed": + return { ...state, polygonClosed: true }; + case "polygon_reopened": + return { ...state, polygonClosed: false }; + case "polygon_state_restored": + return { ...state, polygonVertices: action.vertices, polygonClosed: action.closed, selectedVertex: null, dirty: true }; + + case "set_grabcut_rect": + return { ...state, grabcutRect: action.rect }; + case "set_grabcut_hint_mode": + return { ...state, grabcutHintMode: action.mode }; + case "set_grabcut_brush_radius": + return { ...state, grabcutBrushRadius: action.radius }; + case "set_grabcut_iterations": + return { ...state, grabcutIterations: action.iterations }; + + case "set_snap_target": + return { ...state, snapTarget: action.target }; + case "set_snap_radius": + return { ...state, snapRadius: action.radius }; + case "set_snap_edge_threshold": + return { ...state, snapEdgeThreshold: action.threshold }; + case "set_snap_edge_operator": + return { ...state, snapEdgeOperator: action.operator }; + case "set_snap_reject_below": + return { ...state, snapRejectBelow: action.on }; + case "set_snap_across_discontinuities": + return { ...state, snapAcrossDiscontinuities: action.on }; + + case "tool_applied": + return pushUndo(state); + + case "assist_started": + return { ...state, assistStatus: "loading", assistError: null }; + case "assist_succeeded": + return { + ...pushUndo(state), + assistStatus: "idle", + lastAssistMs: action.ms, + }; + case "assist_failed": + return { + ...state, + assistStatus: "error", + assistError: action.error, + }; + + case "undo": { + if (state.undoStack === 0) return state; + return { + ...state, + undoStack: state.undoStack - 1, + redoStack: Math.min(state.redoStack + 1, MAX_UNDO), + }; + } + case "redo": { + if (state.redoStack === 0) return state; + return { + ...state, + undoStack: Math.min(state.undoStack + 1, MAX_UNDO), + redoStack: state.redoStack - 1, + }; + } + + default: + return state; + } +} diff --git a/web/src/components/maskEditorTokens.ts b/web/src/components/maskEditorTokens.ts new file mode 100644 index 00000000..6e2ab393 --- /dev/null +++ b/web/src/components/maskEditorTokens.ts @@ -0,0 +1,26 @@ +// Design tokens for MaskEditor — warm graphite dark theme with terracotta accent. +// Kept in a separate .ts file so react-refresh only exports non-component constants. +export const T = { + bg: "oklch(0.18 0.008 55)", + bg1: "oklch(0.22 0.010 55)", + bg2: "oklch(0.26 0.011 55)", + bg3: "oklch(0.30 0.012 55)", + line: "oklch(0.36 0.011 55)", + lineSoft: "oklch(0.30 0.010 55)", + text: "oklch(0.95 0.008 80)", + textDim: "oklch(0.74 0.009 70)", + textMute: "oklch(0.56 0.009 70)", + accent: "oklch(0.70 0.12 40)", + accentSoft: "oklch(0.70 0.12 40 / 0.18)", + accentTint: "oklch(0.70 0.12 40 / 0.32)", + slate: "oklch(0.72 0.04 230)", + slateSoft: "oklch(0.72 0.04 230 / 0.18)", + kiln: "oklch(0.78 0.13 75)", + warn: "oklch(0.75 0.13 50)", + ok: "oklch(0.72 0.13 145)", + okSoft: "oklch(0.72 0.13 145 / 0.18)", + fontUi: + "'Manrope', -apple-system, BlinkMacSystemFont, system-ui, sans-serif", + fontMono: "'JetBrains Mono', ui-monospace, Menlo, monospace", + fontDisplay: "'Instrument Serif', 'Times New Roman', serif", +} as const; diff --git a/web/src/stories/MaskEditor.stories.tsx b/web/src/stories/MaskEditor.stories.tsx new file mode 100644 index 00000000..551a6de7 --- /dev/null +++ b/web/src/stories/MaskEditor.stories.tsx @@ -0,0 +1,353 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { useState } from "react"; +import Button from "@mui/material/Button"; +import MaskEditor from "../components/MaskEditor"; + +// Local story images — committed to web/public/stories/ at ≤ 280 KB each. +// The paths are served by Vite's static file middleware in dev and Storybook. +const IMAGES = [ + { + id: "pitcher", + label: "Tall bisque pitcher with lid", + url: "/stories/pitcher.jpg", + width: 900, + height: 1200, + }, + { + id: "vase", + label: "Vase with demo plant", + url: "/stories/vase.jpg", + width: 900, + height: 1200, + }, + { + id: "dish", + label: "Jewelry dish with jewelry", + url: "/stories/dish.jpg", + width: 1200, + height: 900, + }, + { + id: "whiskey", + label: "Whiskey cup with drink", + url: "/stories/whiskey.jpg", + width: 1005, + height: 1200, + }, + { + id: "bowl", + label: "Bowl side with hand", + url: "/stories/bowl.jpg", + width: 900, + height: 1200, + }, + { + id: "juicer", + label: "Wheel thrown juicer", + url: "/stories/juicer.jpg", + width: 1200, + height: 900, + }, +] as const; + +type ImageId = (typeof IMAGES)[number]["id"]; + +const meta = { + title: "Components/MaskEditor", + component: MaskEditor, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "Full-screen mask-editing dialog for #532. Six tools: pre-fill, brush+eraser, polygon edit, flood fill, GrabCut, contour snap. " + + "GrabCut and contour snap are backed by `@opencvjs/web` (WASM in-browser). " + + "The `onCommit` callback receives an RGBA PNG blob (RGB zeroed, alpha = foreground mask).", + }, + inlineStories: false, + iframeHeight: 820, + }, + }, + tags: ["autodocs"], + argTypes: { + imageUrl: { control: "text" }, + imageWidth: { control: "number" }, + imageHeight: { control: "number" }, + candidateMask: { control: "text" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ---- Wrapper that adds a launch button (MaskEditor is a Dialog) ---- +function MaskEditorLauncher( + props: React.ComponentProps & { buttonLabel?: string }, +) { + const [open, setOpen] = useState(false); + const { buttonLabel = "Open MaskEditor", ...rest } = props; + return ( + <> +
+ +
+ { + setOpen(false); + rest.onCancel?.(); + }} + onCommit={(blob) => { + setOpen(false); + rest.onCommit?.(blob); + }} + /> + + ); +} + +// ---- Gallery picker: one card per image, click to open editor ---- +function MaskEditorGallery({ + initialImage = "pitcher", + candidateMask, + onCommit, + onCancel, +}: { + initialImage?: ImageId; + candidateMask?: string; + onCommit?: (blob: Blob) => void; + onCancel?: () => void; +}) { + const [selected, setSelected] = useState<(typeof IMAGES)[number] | null>( + null, + ); + + return ( + <> +
+
+ Select an image to open MaskEditor +
+
+ {IMAGES.map((img) => ( + + ))} +
+
+ + {selected && ( + { + setSelected(null); + onCommit?.(blob); + }} + onCancel={() => { + setSelected(null); + onCancel?.(); + }} + /> + )} + + ); +} + +// ================================================================ +// Stories +// ================================================================ + +/** Click any pottery image to open the full MaskEditor. */ +export const Gallery: Story = { + render: (args) => ( + + ), + args: { + onCommit: fn(), + onCancel: fn(), + imageUrl: IMAGES[0].url, + imageWidth: IMAGES[0].width, + imageHeight: IMAGES[0].height, + }, +}; + +/** Pitcher pre-opened — tall bisque pot with adjacent lid. */ +export const Pitcher: Story = { + render: (args) => ( + + ), + args: { + imageUrl: IMAGES[0].url, + imageWidth: IMAGES[0].width, + imageHeight: IMAGES[0].height, + onCommit: fn(), + onCancel: fn(), + }, +}; + +/** Vase with plant — tests background clutter handling. */ +export const VaseWithPlant: Story = { + render: (args) => ( + + ), + args: { + imageUrl: IMAGES[1].url, + imageWidth: IMAGES[1].width, + imageHeight: IMAGES[1].height, + onCommit: fn(), + onCancel: fn(), + }, +}; + +/** Jewelry dish — landscape orientation, small subject. */ +export const JewelryDish: Story = { + render: (args) => ( + + ), + args: { + imageUrl: IMAGES[2].url, + imageWidth: IMAGES[2].width, + imageHeight: IMAGES[2].height, + onCommit: fn(), + onCancel: fn(), + }, +}; + +/** Whiskey cup — glass/ceramic combo, translucent areas. */ +export const WhiskeyCup: Story = { + render: (args) => ( + + ), + args: { + imageUrl: IMAGES[3].url, + imageWidth: IMAGES[3].width, + imageHeight: IMAGES[3].height, + onCommit: fn(), + onCancel: fn(), + }, +}; + +/** Bowl with hand in frame — tests multi-subject masking. */ +export const BowlWithHand: Story = { + render: (args) => ( + + ), + args: { + imageUrl: IMAGES[4].url, + imageWidth: IMAGES[4].width, + imageHeight: IMAGES[4].height, + onCommit: fn(), + onCancel: fn(), + }, +}; + +/** Wheel thrown juicer — landscape, complex undercut geometry. */ +export const WheelThrownJuicer: Story = { + render: (args) => ( + + ), + args: { + imageUrl: IMAGES[5].url, + imageWidth: IMAGES[5].width, + imageHeight: IMAGES[5].height, + onCommit: fn(), + onCancel: fn(), + }, +};