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:
@@ -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 2–3 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 0–1
|
||||
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, '"');
|
||||
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(' ');
|
||||
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 2–3 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>
|
||||
|
||||
Reference in New Issue
Block a user