mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 02:12:12 +00:00
455 lines
17 KiB
HTML
455 lines
17 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), run <code>@imgly/background-removal</code>, 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 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>
|
|
<select id="sel-type" class="border border-neutral-300 rounded-lg px-3 py-2 bg-white">
|
|
<option value="foreground">foreground</option>
|
|
<option value="mask">mask</option>
|
|
<option value="background">background</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<label class="text-xs text-neutral-400 uppercase tracking-wider">Format</label>
|
|
<select id="sel-format" class="border border-neutral-300 rounded-lg px-3 py-2 bg-white">
|
|
<option value="image/png">image/png</option>
|
|
<option value="image/webp">image/webp</option>
|
|
<option value="image/jpeg">image/jpeg</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<label class="text-xs text-neutral-400 uppercase tracking-wider">Quality (0-1)</label>
|
|
<input id="inp-quality" type="number" min="0" max="1" step="0.05" value="0.9" 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"),
|
|
type: document.getElementById("sel-type"),
|
|
format: document.getElementById("sel-format"),
|
|
quality: document.getElementById("inp-quality"),
|
|
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 toDownloadExt = (mime) => {
|
|
if (mime === "image/webp") return "webp";
|
|
if (mime === "image/jpeg") return "jpg";
|
|
return "png";
|
|
};
|
|
|
|
const makeCard = ({ name, originalUrl, outputUrl, outputMime }, 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 (checkerboard)";
|
|
|
|
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.${toDownloadExt(outputMime)}`;
|
|
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); }
|
|
}
|
|
|
|
function toHexByte(n) {
|
|
return n.toString(16).padStart(2, "0");
|
|
}
|
|
|
|
function detectSignature(bytes) {
|
|
const b = bytes;
|
|
if (b.length >= 3 && b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF) return "jpeg";
|
|
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47 && b[4] === 0x0D && b[5] === 0x0A && b[6] === 0x1A && b[7] === 0x0A) return "png";
|
|
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "webp";
|
|
if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 && (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61) return "gif";
|
|
if (b.length >= 12 && b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70) return "mp4/heic-ish";
|
|
return "unknown";
|
|
}
|
|
|
|
async function inspectImageFile(file) {
|
|
const head = new Uint8Array(await file.slice(0, 16).arrayBuffer());
|
|
return {
|
|
name: file.name,
|
|
size: file.size,
|
|
mime: file.type || "(empty)",
|
|
signature: detectSignature(head),
|
|
magicHex: [...head].map(toHexByte).join(" ")
|
|
};
|
|
}
|
|
|
|
async function tryDecodeWithImageBitmap(blob) {
|
|
const bmp = await createImageBitmap(blob);
|
|
const info = { width: bmp.width, height: bmp.height };
|
|
bmp.close?.();
|
|
return info;
|
|
}
|
|
|
|
function loadImgFromBlob(blob) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = URL.createObjectURL(blob);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
URL.revokeObjectURL(url);
|
|
resolve(img);
|
|
};
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
reject(new Error("HTMLImageElement failed to decode."));
|
|
};
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
function canvasToBlob(canvas, type = "image/png", quality = 0.92) {
|
|
return new Promise((resolve, reject) => {
|
|
canvas.toBlob((blob) => {
|
|
if (!blob) return reject(new Error("Canvas toBlob returned null."));
|
|
resolve(blob);
|
|
}, type, quality);
|
|
});
|
|
}
|
|
|
|
async function normalizeInputImage(file) {
|
|
const meta = await inspectImageFile(file);
|
|
appendLog("input meta:", meta);
|
|
|
|
if (/^\.pending-/i.test(file.name)) {
|
|
appendLog("warn:", "filename looks like temp/incomplete download:", file.name);
|
|
}
|
|
|
|
try {
|
|
const info = await tryDecodeWithImageBitmap(file);
|
|
appendLog("decode ok via createImageBitmap:", info);
|
|
return { blob: file, transcode: false };
|
|
} catch (bitmapErr) {
|
|
appendLog("createImageBitmap decode failed:", formatErr(bitmapErr));
|
|
}
|
|
|
|
try {
|
|
const img = await loadImgFromBlob(file);
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = img.naturalWidth || img.width;
|
|
canvas.height = img.naturalHeight || img.height;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) throw new Error("Could not create 2D canvas context.");
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
const pngBlob = await canvasToBlob(canvas, "image/png", 0.92);
|
|
appendLog("fallback transcode success:", {
|
|
fromMime: file.type || "(empty)",
|
|
toMime: pngBlob.type,
|
|
toSize: pngBlob.size,
|
|
width: canvas.width,
|
|
height: canvas.height
|
|
});
|
|
|
|
try {
|
|
const verify = await tryDecodeWithImageBitmap(pngBlob);
|
|
appendLog("verify transcoded decode ok:", verify);
|
|
} catch (verifyErr) {
|
|
appendLog("verify transcoded decode failed:", formatErr(verifyErr));
|
|
throw verifyErr;
|
|
}
|
|
|
|
return { blob: pngBlob, transcode: true };
|
|
} catch (imgErr) {
|
|
appendLog("img/canvas fallback failed:", formatErr(imgErr));
|
|
}
|
|
|
|
throw new Error(
|
|
`Input image could not be decoded. name="${meta.name}", mime="${meta.mime}", signature="${meta.signature}", magic="${meta.magicHex}". Try a fully downloaded PNG/JPEG/WebP.`
|
|
);
|
|
}
|
|
|
|
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 outputType = el.type.value;
|
|
const outputFormat = el.format.value;
|
|
const quality = clamp(Number.parseFloat(el.quality.value || "0.9") || 0.9, 0, 1);
|
|
appendLog("config:", { outputType, outputFormat, quality });
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
setStatus(`processing ${i + 1}/${files.length}: ${file.name} ...`);
|
|
|
|
const normalized = await normalizeInputImage(file);
|
|
if (normalized.transcode) {
|
|
appendLog("input was transcoded to PNG for stability:", file.name);
|
|
}
|
|
|
|
const config = {
|
|
debug: true,
|
|
output: {
|
|
type: outputType,
|
|
format: outputFormat,
|
|
quality
|
|
},
|
|
progress: (key, current, total) => {
|
|
if (!total) return;
|
|
const pct = Math.round((current / total) * 100);
|
|
el.status.textContent = `downloading model assets (${key}) ${pct}% — file ${i + 1}/${files.length}`;
|
|
}
|
|
};
|
|
|
|
appendLog("calling removeBackground for:", file.name);
|
|
const started = performance.now();
|
|
const outBlob = await removeBackground(normalized.blob, 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,
|
|
outputMime: outBlob.type || outputFormat
|
|
}, 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);
|
|
appendLog("test page booted");
|
|
</script>
|
|
</body>
|
|
</html>
|