/** * ObjectRemover.tsx — self-contained component for the "객체 제거" tab. * * Fully independent of EditorProvider — all state is local. * Phases: "upload" → "uploading" → "painting" → "generating" → "completed" | "failed" * * Canvas logic is shared with MaskEditor via useMaskCanvas. * Polling is shared with MaskEditor via useJobPolling (custom fetcher). * Storage lives under storage/object-removal/{job_id}/steps/{n}/. */ import { useCallback, useEffect, useRef, useState } from "react"; import { Scissors, Upload, Paintbrush, Eraser, Undo2, Download, PencilLine, RotateCcw, History, RefreshCw, } from "lucide-react"; import { useMaskCanvas } from "../hooks/useMaskCanvas"; import { useJobPolling } from "../hooks/useJobPolling"; import { uploadForObjectRemoval, generateObjectRemoval, getObjectRemovalResult, } from "../api/objectRemovalClient"; import { goToStep, API_BASE } from "../api/client"; import type { StepSummary } from "../api/client"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type Phase = "upload" | "uploading" | "painting" | "generating" | "completed" | "failed"; // --------------------------------------------------------------------------- // StepHistory — inline copy (same as MaskEditor; not imported to keep components independent) // --------------------------------------------------------------------------- function StepHistory({ steps, activeStepNumber, currentStep, onGotoStep, }: { steps: StepSummary[]; activeStepNumber: number; currentStep: number; onGotoStep: (n: number) => void; }) { const completed = steps .filter(s => s.status === "completed") .sort((a, b) => a.step_number - b.step_number); if (completed.length <= 1) return null; const maxStep = completed[completed.length - 1].step_number; return (
히스토리
{completed.map(s => { const isActive = s.step_number === activeStepNumber; return ( ); })}
{currentStep < maxStep && (

여기서 추가 작업 시 Step {currentStep + 1}–{maxStep}이 삭제됩니다.

)}
); } const histStyles: Record = { wrap: { marginTop: 24, padding: "14px 16px", background: "#ffffff", border: "1px solid #e8eaed", borderRadius: 10, boxShadow: "0 1px 6px rgba(0,0,0,0.10)", }, label: { fontSize: 11, fontWeight: 700, color: "#94a3b8", textTransform: "uppercase" as const, letterSpacing: "0.07em", display: "block", marginBottom: 10, }, strip: { display: "flex", gap: 6, flexWrap: "wrap" as const, }, stepBtn: { padding: "4px 12px", background: "#fff", border: "1px solid #e2e8f0", borderRadius: 6, cursor: "pointer", fontSize: 13, fontWeight: 500, color: "#475569", }, stepBtnActive: { background: "#3b82f6", border: "1px solid #2563eb", color: "#fff", fontWeight: 600, boxShadow: "0 1px 3px rgba(59,130,246,0.3)", }, discardNote: { margin: "12px 0 0", fontSize: 12, color: "#92400e", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 6, padding: "4px 10px", display: "inline-block", }, }; // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export function ObjectRemover() { const [imageFile, setImageFile] = useState(null); const [imageNaturalWidth, setImageNaturalWidth] = useState(null); const [imageNaturalHeight, setImageNaturalHeight] = useState(null); const [jobId, setJobId] = useState(null); const [phase, setPhase] = useState("upload"); const [uploadError, setUploadError] = useState(null); const [generateError, setGenerateError] = useState(null); const [viewedStepNumber, setViewedStepNumber] = useState(null); const [isDragOver, setIsDragOver] = useState(false); // CSS scale factor applied to the canvas wrapper so it fits narrow viewports. const [canvasScale, setCanvasScale] = useState(1); const fileInputRef = useRef(null); // ── Canvas hook ──────────────────────────────────────────────────────────── const { canvasElRef, displayWidth, displayHeight, toOriginalScale, brushSize, setBrushSize, strokeCount, buildMaskJson, handleClear, handleUndo, setDrawingMode, setBaseUrl, } = useMaskCanvas({ imageFile, imageNaturalWidth, imageNaturalHeight }); // ── Polling hook with object-removal fetcher ─────────────────────────────── const { pollPhase, errorMessage: pollError, steps, currentStep, startPolling, refreshResult, reset: resetPoll, resetPollPhase, } = useJobPolling(getObjectRemovalResult); // ── Phase sync effects ───────────────────────────────────────────────────── useEffect(() => { if (pollPhase === "completed" && phase !== "completed") { setPhase("completed"); setViewedStepNumber(null); } if (pollPhase === "failed") setPhase("failed"); }, [pollPhase, phase]); useEffect(() => { setDrawingMode(phase === "painting"); }, [phase, setDrawingMode]); // ── Canvas CSS scale — keeps canvas within page width on mobile ──────────── // Fabric.js uses getBoundingClientRect() internally, so coordinate mapping // stays correct when a CSS transform scale is applied to the canvas wrapper. useEffect(() => { if (!displayWidth) return; const update = () => { const avail = Math.min(window.innerWidth - 48, 880); setCanvasScale(Math.min(1, avail / displayWidth)); }; update(); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, [displayWidth]); // ── Upload handling ──────────────────────────────────────────────────────── const handleFile = useCallback(async (file: File) => { if (!file.type.startsWith("image/")) { setUploadError(`"${file.name}"은(는) 이미지 파일이 아닙니다.`); return; } setUploadError(null); setPhase("uploading"); try { // Backend returns EXIF-corrected width/height — use directly const { job_id, width, height } = await uploadForObjectRemoval(file); setImageFile(file); setImageNaturalWidth(width); setImageNaturalHeight(height); setJobId(job_id); setPhase("painting"); } catch (e) { setUploadError((e as Error).message); setPhase("upload"); } }, []); const onInputChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) handleFile(file); }; const onDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); }; const onDragLeave = () => setIsDragOver(false); const onDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); const file = e.dataTransfer.files?.[0]; if (file) handleFile(file); }; // ── Generate ─────────────────────────────────────────────────────────────── const handleGenerate = async () => { if (!jobId) return; const maskJson = buildMaskJson(); if (!maskJson) return; setPhase("generating"); setGenerateError(null); try { await generateObjectRemoval(jobId, maskJson); startPolling(jobId); } catch (err) { setGenerateError((err as Error).message); setPhase("failed"); } }; // ── Do More Work ─────────────────────────────────────────────────────────── const handleDoMoreWork = () => { const latestStepData = steps.find(s => s.step_number === currentStep); if (!latestStepData?.result_image_url) return; resetPollPhase(); setBaseUrl(`${API_BASE}${latestStepData.result_image_url}`); setViewedStepNumber(null); setGenerateError(null); setPhase("painting"); }; // ── Step navigation ──────────────────────────────────────────────────────── const handleGotoStep = async (stepNumber: number) => { if (!jobId) return; try { await goToStep(jobId, stepNumber); setViewedStepNumber(stepNumber); refreshResult(jobId); } catch { /* non-critical */ } }; // ── Try Again ────────────────────────────────────────────────────────────── const handleTryAgain = () => { setGenerateError(null); setPhase("painting"); }; // ── Start Over ───────────────────────────────────────────────────────────── const handleStartOver = () => { resetPoll(); setImageFile(null); setImageNaturalWidth(null); setImageNaturalHeight(null); setJobId(null); setViewedStepNumber(null); setUploadError(null); setGenerateError(null); setPhase("upload"); }; // ── Download ─────────────────────────────────────────────────────────────── const handleDownload = async (imageUrl: string | null | undefined) => { if (!imageUrl) return; try { const res = await fetch(`${API_BASE}${imageUrl}`); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "result.jpg"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch { /* user can right-click as fallback */ } }; // ── Upload phase UI ──────────────────────────────────────────────────────── if (phase === "upload" || phase === "uploading") { const isLoading = phase === "uploading"; return (

