/** * Background Remove (rembg) + Sharpen wiring + the live edge-cleanup * tuner that runs on the most-recent rembg cutout. * * rembg-run button: flatten + POST to /api/image/remove-bg with an * optional hint_mask if the user has a wand/lasso selection * active. After the new layer lands, hides every previously- * visible layer so the cutout reads cleanly, and binds the * live-tuner to the new layer. * * Live edge-cleanup tuner: snapshots the pristine cutout the * moment it lands; subsequent feather/grow slider tweaks * rebuild the layer's alpha from that snapshot WITHOUT * re-running the model. * - Grow > 0 → blur snap alpha, threshold low (32) → grow. * - Grow < 0 → blur snap alpha, threshold high (200) → shrink. * - Feather > 0 → blur the whole layer (alpha + RGB) so the * edge softens AND the residual color fringe from the * original background gets blurred away. * * Sharpen: small slider + button; just calls _applyImageTool * against /api/image/sharpen. * * buildSelectionHintMask: pure-ish utility — returns a base64 PNG * (no data: prefix) of the active wand or lasso selection, or * null. Returned so other wand-rembg call sites can use it. * * @param {{ * applyImageTool: (endpoint, payload, layerName, btn, opts?) => Promise, * openCookbookForDependency: (pkg: string) => void, * composite: () => void, * renderLayerPanel: () => void, * uiModule: object, * }} deps * * @returns {{ buildSelectionHintMask: () => string | null }} */ import { state } from './state.js'; export function wireRembgAndSharpen({ applyImageTool, openCookbookForDependency, composite, renderLayerPanel, uiModule, }) { // ── Sharpen ── const sharpenPrev = document.getElementById('ge-sharpen-preview'); if (sharpenPrev) sharpenPrev.style.opacity = '0.5'; document.getElementById('ge-sharpen-amount')?.addEventListener('input', (e) => { document.getElementById('ge-sharpen-label').textContent = e.target.value + '%'; if (sharpenPrev) sharpenPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2); }); document.getElementById('ge-sharpen-run')?.addEventListener('click', () => { const amount = parseInt(document.getElementById('ge-sharpen-amount')?.value || '50'); applyImageTool('/api/image/sharpen', { amount }, 'Sharpened', document.getElementById('ge-sharpen-run')); }); // ── Bg Remove ── document.getElementById('ge-rembg-install-link')?.addEventListener('click', () => { openCookbookForDependency('rembg'); }); document.getElementById('ge-rembg-run')?.addEventListener('click', async () => { const payload = {}; const hint = buildSelectionHintMask(); if (hint) payload.hint_mask = hint; // NB: edge_feather / edge_grow are applied CLIENT-side so the // sliders can re-tune the cutout without re-running the model. const btn = document.getElementById('ge-rembg-run'); const before = state.layers.length; // Snapshot which layers were visible BEFORE the run so we know // which to hide after a successful cutout. const prevVisible = state.layers.filter(l => l.visible).map(l => l.id); await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn); // applyImageTool finishes after fetch but the new layer is added // inside img.onload (one tick later). Poll for up to 60 frames // (~1s) for the new layer to appear before we auto-hide. let frames = 0; while (state.layers.length <= before && frames < 60) { await new Promise(r => requestAnimationFrame(r)); frames++; } if (state.layers.length > before) { const newLayer = state.layers[state.layers.length - 1]; bindRembgLiveTuner(newLayer); // Auto-hide underlying layers so the user sees just the // cutout — the eye toggles back on if they re-enable manually. for (const layer of state.layers) { if (prevVisible.includes(layer.id) && layer.id !== newLayer.id) { layer.visible = false; } } composite(); renderLayerPanel(); } // Reset sliders so the new cutout starts clean. const f = document.getElementById('ge-rembg-feather'); const g = document.getElementById('ge-rembg-grow'); if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); } if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); } }); // ── Live edge-cleanup tuner ── // Snapshots the pristine cutout the moment it lands; slider tweaks // rebuild alpha from that snapshot. function bindRembgLiveTuner(layer) { if (!layer) return; const w = layer.canvas.width, h = layer.canvas.height; const snap = document.createElement('canvas'); snap.width = w; snap.height = h; snap.getContext('2d').drawImage(layer.canvas, 0, 0); state.rembgLiveLayer = layer; state.rembgLiveSnap = snap; rembgApplyEdgeNow(); // initial pass (no-op at 0/0) } let rembgRaf = null; function scheduleRembgApply() { if (rembgRaf) return; rembgRaf = requestAnimationFrame(() => { rembgRaf = null; rembgApplyEdgeNow(); }); } function rembgApplyEdgeNow() { if (!state.rembgLiveLayer || !state.rembgLiveSnap) return; const feather = parseInt(document.getElementById('ge-rembg-feather')?.value || '0', 10); const grow = parseInt(document.getElementById('ge-rembg-grow')?.value || '0', 10); const layer = state.rembgLiveLayer; const snap = state.rembgLiveSnap; const w = snap.width, h = snap.height; const lctx = layer.ctx; // 1) Start fresh from the pristine cutout snapshot. lctx.clearRect(0, 0, w, h); lctx.drawImage(snap, 0, 0); // 2) Edge ±N — dilate / erode alpha via blur+threshold: // grow > 0 → low threshold (32) → halo counts as opaque → grows. // grow < 0 → high threshold (200) → only solid interior → shrinks. // RGB is kept; only alpha is replaced. if (grow !== 0) { const blurC = document.createElement('canvas'); blurC.width = w; blurC.height = h; const bctx = blurC.getContext('2d'); bctx.filter = `blur(${Math.abs(grow)}px)`; bctx.drawImage(snap, 0, 0); bctx.filter = 'none'; const blurred = bctx.getImageData(0, 0, w, h).data; const layerData = lctx.getImageData(0, 0, w, h); const out = layerData.data; const thr = grow > 0 ? 32 : 200; for (let i = 0; i < out.length; i += 4) { out[i + 3] = blurred[i + 3] >= thr ? 255 : 0; } lctx.putImageData(layerData, 0, 0); } // 3) Feather softens whatever edge we have now. Blur the entire // layer (alpha + RGB) — alpha gets smooth falloff, RGB gets a // faint blur at the edge which actually helps hide residual // colour fringing from the original background. if (feather > 0) { const fC = document.createElement('canvas'); fC.width = w; fC.height = h; const fctx = fC.getContext('2d'); fctx.filter = `blur(${feather}px)`; fctx.drawImage(layer.canvas, 0, 0); fctx.filter = 'none'; lctx.clearRect(0, 0, w, h); lctx.drawImage(fC, 0, 0); } composite(); } // ── Slider preview swatches + wiring ── const rembgFeatherPrev = document.getElementById('ge-rembg-feather-preview'); const rembgGrowPrev = document.getElementById('ge-rembg-grow-preview'); function syncRembgFeather(v) { if (!rembgFeatherPrev) return; const inner = Math.max(0, 50 - v * 2.5); rembgFeatherPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${inner}%, transparent 75%)`; } function syncRembgGrow(v) { if (!rembgGrowPrev) return; // -10..+10 → scale 0.6 .. 1.4 so the swatch visibly grows/shrinks. const s = 1 + v * 0.04; rembgGrowPrev.style.transform = `scale(${s})`; rembgGrowPrev.style.background = v < 0 ? 'color-mix(in srgb, var(--fg) 40%, transparent)' : 'var(--fg)'; } syncRembgFeather(2); syncRembgGrow(0); document.getElementById('ge-rembg-feather')?.addEventListener('input', (e) => { const v = parseInt(e.target.value, 10); document.getElementById('ge-rembg-feather-label').textContent = v + 'px'; syncRembgFeather(v); scheduleRembgApply(); }); document.getElementById('ge-rembg-grow')?.addEventListener('input', (e) => { const v = parseInt(e.target.value, 10); document.getElementById('ge-rembg-grow-label').textContent = (v >= 0 ? '+' : '') + v + 'px'; syncRembgGrow(v); scheduleRembgApply(); }); // ── Selection-hint mask builder (used here + by wand-rembg) ── // Full-image white-on-transparent mask PNG (base64, no `data:` // prefix) of whichever selection is active — wand first, lasso // second. Returns null if neither has a selection. function buildSelectionHintMask() { const w = state.imgWidth, h = state.imgHeight; if (state.wandMask && state.wandLayerId) { const off = state.layerOffsets.get(state.wandLayerId) || { x: 0, y: 0 }; const c = document.createElement('canvas'); c.width = w; c.height = h; c.getContext('2d').drawImage(state.wandMask, off.x, off.y); return c.toDataURL('image/png').split(',')[1]; } if (state.lassoPoints.length >= 3 && !state.lassoActive) { const c = document.createElement('canvas'); c.width = w; c.height = h; const ctx = c.getContext('2d'); ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.moveTo(state.lassoPoints[0].x, state.lassoPoints[0].y); for (let i = 1; i < state.lassoPoints.length; i++) { ctx.lineTo(state.lassoPoints[i].x, state.lassoPoints[i].y); } ctx.closePath(); ctx.fill(); return c.toDataURL('image/png').split(',')[1]; } return null; } return { buildSelectionHintMask }; }