import { Download, Lasso, Loader2, Paintbrush, Redo, Sparkles, Trash2, Zap } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { useAuth } from "@/hooks/use-auth"; import { formatHeaders } from "@/lib/api"; import { format, formatFileSize } from "@/lib/format"; import { generateId } from "@/lib/utils"; import { useFeaturesStore } from "@/stores/features-store"; import { useFileStore } from "@/stores/file-store"; import type { EraserCanvasRef } from "./eraser-canvas"; type QualityMode = "fast" | "hq"; const HQ_BUNDLE_ID = "inpaint-hq"; const OUTPUT_FORMATS = [ "png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif", "jxl", ] as const; const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl"]; const SSE_STALL_TIMEOUT_MS = 5 * 60_000; interface ProgressHandlers { onProgress?: (percent: number) => void; onComplete: (result: Record) => void; onFailed: (error: string) => void; onStall: () => void; } /** * Subscribe to async (202) job progress with the same resilience as the * standard tool processor (PRs #203/#204). The original eraser opened a bare * EventSource with no recovery: if SSE silently died (mobile backgrounding, * flaky network, proxy buffering) the UI hung forever at the last percent * (~25%) even though the backend job had finished and saved its result. * * This reconnects on tab refocus -- the progress endpoint replays the terminal * frame from Redis and, after that cache expires, from the durable job record, * so a job that completed while SSE was dead still resolves -- and arms a stall * timeout that fails gracefully instead of hanging. Returns a cleanup the * caller must invoke on sync completion, error, or unmount. */ export function subscribeEraseObjectJobProgress( clientJobId: string, handlers: ProgressHandlers, ): () => void { let es: EventSource | null = null; let stall: ReturnType | null = null; let done = false; const onVisible = () => { if (done || document.visibilityState !== "visible") return; if (es && es.readyState === EventSource.OPEN) return; setTimeout(open, 500); }; const cleanup = () => { if (done) return; done = true; if (stall) clearTimeout(stall); stall = null; if (es) es.close(); es = null; document.removeEventListener("visibilitychange", onVisible); }; const resetStall = () => { if (stall) clearTimeout(stall); stall = setTimeout(() => { cleanup(); handlers.onStall(); }, SSE_STALL_TIMEOUT_MS); }; function open() { if (done) return; if (es && es.readyState === EventSource.OPEN) return; if (es) es.close(); try { es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`); } catch { return; } es.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === "heartbeat") { resetStall(); return; } if (data.type !== "single") return; resetStall(); if (data.phase === "complete" && data.result) { cleanup(); handlers.onComplete(data.result as Record); return; } if (data.phase === "failed") { cleanup(); handlers.onFailed(typeof data.error === "string" ? data.error : "Processing failed"); return; } if (typeof data.percent === "number") handlers.onProgress?.(data.percent); } catch { // Ignore malformed SSE frames } }; // A transient drop triggers the browser's built-in reconnect; on reconnect // the backend replays the terminal frame, so a completed job still resolves. es.onerror = () => {}; } document.addEventListener("visibilitychange", onVisible); open(); resetStall(); return cleanup; } interface EraseObjectSettingsProps { eraserRef: React.RefObject; hasStrokes: boolean; brushSize: number; onBrushSizeChange: (size: number) => void; mode: "brush" | "lasso"; onModeChange: (mode: "brush" | "lasso") => void; onMaskCenter?: (centerPct: number) => void; maskedFileCount: number; } export function EraseObjectSettings({ eraserRef, hasStrokes, brushSize, onBrushSizeChange: setBrushSize, mode, onModeChange, onMaskCenter, maskedFileCount, }: EraseObjectSettingsProps) { const { t } = useTranslation(); const { files, entries, processing, error, setProcessing, setError, currentEntry } = useFileStore(); const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle"); const [progressPercent, setProgressPercent] = useState(0); const [progressStage, setProgressStage] = useState(null); const [elapsed, setElapsed] = useState(0); const elapsedRef = useRef | null>(null); const progressCleanupRef = useRef<(() => void) | null>(null); // Tear down any live progress subscription if the component unmounts mid-job. useEffect(() => { return () => { progressCleanupRef.current?.(); if (elapsedRef.current) clearInterval(elapsedRef.current); }; }, []); const [outputFormat, setOutputFormat] = useState("png"); const [quality, setQuality] = useState(95); const [qualityMode, setQualityMode] = useState("fast"); // High-Quality (diffusion) mode is backed by the optional inpaint-hq bundle. // Mirrors the OCR quality control: pick the mode, and if the pack is missing // show the standard install prompt instead of silently running the fast path. const { hasPermission } = useAuth(); const hqBundle = useFeaturesStore((s) => s.bundles.find((b) => b.id === HQ_BUNDLE_ID)); const hqInstalled = hqBundle?.status === "installed"; const installBundle = useFeaturesStore((s) => s.installBundle); const hqInstalling = useFeaturesStore((s) => s.installing[HQ_BUNDLE_ID]); const hqQueued = useFeaturesStore((s) => s.queued.includes(HQ_BUNDLE_ID)); const hqInstallError = useFeaturesStore((s) => s.errors[HQ_BUNDLE_ID]); const needsHqPack = qualityMode === "hq" && !hqInstalled; const isAdmin = hasPermission("features:manage"); const hqSizeBytes = hqBundle?.missingDownloadBytes ?? hqBundle?.downloadBytes; const hqSize = hqSizeBytes ? formatFileSize(hqSizeBytes) : (hqBundle?.estimatedSize ?? "5-7 GB"); const processOneFile = ( entryIndex: number, file: File, maskBlob: Blob, onProgress: (percent: number) => void, ): Promise => { return new Promise((resolve, reject) => { const clientJobId = generateId(); const applyResult = (r: Record) => { useFileStore.getState().updateEntry(entryIndex, { processedUrl: r.downloadUrl as string, processedPreviewUrl: (r.previewUrl as string) ?? null, processedFilename: null, status: "completed", originalSize: r.originalSize as number, processedSize: r.processedSize as number, }); }; const stopProgress = subscribeEraseObjectJobProgress(clientJobId, { onProgress, onComplete: (r) => { applyResult(r); resolve(); }, onFailed: (err) => reject(new Error(err)), onStall: () => reject(new Error("Processing timed out. The result may have saved -- check your files.")), }); const maskFile = new File([maskBlob], "mask.png", { type: "image/png" }); const formData = new FormData(); formData.append("file", file); formData.append("mask", maskFile); formData.append("clientJobId", clientJobId); formData.append("format", outputFormat); formData.append("quality", String(quality)); formData.append("qualityMode", qualityMode); const xhr = new XMLHttpRequest(); xhr.timeout = 600_000; xhr.onload = () => { if (xhr.status === 202) return; stopProgress(); if (xhr.status >= 200 && xhr.status < 300) { try { applyResult(JSON.parse(xhr.responseText)); resolve(); } catch { reject(new Error("Invalid response")); } } else { try { const body = JSON.parse(xhr.responseText); reject( new Error( typeof body.error === "string" ? body.error : typeof body.details === "string" ? body.details : `Failed: ${xhr.status}`, ), ); } catch { reject(new Error(`Processing failed: ${xhr.status}`)); } } }; xhr.onerror = () => { stopProgress(); reject(new Error("Network error")); }; xhr.ontimeout = () => { stopProgress(); reject(new Error("Request timed out")); }; xhr.open("POST", "/api/v1/tools/image/erase-object"); for (const [key, value] of formatHeaders()) { xhr.setRequestHeader(key, value); } xhr.send(formData); }); }; const handleProcess = async () => { if (files.length === 0 || !eraserRef.current) return; const capturedIndex = useFileStore.getState().selectedIndex; const maskBlob = await eraserRef.current.exportMask(); if (!maskBlob) return; // Record where the user painted so the comparison slider starts at that location const maskCenter = eraserRef.current.getMaskCenter(); if (maskCenter !== null && onMaskCenter) { onMaskCenter(maskCenter); } setError(null); setProcessing(true); setProgressPhase("uploading"); setProgressPercent(0); setElapsed(0); const startTime = Date.now(); elapsedRef.current = setInterval(() => { setElapsed(Math.floor((Date.now() - startTime) / 1000)); }, 1000); const clientJobId = generateId(); const applyResult = (r: Record) => { useFileStore.getState().updateEntry(capturedIndex, { processedUrl: r.downloadUrl as string, processedPreviewUrl: (r.previewUrl as string) ?? null, processedFilename: null, status: "completed", originalSize: r.originalSize as number, processedSize: r.processedSize as number, }); }; const finishUi = () => { if (elapsedRef.current) clearInterval(elapsedRef.current); setProcessing(false); setProgressPhase("idle"); setProgressStage(null); }; const stopProgress = subscribeEraseObjectJobProgress(clientJobId, { onProgress: (percent) => { setProgressPhase("processing"); setProgressPercent(15 + (percent / 100) * 85); }, onComplete: (r) => { progressCleanupRef.current = null; applyResult(r); finishUi(); }, onFailed: (err) => { progressCleanupRef.current = null; setError(err); finishUi(); }, onStall: () => { progressCleanupRef.current = null; setError( "Processing timed out with no progress. The result may have saved to your files -- otherwise, try again.", ); finishUi(); }, }); progressCleanupRef.current = stopProgress; const maskFile = new File([maskBlob], "mask.png", { type: "image/png" }); const formData = new FormData(); formData.append("file", entries[capturedIndex].file); formData.append("mask", maskFile); formData.append("clientJobId", clientJobId); formData.append("format", outputFormat); formData.append("quality", String(quality)); formData.append("qualityMode", qualityMode); const xhr = new XMLHttpRequest(); xhr.timeout = 600_000; xhr.upload.onprogress = (e) => { if (e.lengthComputable) { setProgressPercent((e.loaded / e.total) * 15); } }; xhr.upload.onload = () => { setProgressPhase("processing"); setProgressPercent(15); }; xhr.onload = () => { // 202 = async: the progress subscription drives completion via SSE. if (xhr.status === 202) return; stopProgress(); progressCleanupRef.current = null; if (xhr.status >= 200 && xhr.status < 300) { try { applyResult(JSON.parse(xhr.responseText)); } catch { setError("Invalid response"); } } else { try { const body = JSON.parse(xhr.responseText); setError( typeof body.error === "string" ? body.error : typeof body.details === "string" ? body.details : `Failed: ${xhr.status}`, ); } catch { setError(`Processing failed: ${xhr.status}`); } } finishUi(); }; xhr.onerror = () => { stopProgress(); progressCleanupRef.current = null; setError("Network error"); finishUi(); }; xhr.ontimeout = () => { stopProgress(); progressCleanupRef.current = null; setError("Request timed out - the server may be overloaded. Try again."); finishUi(); }; xhr.open("POST", "/api/v1/tools/image/erase-object"); formatHeaders().forEach((value, key) => { xhr.setRequestHeader(key, value); }); xhr.send(formData); }; const handleProcessAll = async () => { if (!eraserRef.current) return; const masks = await eraserRef.current.exportAllMasks(); if (masks.size === 0) return; const { entries: currentEntries } = useFileStore.getState(); // Map blobUrl -> entry index const blobToIndex = new Map(); for (let i = 0; i < currentEntries.length; i++) { blobToIndex.set(currentEntries[i].blobUrl, i); } const work: { index: number; file: File; maskBlob: Blob }[] = []; for (const [blobUrl, maskBlob] of masks) { const idx = blobToIndex.get(blobUrl); if (idx !== undefined) { work.push({ index: idx, file: currentEntries[idx].file, maskBlob }); } } if (work.length === 0) return; setError(null); setProcessing(true); setProgressPhase("uploading"); setProgressPercent(0); setElapsed(0); const startTime = Date.now(); elapsedRef.current = setInterval(() => { setElapsed(Math.floor((Date.now() - startTime) / 1000)); }, 1000); for (let wi = 0; wi < work.length; wi++) { const { index, file, maskBlob } = work[wi]; const basePercent = (wi / work.length) * 100; const sliceWeight = 100 / work.length; setProgressPhase("processing"); setProgressPercent(basePercent); setProgressStage(`Erasing ${wi + 1}/${work.length}`); useFileStore.getState().updateEntry(index, { status: "processing", error: null }); try { await processOneFile(index, file, maskBlob, (pct) => { setProgressPercent(basePercent + (pct / 100) * sliceWeight); }); } catch (err) { useFileStore.getState().updateEntry(index, { status: "failed", error: err instanceof Error ? err.message : "Processing failed", }); } } if (elapsedRef.current) clearInterval(elapsedRef.current); setProcessing(false); setProgressPhase("idle"); setProgressStage(null); }; const hasFile = files.length > 0; return (
{/* Mode: brush vs lasso */}
{/* Quality: Fast (LaMa, always available) vs High quality (diffusion, inpaint-hq) */}
{qualityMode === "hq" && (

{t.toolSettings["erase-object"].qualityHint}

)} {needsHqPack && (

{format(t.features.requiresDownload, { size: hqSize })}

{isAdmin ? ( ) : (

{t.features.notEnabledDescription}

)} {hqInstallError &&

{hqInstallError}

}
)}
{/* Brush size (brush mode only) */} {mode === "brush" && (
{brushSize}px
setBrushSize(Number(e.target.value))} className="w-full mt-1" />
{t.toolSettings["erase-object"].fine} {t.toolSettings["erase-object"].wide}
)} {/* Clear / Undo */} {hasStrokes && (
)} {/* Output Format */}
{/* Quality (lossy formats only) */} {LOSSY_FORMATS.includes(outputFormat) && (
{quality}
setQuality(Number(e.target.value))} className="w-full mt-1" />
)} {/* Hint */} {hasFile && !hasStrokes && (

{mode === "lasso" ? t.toolSettings["erase-object"].lassoHint : t.toolSettings["erase-object"].paintHint}

)} {/* Error */} {error &&

{error}

} {/* Size info */} {currentEntry?.originalSize != null && currentEntry?.processedSize != null && currentEntry?.status === "completed" && (

Original: {(currentEntry.originalSize / 1024).toFixed(1)} KB

Processed: {(currentEntry.processedSize / 1024).toFixed(1)} KB

)} {/* Process button */} {processing ? ( ) : ( )} {/* Download */} {currentEntry?.processedUrl && ( Download )}
); }