mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 10:12:13 +00:00
373 lines
14 KiB
HTML
373 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>vibegif.lol — background-removal test</title>
|
|
<style>
|
|
@font-face {
|
|
font-family: "Stain";
|
|
src: url("https://cdn.jsdelivr.net/gh/multipleof4/stain.otf@master/dist/Stain.otf") format("opentype");
|
|
font-weight: normal;
|
|
font-style: normal;
|
|
}
|
|
body, input, select, button, a, label, code, pre {
|
|
font-family: "Stain", sans-serif;
|
|
}
|
|
.checker {
|
|
background-image:
|
|
linear-gradient(45deg, #eee 25%, transparent 25%),
|
|
linear-gradient(-45deg, #eee 25%, transparent 25%),
|
|
linear-gradient(45deg, transparent 75%, #eee 75%),
|
|
linear-gradient(-45deg, transparent 75%, #eee 75%);
|
|
background-size: 20px 20px;
|
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
|
}
|
|
</style>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
</head>
|
|
<body class="bg-white text-neutral-800 min-h-screen">
|
|
<main class="max-w-6xl mx-auto px-4 py-8 flex flex-col gap-6">
|
|
<header class="flex items-center justify-between">
|
|
<h1 class="text-4xl tracking-tight">bg test<span class="text-neutral-400">.lol</span></h1>
|
|
<a href="/" class="text-sm underline text-neutral-500 hover:text-neutral-800">back to vibegif</a>
|
|
</header>
|
|
|
|
<p class="text-neutral-500">
|
|
Upload image(s), convert to PNG, remove background, inspect output + logs.
|
|
First run can take longer due model download.
|
|
</p>
|
|
|
|
<section class="grid md:grid-cols-5 gap-4">
|
|
<div class="flex flex-col gap-2 md:col-span-2">
|
|
<label class="text-xs text-neutral-400 uppercase tracking-wider">Files</label>
|
|
<input id="inp-files" type="file" multiple accept="image/*" class="border border-neutral-300 rounded-lg px-3 py-2" />
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<label class="text-xs text-neutral-400 uppercase tracking-wider">Output Type</label>
|
|
<input value="foreground (fixed)" disabled class="border border-neutral-300 rounded-lg px-3 py-2 bg-neutral-100 text-neutral-500" />
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<label class="text-xs text-neutral-400 uppercase tracking-wider">Format</label>
|
|
<input value="image/png (fixed)" disabled class="border border-neutral-300 rounded-lg px-3 py-2 bg-neutral-100 text-neutral-500" />
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<label class="text-xs text-neutral-400 uppercase tracking-wider">Max Side (px)</label>
|
|
<input id="inp-max-side" type="number" min="256" max="4096" step="32" value="1024" class="border border-neutral-300 rounded-lg px-3 py-2" />
|
|
</div>
|
|
</section>
|
|
|
|
<section class="flex flex-wrap items-center gap-3">
|
|
<button id="btn-run" class="bg-neutral-800 text-white rounded-lg px-5 py-3 hover:bg-neutral-700 transition">remove background</button>
|
|
<button id="btn-clear" class="border border-neutral-300 rounded-lg px-5 py-3 hover:bg-neutral-100 transition">clear</button>
|
|
<p id="status" class="text-sm text-neutral-500"></p>
|
|
</section>
|
|
|
|
<section class="border border-neutral-200 rounded-xl p-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<p class="text-xs text-neutral-400 uppercase tracking-wider">debug log</p>
|
|
<button id="btn-clear-log" class="text-xs underline text-neutral-500 hover:text-neutral-800">clear log</button>
|
|
</div>
|
|
<pre id="log" class="text-xs whitespace-pre-wrap break-words max-h-56 overflow-auto bg-neutral-50 rounded-lg p-3"></pre>
|
|
</section>
|
|
|
|
<section id="results" class="grid md:grid-cols-2 gap-4"></section>
|
|
</main>
|
|
|
|
<script type="module">
|
|
const el = {
|
|
files: document.getElementById("inp-files"),
|
|
maxSide: document.getElementById("inp-max-side"),
|
|
run: document.getElementById("btn-run"),
|
|
clear: document.getElementById("btn-clear"),
|
|
clearLog: document.getElementById("btn-clear-log"),
|
|
status: document.getElementById("status"),
|
|
log: document.getElementById("log"),
|
|
results: document.getElementById("results")
|
|
};
|
|
|
|
const objectUrls = new Set();
|
|
let removeBackgroundFn = null;
|
|
let loadingLibPromise = null;
|
|
|
|
const now = () => new Date().toLocaleTimeString();
|
|
|
|
const appendLog = (...args) => {
|
|
const line = `[${now()}] ${args.map((v) => {
|
|
if (v instanceof Error) return `${v.name}: ${v.message}`;
|
|
if (typeof v === "object") {
|
|
try { return JSON.stringify(v); } catch { return String(v); }
|
|
}
|
|
return String(v);
|
|
}).join(" ")}`;
|
|
console.log("[bg-test]", ...args);
|
|
if (el.log) {
|
|
el.log.textContent += (el.log.textContent ? "\n" : "") + line;
|
|
el.log.scrollTop = el.log.scrollHeight;
|
|
}
|
|
};
|
|
|
|
const setStatus = (msg) => {
|
|
el.status.textContent = msg || "";
|
|
appendLog("STATUS:", msg || "");
|
|
};
|
|
|
|
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
|
|
|
const makeCard = ({ name, originalUrl, outputUrl }, idx) => {
|
|
const card = document.createElement("article");
|
|
card.className = "border border-neutral-200 rounded-xl p-4 flex flex-col gap-3";
|
|
|
|
const title = document.createElement("h2");
|
|
title.className = "text-lg";
|
|
title.textContent = `${idx + 1}. ${name}`;
|
|
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "grid grid-cols-2 gap-3";
|
|
|
|
const left = document.createElement("div");
|
|
left.className = "flex flex-col gap-2";
|
|
|
|
const leftLabel = document.createElement("p");
|
|
leftLabel.className = "text-xs text-neutral-400 uppercase tracking-wider";
|
|
leftLabel.textContent = "original";
|
|
|
|
const leftImg = document.createElement("img");
|
|
leftImg.src = originalUrl;
|
|
leftImg.className = "w-full h-56 object-contain rounded-lg border border-neutral-200 bg-white";
|
|
|
|
left.append(leftLabel, leftImg);
|
|
|
|
const right = document.createElement("div");
|
|
right.className = "flex flex-col gap-2";
|
|
|
|
const rightLabel = document.createElement("p");
|
|
rightLabel.className = "text-xs text-neutral-400 uppercase tracking-wider";
|
|
rightLabel.textContent = "background removed (transparent png)";
|
|
|
|
const checker = document.createElement("div");
|
|
checker.className = "checker rounded-lg border border-neutral-200 h-56 flex items-center justify-center overflow-hidden";
|
|
|
|
const rightImg = document.createElement("img");
|
|
rightImg.src = outputUrl;
|
|
rightImg.className = "max-w-full max-h-full object-contain";
|
|
checker.appendChild(rightImg);
|
|
|
|
right.append(rightLabel, checker);
|
|
wrap.append(left, right);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "flex items-center gap-3";
|
|
|
|
const dl = document.createElement("a");
|
|
dl.href = outputUrl;
|
|
dl.download = `${name.replace(/\.[^.]+$/, "")}-nobg.png`;
|
|
dl.className = "bg-neutral-800 text-white rounded-lg px-4 py-2 hover:bg-neutral-700 transition no-underline";
|
|
dl.textContent = "download output";
|
|
|
|
actions.append(dl);
|
|
card.append(title, wrap, actions);
|
|
return card;
|
|
};
|
|
|
|
const revokeAllUrls = () => {
|
|
for (const url of objectUrls) URL.revokeObjectURL(url);
|
|
objectUrls.clear();
|
|
appendLog("revoked object urls");
|
|
};
|
|
|
|
const clearResults = () => {
|
|
revokeAllUrls();
|
|
el.results.innerHTML = "";
|
|
el.status.textContent = "";
|
|
appendLog("cleared results");
|
|
};
|
|
|
|
const setBusy = (busy) => {
|
|
el.run.disabled = busy;
|
|
el.run.style.opacity = busy ? "0.5" : "1";
|
|
el.run.style.cursor = busy ? "not-allowed" : "pointer";
|
|
};
|
|
|
|
async function loadLib() {
|
|
if (removeBackgroundFn) return removeBackgroundFn;
|
|
if (loadingLibPromise) return loadingLibPromise;
|
|
|
|
const urls = [
|
|
"https://esm.sh/@imgly/background-removal@1.7.0?bundle",
|
|
"https://cdn.jsdelivr.net/npm/@imgly/background-removal@1.7.0/+esm"
|
|
];
|
|
|
|
loadingLibPromise = (async () => {
|
|
let lastErr = null;
|
|
|
|
for (const url of urls) {
|
|
try {
|
|
appendLog("trying import:", url);
|
|
const mod = await import(url);
|
|
const fn = mod?.default || mod?.removeBackground || mod?.imglyRemoveBackground;
|
|
if (typeof fn !== "function") {
|
|
throw new Error(`No usable remove function export. keys=${Object.keys(mod || {}).join(",")}`);
|
|
}
|
|
removeBackgroundFn = fn;
|
|
appendLog("library loaded from:", url);
|
|
return removeBackgroundFn;
|
|
} catch (err) {
|
|
lastErr = err;
|
|
appendLog("import failed:", url, err);
|
|
}
|
|
}
|
|
|
|
throw lastErr || new Error("Failed to load @imgly/background-removal from all CDNs.");
|
|
})();
|
|
|
|
return loadingLibPromise;
|
|
}
|
|
|
|
function formatErr(err) {
|
|
if (!err) return "Unknown error";
|
|
if (err instanceof Error) return `${err.name}: ${err.message}`;
|
|
try { return JSON.stringify(err); } catch { return String(err); }
|
|
}
|
|
|
|
async function fileToPng(file, maxSide) {
|
|
const srcUrl = URL.createObjectURL(file);
|
|
try {
|
|
const img = new Image();
|
|
img.decoding = "async";
|
|
img.src = srcUrl;
|
|
await img.decode();
|
|
|
|
let w = img.naturalWidth || img.width;
|
|
let h = img.naturalHeight || img.height;
|
|
const longest = Math.max(w, h);
|
|
const scale = longest > maxSide ? (maxSide / longest) : 1;
|
|
|
|
w = Math.max(1, Math.round(w * scale));
|
|
h = Math.max(1, Math.round(h * scale));
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) throw new Error("Could not create 2D canvas context");
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.drawImage(img, 0, 0, w, h);
|
|
|
|
const blob = await new Promise((resolve, reject) => {
|
|
canvas.toBlob((b) => b ? resolve(b) : reject(new Error("Failed converting to PNG")), "image/png", 1);
|
|
});
|
|
|
|
const pngName = `${file.name.replace(/\.[^.]+$/, "")}.png`;
|
|
return new File([blob], pngName, { type: "image/png" });
|
|
} finally {
|
|
URL.revokeObjectURL(srcUrl);
|
|
}
|
|
}
|
|
|
|
el.clear.addEventListener("click", clearResults);
|
|
el.clearLog.addEventListener("click", () => { el.log.textContent = ""; });
|
|
|
|
window.addEventListener("error", (e) => {
|
|
appendLog("window.error:", e.message || e.error || e);
|
|
});
|
|
|
|
window.addEventListener("unhandledrejection", (e) => {
|
|
appendLog("unhandledrejection:", formatErr(e.reason));
|
|
});
|
|
|
|
el.run.addEventListener("click", async () => {
|
|
const files = [...(el.files.files || [])];
|
|
appendLog("run clicked. files:", files.map(f => `${f.name} (${f.size} bytes)`));
|
|
|
|
if (!files.length) {
|
|
setStatus("pick at least one image first.");
|
|
el.files.focus();
|
|
return;
|
|
}
|
|
|
|
clearResults();
|
|
setBusy(true);
|
|
|
|
try {
|
|
setStatus("loading @imgly/background-removal...");
|
|
const removeBackground = await loadLib();
|
|
|
|
const maxSide = clamp(Number.parseInt(el.maxSide.value || "1024", 10) || 1024, 256, 4096);
|
|
appendLog("config:", { outputType: "foreground", outputFormat: "image/png", maxSide });
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
setStatus(`preparing ${i + 1}/${files.length}: ${file.name} ...`);
|
|
|
|
appendLog("converting to PNG:", file.name);
|
|
const startedPrep = performance.now();
|
|
const pngFile = await fileToPng(file, maxSide);
|
|
const prepMs = Math.round(performance.now() - startedPrep);
|
|
appendLog("png ready:", pngFile.name, `${pngFile.size} bytes`, `in ${prepMs}ms`);
|
|
|
|
const config = {
|
|
debug: false,
|
|
output: {
|
|
type: "foreground",
|
|
format: "image/png",
|
|
quality: 1
|
|
},
|
|
progress: (key, current, total) => {
|
|
if (!total) return;
|
|
const pct = Math.round((current / total) * 100);
|
|
el.status.textContent = `loading model assets (${key}) ${pct}% — file ${i + 1}/${files.length}`;
|
|
}
|
|
};
|
|
|
|
appendLog("calling removeBackground for:", pngFile.name);
|
|
setStatus(`processing ${i + 1}/${files.length}: ${file.name} ...`);
|
|
const started = performance.now();
|
|
const outBlob = await removeBackground(pngFile, config);
|
|
const ms = Math.round(performance.now() - started);
|
|
appendLog("done:", file.name, `in ${ms}ms`, `blob=${outBlob.type} ${outBlob.size} bytes`);
|
|
|
|
const originalUrl = URL.createObjectURL(file);
|
|
const outputUrl = URL.createObjectURL(outBlob);
|
|
objectUrls.add(originalUrl);
|
|
objectUrls.add(outputUrl);
|
|
|
|
const card = makeCard({
|
|
name: file.name,
|
|
originalUrl,
|
|
outputUrl
|
|
}, i);
|
|
|
|
el.results.appendChild(card);
|
|
}
|
|
|
|
setStatus(`done ✓ processed ${files.length} image${files.length > 1 ? "s" : ""}.`);
|
|
} catch (err) {
|
|
const msg = formatErr(err);
|
|
console.error(err);
|
|
setStatus(`error: ${msg}`);
|
|
appendLog("FATAL:", msg, err?.stack || "");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
});
|
|
|
|
window.addEventListener("beforeunload", revokeAllUrls);
|
|
|
|
const idleWarmup = () => loadLib().then(
|
|
() => appendLog("warmup: library ready"),
|
|
(err) => appendLog("warmup failed:", formatErr(err))
|
|
);
|
|
|
|
if ("requestIdleCallback" in window) requestIdleCallback(idleWarmup, { timeout: 2500 });
|
|
else setTimeout(idleWarmup, 800);
|
|
|
|
appendLog("test page booted");
|
|
</script>
|
|
</body>
|
|
</html>
|