/* ========================================================= Watermark Remover — Frontend Logic ========================================================= */ "use strict"; // ── State ───────────────────────────────────────────────── const state = { fileId: null, ext: null, isVideo: false, method: "opencv", videoMode: "fast", tool: "brush", // "brush" | "box" | "eraser" drawing: false, brushSize: 24, originalW: 0, originalH: 0, displayScale:1, lastX: null, lastY: null, boxStartX: null, boxStartY: null, boxSnapshot: null, sliderPct: 50, // compare slider position (0–100) sliderDragging: false, downloadUrl: null, }; // ── Elements ────────────────────────────────────────────── const dropZone = document.getElementById("drop-zone"); const fileInput = document.getElementById("file-input"); const chooseBtn = document.getElementById("choose-btn"); const changeFileBtn = document.getElementById("change-file-btn"); const maskPanel = document.getElementById("mask-panel"); const comparePanel = document.getElementById("compare-panel"); const canvasScroll = document.getElementById("canvas-scroll"); const imgCanvas = document.getElementById("img-canvas"); const overlayCanvas = document.getElementById("overlay-canvas"); const brushRange = document.getElementById("brush-size"); const brushLabel = document.getElementById("brush-label"); const brushBtn = document.getElementById("brush-btn"); const boxBtn = document.getElementById("box-btn"); const eraserBtn = document.getElementById("eraser-btn"); const samBtn = document.getElementById("sam-btn"); const clearBtn = document.getElementById("clear-btn"); const fileNameLabel = document.getElementById("file-name-label"); const sourceVideoBar = document.getElementById("source-video-bar"); const sourceVideo = document.getElementById("source-video"); const detectBtn = document.getElementById("detect-btn"); const processBtn = document.getElementById("process-btn"); const methodSeg = document.getElementById("method-seg"); const modeSeg = document.getElementById("mode-seg"); const videoOpts = document.getElementById("video-opts"); const lamaBtn = document.getElementById("lama-btn"); const sdBtn = document.getElementById("sd-btn"); const statusBlock = document.getElementById("status-block"); const statusText = document.getElementById("status-text"); const statusSpinner = document.getElementById("status-spinner"); const progressWrap = document.getElementById("progress-wrap"); const progressFill = document.getElementById("progress-fill"); const progressLabel = document.getElementById("progress-label"); const resultSection = document.getElementById("result-section"); const downloadBtn = document.getElementById("download-btn"); const capsBar = document.getElementById("caps-bar"); const backToMaskBtn = document.getElementById("back-to-mask-btn"); const newFileBtn = document.getElementById("new-file-btn"); const cmpFileLabel = document.getElementById("cmp-file-label"); // Compare slider elements const compareWrap = document.getElementById("compare-wrap"); const compareHandle = document.getElementById("compare-handle"); const compareBefore = document.getElementById("compare-before"); const cmpBeforeImg = document.getElementById("cmp-before-img"); const cmpAfterImg = document.getElementById("cmp-after-img"); const cmpBeforeVideo = document.getElementById("cmp-before-video"); const cmpAfterVideo = document.getElementById("cmp-after-video"); const compareVideoBar = document.getElementById("compare-video-bar"); const cmpPlayBtn = document.getElementById("cmp-play-btn"); const cmpPlayIcon = document.getElementById("cmp-play-icon"); const cmpSeek = document.getElementById("cmp-seek"); const cmpTime = document.getElementById("cmp-time"); const imgCtx = imgCanvas.getContext("2d"); const overlayCtx= overlayCanvas.getContext("2d"); // ── Init ───────────────────────────────────────────────── (async function init() { try { const res = await fetch("/capabilities"); const caps = await res.json(); [{ label: "OpenCV", on: caps.opencv }, { label: "LaMa", on: caps.lama }, { label: "SD", on: caps.sd }, { label: "EasyOCR", on: caps.easyocr }, { label: "SAM", on: caps.sam }] .forEach(({ label, on }) => { const b = document.createElement("span"); b.className = "cap-badge" + (on ? " active" : ""); b.textContent = label; capsBar.appendChild(b); }); if (!caps.lama) { lamaBtn.disabled = true; lamaBtn.title = "Install simple-lama-inpainting to enable"; } if (!caps.sam) { samBtn.disabled = true; samBtn.title = "SAM model not available"; } if (!caps.sd) { sdBtn.disabled = true; sdBtn.title = "Install diffusers to enable"; } } catch (_) {} })(); // ── File handling ───────────────────────────────────────── chooseBtn.addEventListener("click", () => fileInput.click()); changeFileBtn.addEventListener("click",() => fileInput.click()); fileInput.addEventListener("change", () => { if (fileInput.files[0]) handleFile(fileInput.files[0]); }); dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }); dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over")); dropZone.addEventListener("drop", (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]); }); async function handleFile(file) { setStatus("Uploading…"); const form = new FormData(); form.append("file", file); let data; try { const res = await fetch("/upload", { method: "POST", body: form }); if (!res.ok) throw new Error(await res.text()); data = await res.json(); } catch (err) { setStatus("Upload failed: " + err.message, "error"); return; } state.fileId = data.file_id; state.ext = data.ext; state.isVideo = data.is_video; state.downloadUrl = null; clearStatus(); hideResult(); showMaskPanel(); fileNameLabel.textContent = file.name; cmpFileLabel.textContent = file.name; videoOpts.classList.toggle("hidden", !data.is_video); // Video: show the source player so user can watch original before masking if (data.is_video) { sourceVideo.src = `/source/${data.file_id}${data.ext}`; sourceVideoBar.classList.remove("hidden"); } else { sourceVideo.src = ""; sourceVideoBar.classList.add("hidden"); } // Load first-frame preview into canvas await loadPreview(data.file_id); detectBtn.disabled = false; processBtn.disabled = false; } function showMaskPanel() { dropZone.classList.add("hidden"); maskPanel.classList.remove("hidden"); comparePanel.classList.add("hidden"); } function showComparePanel() { maskPanel.classList.add("hidden"); comparePanel.classList.remove("hidden"); } backToMaskBtn.addEventListener("click", showMaskPanel); newFileBtn.addEventListener("click", () => fileInput.click()); // ── Canvas preview ──────────────────────────────────────── async function loadPreview(fileId) { await new Promise((r) => requestAnimationFrame(r)); return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { state.originalW = img.naturalWidth; state.originalH = img.naturalHeight; const maxW = Math.max(canvasScroll.clientWidth - 48, 400); const maxH = Math.max(canvasScroll.clientHeight - 48, 300); const scale = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1); state.displayScale = scale; const dw = Math.round(img.naturalWidth * scale); const dh = Math.round(img.naturalHeight * scale); imgCanvas.width = overlayCanvas.width = dw; imgCanvas.height= overlayCanvas.height = dh; imgCtx.drawImage(img, 0, 0, dw, dh); clearOverlay(); resolve(); }; img.onerror = reject; img.src = `/preview/${fileId}?t=${Date.now()}`; }); } // ── Tool: Brush ─────────────────────────────────────────── function drawStroke(x, y) { overlayCtx.save(); overlayCtx.globalCompositeOperation = state.tool === "eraser" ? "destination-out" : "source-over"; overlayCtx.fillStyle = "rgba(220, 60, 60, 0.55)"; overlayCtx.strokeStyle= "rgba(220, 60, 60, 0.55)"; overlayCtx.lineWidth = state.brushSize * state.displayScale; overlayCtx.lineCap = "round"; overlayCtx.lineJoin = "round"; if (state.lastX !== null) { overlayCtx.beginPath(); overlayCtx.moveTo(state.lastX, state.lastY); overlayCtx.lineTo(x, y); overlayCtx.stroke(); } overlayCtx.beginPath(); overlayCtx.arc(x, y, (state.brushSize * state.displayScale) / 2, 0, Math.PI * 2); overlayCtx.fill(); overlayCtx.restore(); state.lastX = x; state.lastY = y; } // ── Tool: Box ──────────────────────────────────────────── function boxDragStart(x, y) { state.boxStartX = x; state.boxStartY = y; state.boxSnapshot= overlayCtx.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height); } function boxDragMove(x, y) { if (state.boxSnapshot) overlayCtx.putImageData(state.boxSnapshot, 0, 0); const rx = Math.min(state.boxStartX, x); const ry = Math.min(state.boxStartY, y); const rw = Math.abs(x - state.boxStartX); const rh = Math.abs(y - state.boxStartY); overlayCtx.save(); overlayCtx.globalCompositeOperation = "source-over"; overlayCtx.fillStyle = "rgba(220, 60, 60, 0.45)"; overlayCtx.strokeStyle= "rgba(220, 60, 60, 0.9)"; overlayCtx.lineWidth = 1.5; overlayCtx.fillRect(rx, ry, rw, rh); overlayCtx.strokeRect(rx, ry, rw, rh); overlayCtx.restore(); } function boxDragEnd(x, y) { boxDragMove(x, y); state.boxSnapshot = null; state.boxStartX = null; state.boxStartY = null; } // ── Canvas pointer routing ──────────────────────────────── function getCanvasPos(e) { const rect = overlayCanvas.getBoundingClientRect(); const clientX= e.touches ? e.touches[0].clientX : e.clientX; const clientY= e.touches ? e.touches[0].clientY : e.clientY; return { x: (clientX - rect.left) * (overlayCanvas.width / rect.width), y: (clientY - rect.top) * (overlayCanvas.height / rect.height), }; } overlayCanvas.addEventListener("mousedown", (e) => { const { x, y } = getCanvasPos(e); if (state.tool === "sam") { samSegment(x, y); return; } state.drawing = true; if (state.tool === "box") { boxDragStart(x, y); } else { state.lastX = null; drawStroke(x, y); } }); overlayCanvas.addEventListener("mousemove", (e) => { if (!state.drawing) return; const { x, y } = getCanvasPos(e); if (state.tool === "box") { boxDragMove(x, y); } else { drawStroke(x, y); } }); overlayCanvas.addEventListener("mouseup", (e) => { if (!state.drawing) return; const { x, y } = getCanvasPos(e); if (state.tool === "box") boxDragEnd(x, y); state.drawing = false; state.lastX = null; state.lastY = null; }); overlayCanvas.addEventListener("mouseleave", () => { if (state.drawing && state.tool === "box" && state.boxSnapshot) state.boxSnapshot = null; state.drawing = false; state.lastX = null; state.lastY = null; }); overlayCanvas.addEventListener("touchstart", (e) => { e.preventDefault(); state.drawing = true; const { x, y } = getCanvasPos(e); if (state.tool === "box") { boxDragStart(x, y); } else { state.lastX = null; drawStroke(x, y); } }, { passive: false }); overlayCanvas.addEventListener("touchmove", (e) => { e.preventDefault(); if (!state.drawing) return; const { x, y } = getCanvasPos(e); if (state.tool === "box") { boxDragMove(x, y); } else { drawStroke(x, y); } }, { passive: false }); overlayCanvas.addEventListener("touchend", (e) => { if (state.drawing && state.tool === "box") { const { x, y } = getCanvasPos({ touches: e.changedTouches }); boxDragEnd(x, y); } state.drawing = false; state.lastX = null; state.lastY = null; }); // ── Toolbar controls ────────────────────────────────────── function setTool(tool) { state.tool = tool; overlayCanvas.style.cursor = tool === "sam" ? "cell" : "crosshair"; [brushBtn, boxBtn, eraserBtn, samBtn].forEach((b) => b.classList.remove("active")); ({ brush: brushBtn, box: boxBtn, eraser: eraserBtn, sam: samBtn })[tool]?.classList.add("active"); } brushBtn.addEventListener("click", () => setTool("brush")); boxBtn.addEventListener("click", () => setTool("box")); eraserBtn.addEventListener("click", () => setTool("eraser")); samBtn.addEventListener("click", () => setTool("sam")); brushRange.addEventListener("input", () => { state.brushSize = parseInt(brushRange.value); brushLabel.textContent = brushRange.value + "px"; }); clearBtn.addEventListener("click", clearOverlay); function clearOverlay() { overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); } // SAM click-to-segment async function samSegment(x, y) { if (!state.fileId) return; samBtn.disabled = true; samBtn.textContent = "SAM…"; overlayCanvas.style.cursor = "wait"; try { const form = new FormData(); form.append("file_id", state.fileId); form.append("ext", state.ext); form.append("x", x); form.append("y", y); form.append("canvas_w", overlayCanvas.width); form.append("canvas_h", overlayCanvas.height); const res = await fetch("/segment", { method: "POST", body: form }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "SAM failed"); // Paint the returned mask onto the overlay canvas const img = new Image(); img.onload = () => { overlayCtx.save(); overlayCtx.globalCompositeOperation = "source-over"; // Tint the mask red (draw mask, then composite colour) const tmpCanvas = document.createElement("canvas"); tmpCanvas.width = overlayCanvas.width; tmpCanvas.height = overlayCanvas.height; const tmpCtx = tmpCanvas.getContext("2d"); tmpCtx.drawImage(img, 0, 0, overlayCanvas.width, overlayCanvas.height); // Use the mask as alpha, fill red tmpCtx.globalCompositeOperation = "source-in"; tmpCtx.fillStyle = "rgba(220, 60, 60, 0.55)"; tmpCtx.fillRect(0, 0, tmpCanvas.width, tmpCanvas.height); overlayCtx.drawImage(tmpCanvas, 0, 0); overlayCtx.restore(); }; img.src = data.mask; } catch (err) { setStatus("SAM error: " + err.message, "error"); } finally { samBtn.disabled = false; samBtn.textContent = "SAM ✦"; overlayCanvas.style.cursor = "cell"; } } // Segmented controls function bindSegmented(el, key) { el.querySelectorAll("button").forEach((btn) => btn.addEventListener("click", () => { if (btn.disabled) return; el.querySelectorAll("button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); state[key] = btn.dataset.val; }) ); } bindSegmented(methodSeg, "method"); bindSegmented(modeSeg, "videoMode"); // ── Mask extraction ─────────────────────────────────────── function getMaskDataURL() { const tmp = document.createElement("canvas"); tmp.width = state.originalW; tmp.height = state.originalH; const ctx = tmp.getContext("2d"); ctx.drawImage(overlayCanvas, 0, 0, state.originalW, state.originalH); const d = ctx.getImageData(0, 0, state.originalW, state.originalH); for (let i = 0; i < d.data.length; i += 4) { const v = d.data[i + 3] > 10 ? 255 : 0; d.data[i] = d.data[i+1] = d.data[i+2] = v; d.data[i+3] = 255; } ctx.putImageData(d, 0, 0); return tmp.toDataURL("image/png"); } function hasMask() { const d = overlayCtx.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height).data; for (let i = 3; i < d.length; i += 4) if (d[i] > 10) return true; return false; } // ── Auto-detect ─────────────────────────────────────────── detectBtn.addEventListener("click", async () => { if (!state.fileId) return; setStatus("Detecting watermarks…"); detectBtn.disabled = true; try { const form = new FormData(); form.append("file_id", state.fileId); form.append("ext", state.ext); const res = await fetch("/detect", { method: "POST", body: form }); if (!res.ok) throw new Error(await res.text()); const data = await res.json(); if (!data.regions?.length) { setStatus("No watermarks detected automatically. Draw the mask manually.", "warn"); return; } const sx = overlayCanvas.width / data.image_width; const sy = overlayCanvas.height / data.image_height; overlayCtx.save(); overlayCtx.fillStyle = "rgba(220, 60, 60, 0.55)"; data.regions.forEach(({ x, y, w, h }) => overlayCtx.fillRect(x*sx, y*sy, w*sx, h*sy)); overlayCtx.restore(); setStatus(`Detected ${data.regions.length} region(s). Adjust the mask if needed, then click Remove.`, "success"); } catch (err) { setStatus("Detection failed: " + err.message, "error"); } finally { detectBtn.disabled = false; } }); // ── Process ─────────────────────────────────────────────── processBtn.addEventListener("click", async () => { if (!state.fileId) return; if (!hasMask()) { setStatus("Draw a mask over the watermark first, or use Auto Detect.", "warn"); return; } // SD is images only — too slow for per-frame video if (state.isVideo && state.method === "sd") { setStatus("⚠ Stable Diffusion is for images only — switch to OpenCV or LaMa for video.", "warn"); return; } // Warn when LaMa + thorough video would take hours (allow after confirmation) if (state.isVideo && state.videoMode === "thorough" && state.method === "lama") { const ok = confirm("LaMa + Thorough inpaints every frame with the ML model — expect 30–60 s per frame on CPU.\n\nFor an 8-second video that could be several hours.\n\nProceed anyway?"); if (!ok) { setStatus("⚠ LaMa + Thorough can take hours. Switched to OpenCV or Fast mode recommended.", "warn"); return; } } setStatus(state.isVideo ? "Starting video processing…" : "Starting inpainting…"); showProgress(0, state.method === "lama" ? "Loading LaMa model (first run ~20s)…" : ""); processBtn.disabled = true; detectBtn.disabled = true; hideResult(); const maskDataURL = getMaskDataURL(); const form = new FormData(); form.append("file_id", state.fileId); form.append("ext", state.ext); form.append("mask_data", maskDataURL); form.append("method", state.method); if (state.isVideo) form.append("mode", state.videoMode); try { const res = await fetch(state.isVideo ? "/process/video" : "/process/image", { method: "POST", body: form }); if (!res.ok) throw new Error(await res.text()); const { job_id } = await res.json(); await pollJob(job_id); } catch (err) { setStatus("Failed: " + err.message, "error"); hideProgress(); } finally { processBtn.disabled = false; detectBtn.disabled = false; } }); async function pollJob(jobId) { while (true) { await new Promise((r) => setTimeout(r, 800)); let data; try { const res = await fetch(`/status/${jobId}`); data = await res.json(); } catch (_) { continue; } const pct = data.total > 0 ? Math.round((data.progress / data.total) * 100) : null; setStatus(data.message || "Processing…"); if (pct !== null) showProgress(pct, data.message); if (data.status === "done") { hideProgress(); state.downloadUrl = data.download_url; await showResult(data.download_url); setStatus("Done — drag the slider to compare.", "success"); break; } else if (data.status === "error") { hideProgress(); setStatus("Error: " + data.message, "error"); break; } } } // ── Result / Compare view ───────────────────────────────── async function showResult(downloadUrl) { resultSection.classList.remove("hidden"); downloadBtn.href = downloadUrl; downloadBtn.classList.remove("hidden"); if (state.isVideo) { // Hide images, show videos cmpBeforeImg.style.display = "none"; cmpAfterImg.style.display = "none"; cmpBeforeVideo.style.display= ""; cmpAfterVideo.style.display = ""; cmpBeforeVideo.src = `/source/${state.fileId}${state.ext}`; cmpAfterVideo.src = downloadUrl; await Promise.all([loadMedia(cmpBeforeVideo), loadMedia(cmpAfterVideo)]); compareVideoBar.classList.remove("hidden"); wireVideoSync(); } else { // Hide videos, show images cmpBeforeVideo.style.display = "none"; cmpAfterVideo.style.display = "none"; cmpBeforeImg.style.display = ""; cmpAfterImg.style.display = ""; cmpBeforeImg.src = `/preview/${state.fileId}?t=${Date.now()}`; cmpAfterImg.src = downloadUrl + `?t=${Date.now()}`; await Promise.all([loadMedia(cmpBeforeImg), loadMedia(cmpAfterImg)]); compareVideoBar.classList.add("hidden"); } setSlider(50); showComparePanel(); } function loadMedia(el) { return new Promise((resolve) => { if (el.tagName === "IMG") { if (el.complete) { resolve(); return; } el.onload = el.onerror = resolve; } else { el.onloadedmetadata = el.onerror = resolve; el.load(); } }); } function hideResult() { resultSection.classList.add("hidden"); downloadBtn.classList.add("hidden"); compareVideoBar.classList.add("hidden"); } // ── Compare slider ──────────────────────────────────────── function setSlider(pct) { state.sliderPct = Math.max(0, Math.min(100, pct)); compareBefore.style.clipPath = `inset(0 ${100 - state.sliderPct}% 0 0)`; compareHandle.style.left = state.sliderPct + "%"; } function sliderPosFromEvent(e) { const rect = compareWrap.getBoundingClientRect(); const clientX = e.touches ? e.touches[0].clientX : e.clientX; return ((clientX - rect.left) / rect.width) * 100; } compareWrap.addEventListener("mousedown", (e) => { state.sliderDragging = true; setSlider(sliderPosFromEvent(e)); }); window.addEventListener("mousemove", (e) => { if (!state.sliderDragging) return; setSlider(sliderPosFromEvent(e)); }); window.addEventListener("mouseup", () => { state.sliderDragging = false; }); compareWrap.addEventListener("touchstart", (e) => { state.sliderDragging = true; setSlider(sliderPosFromEvent(e)); }, { passive: true }); window.addEventListener("touchmove", (e) => { if (!state.sliderDragging) return; setSlider(sliderPosFromEvent(e)); }, { passive: true }); window.addEventListener("touchend", () => { state.sliderDragging = false; }); // ── Video sync (compare) ────────────────────────────────── let _syncWired = false; function wireVideoSync() { if (_syncWired) { _syncWired = false; cmpAfterVideo.pause(); } // After video is the "controller" — before video follows const sync = () => { if (Math.abs(cmpAfterVideo.currentTime - cmpBeforeVideo.currentTime) > 0.15) { cmpBeforeVideo.currentTime = cmpAfterVideo.currentTime; } }; cmpAfterVideo.addEventListener("timeupdate", sync); cmpAfterVideo.addEventListener("play", () => cmpBeforeVideo.play().catch(() => {})); cmpAfterVideo.addEventListener("pause", () => cmpBeforeVideo.pause()); cmpAfterVideo.addEventListener("seeked",() => { cmpBeforeVideo.currentTime = cmpAfterVideo.currentTime; }); // Play/pause button cmpPlayBtn.onclick = () => { if (cmpAfterVideo.paused) { cmpAfterVideo.play(); } else { cmpAfterVideo.pause(); } }; // Seek bar cmpAfterVideo.addEventListener("timeupdate", () => { if (cmpAfterVideo.duration) { cmpSeek.value = (cmpAfterVideo.currentTime / cmpAfterVideo.duration) * 100; cmpTime.textContent = `${fmt(cmpAfterVideo.currentTime)} / ${fmt(cmpAfterVideo.duration)}`; } }); cmpSeek.addEventListener("input", () => { cmpAfterVideo.currentTime = (parseFloat(cmpSeek.value) / 100) * cmpAfterVideo.duration; }); cmpAfterVideo.addEventListener("play", () => { cmpPlayBtn.querySelector("svg").innerHTML = ''; }); cmpAfterVideo.addEventListener("pause", () => { cmpPlayBtn.querySelector("svg").innerHTML = ''; }); _syncWired = true; } function fmt(s) { const m = Math.floor(s / 60); const ss= Math.floor(s % 60).toString().padStart(2, "0"); return `${m}:${ss}`; } // ── Auto-detect ─────────────────────────────────────────── // (wired above, no duplicate needed) // ── Status + progress helpers ───────────────────────────── function setStatus(msg, type = "") { statusBlock.classList.remove("hidden", "error", "success", "warn"); statusText.textContent = msg; // Show spinner only while actively processing (no type = still running) statusSpinner.classList.toggle("hidden", !!type); if (type) statusBlock.classList.add(type); } function showProgress(pct, label = "") { progressWrap.classList.remove("hidden"); progressFill.style.width = pct + "%"; progressLabel.textContent = pct + "%"; if (label) statusText.textContent = label; } function hideProgress() { progressWrap.classList.add("hidden"); progressFill.style.width = "0%"; } function clearStatus() { statusBlock.classList.add("hidden"); hideProgress(); }