Spaces:
Paused
Paused
| /* ========================================================= | |
| Watermark Remover β Frontend Logic | |
| ========================================================= */ | |
| ; | |
| // ββ 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 = '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'; }); | |
| cmpAfterVideo.addEventListener("pause", () => { cmpPlayBtn.querySelector("svg").innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>'; }); | |
| _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(); | |
| } | |