dphn/Dolphin3.0-Mistral-24B is the ungated mirror of the Dolphin 3.0 Mistral 24B — exactly what you asked for. It's ~48GB fp16, which needs GPU+CPU split (device_map="auto" with 32GB on GPU, ~16GB in RAM). Let me kick off the download and update the service in parallel.
This commit is contained in:
@@ -1459,6 +1459,13 @@
|
||||
font-size: 9px; font-weight: 700; border-radius: 50%; width: 15px; height: 15px;
|
||||
display: flex; align-items: center; justify-content: center; line-height: 1;
|
||||
}
|
||||
.sb-gen-refs .sb-gen-ref-x {
|
||||
position: absolute; top: -4px; right: -4px; background: #dc2626; color: #fff;
|
||||
font-size: 11px; font-weight: 700; border-radius: 50%; width: 15px; height: 15px;
|
||||
display: none; align-items: center; justify-content: center; line-height: 1;
|
||||
}
|
||||
.sb-gen-refs .sb-gen-ref:hover .sb-gen-ref-x { display: flex; }
|
||||
.sb-gen-refs .sb-gen-ref:hover img { border-color: #dc2626; }
|
||||
#studioAngleBar {
|
||||
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s;
|
||||
@@ -1490,7 +1497,9 @@
|
||||
}
|
||||
.sb-template-card:hover { border-color: #7c3aed; transform: scale(1.02); }
|
||||
.sb-template-card.selected { border-color: #22c55e; }
|
||||
.sb-template-card video { width:100%; height:100%; object-fit:cover; pointer-events:none; }
|
||||
.sb-template-card video,
|
||||
.sb-template-card img { width:100%; height:100%; object-fit:cover; pointer-events:none; }
|
||||
.sb-template-card video { position:absolute; inset:0; }
|
||||
.sb-template-label {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
background: linear-gradient(transparent,rgba(0,0,0,0.82));
|
||||
@@ -1845,11 +1854,13 @@
|
||||
<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="lbInvertAlphaBtn" onclick="lbInvertAlpha()" title="Invert transparency — fixes when the wrong segment was kept">Invert α</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>
|
||||
<button class="sb-btn" id="lbRotateLeftBtn" onclick="lbRotate(-90)" title="Rotate 90° counter-clockwise">⟲ 90°</button>
|
||||
<button class="sb-btn" id="lbRotateRightBtn" onclick="lbRotate(90)" title="Rotate 90° clockwise">⟳ 90°</button>
|
||||
<button class="sb-btn" id="lbPoseBtn" onclick="lbTogglePose()" title="Preview body pose skeleton">Pose</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>
|
||||
@@ -2330,6 +2341,13 @@
|
||||
document.getElementById('studio').classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
stopSlideshow();
|
||||
// Release every streaming/decoding video so closing the studio frees memory
|
||||
// and stops background network streams (template clips + scenery preview).
|
||||
_tplStopAll();
|
||||
['sceneVideoEl', 'lbVideo', 'cltVideoPreview'].forEach(id => {
|
||||
const v = document.getElementById(id);
|
||||
if (v) { v.pause(); v.removeAttribute('src'); v.load(); }
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSlideshow() {
|
||||
@@ -2391,6 +2409,8 @@
|
||||
|
||||
function updateStudio() {
|
||||
const fname = lbNames[lbIdx];
|
||||
if (document.getElementById('poseCanvas')) _removePoseOverlay(); // skeleton is per-image
|
||||
document.getElementById('poseResults')?.remove();
|
||||
_fsModelFilename = fname; // keep faceswap/scenery/segment in sync
|
||||
const isVid = isVideo(fname) || fileContentType[fname] === 'video';
|
||||
const lbImgEl = document.getElementById('lbImg');
|
||||
@@ -2442,6 +2462,11 @@
|
||||
if (faceswapBtn)faceswapBtn.style.display = !isVid ? '' : 'none';
|
||||
const cropBtn = document.getElementById('lbCropBtn');
|
||||
if (cropBtn) cropBtn.style.display = (!isVid && fileHasBg[fname] === false) ? '' : 'none';
|
||||
// Invert α is only meaningful on a transparent image (after background removal).
|
||||
const invAlphaBtn = document.getElementById('lbInvertAlphaBtn');
|
||||
if (invAlphaBtn) invAlphaBtn.style.display = (!isVid && fileHasBg[fname] === false) ? '' : 'none';
|
||||
const poseBtn = document.getElementById('lbPoseBtn');
|
||||
if (poseBtn) poseBtn.style.display = isVid ? 'none' : '';
|
||||
const archiveBtn = document.getElementById('lbArchiveBtn');
|
||||
if (archiveBtn) archiveBtn.textContent = fileArchived[fname] ? 'Restore' : 'Archive';
|
||||
|
||||
@@ -2767,7 +2792,6 @@
|
||||
showToast('Rotated', 'success');
|
||||
lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
|
||||
updateStudio();
|
||||
refreshNow();
|
||||
} else {
|
||||
showToast('Rotate failed: ' + await r.text(), 'error');
|
||||
}
|
||||
@@ -2775,6 +2799,239 @@
|
||||
btns.forEach(b => { if (b) b.disabled = false; });
|
||||
}
|
||||
|
||||
// --- body-pose skeleton preview (interactive) ---
|
||||
const POSE_MIN_SCORE = 0.3;
|
||||
const POSE_KP_COLORS = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#a855f7'];
|
||||
|
||||
function _removePoseOverlay() {
|
||||
document.getElementById('poseCanvas')?.remove();
|
||||
document.getElementById('poseToolbar')?.remove();
|
||||
window._poseState = null;
|
||||
const btn = document.getElementById('lbPoseBtn');
|
||||
if (btn) btn.classList.remove('primary');
|
||||
}
|
||||
|
||||
async function lbTogglePose() {
|
||||
if (document.getElementById('poseCanvas')) { _removePoseOverlay(); return; }
|
||||
const fname = lbNames[lbIdx];
|
||||
if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image', 'info'); return; }
|
||||
const btn = document.getElementById('lbPoseBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||||
try {
|
||||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/pose`, { method: 'POST' });
|
||||
if (r.status === 501) {
|
||||
showToast('Pose estimator not installed on the server (pip install rtmlib onnxruntime)', 'info', 6000);
|
||||
} else if (r.ok) {
|
||||
const d = await r.json();
|
||||
if (!d.people || !d.people.length) showToast('No person detected', 'info');
|
||||
else { _initPoseOverlay(d, fname); showToast(`Pose (${d.backend}) — drag joints to edit`, 'success'); }
|
||||
} else {
|
||||
showToast('Pose failed: ' + await r.text(), 'error');
|
||||
}
|
||||
} catch (e) { showToast('Pose failed: ' + e, 'error'); }
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Pose'; }
|
||||
}
|
||||
|
||||
function _initPoseOverlay(data, fname) {
|
||||
_removePoseOverlay();
|
||||
const viewer = document.getElementById('studioViewer');
|
||||
const img = document.getElementById('lbImg');
|
||||
if (!viewer || !img) return;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = 'poseCanvas';
|
||||
canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;cursor:grab;z-index:90;';
|
||||
viewer.appendChild(canvas);
|
||||
canvas.width = viewer.clientWidth;
|
||||
canvas.height = viewer.clientHeight;
|
||||
|
||||
// object-fit:contain letterbox mapping image px ↔ canvas px.
|
||||
const iW = data.width, iH = data.height;
|
||||
const scale = Math.min(canvas.width / iW, canvas.height / iH);
|
||||
const st = window._poseState = {
|
||||
fname, canvas, ctx: canvas.getContext('2d'),
|
||||
people: data.people.map(p => p.map(k => k.slice())), // deep copy (editable)
|
||||
skeleton: data.skeleton, width: iW, height: iH,
|
||||
scale, offX: (canvas.width - iW * scale) / 2, offY: (canvas.height - iH * scale) / 2,
|
||||
drag: null,
|
||||
};
|
||||
|
||||
// Floating toolbar (top-left so it doesn't fight the crop bar on the right).
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'poseToolbar';
|
||||
bar.style.cssText = 'position:absolute;top:8px;left:8px;display:flex;gap:6px;z-index:102;';
|
||||
bar.innerHTML =
|
||||
'<button class="sb-btn" onclick="lbPoseReset()" title="Re-detect the original pose">Reset</button>'
|
||||
+ '<button class="sb-btn primary" onclick="lbFindSimilarPose()" title="Find library images in this pose">Similar pose</button>';
|
||||
viewer.appendChild(bar);
|
||||
|
||||
canvas.addEventListener('mousedown', _posePointerDown);
|
||||
canvas.addEventListener('mousemove', _posePointerMove);
|
||||
window.addEventListener('mouseup', _posePointerUp);
|
||||
_drawPose();
|
||||
const btn = document.getElementById('lbPoseBtn');
|
||||
if (btn) btn.classList.add('primary');
|
||||
}
|
||||
|
||||
function _drawPose() {
|
||||
const st = window._poseState; if (!st) return;
|
||||
const { ctx, canvas, scale, offX, offY } = st;
|
||||
const cx = x => offX + x * scale, cy = y => offY + y * scale;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
st.people.forEach((kpts, pi) => {
|
||||
const color = POSE_KP_COLORS[pi % POSE_KP_COLORS.length];
|
||||
ctx.lineWidth = 3; ctx.strokeStyle = color;
|
||||
st.skeleton.forEach(([a, b]) => {
|
||||
const pa = kpts[a], pb = kpts[b];
|
||||
if (!pa || !pb || pa[2] < POSE_MIN_SCORE || pb[2] < POSE_MIN_SCORE) return;
|
||||
ctx.beginPath(); ctx.moveTo(cx(pa[0]), cy(pa[1])); ctx.lineTo(cx(pb[0]), cy(pb[1])); ctx.stroke();
|
||||
});
|
||||
kpts.forEach((p, ki) => {
|
||||
if (!p || p[2] < POSE_MIN_SCORE) return;
|
||||
const hot = st.drag && st.drag.pi === pi && st.drag.ki === ki;
|
||||
ctx.beginPath(); ctx.arc(cx(p[0]), cy(p[1]), hot ? 7 : 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fff'; ctx.fill();
|
||||
ctx.lineWidth = 2; ctx.strokeStyle = color; ctx.stroke();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _poseHitTest(mx, my) {
|
||||
const st = window._poseState; if (!st) return null;
|
||||
const cx = x => st.offX + x * st.scale, cy = y => st.offY + y * st.scale;
|
||||
let best = null, bestD = 14 * 14; // ~14px grab radius
|
||||
st.people.forEach((kpts, pi) => kpts.forEach((p, ki) => {
|
||||
if (!p || p[2] < POSE_MIN_SCORE) return;
|
||||
const dx = mx - cx(p[0]), dy = my - cy(p[1]), d = dx * dx + dy * dy;
|
||||
if (d < bestD) { bestD = d; best = { pi, ki }; }
|
||||
}));
|
||||
return best;
|
||||
}
|
||||
|
||||
function _poseEvtXY(e) {
|
||||
const r = window._poseState.canvas.getBoundingClientRect();
|
||||
return [e.clientX - r.left, e.clientY - r.top];
|
||||
}
|
||||
function _posePointerDown(e) {
|
||||
const st = window._poseState; if (!st) return;
|
||||
const [mx, my] = _poseEvtXY(e);
|
||||
const hit = _poseHitTest(mx, my);
|
||||
if (hit) { st.drag = hit; st.canvas.style.cursor = 'grabbing'; _drawPose(); e.preventDefault(); }
|
||||
}
|
||||
function _posePointerMove(e) {
|
||||
const st = window._poseState; if (!st) return;
|
||||
if (!st.drag) {
|
||||
const [mx, my] = _poseEvtXY(e);
|
||||
st.canvas.style.cursor = _poseHitTest(mx, my) ? 'grab' : 'default';
|
||||
return;
|
||||
}
|
||||
const [mx, my] = _poseEvtXY(e);
|
||||
const p = st.people[st.drag.pi][st.drag.ki];
|
||||
p[0] = Math.max(0, Math.min(st.width, (mx - st.offX) / st.scale));
|
||||
p[1] = Math.max(0, Math.min(st.height, (my - st.offY) / st.scale));
|
||||
if (p[2] < POSE_MIN_SCORE) p[2] = 1.0; // dragging a hidden joint makes it visible
|
||||
_drawPose();
|
||||
}
|
||||
function _posePointerUp() {
|
||||
const st = window._poseState; if (!st || !st.drag) return;
|
||||
st.drag = null; st.canvas.style.cursor = 'grab'; _drawPose();
|
||||
}
|
||||
|
||||
async function lbPoseReset() {
|
||||
const st = window._poseState; if (!st) return;
|
||||
const fname = st.fname;
|
||||
try {
|
||||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/pose`, { method: 'POST' });
|
||||
if (r.ok) { const d = await r.json(); if (d.people?.length) _initPoseOverlay(d, fname); }
|
||||
} catch (e) { showToast('Reset failed: ' + e, 'error'); }
|
||||
}
|
||||
|
||||
async function lbFindSimilarPose() {
|
||||
const st = window._poseState; if (!st) return;
|
||||
// Use the (possibly edited) primary skeleton.
|
||||
const kpts = st.people[0];
|
||||
const btn = event?.target;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`${API}/pose/similar`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keypoints: kpts, width: st.width, height: st.height, limit: 12 }),
|
||||
});
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
_renderPoseResults(d.similar || []);
|
||||
} else if (r.status === 400) {
|
||||
showToast('Pose too sparse — drag more joints into place', 'info');
|
||||
} else {
|
||||
showToast('Similar failed: ' + await r.text(), 'error');
|
||||
}
|
||||
} catch (e) { showToast('Similar failed: ' + e, 'error'); }
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
|
||||
function _renderPoseResults(items) {
|
||||
document.getElementById('poseResults')?.remove();
|
||||
const viewer = document.getElementById('studioViewer');
|
||||
if (!viewer) return;
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'poseResults';
|
||||
panel.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:103;background:rgba(0,0,0,0.85);'
|
||||
+ 'padding:8px;display:flex;gap:8px;overflow-x:auto;align-items:center;';
|
||||
if (!items.length) {
|
||||
panel.innerHTML = '<span style="color:#aaa;font-size:12px;flex:1">No similar poses found — try building the pose index.</span>'
|
||||
+ '<button class="sb-btn" onclick="lbBuildPoseIndex()">Build index</button>'
|
||||
+ '<button class="sb-btn" onclick="document.getElementById(\'poseResults\')?.remove()">Close</button>';
|
||||
} else {
|
||||
panel.innerHTML = '<span style="color:#aaa;font-size:11px;flex-shrink:0">Similar poses:</span>'
|
||||
+ items.map(it => {
|
||||
const u = IMAGE_FOLDER + it.filename + '?t=' + Date.now();
|
||||
return `<div title="dist ${it.distance}" style="flex-shrink:0;cursor:pointer;text-align:center"
|
||||
onclick="openPoseResult('${(it.group_id||'').replace(/'/g,"\\'")}','${it.filename.replace(/'/g,"\\'")}')">
|
||||
<img src="${u}" loading="lazy" style="width:64px;height:64px;object-fit:cover;border-radius:5px;border:1px solid #444" onerror="this.style.opacity='0.3'">
|
||||
<div style="font-size:9px;color:#777">${it.distance}</div></div>`;
|
||||
}).join('')
|
||||
+ '<button class="sb-btn" style="flex-shrink:0" onclick="document.getElementById(\'poseResults\')?.remove()">Close</button>';
|
||||
}
|
||||
viewer.appendChild(panel);
|
||||
}
|
||||
|
||||
function openPoseResult(gid, fname) {
|
||||
_removePoseOverlay();
|
||||
document.getElementById('poseResults')?.remove();
|
||||
if (gid && groupData.has(gid)) {
|
||||
const i = groupData.get(gid).names.indexOf(fname);
|
||||
openStudio(gid, i >= 0 ? i : 0); // openStudio sets lbUrls/lbNames/lbIdx + updateStudio
|
||||
} else {
|
||||
const i = lbNames.indexOf(fname);
|
||||
if (i >= 0) { lbIdx = i; updateStudio(); }
|
||||
else showToast('Open its group from the gallery: ' + fname, 'info', 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function lbBuildPoseIndex() {
|
||||
try {
|
||||
const r = await fetch(`${API}/pose/index`, { method: 'POST' });
|
||||
if (r.status === 501) { showToast('Pose estimator not installed', 'info'); return; }
|
||||
if (!r.ok) { showToast('Index failed: ' + await r.text(), 'error'); return; }
|
||||
showToast('Building pose index…', 'info');
|
||||
_pollPoseIndex();
|
||||
} catch (e) { showToast('Index failed: ' + e, 'error'); }
|
||||
}
|
||||
|
||||
async function _pollPoseIndex() {
|
||||
try {
|
||||
const r = await fetch(`${API}/pose/index/status`);
|
||||
if (r.ok) {
|
||||
const s = await r.json();
|
||||
if (s.running) {
|
||||
showToast(`Pose index: ${s.done}/${s.total}…`, 'info', 2500);
|
||||
setTimeout(_pollPoseIndex, 2000);
|
||||
} else {
|
||||
showToast(`Pose index ready (${s.indexed} images)`, 'success');
|
||||
}
|
||||
}
|
||||
} catch (e) { /* stop polling */ }
|
||||
}
|
||||
|
||||
function startManualCrop() {
|
||||
const fname = lbNames[lbIdx];
|
||||
if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to crop', 'info'); return; }
|
||||
@@ -2930,23 +3187,54 @@
|
||||
|
||||
async function lbRemoveBg() {
|
||||
const fname = lbNames[lbIdx];
|
||||
showToast(`Removing background for ${fname}...`);
|
||||
const btn = document.getElementById('lbNoBgBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Removing…'; }
|
||||
showToast('Removing background…');
|
||||
try {
|
||||
const r = await fetch(`${API}/remove-background/${fname}`, { method: 'POST' });
|
||||
if (r.ok) {
|
||||
showToast(`Background removed for ${fname}`, 'success');
|
||||
// Refresh current image in lightbox by adding timestamp
|
||||
const img = document.getElementById('lbImg');
|
||||
img.src = img.src.split('?')[0] + '?t=' + Date.now();
|
||||
refreshNow();
|
||||
showToast('Background removed', 'success');
|
||||
fileHasBg[fname] = false;
|
||||
_bustStudioImage(fname); // cache-busts viewer + calls updateStudio()
|
||||
} else {
|
||||
showToast(`Failed to remove background for ${fname}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`Failed to remove background: ${e}`, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'No BG'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function lbInvertAlpha() {
|
||||
const fname = lbNames[lbIdx];
|
||||
if (!fname || !/\.(png|webp)$/i.test(fname)) { showToast('Invert needs a transparent PNG', 'info'); return; }
|
||||
const btn = document.getElementById('lbInvertAlphaBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/invert-alpha`, { method: 'POST' });
|
||||
if (r.ok) {
|
||||
showToast('Transparency inverted', 'success');
|
||||
fileHasBg[fname] = false;
|
||||
_bustStudioImage(fname);
|
||||
refreshNow();
|
||||
} else {
|
||||
showToast('Invert failed: ' + await r.text(), 'error');
|
||||
}
|
||||
} catch (e) { showToast('Invert failed: ' + e, 'error'); }
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
|
||||
// Force the studio image (filmstrip + main viewer) to reload from disk.
|
||||
function _bustStudioImage(fname) {
|
||||
const t = Date.now();
|
||||
const i = lbNames.indexOf(fname);
|
||||
if (i >= 0) lbUrls[i] = IMAGE_FOLDER + fname + '?t=' + t;
|
||||
const img = document.getElementById('lbImg');
|
||||
if (img && lbNames[lbIdx] === fname) img.src = IMAGE_FOLDER + fname + '?t=' + t;
|
||||
updateStudio();
|
||||
}
|
||||
|
||||
async function deleteGroup(el, event) {
|
||||
if (event) event.stopPropagation();
|
||||
const gid = el.closest('.image-card').dataset.group;
|
||||
@@ -3018,8 +3306,9 @@
|
||||
showToast(`Error uploading ${fileName}: ${e}`, 'error');
|
||||
}
|
||||
}
|
||||
// Trigger refresh
|
||||
refreshNow();
|
||||
// The source image is registered by a background task slightly after /upload
|
||||
// returns; one debounced refresh picks it up without piling up concurrent loads.
|
||||
setTimeout(refreshNow, 800);
|
||||
}
|
||||
|
||||
// --- clipboard paste support ---
|
||||
@@ -3131,6 +3420,8 @@
|
||||
}
|
||||
|
||||
async function loadImages() {
|
||||
if (loadImages._inFlight) return;
|
||||
loadImages._inFlight = true;
|
||||
const gallery = document.getElementById('gallery');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
statusDot.classList.add('updating');
|
||||
@@ -3204,6 +3495,7 @@
|
||||
</div>`;
|
||||
}
|
||||
statusDot.classList.remove('updating');
|
||||
loadImages._inFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3311,14 +3603,27 @@
|
||||
if (fresh) {
|
||||
const curName = lbNames[lbIdx];
|
||||
const newIdx = fresh.names.indexOf(curName);
|
||||
// Snapshot ref filenames before overwriting lbNames
|
||||
const oldRefNames = _sbRefIndices.map(i => lbNames[i]).filter(Boolean);
|
||||
lbUrls = fresh.urls;
|
||||
lbNames = fresh.names;
|
||||
lbIdx = newIdx >= 0 ? newIdx : Math.min(lbIdx, fresh.names.length - 1);
|
||||
// Keep lbIdx stable when current image not yet in fresh data
|
||||
// (e.g. duplicate just created — server JSON may lag by the debounce window).
|
||||
if (newIdx >= 0) {
|
||||
lbIdx = newIdx;
|
||||
} else if (lbIdx >= fresh.names.length) {
|
||||
lbIdx = fresh.names.length - 1;
|
||||
}
|
||||
// Re-map multi-ref selections to new positions by filename
|
||||
_sbRefIndices = oldRefNames
|
||||
.map(n => fresh.names.indexOf(n))
|
||||
.filter(i => i >= 0);
|
||||
updateStudio();
|
||||
}
|
||||
}
|
||||
|
||||
statusDot.classList.remove('updating');
|
||||
loadImages._inFlight = false;
|
||||
}
|
||||
|
||||
// Discover images by trying common patterns (fallback for file:// protocol)
|
||||
@@ -3365,8 +3670,13 @@
|
||||
if (!_isStudioOpen()) refreshNow();
|
||||
}
|
||||
|
||||
// Debounced refresh — coalesces multiple rapid calls into one loadImages() run.
|
||||
// Direct callers that need a response (pollJob completion, etc.) still use
|
||||
// loadImages() directly; everything else should go through refreshNow().
|
||||
let _refreshTimer = null;
|
||||
function refreshNow() {
|
||||
loadImages();
|
||||
clearTimeout(_refreshTimer);
|
||||
_refreshTimer = setTimeout(loadImages, 80);
|
||||
}
|
||||
|
||||
const API = 'http://127.0.0.1:8500';
|
||||
@@ -3711,7 +4021,7 @@
|
||||
const data = groupData.get(lbCurrentGid);
|
||||
if (!data) return;
|
||||
const filename = data.names[lbIdx];
|
||||
const btn = document.querySelector('#lightbox .lb-btn[onclick="tagCurrentImage()"]');
|
||||
const btn = document.querySelector('button[onclick="tagCurrentImage()"]');
|
||||
if (btn) btn.textContent = '…';
|
||||
try {
|
||||
const r = await fetch(`${API}/tag`, {
|
||||
@@ -3732,7 +4042,7 @@
|
||||
showToast(`Tag failed: ${err}`, 'info');
|
||||
}
|
||||
} catch (e) { showToast('API not reachable', 'info'); }
|
||||
if (btn) btn.textContent = 'Tag';
|
||||
if (btn) btn.textContent = 'Re-tag';
|
||||
}
|
||||
|
||||
async function extractCurrentImage() {
|
||||
@@ -3840,17 +4150,24 @@
|
||||
}
|
||||
if (!document.getElementById('studio').classList.contains('open')) return;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
if (e.key === 'ArrowLeft') { lbNav(-1); e.preventDefault(); }
|
||||
if (e.key === 'ArrowRight') { lbNav(1); e.preventDefault(); }
|
||||
if (e.key === 'Home') {
|
||||
lbIdx = 0;
|
||||
updateStudio();
|
||||
e.preventDefault();
|
||||
// Throttle filmstrip navigation — ignore if last nav was < 80 ms ago.
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
const now = Date.now();
|
||||
if (now - (document._lbNavLast || 0) >= 80) {
|
||||
document._lbNavLast = now;
|
||||
lbNav(e.key === 'ArrowLeft' ? -1 : 1);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === 'End') {
|
||||
lbIdx = lbUrls.length - 1;
|
||||
updateStudio();
|
||||
e.preventDefault();
|
||||
if (e.key === 'Home') {
|
||||
lbIdx = 0;
|
||||
updateStudio();
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === 'End') {
|
||||
lbIdx = lbUrls.length - 1;
|
||||
updateStudio();
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === ' ') {
|
||||
const vid = document.getElementById('lbVideo');
|
||||
@@ -3859,22 +4176,24 @@
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT') { lbToggleHidden(); }
|
||||
if (e.key === 'F' || e.key === 'f') {
|
||||
lbSetPreferred();
|
||||
e.preventDefault();
|
||||
// Guard async actions against concurrent executions via a simple inflight flag.
|
||||
if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT') {
|
||||
if (!document._lbHideInFlight) { document._lbHideInFlight = true; lbToggleHidden().finally(() => { document._lbHideInFlight = false; }); }
|
||||
}
|
||||
if (e.key === 'Delete') {
|
||||
lbArchive();
|
||||
e.preventDefault();
|
||||
if (e.key === 'F' || e.key === 'f') {
|
||||
lbSetPreferred();
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
lbMoveInGroup(-1);
|
||||
e.preventDefault();
|
||||
if (e.key === 'Delete') {
|
||||
lbArchive();
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
lbMoveInGroup(1);
|
||||
e.preventDefault();
|
||||
// ArrowUp/Down move images within the group — debounce rapid keypresses.
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
clearTimeout(document._lbMoveTimer);
|
||||
const d = e.key === 'ArrowUp' ? -1 : 1;
|
||||
document._lbMoveTimer = setTimeout(() => lbMoveInGroup(d), 120);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3955,7 +4274,7 @@
|
||||
|
||||
async function setAsPreferred(filename, btn) {
|
||||
try {
|
||||
const r = await fetch(`/images/${encodeURIComponent(filename)}/set-preferred`, {method:'POST'});
|
||||
const r = await fetch(`${API}/images/${encodeURIComponent(filename)}/set-preferred`, {method:'POST'});
|
||||
if (!r.ok) { console.error('set-preferred failed', r.status); return; }
|
||||
const data = await r.json();
|
||||
// Update sort orders locally
|
||||
@@ -4008,6 +4327,7 @@
|
||||
}
|
||||
|
||||
function switchSidebarTab(tab, initial) {
|
||||
_tplStopAll(); // release any hovered template video before re-rendering panels
|
||||
_activeSidebarTab = tab;
|
||||
localStorage.setItem('studioSidebarTab', tab);
|
||||
document.querySelectorAll('#studioSidebar .sb-tab').forEach(t =>
|
||||
@@ -4143,9 +4463,9 @@
|
||||
const fn = lbNames[idx] || '';
|
||||
const u = lbUrls[idx] || '';
|
||||
const thumb = isVideo(fn) ? posterFor(u) : u;
|
||||
return `<div class="sb-gen-ref" title="${escHtml(fn)}"><img src="${thumb}" loading="lazy" onerror="this.style.opacity='0.3'"><div class="sb-gen-ref-badge">${pos+1}</div></div>`;
|
||||
return `<div class="sb-gen-ref" title="Click to remove · ${escHtml(fn)}" style="cursor:pointer" onclick="sbRemoveRef(${idx})"><img src="${thumb}" loading="lazy" onerror="this.style.opacity='0.3'"><div class="sb-gen-ref-badge">${pos+1}</div><div class="sb-gen-ref-x">×</div></div>`;
|
||||
}).join('');
|
||||
html += `<div class="sb-label" style="margin-bottom:4px">References <span style="color:#555;font-weight:400;font-size:9px">· Shift+Click filmstrip to change</span></div>
|
||||
html += `<div class="sb-label" style="margin-bottom:4px">References <span style="color:#555;font-weight:400;font-size:9px">· Shift+Click filmstrip or click a thumb to remove</span></div>
|
||||
<div class="sb-gen-refs">${refThumbs}</div>`;
|
||||
// With 2–3 refs, let the user choose how the prompt is applied.
|
||||
if (_sbRefIndices.length >= 2) {
|
||||
@@ -4326,6 +4646,15 @@
|
||||
renderSidebarGenerate();
|
||||
}
|
||||
|
||||
// Remove a reference by clicking its thumbnail above the custom prompt — no page
|
||||
// refresh needed (previously refs could only be cleared via Shift+Click filmstrip).
|
||||
function sbRemoveRef(idx) {
|
||||
const pos = _sbRefIndices.indexOf(idx);
|
||||
if (pos >= 0) _sbRefIndices.splice(pos, 1);
|
||||
updateStudio();
|
||||
if (_activeSidebarTab === 'generate') renderSidebarGenerate();
|
||||
}
|
||||
|
||||
function sbSelectWireframe(val) {
|
||||
_sbWireframeRef = val;
|
||||
renderSidebarGenerate();
|
||||
@@ -4543,6 +4872,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- template video cards (faceswap / scenery) ----
|
||||
// The wireframe library is large (dozens of files, >1 GB total) and lives on a
|
||||
// network mount. Rendering every card as an autoplaying <video> spun up that many
|
||||
// simultaneous decoders + range-streams, which OOM-crashed the renderer. Instead
|
||||
// each card shows a static poster frame (a small server-rendered PNG) and only
|
||||
// mounts a single real <video> while hovered — at most one decoder alive at a time.
|
||||
|
||||
// HTML for one template card. `extraHTML` lets faceswap add its trim button.
|
||||
function _tplCardHTML(name, selected, onclickExpr, extraHTML = '') {
|
||||
const stem = name.replace(/\.[^.]+$/, '');
|
||||
const sel = selected ? ' selected' : '';
|
||||
const nSafe = name.replace(/'/g, "\\'");
|
||||
return `<div class="sb-template-card${sel}" onclick="${onclickExpr}"
|
||||
onmouseenter="_tplPlay(this,'${nSafe}')" onmouseleave="_tplStop(this)">
|
||||
<img src="${API}/wireframe/frame/${encodeURIComponent(name)}?t=0" loading="lazy" alt="">
|
||||
<div class="sb-template-label">${escHtml(stem)}</div>${extraHTML}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Mount + play a single <video> over the poster while hovered.
|
||||
function _tplPlay(card, name) {
|
||||
if (card._vid) return;
|
||||
const v = document.createElement('video');
|
||||
v.src = `${API}/wireframe/${encodeURIComponent(name)}`;
|
||||
v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto';
|
||||
card.appendChild(v);
|
||||
card._vid = v;
|
||||
v.play().catch(() => {});
|
||||
}
|
||||
|
||||
// Tear the video down completely so the decoder + network stream are released.
|
||||
function _tplStop(card) {
|
||||
const v = card && card._vid;
|
||||
if (!v) return;
|
||||
v.pause();
|
||||
v.removeAttribute('src');
|
||||
v.load(); // aborts the in-flight stream and frees the decoder
|
||||
v.remove();
|
||||
card._vid = null;
|
||||
}
|
||||
|
||||
// Defensive: stop every active template video in the sidebar (called on tab switch
|
||||
// so a hovered clip can't keep streaming under a freshly-rendered panel).
|
||||
function _tplStopAll() {
|
||||
document.querySelectorAll('#studioSidebar .sb-template-card').forEach(_tplStop);
|
||||
}
|
||||
|
||||
// ---- faceswap sidebar tab ----
|
||||
|
||||
async function loadTemplateVideos() {
|
||||
@@ -4612,17 +4988,10 @@
|
||||
} else {
|
||||
html += '<div class="sb-template-grid">';
|
||||
availableVideos.forEach(v => {
|
||||
const stem = v.replace(/\.[^.]+$/, '');
|
||||
const vSafe = v.replace(/'/g, "\\'");
|
||||
const sel = _fsSelectedVideo === v ? ' selected' : '';
|
||||
html += `<div class="sb-template-card${sel}" onclick="sbSelectTemplate('${vSafe}')"
|
||||
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>
|
||||
<button class="sb-template-trim-btn" title="Trim video"
|
||||
onclick="event.stopPropagation();openTrimPanel('${vSafe}')">✂</button>
|
||||
</div>`;
|
||||
const trimBtn = `<button class="sb-template-trim-btn" title="Trim video"
|
||||
onclick="event.stopPropagation();openTrimPanel('${vSafe}')">✂</button>`;
|
||||
html += _tplCardHTML(v, _fsSelectedVideo === v, `sbSelectTemplate('${vSafe}')`, trimBtn);
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
@@ -4658,9 +5027,7 @@
|
||||
</div>`;
|
||||
|
||||
panel.innerHTML = html;
|
||||
requestAnimationFrame(() => {
|
||||
panel.querySelectorAll('.sb-template-card video').forEach(v => v.play().catch(()=>{}));
|
||||
});
|
||||
// Cards show static poster frames; videos mount only on hover (_tplPlay).
|
||||
// Check FaceFusion availability
|
||||
fetch(`${API}/faceswap/check`).then(r => r.json()).then(s => {
|
||||
const hairCb = document.getElementById('sbFsHair');
|
||||
@@ -4863,15 +5230,8 @@
|
||||
if (availableVideos.length) {
|
||||
html += '<div class="sb-template-grid">';
|
||||
availableVideos.forEach(v => {
|
||||
const stem = v.replace(/\.[^.]+$/, '');
|
||||
const vSafe = v.replace(/'/g, "\\'");
|
||||
const sel = _sceneVideo === v ? ' selected' : '';
|
||||
html += `<div class="sb-template-card${sel}" onclick="sceneSelectVideo('${vSafe}')"
|
||||
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>
|
||||
</div>`;
|
||||
html += _tplCardHTML(v, _sceneVideo === v, `sceneSelectVideo('${vSafe}')`);
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
@@ -4913,9 +5273,7 @@
|
||||
if (scrubber) { scrubber.max = _sceneDuration; scrubber.step = Math.max(0.05, _sceneDuration / 500); }
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
panel.querySelectorAll('.sb-template-card video').forEach(v => v.play().catch(()=>{}));
|
||||
});
|
||||
// Cards show static poster frames; videos mount only on hover (_tplPlay).
|
||||
}
|
||||
|
||||
async function sceneSelectVideo(v) {
|
||||
|
||||
Reference in New Issue
Block a user