오브젝트 제거

지우고 싶은 물체가 있는 이미지를 업로드하세요.

!isLoading && fileInputRef.current?.click()} role="button" tabIndex={0} onKeyDown={(e) => e.key === "Enter" && fileInputRef.current?.click()} aria-label="이미지 업로드" > {isLoading ? (

업로드 중…

) : ( <>

{isDragOver ? "여기에 놓으세요!" : "클릭하거나 이미지를 여기에 드래그하세요"}

JPEG, PNG, WEBP, HEIC…

)}
{uploadError && (
오류: {uploadError}
)}
); } // ── Canvas phase UI ──────────────────────────────────────────────────────── const isScaledDown = toOriginalScale > 1.0; const isPainting = phase === "painting"; const isGenerating = phase === "generating"; const isCompleted = phase === "completed"; const isFailed = phase === "failed"; const displayError = generateError ?? pollError; const effectiveViewedStep = viewedStepNumber ?? currentStep; const displayedStep = steps.find(s => s.step_number === effectiveViewedStep) ?? null; const latestStepData = steps.find(s => s.step_number === currentStep) ?? null; const canDoMoreWork = isCompleted && !!latestStepData?.result_image_url; return (
{/* Header */}

지우고 싶은 부분을 칠하세요

Job: {jobId}  · 원본: {imageNaturalWidth}×{imageNaturalHeight}px {isScaledDown ? ` · 캔버스: ${displayWidth}×${displayHeight}px · ${(1 / toOriginalScale).toFixed(3)}×` : " · 원본 크기 표시 (1.0×)"}

