the-adrianator's picture
Initial commit: AI watermark remover
b2c1b6b
/* =========================================================
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 = '<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();
}