Files
LyAhn fa0170daa9 feat(rembg): add RemoveBG Studio - native background removal app
C++ WebView2 host app driving a Python/rembg engine as a JSON-stdio
sidecar, with live before/after preview for images and video frames.
Also includes the original CLI (removebg.py) built on the same
core/ engine.

The WebView2 SDK is fetched on demand via
native/third_party/fetch-webview2.ps1 (wired into the Makefile)
rather than vendored, since it's ~15MB of prebuilt/generated
Microsoft SDK content. A Makefile provides make sync/build/run/clean
as the standard entry points.
2026-07-26 18:28:40 +01:00

592 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"use strict";
const host = window.chrome && window.chrome.webview;
if (!host) {
document.body.insertAdjacentHTML(
"afterbegin",
'<div style="position:fixed;inset:0;z-index:99;display:flex;align-items:center;justify-content:center;background:#16161a;color:#e9e8ee;font-family:sans-serif;">This UI must be run inside the RemoveBG Studio native host.</div>'
);
return;
}
// ---------------------------------------------------------------- IPC
let seq = 0;
const pending = new Map();
function call(cmd, params, onProgress) {
return new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject, onProgress });
host.postMessage({ id, cmd, params: params || {} });
});
}
function callNative(cmd, params) {
return new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
host.postMessage({ target: "native", id, cmd, params: params || {} });
});
}
host.addEventListener("message", (e) => {
const data = e.data;
if (!data || typeof data !== "object") return;
if (data.event === "ready" && data.id === undefined) {
setEngineStatus("ready");
return;
}
if (data.event === "log") {
appendLog(data.message, false);
return;
}
const entry = pending.get(data.id);
if (!entry) return;
if (data.event === "progress") {
entry.onProgress && entry.onProgress(data);
if (data.message) setStatusOnly(data.message);
return;
}
if (data.event === "done") {
pending.delete(data.id);
entry.resolve(data.result);
return;
}
if (data.event === "error") {
pending.delete(data.id);
appendLog(data.message, true);
entry.reject(new Error(data.message || "Unknown error"));
return;
}
});
// ---------------------------------------------------------------- chrome / status
const engineStatusEl = document.getElementById("engineStatus");
function setEngineStatus(state) { engineStatusEl.className = "rail-status " + state; }
const statusText = document.getElementById("statusText");
const progressFill = document.getElementById("progressFill");
const logPanel = document.getElementById("logPanel");
const logToggle = document.getElementById("logToggle");
logToggle.addEventListener("click", () => {
logPanel.classList.toggle("is-open");
logToggle.classList.toggle("is-open");
});
function setStatusOnly(message) { if (message) statusText.textContent = message; }
function appendLog(message, isError) {
if (!message) return;
const line = document.createElement("div");
if (isError) line.classList.add("is-error");
line.textContent = message;
logPanel.appendChild(line);
logPanel.scrollTop = logPanel.scrollHeight;
statusText.textContent = message;
}
function setProgress(current, total) {
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
progressFill.style.width = pct + "%";
}
// ---------------------------------------------------------------- nav
const railButtons = document.querySelectorAll(".rail-btn");
const viewTitle = document.getElementById("viewTitle");
const imgModeSegWrap = document.getElementById("imgModeSeg");
const stages = { image: document.getElementById("stage-image"), video: document.getElementById("stage-video") };
const panels = { image: document.getElementById("panel-image"), video: document.getElementById("panel-video") };
const titles = { image: "Image Processor", video: "Video Processor" };
railButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const view = btn.dataset.view;
railButtons.forEach((b) => b.classList.toggle("is-active", b === btn));
Object.keys(stages).forEach((k) => {
stages[k].hidden = k !== view;
panels[k].hidden = k !== view;
});
viewTitle.textContent = titles[view];
imgModeSegWrap.style.visibility = view === "image" ? "visible" : "hidden";
});
});
// ---------------------------------------------------------------- models
const MODEL_DESCRIPTIONS = {};
function populateModelSelect(select) {
select.innerHTML = "";
for (const id of Object.keys(MODEL_DESCRIPTIONS)) {
const opt = document.createElement("option");
opt.value = id;
opt.textContent = id;
select.appendChild(opt);
}
}
async function loadModels() {
try {
const result = await call("list_models", {});
Object.assign(MODEL_DESCRIPTIONS, result.models);
populateModelSelect(imgModel);
populateModelSelect(vidModel);
imgModel.value = "u2net";
vidModel.value = "u2net";
updateModelDesc();
} catch (err) {
appendLog("Failed to load model list: " + err.message, true);
}
}
// ---------------------------------------------------------------- helpers
function basename(p) {
if (!p) return "";
const parts = p.split(/[\\/]/);
return parts[parts.length - 1];
}
function setPathLine(el, path, emptyText) {
if (path) { el.textContent = path; el.title = path; el.classList.add("has-value"); }
else { el.textContent = emptyText; el.title = ""; el.classList.remove("has-value"); }
}
function defaultImageOutput(inputPath, forBatch) {
if (!inputPath) return "";
if (forBatch) {
const dir = inputPath.substring(0, inputPath.lastIndexOf("\\"));
return (dir || inputPath) + "\\output_nobg";
}
const dot = inputPath.lastIndexOf(".");
const base = dot > -1 ? inputPath.substring(0, dot) : inputPath;
return base + "_nobg.png";
}
function defaultVideoOutput(inputPath, exportFormat) {
if (!inputPath) return "";
const dot = inputPath.lastIndexOf(".");
const base = dot > -1 ? inputPath.substring(0, dot) : inputPath;
if (exportFormat === "png_sequence") return base + "_frames";
const ext = exportFormat === "webm_transparent" ? "webm" : exportFormat === "mp4_solid" ? "mp4" : "mov";
return base + "_nobg." + ext;
}
function debounce(fn, ms) {
let t = null;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
function setupDropTarget(el, onFiles) {
el.addEventListener("dragover", (e) => { e.preventDefault(); });
el.addEventListener("drop", (e) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files || []);
const paths = files.map((f) => f.path).filter(Boolean);
onFiles(paths.length ? paths : null);
});
}
function bindSwatchRow(container, onChange) {
container.querySelectorAll(".swatch-btn").forEach((btn) => {
btn.addEventListener("click", () => {
container.querySelectorAll(".swatch-btn").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
onChange(btn.dataset.bg);
});
});
}
function bindToggle(el, onChange) {
let on = false;
el.addEventListener("click", () => { on = !on; el.classList.toggle("is-on", on); onChange(on); });
return () => on;
}
function bindCompareHandle(stageEl, handleEl) {
// Dragging can start anywhere on the image, not just the thin divider
// line — the handle is just the visual affordance. This mirrors the
// usual before/after slider UX and avoids requiring pixel-precise aim.
let dragging = false;
const setSplit = (clientX) => {
const rect = stageEl.getBoundingClientRect();
const pct = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100));
stageEl.style.setProperty("--split", pct.toFixed(2));
};
const start = (e) => {
dragging = true;
handleEl.classList.add("is-dragging");
stageEl.setPointerCapture(e.pointerId);
setSplit(e.clientX);
e.preventDefault();
};
const move = (e) => { if (dragging) setSplit(e.clientX); };
const end = (e) => {
dragging = false;
handleEl.classList.remove("is-dragging");
try { stageEl.releasePointerCapture(e.pointerId); } catch (err) { /* already released */ }
};
stageEl.addEventListener("pointerdown", start);
stageEl.addEventListener("pointermove", move);
stageEl.addEventListener("pointerup", end);
stageEl.addEventListener("pointercancel", end);
}
function bgColorFor(kind, customHex) {
return kind === "transparent" ? null
: kind === "green" ? "#00FF00"
: kind === "white" ? "#FFFFFF"
: kind === "black" ? "#000000"
: customHex;
}
// ==================================================================
// IMAGE VIEW
// ==================================================================
const imgModel = document.getElementById("imgModel");
const imgModelDesc = document.getElementById("imgModelDesc");
const imgEmpty = document.getElementById("imgEmpty");
const imgOpenBtn = document.getElementById("imgOpenBtn");
const imgBrowseInput = document.getElementById("imgBrowseInput");
const imgCompare = document.getElementById("imgCompare");
const imgCompareStage = document.getElementById("imgCompareStage");
const imgOriginalImg = document.getElementById("imgOriginalImg");
const imgProcessedImg = document.getElementById("imgProcessedImg");
const imgHandle = document.getElementById("imgHandle");
const imgLoading = document.getElementById("imgLoading");
const imgBatchList = document.getElementById("imgBatchList");
const imgBatchListBody = document.getElementById("imgBatchListBody");
const imgInputPath = document.getElementById("imgInputPath");
const imgOutputPath = document.getElementById("imgOutputPath");
const imgBrowseOutput = document.getElementById("imgBrowseOutput");
const imgFormat = document.getElementById("imgFormat");
const imgFormatField = document.getElementById("imgFormatField");
const imgBgSeg = document.getElementById("imgBgSeg");
const imgSwatch = document.getElementById("imgSwatch");
const imgAlphaToggle = document.getElementById("imgAlphaToggle");
const imgStartBtn = document.getElementById("imgStartBtn");
const stageImageEl = document.getElementById("stage-image");
let imgMode = "single";
let imgInputs = [];
let imgOutput = "";
let imgBg = "transparent";
let imgCustomHex = "#00FF00";
const getImgAlpha = bindToggle(imgAlphaToggle, () => queuePreview());
bindCompareHandle(imgCompareStage, imgHandle);
document.getElementById("imgModeSeg").querySelectorAll(".seg-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll("#imgModeSeg .seg-btn").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
imgMode = btn.dataset.mode;
imgFormatField.style.display = imgMode === "batch" ? "flex" : "flex";
resetImageInputState();
});
});
function resetImageInputState() {
imgInputs = [];
imgOutput = "";
setPathLine(imgInputPath, "", "No input selected");
setPathLine(imgOutputPath, "", "Not set");
showImageEmptyState();
}
function showImageEmptyState() {
imgEmpty.hidden = false;
imgCompare.hidden = true;
imgLoading.hidden = true;
imgBatchList.hidden = true;
}
bindSwatchRow(imgBgSeg, async (bg) => {
imgBg = bg;
if (bg === "custom") {
const res = await callNative("dialog-pick-color", { initial: imgCustomHex });
if (res.hex) { imgCustomHex = res.hex; imgSwatch.style.background = res.hex; }
}
queuePreview();
});
imgModel.addEventListener("change", () => { updateModelDesc(); queuePreview(); });
function updateModelDesc() { imgModelDesc.textContent = MODEL_DESCRIPTIONS[imgModel.value] || ""; }
async function pickImageInput() {
if (imgMode === "single") {
const res = await callNative("dialog-open-image", {});
return res.path ? [res.path] : [];
}
const res = await callNative("dialog-open-images", {});
return res.paths || [];
}
async function setImageInputs(paths) {
if (!paths || !paths.length) return;
imgInputs = imgMode === "single" ? [paths[0]] : paths;
setPathLine(imgInputPath, imgMode === "single" ? imgInputs[0] : `${imgInputs.length} file(s) selected`, "No input selected");
if (!imgOutput) {
imgOutput = defaultImageOutput(imgInputs[0], imgMode === "batch");
setPathLine(imgOutputPath, imgOutput, "Not set");
}
if (imgMode === "batch") {
imgEmpty.hidden = true;
imgCompare.hidden = true;
imgLoading.hidden = true;
imgBatchList.hidden = false;
imgBatchListBody.innerHTML = "";
imgInputs.forEach((p) => {
const row = document.createElement("div");
row.className = "batch-row";
row.textContent = basename(p);
imgBatchListBody.appendChild(row);
});
} else {
imgBatchList.hidden = true;
runImagePreview();
}
}
[imgOpenBtn, imgEmpty, imgBrowseInput].forEach((el) => {
el.addEventListener("click", async () => setImageInputs(await pickImageInput()));
});
setupDropTarget(stageImageEl, (paths) => { if (paths) setImageInputs(paths); else pickImageInput().then(setImageInputs); });
async function runImagePreview() {
if (imgMode !== "single" || !imgInputs.length) return;
imgEmpty.hidden = true;
imgBatchList.hidden = true;
imgCompare.hidden = true;
imgLoading.hidden = false;
try {
const result = await call("preview_image", {
input: imgInputs[0],
model: imgModel.value,
alpha_matting: getImgAlpha(),
bg_color: bgColorFor(imgBg, imgCustomHex),
});
imgOriginalImg.src = "data:image/jpeg;base64," + result.original_b64;
imgProcessedImg.src = "data:image/png;base64," + result.preview_b64;
imgLoading.hidden = true;
imgCompare.hidden = false;
} catch (err) {
imgLoading.hidden = true;
imgEmpty.hidden = false;
appendLog("Preview failed: " + err.message, true);
}
}
const queuePreview = debounce(runImagePreview, 260);
imgBrowseOutput.addEventListener("click", async () => {
if (imgMode === "single") {
const suggested = imgInputs[0] ? basename(defaultImageOutput(imgInputs[0], false)) : "output.png";
const res = await callNative("dialog-save-image", { suggestedName: suggested });
if (res.path) { imgOutput = res.path; setPathLine(imgOutputPath, imgOutput, "Not set"); }
} else {
const res = await callNative("dialog-open-folder", { title: "Select Output Folder" });
if (res.path) { imgOutput = res.path; setPathLine(imgOutputPath, imgOutput, "Not set"); }
}
});
imgStartBtn.addEventListener("click", async () => {
if (!imgInputs.length) { appendLog("Select an input image first.", true); return; }
if (!imgOutput) { appendLog("Choose an output destination first.", true); return; }
const bgColor = bgColorFor(imgBg, imgCustomHex);
imgStartBtn.disabled = true;
setEngineStatus("busy");
setProgress(0, 1);
try {
if (imgMode === "single") {
appendLog(`Saving ${basename(imgInputs[0])}`, false);
const result = await call("process_image", {
input: imgInputs[0], output: imgOutput, model: imgModel.value,
alpha_matting: getImgAlpha(), bg_color: bgColor,
});
setProgress(1, 1);
appendLog(`Saved → ${result.output}`, false);
} else {
const result = await call(
"process_batch",
{ inputs: imgInputs, output_dir: imgOutput, model: imgModel.value, alpha_matting: getImgAlpha(), bg_color: bgColor, format: imgFormat.value },
(p) => setProgress(p.current, p.total)
);
appendLog(`Batch complete: ${result.count} image(s) saved to ${imgOutput}`, false);
}
setEngineStatus("ready");
} catch (err) {
setEngineStatus("error");
appendLog("Failed: " + err.message, true);
} finally {
imgStartBtn.disabled = false;
}
});
// ==================================================================
// VIDEO VIEW
// ==================================================================
const vidModel = document.getElementById("vidModel");
const vidEmpty = document.getElementById("vidEmpty");
const vidOpenBtn = document.getElementById("vidOpenBtn");
const vidBrowseInput = document.getElementById("vidBrowseInput");
const vidCompare = document.getElementById("vidCompare");
const vidCompareStage = document.getElementById("vidCompareStage");
const vidOriginalImg = document.getElementById("vidOriginalImg");
const vidProcessedImg = document.getElementById("vidProcessedImg");
const vidHandle = document.getElementById("vidHandle");
const vidLoading = document.getElementById("vidLoading");
const vidMeta = document.getElementById("vidMeta");
const vidRefreshPreview = document.getElementById("vidRefreshPreview");
const vidInputPath = document.getElementById("vidInputPath");
const vidOutputPath = document.getElementById("vidOutputPath");
const vidBrowseOutput = document.getElementById("vidBrowseOutput");
const vidExportFormat = document.getElementById("vidExportFormat");
const vidBgSeg = document.getElementById("vidBgSeg");
const vidFps = document.getElementById("vidFps");
const vidAlphaToggle = document.getElementById("vidAlphaToggle");
const vidStartBtn = document.getElementById("vidStartBtn");
const stageVideoEl = document.getElementById("stage-video");
let vidInput = "";
let vidOutput = "";
let vidOutputIsDefault = true; // false once the user explicitly browses for output
let vidBg = "transparent";
const getVidAlpha = bindToggle(vidAlphaToggle, () => queueVideoPreview());
bindCompareHandle(vidCompareStage, vidHandle);
bindSwatchRow(vidBgSeg, (bg) => { vidBg = bg; queueVideoPreview(); });
vidModel.addEventListener("change", () => queueVideoPreview());
vidRefreshPreview.addEventListener("click", () => runVideoPreview());
vidExportFormat.addEventListener("change", () => {
// The default suggested output's extension (or folder-vs-file shape)
// depends on the export format, so keep it in sync unless the user
// has already picked their own destination.
if (vidOutputIsDefault && vidInput) {
vidOutput = defaultVideoOutput(vidInput, vidExportFormat.value);
setPathLine(vidOutputPath, vidOutput, "Not set");
}
});
async function pickVideoInput() {
const res = await callNative("dialog-open-video", {});
return res.path || null;
}
async function setVideoInput(path) {
if (!path) return;
vidInput = path;
setPathLine(vidInputPath, vidInput, "No video selected");
if (!vidOutput || vidOutputIsDefault) {
vidOutput = defaultVideoOutput(vidInput, vidExportFormat.value);
vidOutputIsDefault = true;
setPathLine(vidOutputPath, vidOutput, "Not set");
}
vidMeta.textContent = "Analyzing…";
try {
const info = await call("get_video_info", { path: vidInput });
vidMeta.textContent = `${info.width}×${info.height} · ${info.fps.toFixed(2)} fps · ~${info.frame_count} frames · Audio: ${info.has_audio ? "Yes" : "No"}`;
} catch (err) {
vidMeta.textContent = "Could not analyze video: " + err.message;
}
runVideoPreview();
}
[vidOpenBtn, vidEmpty, vidBrowseInput].forEach((el) => {
el.addEventListener("click", async () => setVideoInput(await pickVideoInput()));
});
setupDropTarget(stageVideoEl, (paths) => { if (paths && paths[0]) setVideoInput(paths[0]); else pickVideoInput().then(setVideoInput); });
async function runVideoPreview() {
if (!vidInput) return;
vidEmpty.hidden = true;
vidCompare.hidden = true;
vidLoading.hidden = false;
try {
const result = await call("preview_video_frame", {
video: vidInput,
model: vidModel.value,
alpha_matting: getVidAlpha(),
bg_color: bgColorFor(vidBg, null),
});
vidOriginalImg.src = "data:image/jpeg;base64," + result.original_b64;
vidProcessedImg.src = "data:image/png;base64," + result.preview_b64;
vidLoading.hidden = true;
vidCompare.hidden = false;
} catch (err) {
vidLoading.hidden = true;
vidEmpty.hidden = false;
appendLog("Video preview failed: " + err.message, true);
}
}
const queueVideoPreview = debounce(runVideoPreview, 260);
vidBrowseOutput.addEventListener("click", async () => {
let res;
if (vidExportFormat.value === "png_sequence") {
res = await callNative("dialog-open-folder", { title: "Select Output Folder for PNG Sequence" });
} else {
const suggested = vidInput ? basename(defaultVideoOutput(vidInput, vidExportFormat.value)) : "output.mov";
res = await callNative("dialog-save-video", { suggestedName: suggested });
}
if (res.path) {
vidOutput = res.path;
vidOutputIsDefault = false;
setPathLine(vidOutputPath, vidOutput, "Not set");
}
});
vidStartBtn.addEventListener("click", async () => {
if (!vidInput) { appendLog("Select a video file first.", true); return; }
if (!vidOutput) { appendLog("Choose an output destination first.", true); return; }
const bgColor = bgColorFor(vidBg, null);
const fps = parseFloat(vidFps.value) || 0;
vidStartBtn.disabled = true;
setEngineStatus("busy");
setProgress(0, 1);
appendLog(`Starting video processing: ${basename(vidInput)}`, false);
try {
const result = await call(
"process_video",
{ video: vidInput, output: vidOutput, model: vidModel.value, fps: fps > 0 ? fps : null, export_format: vidExportFormat.value, bg_color: bgColor, alpha_matting: getVidAlpha() },
(p) => setProgress(p.current, p.total)
);
setEngineStatus("ready");
appendLog(`Video complete → ${result.output}`, false);
} catch (err) {
setEngineStatus("error");
appendLog("Failed: " + err.message, true);
} finally {
vidStartBtn.disabled = false;
}
});
// ---------------------------------------------------------------- boot
setEngineStatus("");
loadModels();
})();