update poses

This commit is contained in:
mike
2026-06-22 19:00:19 +02:00
parent c0d863ee09
commit 9e99c85134
6 changed files with 691 additions and 77 deletions

View File

@@ -1675,6 +1675,7 @@
<button class="btn" onclick="setIntervalTime(30)">30s</button>
<button class="btn" onclick="setIntervalTime(120)">2m</button>
<button class="btn primary" onclick="refreshNow()">Refresh Now</button>
<button class="btn" id="archiveViewBtn" onclick="toggleArchiveView()" title="View archived items">📦 Archive</button>
<button class="btn" id="selectBtn" onclick="toggleSelectMode()">Select</button>
<button class="btn" id="privacyBtn" onclick="togglePrivacyMode()" title="Privacy mode — hides content on focus loss (P)">🔓</button>
<button class="btn" onclick="cleanupDb()" title="Remove DB records for deleted/missing files" style="font-size:11px;opacity:0.6">Clean DB</button>
@@ -1745,6 +1746,16 @@
</div>
<!-- Film strip / variant thumbnails -->
<div class="studio-filmstrip" id="lbVariantStrip"></div>
<!-- Slideshow controls -->
<div id="slideshowBar" style="display:flex;align-items:center;gap:6px;padding:4px 8px;background:#111;border-top:1px solid #1a1a1a;flex-shrink:0">
<button onclick="toggleSlideshow()" id="slideshowBtn" style="background:none;border:1px solid #333;color:#888;font-size:11px;padding:2px 8px;border-radius:4px;cursor:pointer">▶ Slideshow</button>
<select id="slideshowInterval" onchange="updateSlideshowInterval()" style="background:#111;border:1px solid #333;color:#888;font-size:11px;padding:2px 4px;border-radius:4px">
<option value="2000">2s</option>
<option value="4000" selected>4s</option>
<option value="8000">8s</option>
<option value="15000">15s</option>
</select>
</div>
</div>
<!-- Sidebar toolbox -->
@@ -1791,7 +1802,9 @@
<button class="sb-btn" id="lbNoBgBtn" onclick="lbRemoveBg()">No BG</button>
<button class="sb-btn" id="lbCropBtn" style="display:none" onclick="lbAutoCrop()" title="Crop away transparent border">Crop</button>
<button class="sb-btn" id="lbDuplicateBtn" onclick="lbDuplicate()" title="Duplicate image into same group">Duplicate</button>
<button class="sb-btn" id="lbManualCropBtn" onclick="startManualCrop()" title="Drag to crop image">Crop…</button>
<a class="sb-btn" id="lbDownloadBtn" download style="text-decoration:none">Download</a>
<button class="sb-btn" id="lbArchiveBtn" onclick="lbArchive()" style="color:#f59e0b" title="Move to archive (recoverable)">Archive</button>
<button class="sb-btn danger" id="lbDeleteBtn" onclick="lbDeleteArm()" title="Click once to arm, then again to confirm">Delete</button>
</div>
<div id="lbSourceRefs" class="sb-source-refs" style="display:none"></div>
@@ -1821,6 +1834,7 @@
<button class="btn" onclick="deselectAll()">None</button>
<button class="btn" id="groupBtn" onclick="groupSelected()">Group</button>
<button class="btn" id="multiRefBtn" onclick="processMultiRef()" style="display:none" title="Use selected (23) as combined references → single output">Multi-ref</button>
<span id="multiRefLegend" style="display:none;font-size:10px;color:#888;white-space:nowrap"></span>
<button class="btn primary" id="processBtn" onclick="processSelected()">Process</button>
<span class="batch-progress" id="batchProgress"></span>
<button class="btn" onclick="toggleSelectMode()">Done</button>
@@ -2116,6 +2130,8 @@
let filePoses = {}; // filename → pose name
let filePrompts = {}; // filename → generation prompt
let fileHidden = {}; // filename → boolean (hidden from cycling)
let fileArchived = {}; // filename → boolean
let _archiveViewActive = false; // when true, show archived items only
let fileHasBg = {}; // filename → boolean (has background, default true)
let fileSourceRefs = {}; // filename → array of source filenames
let fileHasClothing = {}; // filename → boolean|null (null = unknown)
@@ -2134,6 +2150,10 @@
let _sbPoseEdits = {}; // poseName → edited text
let _sbGenJobId = null;
let _sbGenJobPollTimer= null;
let _followLatestGid = null; // set to gid after generation to auto-jump to newest image
let _slideshowActive = false;
let _slideshowTimer = null;
let _slideshowInterval= 4000;
let _fsTrimVideo = null;
function escHtml(s) {
@@ -2239,6 +2259,36 @@
function closeStudio() {
document.getElementById('studio').classList.remove('open');
document.body.style.overflow = '';
stopSlideshow();
}
function toggleSlideshow() {
if (_slideshowActive) stopSlideshow();
else startSlideshow();
}
function startSlideshow() {
if (_slideshowTimer) clearInterval(_slideshowTimer);
_slideshowActive = true;
const btn = document.getElementById('slideshowBtn');
if (btn) { btn.textContent = '⏸ Pause'; btn.style.color = '#60a5fa'; }
_slideshowTimer = setInterval(() => {
if (lbUrls.length > 1) { lbIdx = (lbIdx + 1) % lbUrls.length; updateStudio(); }
}, _slideshowInterval);
}
function stopSlideshow() {
_slideshowActive = false;
clearInterval(_slideshowTimer);
_slideshowTimer = null;
const btn = document.getElementById('slideshowBtn');
if (btn) { btn.textContent = '▶ Slideshow'; btn.style.color = ''; }
}
function updateSlideshowInterval() {
const sel = document.getElementById('slideshowInterval');
if (sel) _slideshowInterval = parseInt(sel.value, 10);
if (_slideshowActive) startSlideshow(); // restart with new interval
}
function lbNav(dir) {
@@ -2304,6 +2354,8 @@
if (faceswapBtn)faceswapBtn.style.display = !isVid ? '' : 'none';
const cropBtn = document.getElementById('lbCropBtn');
if (cropBtn) cropBtn.style.display = (!isVid && fileHasBg[fname] === false) ? '' : 'none';
const archiveBtn = document.getElementById('lbArchiveBtn');
if (archiveBtn) archiveBtn.textContent = fileArchived[fname] ? 'Restore' : 'Archive';
// Source refs
const refs = fileSourceRefs[fname];
@@ -2521,6 +2573,60 @@
}
}
async function lbArchive() {
const fname = lbNames[lbIdx];
if (!fname) return;
const btn = document.getElementById('lbArchiveBtn');
if (btn) btn.disabled = true;
try {
const endpoint = fileArchived[fname] ? 'unarchive' : 'archive';
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/${endpoint}`, { method: 'POST' });
if (r.ok) {
fileArchived[fname] = !fileArchived[fname];
const action = fileArchived[fname] ? 'Archived' : 'Restored';
showToast(`${action}: ${fname}`, 'success');
// Remove from current filmstrip view and navigate
lbUrls.splice(lbIdx, 1);
lbNames.splice(lbIdx, 1);
if (lbNames.length === 0) {
closeStudio();
refreshNow();
} else {
lbIdx = Math.min(lbIdx, lbNames.length - 1);
updateStudio();
refreshNow();
}
} else {
showToast('Archive failed', 'error');
}
} catch (e) { showToast('Archive error: ' + e, 'error'); }
if (btn) btn.disabled = false;
}
function toggleArchiveView() {
_archiveViewActive = !_archiveViewActive;
const btn = document.getElementById('archiveViewBtn');
if (btn) {
btn.textContent = _archiveViewActive ? '📦 Back to Gallery' : '📦 Archive';
btn.style.color = _archiveViewActive ? '#f59e0b' : '';
}
// Show/hide archive banner
let banner = document.getElementById('archiveBanner');
if (_archiveViewActive) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'archiveBanner';
banner.style.cssText = 'background:#7c3aed22;border:1px solid #7c3aed;border-radius:6px;padding:8px 14px;font-size:12px;color:#a78bfa;margin:8px 0;text-align:center';
banner.textContent = '📦 Viewing archived items — these are hidden from the main gallery';
const gallery = document.getElementById('gallery');
if (gallery) gallery.parentNode.insertBefore(banner, gallery);
}
} else {
if (banner) banner.remove();
}
refreshNow();
}
async function lbAutoCrop() {
const fname = lbNames[lbIdx];
if (!fname) return;
@@ -2539,6 +2645,120 @@
if (btn) { btn.disabled = false; btn.textContent = 'Crop'; }
}
function startManualCrop() {
const fname = lbNames[lbIdx];
if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to crop', 'info'); return; }
const viewer = document.getElementById('studioViewer');
const img = document.getElementById('lbImg');
if (!img || img.style.display === 'none') { showToast('No image displayed', 'info'); return; }
if (document.getElementById('cropCanvas')) return; // already active
const canvas = document.createElement('canvas');
canvas.id = 'cropCanvas';
canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;cursor:crosshair;z-index:100;';
viewer.appendChild(canvas);
const bar = document.createElement('div');
bar.id = 'cropBar';
bar.style.cssText = 'position:absolute;bottom:0;left:0;right:0;display:flex;gap:8px;padding:8px;background:rgba(0,0,0,0.75);z-index:101;justify-content:flex-end;align-items:center;';
bar.innerHTML = '<span style="flex:1;font-size:11px;color:#aaa">Drag to select crop area</span>'
+ '<button class="sb-btn" onclick="cancelManualCrop()">Cancel</button>'
+ '<button class="sb-btn primary" id="cropConfirmBtn" disabled onclick="confirmManualCrop()">Crop</button>';
viewer.appendChild(bar);
const st = window._cropState = { canvas, viewer, img, fname, x1: 0, y1: 0, x2: 0, y2: 0, dragging: false };
function resize() { canvas.width = viewer.clientWidth; canvas.height = viewer.clientHeight; draw(); }
function draw() {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const w = Math.abs(st.x2 - st.x1), h = Math.abs(st.y2 - st.y1);
if (w < 2 || h < 2) return;
const rx = Math.min(st.x1, st.x2), ry = Math.min(st.y1, st.y2);
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.clearRect(rx, ry, w, h);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.strokeRect(rx + 0.5, ry + 0.5, w - 1, h - 1);
}
st.draw = draw;
canvas.addEventListener('mousedown', e => {
const r = canvas.getBoundingClientRect();
st.x1 = st.x2 = e.clientX - r.left;
st.y1 = st.y2 = e.clientY - r.top;
st.dragging = true;
const cb = document.getElementById('cropConfirmBtn');
if (cb) cb.disabled = true;
});
canvas.addEventListener('mousemove', e => {
if (!st.dragging) return;
const r = canvas.getBoundingClientRect();
st.x2 = e.clientX - r.left;
st.y2 = e.clientY - r.top;
draw();
});
canvas.addEventListener('mouseup', () => {
st.dragging = false;
draw();
const w = Math.abs(st.x2 - st.x1), h = Math.abs(st.y2 - st.y1);
const cb = document.getElementById('cropConfirmBtn');
if (cb) cb.disabled = (w < 5 || h < 5);
});
resize();
}
function cancelManualCrop() {
document.getElementById('cropCanvas')?.remove();
document.getElementById('cropBar')?.remove();
window._cropState = null;
}
async function confirmManualCrop() {
const st = window._cropState;
if (!st) return;
const { canvas, img, fname } = st;
// Convert canvas rect → image pixel coords (account for object-fit: contain letterbox)
const cW = canvas.width, cH = canvas.height;
const iW = img.naturalWidth, iH = img.naturalHeight;
const scale = Math.min(cW / iW, cH / iH);
const dW = iW * scale, dH = iH * scale;
const offX = (cW - dW) / 2, offY = (cH - dH) / 2;
const toImg = (cx, cy) => ({
x: Math.round(Math.max(0, Math.min(iW, (cx - offX) / scale))),
y: Math.round(Math.max(0, Math.min(iH, (cy - offY) / scale))),
});
const p1 = toImg(Math.min(st.x1, st.x2), Math.min(st.y1, st.y2));
const p2 = toImg(Math.max(st.x1, st.x2), Math.max(st.y1, st.y2));
if (p2.x - p1.x < 2 || p2.y - p1.y < 2) { showToast('Crop area too small', 'info'); return; }
const btn = document.getElementById('cropConfirmBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Cropping…'; }
try {
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/crop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
});
if (r.ok) {
showToast('Cropped', 'success');
cancelManualCrop();
lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
updateStudio();
} else {
showToast('Crop failed: ' + await r.text(), 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Crop'; }
}
} catch (e) {
showToast('Crop error: ' + e, 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Crop'; }
}
}
async function lbDuplicate() {
const fname = lbNames[lbIdx];
if (!fname) return;
@@ -2671,11 +2891,11 @@
}
});
function showToast(message, type = 'info') {
function showToast(message, type = 'info', duration = 3000) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => toast.classList.remove('show'), 3000);
setTimeout(() => toast.classList.remove('show'), duration);
}
function formatTime(date) {
@@ -2747,7 +2967,8 @@
// Primary: live list from API (always up to date, works after batch jobs)
try {
const r = await fetch(`${API}/images`, { signal: AbortSignal.timeout(12000) });
const url = _archiveViewActive ? `${API}/images?archived=true` : `${API}/images`;
const r = await fetch(url, { signal: AbortSignal.timeout(12000) });
if (r.ok) {
const data = await r.json();
files = data.images.map(img => img.filename);
@@ -2760,6 +2981,7 @@
if (img.group_name && img.group_id) groupNames[img.group_id] = img.group_name;
fileSortOrders[img.filename] = img.sort_order ?? null;
fileHidden[img.filename] = !!img.hidden;
fileArchived[img.filename] = !!img.archived;
fileHasBg[img.filename] = img.has_background !== false;
fileHasClothing[img.filename] = img.has_clothing ?? null;
fileContentType[img.filename] = img.content_type || 'image';
@@ -2840,6 +3062,15 @@
const visibleIdx = names.map((n, i) => i).filter(i => !fileHidden[names[i]]);
groupData.set(gid, { urls, names, visibleIdx });
// Auto-follow: if a generation just finished for this group, jump to the last (newest) image
if (_followLatestGid === gid && _isStudioOpen() && lbCurrentGid === gid && names.length > 0) {
_followLatestGid = null;
lbIdx = names.length - 1;
lbUrls = urls;
lbNames = names;
updateStudio();
}
const isNew = gf.some(f => newFileNames.has(f.cleanName));
const isRecent = gIdx < 3;
const count = gf.length;
@@ -3137,7 +3368,23 @@
const n = selectedFiles.size;
document.getElementById('batchCount').textContent = `${n} group${n !== 1 ? 's' : ''} selected`;
document.getElementById('processBtn').textContent = n > 0 ? `Process (${n})` : 'Process';
document.getElementById('multiRefBtn').style.display = (n >= 2 && n <= 3) ? '' : 'none';
const showMR = (n >= 2 && n <= 3);
document.getElementById('multiRefBtn').style.display = showMR ? '' : 'none';
const legend = document.getElementById('multiRefLegend');
if (legend) {
if (showMR) {
const names = [...selectedFiles].map((gid, i) => {
const data = groupData.get(gid);
const idx = selectedVariants.get(gid) ?? 0;
const fname = data?.names[idx] ?? gid;
return `ref${i+1}=${fname.split('/').pop().replace(/\?.*$/,'').slice(0,24)}`;
});
legend.textContent = names.join(' · ');
legend.style.display = '';
} else {
legend.style.display = 'none';
}
}
}
function selectAll() {
@@ -3226,6 +3473,12 @@
if (prompt) { prompts.push(prompt); poses.push(null); savePromptHistory(prompt); }
poseEntries.forEach(e => { prompts.push(e.text); poses.push(e.name); });
// Show which filename maps to which ref slot
const shortName = f => f.split('/').pop().replace(/\?.*$/, '');
let refLabel = `Ref1: ${shortName(filenames[0])} Ref2: ${shortName(filenames[1])}`;
if (filenames[2]) refLabel += ` Ref3: ${shortName(filenames[2])}`;
showToast(refLabel, 'info', 5000);
document.getElementById('batchProgress').textContent = 'Submitting…';
try {
const r = await fetch(`${API}/multi-ref`, {
@@ -3697,8 +3950,12 @@
const job = await jr.json();
const finished = job.done + job.failed;
textEl.textContent = `Generating… ${finished}/${total}`;
// Refresh UI as each task completes
if (job.done > prevDone) { prevDone = job.done; refreshNow(); }
// Refresh UI as each task completes; auto-follow to newest image
if (job.done > prevDone) {
prevDone = job.done;
_followLatestGid = lbCurrentGid;
refreshNow();
}
if (job.status === 'running') { pollGen(); return; }
if (job.status === 'cancelled') {
statusEl.style.display = 'none';
@@ -3715,6 +3972,7 @@
_fsSelectedPoses.clear();
_sbPoseEdits = {};
_sbGenJobId = null;
_followLatestGid = lbCurrentGid;
refreshNow();
renderSidebarGenerate();
} else {
@@ -3804,7 +4062,7 @@
const vSafe = v.replace(/'/g, "\\'");
const sel = _fsSelectedVideo === v ? ' selected' : '';
html += `<div class="sb-template-card${sel}" onclick="sbSelectTemplate('${vSafe}')"
onmouseenter="this.querySelector('video').play()" onmouseleave="this.querySelector('video').pause()">
onmouseenter="const v=this.querySelector('video');v&&v.play().catch(()=>{})" onmouseleave="const v=this.querySelector('video');v&&v.pause()">
<video src="${API}/wireframe/${encodeURIComponent(v)}" muted loop playsinline
preload="metadata" style="pointer-events:none;width:100%;height:100%;object-fit:cover"></video>
<div class="sb-template-label">${escHtml(stem)}</div>
@@ -3825,6 +4083,17 @@
<span>FaceFusion</span>
<span id="sbFsHairBadge" style="font-size:10px;color:#f87;background:#2a1a1a;padding:1px 5px;border-radius:3px">not installed</span>
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer" title="Generate at lower resolution for quick preview">
<input type="checkbox" id="sbFsPreview" onchange="document.getElementById('sbFsPreviewOpts').style.display=this.checked?'flex':'none'">
<span>Preview mode</span>
</label>
<div id="sbFsPreviewOpts" style="display:none;align-items:center;gap:6px;padding-left:20px">
<span style="font-size:11px;color:#888">Scale:</span>
<select id="sbFsPreviewScale" style="font-size:11px;background:#1a1a1a;color:#ccc;border:1px solid #333;border-radius:3px;padding:1px 4px">
<option value="0.5">50%</option>
<option value="0.25">25%</option>
</select>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span id="sbFsJobStatus" style="flex:1;font-size:11px;color:#888;display:none">
@@ -3865,21 +4134,26 @@
async function submitFaceswap() {
if (!_fsModelFilename || !_fsSelectedVideo) return;
const enhance = document.getElementById('sbFsEnhance')?.checked ?? true;
const hairEl = document.getElementById('sbFsHair');
const hair = (hairEl?.checked && !hairEl?.disabled) ?? false;
const enhance = document.getElementById('sbFsEnhance')?.checked ?? true;
const hairEl = document.getElementById('sbFsHair');
const hair = (hairEl?.checked && !hairEl?.disabled) ?? false;
const previewCb = document.getElementById('sbFsPreview');
const previewScale = (previewCb?.checked)
? parseFloat(document.getElementById('sbFsPreviewScale')?.value ?? '0.5')
: 1.0;
const btn = document.getElementById('sbFsStartBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; }
const statusEl = document.getElementById('sbFsJobStatus');
const textEl = document.getElementById('sbFsJobText');
if (statusEl) statusEl.style.display = 'inline-flex';
const modeLabel = hair ? 'FaceFusion + hair' : (enhance ? 'insightface + enhance' : 'insightface');
const prevTag = previewScale < 1.0 ? ` preview ${Math.round(previewScale * 100)}%` : '';
const modeLabel = (hair ? 'FaceFusion + hair' : (enhance ? 'insightface + enhance' : 'insightface')) + prevTag;
if (textEl) textEl.textContent = `Starting (${modeLabel})…`;
try {
const r = await fetch(`${API}/faceswap`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_filename: _fsModelFilename, video_name: _fsSelectedVideo, enhance, hair }),
body: JSON.stringify({ model_filename: _fsModelFilename, video_name: _fsSelectedVideo, enhance, hair, preview_scale: previewScale }),
});
if (!r.ok) {
if (textEl) textEl.textContent = `Error: ${await r.text()}`;
@@ -4038,7 +4312,7 @@
const vSafe = v.replace(/'/g, "\\'");
const sel = _sceneVideo === v ? ' selected' : '';
html += `<div class="sb-template-card${sel}" onclick="sceneSelectVideo('${vSafe}')"
onmouseenter="this.querySelector('video').play()" onmouseleave="this.querySelector('video').pause()">
onmouseenter="const v=this.querySelector('video');v&&v.play().catch(()=>{})" onmouseleave="const v=this.querySelector('video');v&&v.pause()">
<video src="${API}/wireframe/${encodeURIComponent(v)}" muted loop playsinline
preload="metadata" style="pointer-events:none;width:100%;height:100%;object-fit:cover"></video>
<div class="sb-template-label">${escHtml(stem)}</div>
@@ -4270,7 +4544,8 @@
simply shows the original again without any file change.
</div>
<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<button class="sb-btn" id="sbSam2Btn" onclick="sam2RemoveBg()">Remove BG</button>
<button class="sb-btn" id="sbSam2Btn" onclick="sam2RemoveBg()" title="SAM2 segmentation — preserves hair/glasses/accessories; falls back to rembg if unavailable">Remove BG (SAM2)</button>
<button class="sb-btn" id="sbRembgBtn" onclick="rembgRemoveBg()" title="rembg (U2Net) — fast background removal without SAM2">Remove BG (rembg)</button>
<button class="sb-btn" id="sbRestoreBgBtn" onclick="restoreBg()">Restore BG</button>
</div>
<div id="sbSegStatus" style="font-size:11px;color:#aaa;min-height:16px;margin-bottom:10px"></div>
@@ -4281,7 +4556,8 @@
</label>
<div style="font-size:10px;color:#555;line-height:1.4">
SAM2 merges all non-border masks (preserves hair, glasses, sandals).<br>
Requires sam2_checkpoint in config.json.
rembg (U2Net) is faster and works without SAM2 checkpoint.<br>
Requires sam2_checkpoint in config.json for SAM2 path.
</div>`;
// Check SAM2 availability
fetch(`${API}/sam2/check`).then(r => r.json()).then(d => {
@@ -4339,6 +4615,36 @@
showToast('Original image shown', 'success');
}
async function rembgRemoveBg() {
const fname = lbNames[lbIdx];
if (!fname) return;
const btn = document.getElementById('sbRembgBtn');
const statusEl = document.getElementById('sbSegStatus');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Removing background (rembg)…';
try {
const r = await fetch(`${API}/remove-background/${encodeURIComponent(fname)}`, { method: 'POST' });
if (!r.ok) {
if (statusEl) statusEl.textContent = 'Error: ' + await r.text();
} else {
const d = await r.json();
if (statusEl) statusEl.textContent = 'Done (rembg)';
showToast('Background removed (rembg)', 'success');
const nobgUrl = IMAGE_FOLDER + d.nobg_filename + '?t=' + Date.now();
_nobgSidecars.set(fname, nobgUrl);
const preload = new Image();
preload.onload = () => {
const img = document.getElementById('lbImg');
if (img) img.src = nobgUrl;
const cb = document.getElementById('sbCheckerboard');
if (cb && !cb.checked) { cb.checked = true; toggleCheckerboard(true); }
};
preload.src = nobgUrl;
}
} catch (e) { if (statusEl) statusEl.textContent = 'Error: ' + e; }
if (btn) btn.disabled = false;
}
function toggleCheckerboard(on) {
const viewer = document.getElementById('studioViewer');
if (viewer) viewer.classList.toggle('show-checker', on);