{/* Toolbar — visible only while painting */} {isPainting && (
)} {/* Canvas + overlay — scales down to fit narrow viewports */}
{isGenerating && (

오브젝트 제거 중…

2초마다 결과 확인 중

)}
{/* Painting hint */} {isPainting && (

{strokeCount === 0 ? <>지우고 싶은 부분을 빨간색으로 칠한 뒤 오브젝트 제거를 클릭하세요. : <>{strokeCount}개의 획을 그렸습니다 — 수정하거나 오브젝트 제거를 클릭하세요.}

)} {/* Completed result section */} {isCompleted && displayedStep && (
{effectiveViewedStep === 0 ? (

Step 0 — 업로드 원본

) : (

✓ Step {effectiveViewedStep} 완료.

)}
{effectiveViewedStep > 0 && (

이전

{displayedStep.original_image_url && ( Before )}
)}

{effectiveViewedStep === 0 ? "원본" : "이후"}

{displayedStep.result_image_url && ( After )}
{canDoMoreWork && ( )}
)} {/* Failed section */} {isFailed && (

✗ 제거에 실패했습니다.

오류: {displayError}

획은 보존되어 있습니다 — 다시 시도해 보세요.

{steps.length > 1 && ( )}
)}
); } // --------------------------------------------------------------------------- // Spinner // --------------------------------------------------------------------------- function Spinner() { return
; } const spinnerStyle: React.CSSProperties = { width: 36, height: 36, border: "4px solid #e5e7eb", borderTop: "4px solid #3b82f6", borderRadius: "50%", animation: "spin 0.9s linear infinite", }; if (typeof document !== "undefined") { const id = "__obj_remover_spin"; if (!document.getElementById(id)) { const s = document.createElement("style"); s.id = id; s.textContent = "@keyframes spin { to { transform: rotate(360deg); } }"; document.head.appendChild(s); } } // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"; const styles: Record = { page: { maxWidth: 880, margin: "48px auto 48px", padding: "0 24px", fontFamily: FONT, color: "#111827", }, heading: { fontSize: 22, fontWeight: 600, marginBottom: 4, letterSpacing: "-0.01em", color: "#111827", }, heading2: { fontSize: 22, fontWeight: 600, margin: "0 0 4px", letterSpacing: "-0.01em", color: "#111827", }, sub: { color: "#6b7280", fontSize: 14, margin: "0 0 24px", }, header: { marginBottom: 24, paddingBottom: 16, borderBottom: "1px solid #f0f2f4", }, meta: { fontSize: 12, color: "#9ca3af", margin: 0, }, dropZone: { border: "2px dashed #b0b7c3", borderRadius: 12, minHeight: 220, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", cursor: "pointer", transition: "border-color 0.15s, background 0.15s", background: "#ffffff", overflow: "hidden", boxShadow: "0 1px 4px rgba(0,0,0,0.08)", }, dropZoneActive: { borderColor: "#3b82f6", background: "#eff6ff", }, dropZoneDisabled: { cursor: "not-allowed", opacity: 0.6, }, dropLabel: { fontSize: 14, fontWeight: 500, color: "#374151", margin: 0, }, dropHint: { fontSize: 12, color: "#9ca3af", marginTop: 4, }, statusText: { color: "#3b82f6", fontSize: 14, }, errorBox: { marginTop: 16, padding: "12px 16px", background: "#fef2f2", border: "1px solid #fca5a5", borderRadius: 8, color: "#b91c1c", fontSize: 13, display: "flex", alignItems: "center", gap: 12, boxShadow: "0 1px 3px rgba(239,68,68,0.08)", }, retryBtn: { marginLeft: "auto", padding: "4px 12px", background: "#fff", border: "1px solid #fca5a5", borderRadius: 6, cursor: "pointer", fontSize: 13, color: "#b91c1c", }, toolbar: { display: "flex", alignItems: "center", gap: 8, marginBottom: 16, padding: "10px 12px", background: "#ffffff", borderRadius: 8, border: "1px solid #e8eaed", flexWrap: "wrap", }, label: { display: "flex", alignItems: "center", gap: 6, fontSize: 14, color: "#374151", flex: "1 1 auto", minWidth: 0, }, iconGap: { marginRight: 4, verticalAlign: "middle", flexShrink: 0, } as React.CSSProperties, slider: { flex: "1 1 60px", minWidth: 60, maxWidth: 160, marginLeft: 6, accentColor: "#3b82f6", }, btnPrimary: { display: "inline-flex", alignItems: "center", padding: "8px 18px", background: "#3b82f6", border: "none", borderRadius: 7, cursor: "pointer", fontSize: 14, fontWeight: 600, color: "#fff", letterSpacing: "-0.01em", boxShadow: "0 1px 3px rgba(59,130,246,0.25)", }, btnSecondary: { display: "inline-flex", alignItems: "center", padding: "7px 14px", background: "#fff", border: "1px solid #d1d5db", borderRadius: 7, cursor: "pointer", fontSize: 14, fontWeight: 500, color: "#374151", boxShadow: "0 1px 2px rgba(0,0,0,0.05)", }, btnGhost: { display: "inline-flex", alignItems: "center", padding: "7px 12px", background: "transparent", border: "1px solid #e5e7eb", borderRadius: 7, cursor: "pointer", fontSize: 13, color: "#6b7280", }, btnDisabled: { background: "#d1d5db", cursor: "not-allowed", boxShadow: "none", }, canvasWrapper: { border: "1px solid #e5e7eb", borderRadius: 10, overflow: "hidden", lineHeight: 0, boxShadow: "0 2px 16px rgba(0,0,0,0.12)", }, overlay: { position: "absolute", inset: 0, background: "rgba(255,255,255,0.80)", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: 10, backdropFilter: "blur(3px)", }, overlayCard: { display: "flex", flexDirection: "column", alignItems: "center", gap: 12, background: "#fff", padding: "28px 40px", borderRadius: 12, boxShadow: "0 8px 32px rgba(0,0,0,0.12)", }, overlayTitle: { margin: 0, fontWeight: 600, fontSize: 14, color: "#111827", }, overlaySubtitle: { margin: 0, fontSize: 13, color: "#6b7280", }, hint: { marginTop: 12, fontSize: 13, color: "#6b7280", lineHeight: 1.5, }, resultSection: { marginTop: 24, }, comparisonHeader: { marginTop: 20, }, comparison: { display: "flex", gap: 20, marginTop: 16, flexWrap: "wrap", }, compareCol: { flex: 1, minWidth: 200, textAlign: "center" as const, }, compareLabel: { margin: "0 0 8px", fontWeight: 600, fontSize: 13, color: "#6b7280", textTransform: "uppercase" as const, letterSpacing: "0.05em", }, compareImg: { maxWidth: "100%", borderRadius: 8, border: "1px solid #e5e7eb", display: "block", margin: "0 auto", boxShadow: "0 1px 4px rgba(0,0,0,0.06)", }, resultActions: { display: "flex", gap: 8, marginTop: 20, flexWrap: "wrap", }, bannerTitle: { margin: "0 0 6px", fontWeight: 600, fontSize: 14, color: "#111827", }, errorBanner: { marginTop: 16, padding: "16px 20px", background: "#fef2f2", border: "1px solid #fca5a5", borderRadius: 10, color: "#991b1b", boxShadow: "0 1px 4px rgba(239,68,68,0.08)", }, bannerSub: { margin: "4px 0", fontSize: 13, }, };