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:
mike
2026-06-24 13:10:07 +02:00
parent 42f924566e
commit 8df588e594
5 changed files with 1081 additions and 130 deletions

View File

@@ -1439,7 +1439,26 @@
text-align: center; transition: all 0.12s;
}
.sb-angle-btn:hover { border-color: #444; color: #ccc; }
.sb-angle-btn.done { border-color: #14532d; background: #052e16; color: #4ade80; position: relative; }
.sb-angle-btn.done::after { content: '✓'; position: absolute; top: 1px; right: 3px; font-size: 9px; color: #4ade80; opacity: 0.9; }
.sb-angle-btn.selected { border-color: #0e7490; background: #082f49; color: #7dd3fc; font-weight: 600; }
/* Generate panel: sticky footer (custom prompt + Generate) and selected-ref thumbnails */
.sb-gen-footer {
position: sticky; bottom: 0; margin: 8px -13px -12px; padding: 10px 13px;
background: #0d0d10; border-top: 1px solid #1f1f24;
}
.sb-gen-refs { display: flex; gap: 5px; margin-bottom: 8px; flex-wrap: wrap; }
.sb-gen-refs .sb-gen-ref { position: relative; width: 40px; height: 40px; flex-shrink: 0; }
.sb-gen-refs .sb-gen-ref img {
width: 40px; height: 40px; object-fit: cover; border-radius: 4px;
border: 1px solid #3d2008; background: #111;
}
.sb-gen-refs .sb-gen-ref-badge {
position: absolute; top: -4px; left: -4px; background: #f97316; color: #111;
font-size: 9px; font-weight: 700; border-radius: 50%; width: 15px; height: 15px;
display: flex; align-items: center; justify-content: center; line-height: 1;
}
#studioAngleBar {
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s;
@@ -1829,6 +1848,8 @@
<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>
<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>
@@ -2175,6 +2196,7 @@
let _fsSelectedPoses = new Set();
let _sbSelectedAngles = new Set(); // selected camera angle names
let _sbRefIndices = []; // ordered filmstrip indices selected as multi-ref (#1, #2, #3)
let _sbRefMode = 'combine'; // with 23 refs: 'combine' → 1 multi-ref output; 'each' → one edit per ref (cross product)
let _sbWireframeRef = ''; // wireframe video name for pose guide
let _sbWireframeTime = 0.5; // normalized frame time 01
let _sbPoseEdits = {}; // poseName → edited text
@@ -2484,10 +2506,7 @@
const faceBook = document.getElementById('lbFaceBook');
const faceThumb = document.getElementById('lbFaceThumb');
if (faceBook && faceThumb && lbCurrentGid && lbIdx === 0 && !isVid) {
const faceFname = lbCurrentGid.replace(/\//g, '_') + '_face.png';
faceThumb.src = IMAGE_FOLDER + faceFname + '?t=' + Date.now();
faceThumb.onerror = () => { faceBook.style.display = 'none'; };
faceThumb.onload = () => { faceBook.style.display = ''; };
loadFaceThumb(lbCurrentGid);
} else if (faceBook) {
faceBook.style.display = 'none';
}
@@ -2641,11 +2660,13 @@
lbNames.splice(lbIdx, 1);
if (lbNames.length === 0) {
closeStudio();
refreshNow();
// Add a small delay to allow static regeneration to complete before refresh
setTimeout(refreshNow, 500);
} else {
lbIdx = Math.min(lbIdx, lbNames.length - 1);
updateStudio();
refreshNow();
// Add a small delay to allow static regeneration to complete before refresh
setTimeout(refreshNow, 500);
}
} else {
showToast(`Failed to delete`, 'error');
@@ -2674,11 +2695,13 @@
lbNames.splice(lbIdx, 1);
if (lbNames.length === 0) {
closeStudio();
refreshNow();
// Add a small delay to allow static regeneration to complete before refresh
setTimeout(refreshNow, 500);
} else {
lbIdx = Math.min(lbIdx, lbNames.length - 1);
updateStudio();
refreshNow();
// Add a small delay to allow static regeneration to complete before refresh
setTimeout(refreshNow, 500);
}
} else {
showToast('Archive failed', 'error');
@@ -2729,6 +2752,29 @@
if (btn) { btn.disabled = false; btn.textContent = 'Crop'; }
}
async function lbRotate(degrees) {
const fname = lbNames[lbIdx];
if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to rotate', 'info'); return; }
const btns = [document.getElementById('lbRotateLeftBtn'), document.getElementById('lbRotateRightBtn')];
btns.forEach(b => { if (b) b.disabled = true; });
try {
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/rotate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ degrees }),
});
if (r.ok) {
showToast('Rotated', 'success');
lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
updateStudio();
refreshNow();
} else {
showToast('Rotate failed: ' + await r.text(), 'error');
}
} catch (e) { showToast('Rotate failed: ' + e, 'error'); }
btns.forEach(b => { if (b) b.disabled = false; });
}
function startManualCrop() {
const fname = lbNames[lbIdx];
if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to crop', 'info'); return; }
@@ -2744,8 +2790,11 @@
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;';
// Anchored at the TOP so the buttons never block cropping to the bottom edge.
bar.style.cssText = 'position:absolute;top: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>'
+ '<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#ccc;cursor:pointer" title="Keep the original and crop a copy instead">'
+ '<input type="checkbox" id="cropAsCopy" checked style="cursor:pointer">Save as copy</label>'
+ '<button class="sb-btn" onclick="cancelManualCrop()">Cancel</button>'
+ '<button class="sb-btn primary" id="cropConfirmBtn" disabled onclick="confirmManualCrop()">Crop</button>';
viewer.appendChild(bar);
@@ -2820,19 +2869,31 @@
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 asCopy = document.getElementById('cropAsCopy')?.checked ?? false;
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 }),
body: JSON.stringify({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y, as_copy: asCopy }),
});
if (r.ok) {
showToast('Cropped', 'success');
const d = await r.json().catch(() => ({}));
cancelManualCrop();
lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
updateStudio();
if (asCopy && d.new_filename) {
showToast('Cropped to copy', 'success');
// Insert the cropped copy right after the original 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('Cropped', 'success');
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'; }
@@ -2901,7 +2962,8 @@
const r = await fetch(`${API}/groups/${gid}`, { method: 'DELETE' });
if (r.ok) {
showToast(`Group ${gid} deleted`, 'success');
refreshNow();
// Add a small delay to allow static regeneration to complete before refresh
setTimeout(refreshNow, 500);
} else {
showToast(`Failed to delete group ${gid}`, 'error');
}
@@ -3078,7 +3140,7 @@
// Primary: pre-generated static JSON (fast, no DB round-trip per request)
try {
const r = await fetch(`${API}/output/_data/images.json`, { signal: AbortSignal.timeout(4000) });
const r = await fetch(`${API}/output/_data/images.json?t=${Date.now()}`, { signal: AbortSignal.timeout(4000) });
if (r.ok) {
const data = await r.json();
let imgs = data.images || [];
@@ -3780,6 +3842,16 @@
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();
}
if (e.key === 'End') {
lbIdx = lbUrls.length - 1;
updateStudio();
e.preventDefault();
}
if (e.key === ' ') {
const vid = document.getElementById('lbVideo');
if (vid && vid.style.display !== 'none') {
@@ -3788,6 +3860,22 @@
}
}
if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT') { lbToggleHidden(); }
if (e.key === 'F' || e.key === 'f') {
lbSetPreferred();
e.preventDefault();
}
if (e.key === 'Delete') {
lbArchive();
e.preventDefault();
}
if (e.key === 'ArrowUp') {
lbMoveInGroup(-1);
e.preventDefault();
}
if (e.key === 'ArrowDown') {
lbMoveInGroup(1);
e.preventDefault();
}
});
});
@@ -3838,6 +3926,33 @@
document.getElementById('privacyOverlay').style.display = 'none';
}
// Load the face-book thumbnail for a group. Face extraction runs
// asynchronously after "set preferred", so we ask the API whether the
// crop exists (works over file://, unlike probing with an <img> that
// logs a 404 during the race) and poll a few times while it's pending.
async function loadFaceThumb(gid, retries = 0) {
const faceBook = document.getElementById('lbFaceBook');
const faceThumb = document.getElementById('lbFaceThumb');
if (!faceBook || !faceThumb || !gid) return;
try {
const r = await fetch(`${API}/faces/${encodeURIComponent(gid)}`);
const d = await r.json();
// Ignore stale responses if the user navigated away.
if (lbCurrentGid !== gid || lbIdx !== 0) return;
if (d.exists) {
faceThumb.onerror = () => { faceBook.style.display = 'none'; };
faceThumb.src = IMAGE_FOLDER + d.filename + '?t=' + Date.now();
faceBook.style.display = '';
} else if (retries > 0) {
setTimeout(() => loadFaceThumb(gid, retries - 1), 1500);
} else {
faceBook.style.display = 'none';
}
} catch (e) {
faceBook.style.display = 'none';
}
}
async function setAsPreferred(filename, btn) {
try {
const r = await fetch(`/images/${encodeURIComponent(filename)}/set-preferred`, {method:'POST'});
@@ -3848,6 +3963,8 @@
fileSortOrders[filename] = 0;
// Re-open picker to reflect new order
showVariantPicker(filename);
// Poll for the freshly-extracted face crop (async on the server).
loadFaceThumb(gid, 8);
} catch(e) { console.error(e); }
}
@@ -3930,18 +4047,20 @@
const relAngles = CAMERA_ANGLES.filter(a => a.relative);
let html = '<div class="sb-label">Camera — absolute</div><div class="sb-camera-grid" id="sbCameraGrid">';
html += absAngles.map(a => {
const sel = _sbSelectedAngles.has(a.name) ? ' selected' : '';
const sel = _sbSelectedAngles.has(a.name) ? ' selected' : '';
const done = donePoses.has(a.name) ? ' done' : '';
const nSafe = a.name.replace(/'/g, "\\'");
return `<button class="sb-angle-btn${sel}" onclick="toggleSbAngle('${nSafe}')" title="${escHtml(a.prompt)}">${a.icon}<br><span style="font-size:9px">${escHtml(a.name)}</span></button>`;
return `<button class="sb-angle-btn${done}${sel}" onclick="toggleSbAngle('${nSafe}')" title="${escHtml(a.prompt)}">${a.icon}<br><span style="font-size:9px">${escHtml(a.name)}</span></button>`;
}).join('');
html += '</div>';
// Relative rotations
html += '<div class="sb-label" style="margin-top:6px">Camera — relative</div><div class="sb-camera-grid" id="sbRelAngleGrid">';
html += relAngles.map(a => {
const sel = _sbSelectedAngles.has(a.name) ? ' selected' : '';
const sel = _sbSelectedAngles.has(a.name) ? ' selected' : '';
const done = donePoses.has(a.name) ? ' done' : '';
const nSafe = a.name.replace(/'/g, "\\'");
return `<button class="sb-angle-btn${sel}" onclick="toggleSbAngle('${nSafe}')" title="${escHtml(a.prompt)}">${a.icon}<br><span style="font-size:9px">${escHtml(a.name)}</span></button>`;
return `<button class="sb-angle-btn${done}${sel}" onclick="toggleSbAngle('${nSafe}')" title="${escHtml(a.prompt)}">${a.icon}<br><span style="font-size:9px">${escHtml(a.name)}</span></button>`;
}).join('');
html += '</div>';
@@ -3981,43 +4100,135 @@
}
html += `</div>`;
// Fine-tune section: one editable textarea per selected pose
// Fine-tune section: one editable textarea per selected pose.
// "Save" persists the edit to poses.md; "Delete" removes the pose entirely.
if (_fsSelectedPoses.size > 0) {
html += `<div class="sb-sep"></div><div class="sb-label">Fine-tune poses</div>`;
html += `<div class="sb-sep"></div><div class="sb-label">Fine-tune poses
<span style="font-size:9px;color:#555;font-weight:400">· Save persists to poses.md</span></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}"
const isBeta = !!(availablePoses[name]?.beta);
html += `<div class="sb-pose-edit" data-pose="${escHtml(name)}">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">
<span style="font-size:11px;color:#888;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(name)}</span>
<label style="font-size:10px;color:#777;display:flex;align-items:center;gap:3px;cursor:pointer"><input type="checkbox" class="sb-pose-beta" ${isBeta?'checked':''}>beta</label>
<button class="sb-btn" style="padding:2px 8px;font-size:10px" onclick="savePoseToServer(this)">Save</button>
<button class="sb-btn danger" style="padding:2px 8px;font-size:10px" onclick="deletePoseFromServer(this)">Delete</button>
</div>
<textarea id="sbPoseEdit_${idSafe}" class="sb-pose-text"
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>`;
oninput="sbSavePoseEdit('${name.replace(/'/g,"\\'")}')">${escHtml(curText)}</textarea>
</div>`;
});
}
// Add a brand-new pose (persisted to poses.md).
html += `<div class="sb-sep"></div>
<div class="sb-label">Custom prompt</div>`;
<details style="margin-bottom:6px">
<summary style="font-size:11px;color:#888;cursor:pointer">+ New pose</summary>
<input type="text" id="sbNewPoseName" placeholder="Pose name"
style="width:100%;box-sizing:border-box;background:#111;border:1px solid #2a2a2a;border-radius:5px;color:#ccc;font-size:11px;padding:6px;margin:6px 0 4px">
<textarea id="sbNewPoseText" placeholder="Prompt text"
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:48px"></textarea>
<label style="font-size:10px;color:#777;display:flex;align-items:center;gap:3px;cursor:pointer;margin-top:4px"><input type="checkbox" id="sbNewPoseBeta">beta</label>
<button class="sb-btn primary" style="margin-top:4px;font-size:10px;padding:3px 10px" onclick="addNewPose()">Add pose</button>
</details>`;
// Sticky footer — always visible without scrolling: refs + custom prompt + Generate
html += `<div class="sb-gen-footer">`;
if (_sbRefIndices.length > 0) {
const shortN = n => n.split('/').pop().replace(/\?.*$/, '').replace(/^[0-9_]+/, '').slice(0, 22) || n.split('/').pop().slice(0, 22);
const items = _sbRefIndices.map((idx, pos) => {
const refThumbs = _sbRefIndices.map((idx, pos) => {
const fn = lbNames[idx] || '';
return `<span style="color:#f97316;font-weight:bold">#${pos+1}</span> <span style="color:#aaa" title="${escHtml(fn)}">${escHtml(shortN(fn))}</span>`;
}).join(' &nbsp; ');
html += `<div style="font-size:10px;background:#1a1208;border:1px solid #3d2008;border-radius:5px;padding:5px 8px;margin-bottom:6px;line-height:1.8">${items}<span style="color:#555;margin-left:8px;font-size:9px">Shift+Click filmstrip to change</span></div>`;
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>`;
}).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>
<div class="sb-gen-refs">${refThumbs}</div>`;
// With 23 refs, let the user choose how the prompt is applied.
if (_sbRefIndices.length >= 2) {
const n = _sbRefIndices.length;
const modeBtn = (mode, label, tip) =>
`<button onclick="setSbRefMode('${mode}')" title="${tip}"
style="flex:1;padding:5px 6px;font-size:10px;border-radius:5px;cursor:pointer;
border:1px solid ${_sbRefMode===mode?'#2563eb':'#2a2a2a'};
background:${_sbRefMode===mode?'#0c1f44':'#111'};
color:${_sbRefMode===mode?'#7dabff':'#888'}">${label}</button>`;
html += `<div style="display:flex;gap:4px;margin:6px 0 4px">
${modeBtn('combine', 'Combine → 1', 'Blend all selected refs into ONE image (#1 = image1, #2 = image2, #3 = image3)')}
${modeBtn('each', `Each → ${n}`, 'Apply the prompt to each ref separately → one output per ref')}
</div>
<div style="font-size:9px;color:#555;margin-bottom:2px">${_sbRefMode==='combine'
? 'One combined image; reference slots as #1/#2/#3 in the prompt.'
: 'One edit per selected reference.'}</div>`;
}
}
html += `<input type="text" class="sb-input" id="sbGenPromptInput"
list="promptHistory"
placeholder="Prompt — overrides pose" oninput="updateSbGenBtn()" style="margin-bottom:10px"
value="${escHtml(prevPromptVal)}">
html += `<div class="sb-label">Custom prompt</div>
<div class="sb-prompt-wrap" style="position:relative;margin-bottom:10px">
<textarea class="sb-input" id="sbGenPromptInput" rows="1" autocomplete="off"
placeholder="Prompt — overrides pose"
oninput="updateSbGenBtn();autoGrowPrompt(this);showPromptSuggest(this)"
onfocus="autoGrowPrompt(this);showPromptSuggest(this)"
onblur="setTimeout(hidePromptSuggest,150)"
onkeydown="if(event.key==='Escape')hidePromptSuggest()"
style="resize:none;max-height:160px;overflow-y:auto;line-height:1.4;font-family:inherit">${escHtml(prevPromptVal)}</textarea>
<div id="sbPromptSuggest" style="display:none;position:absolute;left:0;right:0;z-index:50;max-height:180px;overflow-y:auto;background:#18181b;border:1px solid #2a2a2a;border-radius:6px;margin-top:2px;box-shadow:0 6px 20px rgba(0,0,0,0.6)"></div>
</div>
<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>
</div>`;
panel.innerHTML = html;
updateSbGenBtn();
const promptEl = document.getElementById('sbGenPromptInput');
if (promptEl) autoGrowPrompt(promptEl);
}
// Grow the custom-prompt textarea to fit its content (capped by max-height in CSS).
function autoGrowPrompt(el) {
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}
// History autocomplete for the custom-prompt textarea. <datalist> can't bind to a
// <textarea>, so this reuses the same `batchPromptHistory` store that feeds the
// #promptHistory datalist and renders a small suggestion dropdown.
function showPromptSuggest(el) {
const box = document.getElementById('sbPromptSuggest');
if (!box) return;
const hist = JSON.parse(localStorage.getItem('batchPromptHistory') || '[]');
const q = (el.value || '').trim().toLowerCase();
const matches = (q ? hist.filter(p => p.toLowerCase().includes(q) && p.toLowerCase() !== q) : hist).slice(0, 10);
if (!matches.length) { box.style.display = 'none'; return; }
box.style.top = (el.offsetTop + el.offsetHeight) + 'px';
box.innerHTML = matches.map(p =>
`<div class="prompt-suggest-item" onmousedown="event.preventDefault();pickPromptSuggest(this)"
style="padding:6px 9px;font-size:11px;color:#ccc;cursor:pointer;border-bottom:1px solid #222;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
onmouseover="this.style.background='#1f2937'" onmouseout="this.style.background=''"
data-val="${escHtml(p)}" title="${escHtml(p)}">${escHtml(p)}</div>`).join('');
box.style.display = 'block';
}
function hidePromptSuggest() {
const box = document.getElementById('sbPromptSuggest');
if (box) box.style.display = 'none';
}
function pickPromptSuggest(item) {
const el = document.getElementById('sbGenPromptInput');
if (!el) return;
el.value = item.dataset.val;
hidePromptSuggest();
updateSbGenBtn();
autoGrowPrompt(el);
el.focus();
}
function sbSavePoseEdit(name) {
@@ -4025,6 +4236,79 @@
if (el) _sbPoseEdits[name] = el.value;
}
// Refetch poses from the backend, then re-render both the Generate tab and batch menu.
async function _reloadPosesUI() {
try {
const r = await fetch(`${API}/poses`);
if (r.ok) availablePoses = await r.json();
} catch (e) { /* non-fatal */ }
renderSidebarGenerate();
if (document.getElementById('poseMenu')) renderPoseMenu();
}
async function savePoseToServer(btn) {
const box = btn.closest('.sb-pose-edit');
if (!box) return;
const name = box.dataset.pose;
const text = box.querySelector('.sb-pose-text')?.value ?? '';
const beta = box.querySelector('.sb-pose-beta')?.checked ?? false;
btn.disabled = true;
try {
const r = await fetch(`${API}/poses`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, text, beta }),
});
if (r.ok) {
delete _sbPoseEdits[name]; // edit is now the saved baseline
showToast('Pose saved', 'success');
await _reloadPosesUI();
} else {
showToast('Save failed: ' + await r.text(), 'error');
btn.disabled = false;
}
} catch (e) { showToast('Save error: ' + e, 'error'); btn.disabled = false; }
}
async function deletePoseFromServer(btn) {
const box = btn.closest('.sb-pose-edit');
if (!box) return;
const name = box.dataset.pose;
if (!confirm(`Delete pose "${name}"?`)) return;
btn.disabled = true;
try {
const r = await fetch(`${API}/poses/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (r.ok) {
_fsSelectedPoses.delete(name);
selectedPoses.delete(name);
delete _sbPoseEdits[name];
showToast('Pose deleted', 'success');
await _reloadPosesUI();
} else {
showToast('Delete failed: ' + await r.text(), 'error');
btn.disabled = false;
}
} catch (e) { showToast('Delete error: ' + e, 'error'); btn.disabled = false; }
}
async function addNewPose() {
const name = (document.getElementById('sbNewPoseName')?.value || '').trim();
const text = (document.getElementById('sbNewPoseText')?.value || '').trim();
const beta = document.getElementById('sbNewPoseBeta')?.checked ?? false;
if (!name) { showToast('Pose name is required', 'info'); return; }
try {
const r = await fetch(`${API}/poses`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, text, beta }),
});
if (r.ok) {
showToast('Pose added', 'success');
await _reloadPosesUI();
} else {
showToast('Add failed: ' + await r.text(), 'error');
}
} catch (e) { showToast('Add error: ' + e, 'error'); }
}
function toggleSbPose(name) {
if (_fsSelectedPoses.has(name)) _fsSelectedPoses.delete(name);
else _fsSelectedPoses.add(name);
@@ -4037,6 +4321,11 @@
renderSidebarGenerate();
}
function setSbRefMode(mode) {
_sbRefMode = mode;
renderSidebarGenerate();
}
function sbSelectWireframe(val) {
_sbWireframeRef = val;
renderSidebarGenerate();
@@ -4147,7 +4436,9 @@
// Camera angles — treated as prompts with no pose tag
_sbSelectedAngles.forEach(name => {
const ang = CAMERA_ANGLES.find(a => a.name === name);
if (ang) { prompts.push(ang.prompt); poses.push(null); }
// Tag the output with the angle name (as a pose) so the angle button
// shows a green ✓ once generated, mirroring pose tracking.
if (ang) { prompts.push(ang.prompt); poses.push(name); }
});
if (promptVal) { prompts.push(promptVal); poses.push(null); savePromptHistory(promptVal); }
if (prompts.length === 0) return;
@@ -4163,16 +4454,30 @@
const refFilenames = _sbRefIndices.length > 0
? _sbRefIndices.map(idx => lbNames[idx]).filter(Boolean)
: [_fsModelFilename];
// Routing depends on the References mode toggle:
// 'combine' (≥2 refs) → /multi-ref: filenames[0] is primary (image1),
// the rest are extra refs (image2/image3) → ONE output per prompt.
// 'each' or a single ref → /batch: cross-products filenames × prompts
// → one edit per ref. (This was the only path before, which is why
// selecting 3 refs always produced 3 images.)
const useMultiRef = refFilenames.length >= 2 && _sbRefMode === 'combine';
const endpoint = useMultiRef ? '/multi-ref' : '/batch';
const payload = useMultiRef
? { filenames: refFilenames, prompt: prompts, poses, seed: -1, max_area: 0 }
: { filenames: refFilenames, prompt: prompts, poses, seed: -1, max_area: 0,
group_id: lbCurrentGid,
wireframe_ref: _sbWireframeRef || null, wireframe_time: _sbWireframeTime };
if (useMultiRef) {
const shortName = f => f.split('/').pop().replace(/\?.*$/, '');
let refLabel = `Combining → image1: ${shortName(refFilenames[0])}, image2: ${shortName(refFilenames[1])}`;
if (refFilenames[2]) refLabel += `, image3: ${shortName(refFilenames[2])}`;
showToast(refLabel, 'info', 5000);
}
try {
const r = await fetch(`${API}/batch`, {
const r = await fetch(`${API}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filenames: refFilenames, prompt: prompts,
poses, seed: -1, max_area: 0, group_id: lbCurrentGid,
wireframe_ref: _sbWireframeRef || null,
wireframe_time: _sbWireframeTime,
}),
body: JSON.stringify(payload),
});
if (!r.ok) {
textEl.textContent = `Error: ${await r.text()}`;
@@ -4214,6 +4519,7 @@
if (cancelBtn) cancelBtn.style.display = 'none';
btn.disabled = false; btn.textContent = 'Generate';
_fsSelectedPoses.clear();
_sbSelectedAngles.clear();
_sbPoseEdits = {};
_sbGenJobId = null;
_followLatestGid = lbCurrentGid;
@@ -4435,6 +4741,7 @@
if (textEl) textEl.textContent = `Done! ${job.output || ''}`;
if (btn) btn.textContent = 'Done ✓';
showToast(`Faceswap complete: ${job.output || ''}`, 'success');
_followLatestGid = lbCurrentGid;
setTimeout(() => { refreshNow(); }, 1800);
} else {
if (textEl) textEl.textContent = `Error: ${job.error || 'unknown'}`;
@@ -4917,7 +5224,12 @@
// Fire-and-forget face extraction for face-book
fetch(`${API}/images/${encodeURIComponent(fname)}/extract-face`, { method: 'POST' })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.status === 'queued') showToast('Extracting face reference…', 'info'); })
.then(d => {
if (d?.status === 'queued') {
showToast('Extracting face reference…', 'info');
loadFaceThumb(lbCurrentGid, 8); // poll until the crop lands
}
})
.catch(() => {});
} catch(e) { console.error(e); }
}
@@ -4943,6 +5255,7 @@
async function handleUploadToGroup(files, groupId) {
showToast(`Adding ${files.length} image(s) to group…`);
const added = [];
for (const file of files) {
const fd = new FormData();
fd.append('image', file);
@@ -4950,11 +5263,38 @@
fd.append('skip_poses', 'true');
try {
const r = await fetch(`${API}/upload`, { method: 'POST', body: fd });
if (!r.ok) showToast('Upload failed: ' + await r.text(), 'error');
if (r.ok) {
const d = await r.json().catch(() => ({}));
if (d.filename) added.push(d.filename);
} else {
showToast('Upload failed: ' + await r.text(), 'error');
}
} catch (e) { showToast('Upload error: ' + e, 'error'); }
}
// Optimistically show the new images immediately — the static images.json the
// gallery reads from is rebuilt on a background thread, so refreshNow() alone
// would race ahead of it and the images wouldn't appear until the next refresh.
if (added.length) {
const fresh = groupData.get(groupId);
added.forEach(fn => {
const url = IMAGE_FOLDER + fn + '?t=' + Date.now();
if (fresh && !fresh.names.includes(fn)) {
fresh.urls.push(url);
fresh.names.push(fn);
if (fresh.visibleIdx) fresh.visibleIdx.push(fresh.names.length - 1);
}
if (_isStudioOpen() && lbCurrentGid === groupId && !lbNames.includes(fn)) {
lbUrls.push(url);
lbNames.push(fn);
}
});
if (_isStudioOpen() && lbCurrentGid === groupId) updateStudio();
}
showToast('Added to group', 'success');
refreshNow();
// Reconcile with the backend once the static data file has been regenerated.
setTimeout(refreshNow, 600);
}
</script>
</body>