edit_api.py:

•
SAM2 fix — switched to SAM2ImagePredictor with a generous bbox (5% margin) instead of points. Bbox-based SAM2 captures the full subject including hair, glasses, and sandals since it doesn't clip with negative-point interference
•
Non-destructive remove-bg — writes <stem>.nobg.png sidecar, original file untouched; registers sidecar in DB under same group
•
New /images/{filename}/duplicate endpoint — copies file with a fresh timestamp name, same group
car.html:
•
sam2RemoveBg() — switches viewer to sidecar URL, auto-enables checkerboard; original file never modified
•
restoreBg() — purely client-side, reverts viewer to original URL (no API call, no file change)
•
Gallery cycling frozen while studio is open (guard in startGroupCycle interval callback)
•
Main page scrollbar hidden when studio opens (body.overflow = hidden), restored on close
•
Delete — two-step inline confirmation: first click arms the button red ("Confirm delete"), second click deletes; stays in studio and navigates to the next image; only closes if it was the last image in the group
•
Duplicate button in Info tab — copies image into same group and navigates to the duplicate immediately
This commit is contained in:
mike
2026-06-22 11:58:51 +02:00
parent 7beed86c9a
commit 8dfe7775ea
7 changed files with 2379 additions and 127 deletions

View File

@@ -1183,6 +1183,7 @@
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
will-change: transform;
}
.fs-options {
display: flex;
@@ -1400,6 +1401,7 @@
border-top-color: #2563eb; border-radius: 50%;
animation: spin 0.8s linear infinite; display: inline-block;
margin-right: 4px; vertical-align: middle;
will-change: transform;
}
.sb-source-refs { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.sb-source-refs img {
@@ -1765,6 +1767,10 @@
onblur="updateName()" onkeydown="if(event.key==='Enter')this.blur()"
style="margin-bottom:8px">
<div id="lbClipDesc" class="sb-status" style="margin-bottom:6px;font-size:11px;color:#555"></div>
<div id="lbGenPromptWrap" style="display:none;margin-bottom:8px">
<div class="sb-label" style="margin-bottom:3px">Generation prompt</div>
<div id="lbGenPrompt" style="font-size:11px;color:#888;line-height:1.4;word-break:break-word"></div>
</div>
<div class="sb-sep"></div>
<div class="sb-label">Order & visibility</div>
<div class="sb-actions" style="margin-bottom:8px">
@@ -1783,8 +1789,10 @@
<div class="sb-actions" style="margin-bottom:8px">
<button class="sb-btn" id="lbExtract" style="display:none" onclick="extractCurrentImage()">Extract</button>
<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>
<a class="sb-btn" id="lbDownloadBtn" download style="text-decoration:none">Download</a>
<button class="sb-btn danger" onclick="lbDelete()">Delete</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>
</div>
@@ -2123,6 +2131,9 @@
let availableVideos = []; // populated from /videos
let _fsActiveTab = 'swap';
let _fsSelectedPoses = new Set();
let _sbPoseEdits = {}; // poseName → edited text
let _sbGenJobId = null;
let _sbGenJobPollTimer= null;
let _fsTrimVideo = null;
function escHtml(s) {
@@ -2177,6 +2188,8 @@
function startGroupCycle(base) {
cycleIdx.set(base, 0); // index into visibleIdx array
const id = setInterval(() => {
// Don't update gallery cards while studio is open — saves GPU and avoids distraction
if (document.getElementById('studio').classList.contains('open')) return;
const data = groupData.get(base);
if (!data || !data.visibleIdx || data.visibleIdx.length < 2) { clearInterval(id); cycleTimers.delete(base); return; }
const card = [...document.querySelectorAll('.image-card')].find(c => c.dataset.group === base);
@@ -2194,6 +2207,8 @@
// --- studio view (replaces lightbox) ---
let lbUrls = [], lbNames = [], lbIdx = 0;
let _activeSidebarTab = localStorage.getItem('studioSidebarTab') || 'info';
// Maps fname → nobg sidecar URL; viewer shows sidecar while this entry exists
const _nobgSidecars = new Map();
function openLightbox(gid, startIdx) { // kept for compat
openStudio(gid, startIdx);
@@ -2209,6 +2224,8 @@
lbIdx = startIdx !== undefined ? startIdx : (cycleIdx.get(gid) || 0);
updateStudio();
document.getElementById('studio').classList.add('open');
// Hide main page scrollbar — nothing in studio scrolls the page
document.body.style.overflow = 'hidden';
// Restore sidebar state
const sidebar = document.getElementById('studioSidebar');
const btn = document.getElementById('sidebarInnerToggle');
@@ -2221,6 +2238,7 @@
function closeStudio() {
document.getElementById('studio').classList.remove('open');
document.body.style.overflow = '';
}
function lbNav(dir) {
@@ -2261,6 +2279,12 @@
const clipEl = document.getElementById('lbClipDesc');
if (clipEl) clipEl.textContent = clipDescriptions[fname] || '';
const genPromptWrap = document.getElementById('lbGenPromptWrap');
const genPromptEl = document.getElementById('lbGenPrompt');
const genPrompt = filePrompts[fname] || '';
if (genPromptWrap) genPromptWrap.style.display = genPrompt ? '' : 'none';
if (genPromptEl) genPromptEl.textContent = genPrompt;
const pose = filePoses[fname];
const poseTag = document.getElementById('lbPoseTag');
if (pose) { poseTag.textContent = '⛷ ' + pose; poseTag.style.display = ''; }
@@ -2278,6 +2302,8 @@
if (noBgBtn) noBgBtn.style.display = (!isVid && fileHasBg[fname] !== false) ? '' : 'none';
if (undressBtn) undressBtn.style.display = (!isVid && fileHasClothing[fname] === true) ? '' : 'none';
if (faceswapBtn)faceswapBtn.style.display = !isVid ? '' : 'none';
const cropBtn = document.getElementById('lbCropBtn');
if (cropBtn) cropBtn.style.display = (!isVid && fileHasBg[fname] === false) ? '' : 'none';
// Source refs
const refs = fileSourceRefs[fname];
@@ -2447,24 +2473,96 @@
}
}
async function lbDelete() {
// Two-step inline confirmation: first click arms the button, second confirms.
let _deleteArmed = false, _deleteArmTimer = null;
function lbDeleteArm() {
const btn = document.getElementById('lbDeleteBtn');
if (!btn) return;
if (_deleteArmed) { lbDeleteConfirm(); return; }
_deleteArmed = true;
btn.textContent = 'Confirm delete';
btn.style.background = '#dc2626';
clearTimeout(_deleteArmTimer);
_deleteArmTimer = setTimeout(() => {
_deleteArmed = false;
if (btn) { btn.textContent = 'Delete'; btn.style.background = ''; }
}, 3500);
}
async function lbDeleteConfirm() {
_deleteArmed = false;
clearTimeout(_deleteArmTimer);
const btn = document.getElementById('lbDeleteBtn');
if (btn) { btn.textContent = 'Delete'; btn.style.background = ''; btn.disabled = true; }
const fname = lbNames[lbIdx];
if (!confirm(`Are you sure you want to delete this image? (${fname})`)) return;
try {
const r = await fetch(`${API}/images/${fname}`, { method: 'DELETE' });
if (r.ok) {
showToast(`Deleted ${fname}`, 'success');
closeLightbox();
refreshNow();
showToast(`Deleted`, 'success');
if (btn) btn.disabled = false;
// Remove from current group view
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(`Failed to delete ${fname}`, 'error');
showToast(`Failed to delete`, 'error');
if (btn) btn.disabled = false;
}
} catch (e) {
showToast(`Failed to delete: ${e}`, 'error');
if (btn) btn.disabled = false;
}
}
async function lbAutoCrop() {
const fname = lbNames[lbIdx];
if (!fname) return;
const btn = document.getElementById('lbCropBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Cropping…'; }
try {
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/autocrop`, { method: 'POST' });
if (r.ok) {
showToast('Cropped', 'success');
lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
updateStudio();
} else {
showToast('Crop failed: ' + await r.text(), 'error');
}
} catch (e) { showToast('Crop failed: ' + e, 'error'); }
if (btn) { btn.disabled = false; btn.textContent = 'Crop'; }
}
async function lbDuplicate() {
const fname = lbNames[lbIdx];
if (!fname) return;
const btn = document.getElementById('lbDuplicateBtn');
if (btn) btn.disabled = true;
showToast('Duplicating…');
try {
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/duplicate`, { method: 'POST' });
if (r.ok) {
const d = await r.json();
showToast('Duplicated', 'success');
// Insert the duplicate right after the current image in the studio strip
lbUrls.splice(lbIdx + 1, 0, IMAGE_FOLDER + d.new_filename + '?t=' + Date.now());
lbNames.splice(lbIdx + 1, 0, d.new_filename);
lbIdx = lbIdx + 1;
updateStudio();
refreshNow();
} else {
showToast('Duplicate failed', 'error');
}
} catch (e) { showToast('Duplicate failed: ' + e, 'error'); }
if (btn) btn.disabled = false;
}
async function lbRemoveBg() {
const fname = lbNames[lbIdx];
showToast(`Removing background for ${fname}...`);
@@ -3455,7 +3553,13 @@
function renderSidebarGenerate() {
const panel = document.getElementById('sbPanelGenerate');
if (!panel) return;
// Use lbNames (current open group) to find already-generated poses
// Preserve live input values before re-render
const prevPromptVal = document.getElementById('sbGenPromptInput')?.value ?? '';
_fsSelectedPoses.forEach(name => {
const el = document.getElementById('sbPoseEdit_' + CSS.escape(name));
if (el) _sbPoseEdits[name] = el.value;
});
const donePoses = new Set();
lbNames.forEach(n => { if (filePoses[n]) donePoses.add(filePoses[n]); });
@@ -3473,21 +3577,44 @@
return `<button class="${cls}" onclick="toggleSbPose('${nSafe}')" title="${tip}">${escHtml(name)}</button>`;
}).join('');
}
html += `</div>
<div class="sb-sep"></div>
html += `</div>`;
// Fine-tune section: one editable textarea per selected pose
if (_fsSelectedPoses.size > 0) {
html += `<div class="sb-sep"></div><div class="sb-label">Fine-tune poses</div>`;
_fsSelectedPoses.forEach(name => {
const origText = String(availablePoses[name]?.text ?? availablePoses[name] ?? name);
const curText = (_sbPoseEdits[name] !== undefined) ? _sbPoseEdits[name] : origText;
const idSafe = escHtml(name).replace(/"/g, '&quot;');
html += `<div style="font-size:11px;color:#888;margin-bottom:3px">${escHtml(name)}</div>
<textarea id="sbPoseEdit_${idSafe}"
style="width:100%;box-sizing:border-box;background:#111;border:1px solid #2a2a2a;border-radius:5px;color:#ccc;font-size:11px;padding:6px;resize:vertical;min-height:60px;margin-bottom:6px"
oninput="sbSavePoseEdit('${name.replace(/'/g,"\\'")}')">${escHtml(curText)}</textarea>`;
});
}
html += `<div class="sb-sep"></div>
<div class="sb-label">Custom prompt</div>
<input type="text" class="sb-input" id="sbGenPromptInput"
placeholder="Prompt — overrides pose" oninput="updateSbGenBtn()" style="margin-bottom:10px">
<div style="display:flex;align-items:center;gap:8px">
<span id="sbGenJobStatus" style="flex:1;font-size:11px;color:#888;display:none">
list="promptHistory"
placeholder="Prompt — overrides pose" oninput="updateSbGenBtn()" style="margin-bottom:10px"
value="${escHtml(prevPromptVal)}">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span id="sbGenJobStatus" style="flex:1;font-size:11px;color:#888;display:none;min-width:120px">
<span class="sb-spinner"></span><span id="sbGenJobText">Generating…</span>
</span>
<button class="sb-btn danger" id="sbCancelBtn" style="display:none" onclick="cancelSbGenerate()">Cancel</button>
<button class="sb-btn primary" id="sbGenBtn" onclick="submitSbGenerate()" disabled>Generate</button>
</div>`;
panel.innerHTML = html;
updateSbGenBtn();
}
function sbSavePoseEdit(name) {
const el = document.getElementById('sbPoseEdit_' + CSS.escape(name));
if (el) _sbPoseEdits[name] = el.value;
}
function toggleSbPose(name) {
if (_fsSelectedPoses.has(name)) _fsSelectedPoses.delete(name);
else _fsSelectedPoses.add(name);
@@ -3503,21 +3630,43 @@
btn.textContent = n > 1 ? `Generate (${n})` : 'Generate';
}
async function cancelSbGenerate() {
if (!_sbGenJobId) return;
const cancelBtn = document.getElementById('sbCancelBtn');
if (cancelBtn) cancelBtn.disabled = true;
try {
await fetch(`${API}/batch/${_sbGenJobId}`, { method: 'DELETE' });
showToast('Generation cancelled', 'info');
} catch (_) {}
clearTimeout(_sbGenJobPollTimer);
_sbGenJobId = null;
const btn = document.getElementById('sbGenBtn');
const statusEl = document.getElementById('sbGenJobStatus');
if (btn) { btn.disabled = false; btn.textContent = 'Generate'; }
if (cancelBtn) { cancelBtn.disabled = false; cancelBtn.style.display = 'none'; }
if (statusEl) statusEl.style.display = 'none';
}
async function submitSbGenerate() {
if (!_fsModelFilename) return;
const promptVal = (document.getElementById('sbGenPromptInput')?.value || '').trim();
const prompts = [], poses = [];
_fsSelectedPoses.forEach(name => {
poses.push(name);
prompts.push(availablePoses[name]?.text ?? availablePoses[name] ?? name);
// Use edited text if available, else original
const edited = _sbPoseEdits[name];
const orig = availablePoses[name]?.text ?? availablePoses[name] ?? name;
prompts.push((edited !== undefined && edited.trim()) ? edited.trim() : String(orig));
});
if (promptVal) { prompts.push(promptVal); poses.push(null); }
if (promptVal) { prompts.push(promptVal); poses.push(null); savePromptHistory(promptVal); }
if (prompts.length === 0) return;
const btn = document.getElementById('sbGenBtn');
btn.disabled = true; btn.textContent = 'Submitting…';
const statusEl = document.getElementById('sbGenJobStatus');
const textEl = document.getElementById('sbGenJobText');
const statusEl = document.getElementById('sbGenJobStatus');
const textEl = document.getElementById('sbGenJobText');
const cancelBtn = document.getElementById('sbCancelBtn');
statusEl.style.display = 'inline-flex';
if (cancelBtn) cancelBtn.style.display = '';
textEl.textContent = `Queuing ${prompts.length} task(s)…`;
try {
const r = await fetch(`${API}/batch`, {
@@ -3530,37 +3679,59 @@
});
if (!r.ok) {
textEl.textContent = `Error: ${await r.text()}`;
btn.disabled = false; btn.textContent = 'Retry'; return;
btn.disabled = false; btn.textContent = 'Retry';
if (cancelBtn) cancelBtn.style.display = 'none';
return;
}
const { job_id, total } = await r.json();
_sbGenJobId = job_id;
showToast(`Generating ${total} image${total !== 1 ? 's' : ''}`, 'info');
textEl.textContent = `Generating… 0/${total}`;
let prevDone = 0;
const pollGen = () => {
clearTimeout(_fsJobPollTimer);
_fsJobPollTimer = setTimeout(async () => {
clearTimeout(_sbGenJobPollTimer);
_sbGenJobPollTimer = setTimeout(async () => {
try {
const jr = await fetch(`${API}/batch/${job_id}`);
if (!jr.ok) { pollGen(); return; }
const job = await jr.json();
textEl.textContent = `Generating… ${job.done + job.failed}/${total}`;
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(); }
if (job.status === 'running') { pollGen(); return; }
if (job.status === 'cancelled') {
statusEl.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'none';
btn.disabled = false; btn.textContent = 'Generate';
_sbGenJobId = null;
return;
}
if (job.status === 'done') {
showToast('Generation complete!', 'success');
statusEl.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'none';
btn.disabled = false; btn.textContent = 'Generate';
_fsSelectedPoses.clear();
_sbPoseEdits = {};
_sbGenJobId = null;
refreshNow();
renderSidebarGenerate();
} else {
textEl.textContent = `Error: ${job.error || 'unknown'}`;
btn.disabled = false; btn.textContent = 'Retry';
if (cancelBtn) cancelBtn.style.display = 'none';
_sbGenJobId = null;
}
} catch (e) { pollGen(); }
}, 2500);
}, 2000);
};
pollGen();
} catch (e) {
textEl.textContent = `API error: ${e}`;
btn.disabled = false; btn.textContent = 'Retry';
if (cancelBtn) cancelBtn.style.display = 'none';
_sbGenJobId = null;
}
}
@@ -3632,8 +3803,9 @@
const stem = v.replace(/\.[^.]+$/, '');
const vSafe = v.replace(/'/g, "\\'");
const sel = _fsSelectedVideo === v ? ' selected' : '';
html += `<div class="sb-template-card${sel}" onclick="sbSelectTemplate('${vSafe}')">
<video src="${API}/wireframe/${encodeURIComponent(v)}" muted loop autoplay playsinline
html += `<div class="sb-template-card${sel}" onclick="sbSelectTemplate('${vSafe}')"
onmouseenter="this.querySelector('video').play()" onmouseleave="this.querySelector('video').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>
<button class="sb-template-trim-btn" title="Trim video"
@@ -3865,8 +4037,9 @@
const stem = v.replace(/\.[^.]+$/, '');
const vSafe = v.replace(/'/g, "\\'");
const sel = _sceneVideo === v ? ' selected' : '';
html += `<div class="sb-template-card${sel}" onclick="sceneSelectVideo('${vSafe}')">
<video src="${API}/wireframe/${encodeURIComponent(v)}" muted loop autoplay playsinline
html += `<div class="sb-template-card${sel}" onclick="sceneSelectVideo('${vSafe}')"
onmouseenter="this.querySelector('video').play()" onmouseleave="this.querySelector('video').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>
</div>`;
@@ -4092,8 +4265,9 @@
if (!panel) return;
panel.innerHTML = `
<div style="font-size:11px;color:#666;margin-bottom:12px;line-height:1.6">
Remove the background using SAM2 (when available) or rembg fallback.
The result is a transparent PNG. Restore BG composites alpha back to white.
Remove background using SAM2 or rembg fallback. The original file is
kept intact — a sidecar <code>.nobg.png</code> is created. Restore BG
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>
@@ -4106,7 +4280,7 @@
<span>Checkerboard (show transparency)</span>
</label>
<div style="font-size:10px;color:#555;line-height:1.4">
SAM2 segments the largest foreground subject.<br>
SAM2 merges all non-border masks (preserves hair, glasses, sandals).<br>
Requires sam2_checkpoint in config.json.
</div>`;
// Check SAM2 availability
@@ -4129,34 +4303,40 @@
if (statusEl) statusEl.textContent = 'Error: ' + await r.text();
} else {
const d = await r.json();
if (statusEl) statusEl.textContent = d.used_sam2 ? 'Done (SAM2)' : 'Done (rembg)';
const label = d.used_sam2 ? 'Done (SAM2)' : 'Done (rembg)';
if (statusEl) statusEl.textContent = label;
showToast('Background removed', 'success');
const img = document.getElementById('lbImg');
if (img) { img.src = img.src.split('?')[0] + '?t=' + Date.now(); }
// Build URL same way as all gallery images: IMAGE_FOLDER + filename
const nobgUrl = IMAGE_FOLDER + d.nobg_filename + '?t=' + Date.now();
_nobgSidecars.set(fname, nobgUrl);
// Preload before swap to avoid black flash
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;
}
async function restoreBg() {
function restoreBg() {
// Non-destructive: just revert the viewer to the original URL.
// The sidecar .nobg.png stays on disk for future use.
const fname = lbNames[lbIdx];
if (!fname) return;
const btn = document.getElementById('sbRestoreBgBtn');
_nobgSidecars.delete(fname);
const img = document.getElementById('lbImg');
if (img) img.src = lbUrls[lbIdx] + (lbUrls[lbIdx].includes('?') ? '&' : '?') + 't=' + Date.now();
const statusEl = document.getElementById('sbSegStatus');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Restoring…';
try {
const r = await fetch(`${API}/restore-background/${encodeURIComponent(fname)}`, { method: 'POST' });
if (!r.ok) {
if (statusEl) statusEl.textContent = 'Error: ' + await r.text();
} else {
if (statusEl) statusEl.textContent = 'Background restored';
showToast('Background restored', 'success');
const img = document.getElementById('lbImg');
if (img) { img.src = img.src.split('?')[0] + '?t=' + Date.now(); }
}
} catch (e) { if (statusEl) statusEl.textContent = 'Error: ' + e; }
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = 'Original restored';
// Turn off checkerboard
const cb = document.getElementById('sbCheckerboard');
if (cb && cb.checked) { cb.checked = false; toggleCheckerboard(false); }
showToast('Original image shown', 'success');
}
function toggleCheckerboard(on) {