= {
+ 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(),
+ },
+};