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-25 02:28:45 +02:00
parent 6d31826c29
commit 7b12ebd866
2 changed files with 267 additions and 112 deletions

View File

@@ -53,4 +53,6 @@ when refresh page, we lose track of current jobs running.
generating poses themself should use the adviced dimensions rather than the base image reference. generating poses themself should use the adviced dimensions rather than the base image reference.
introduce a like/dislike base system for images/group to determine the sort order (and more) introduce a like/dislike base system for images/group to determine the sort order (and more)
- would also dislike the pose - would also dislike the pose
creating scenery should keep both video-frame + ref images as references, we only see 1 image now.

View File

@@ -1586,34 +1586,30 @@
background: rgba(34,197,94,0.9); color: #04210f; background: rgba(34,197,94,0.9); color: #04210f;
border-radius: 5px; padding: 2px 8px; font-size: 11px; font-weight: 600; border-radius: 5px; padding: 2px 8px; font-size: 11px; font-weight: 600;
} }
/* 3-slot reference summary (image1 / image2 / image3) */ /* 3 interactive reference slots (image1 / image2 / image3) */
.scene-slots { display: flex; gap: 6px; margin-bottom: 12px; } .scene-slots { display: flex; gap: 6px; margin-bottom: 12px; }
.scene-slot { .scene-slot {
flex: 1; min-width: 0; background: #141414; border: 1px solid #262626; flex: 1; min-width: 0; background: #141414; border: 1px solid #262626;
border-radius: 7px; overflow: hidden; text-align: center; border-radius: 7px; overflow: hidden; text-align: center;
} }
.scene-slot.filled { border-color: #2563eb; } .scene-slot.filled { border-color: #2563eb; cursor: grab; }
.scene-slot.empty { border-style: dashed; } .scene-slot.empty { border-style: dashed; }
.scene-slot.drop-hover { border-color: #60a5fa; background: #11203f; }
.scene-slot-thumb { .scene-slot-thumb {
width: 100%; aspect-ratio: 1/1; background: #0c0c0c; width: 100%; aspect-ratio: 1/1; background: #0c0c0c; position: relative;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
} }
.scene-slot-thumb img { width: 100%; height: 100%; object-fit: cover; } .scene-slot-thumb img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.scene-slot-thumb .ph { font-size: 18px; color: #333; } .scene-slot-thumb .ph { font-size: 18px; color: #333; }
.scene-slot-x {
position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; line-height: 14px;
background: rgba(0,0,0,0.7); border: 1px solid #444; border-radius: 4px;
color: #ddd; font-size: 10px; cursor: pointer; padding: 0;
}
.scene-slot-x:hover { background: #b91c1c; color: #fff; }
.scene-slot-kind { position: absolute; bottom: 1px; left: 2px; font-size: 10px; }
.scene-slot-cap { font-size: 8.5px; color: #888; padding: 3px 2px 2px; line-height: 1.25; } .scene-slot-cap { font-size: 8.5px; color: #888; padding: 3px 2px 2px; line-height: 1.25; }
.scene-slot-cap b { color: #bbb; display: block; font-size: 9px; } .scene-slot-cap b { color: #bbb; display: block; font-size: 9px; }
/* compact filmstrip picker for image3 */
.scene-pick-row { display: flex; gap: 5px; overflow-x: auto; padding-bottom: 4px; }
.scene-pick {
flex: 0 0 auto; width: 46px; height: 46px; border-radius: 5px; overflow: hidden;
border: 2px solid transparent; cursor: pointer; position: relative; background: #0c0c0c;
}
.scene-pick img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.scene-pick.sel { border-color: #2563eb; }
.scene-pick .badge3 {
position: absolute; bottom: 1px; right: 1px; background: #2563eb; color: #fff;
font-size: 9px; font-weight: 700; border-radius: 3px; padding: 0 3px;
}
/* ===================== SEGMENT TAB ===================== */ /* ===================== SEGMENT TAB ===================== */
.segment-checker-hint { font-size: 10px; color: #444; margin-top: 6px; } .segment-checker-hint { font-size: 10px; color: #444; margin-top: 6px; }
@@ -2243,6 +2239,7 @@
let availableVideos = []; // populated from /videos let availableVideos = []; // populated from /videos
let _fsActiveTab = 'swap'; let _fsActiveTab = 'swap';
let _fsSelectedPoses = new Set(); let _fsSelectedPoses = new Set();
let _sbPoseFilter = ''; // live pose-name filter query (Generate tab)
let _sbSelectedAngles = new Set(); // selected camera angle names let _sbSelectedAngles = new Set(); // selected camera angle names
let _sbRefIndices = []; // ordered filmstrip indices selected as multi-ref (#1, #2, #3) 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 _sbRefMode = 'combine'; // with 23 refs: 'combine' → 1 multi-ref output; 'each' → one edit per ref (cross product)
@@ -2356,7 +2353,12 @@
function openStudio(gid, startIdx) { function openStudio(gid, startIdx) {
const data = groupData.get(gid); const data = groupData.get(gid);
if (!data) return; if (!data) return;
if (lbCurrentGid !== gid) { _sbRefIndices = []; _sceneExtraRef = null; } // clear refs when switching groups if (lbCurrentGid !== gid) { // clear refs when switching groups
_sbRefIndices = [];
_sceneRefs = [null, null, null];
_scenePersonInit = false;
_sceneFrameBytes = null;
}
lbCurrentGid = gid; lbCurrentGid = gid;
lbUrls = data.urls; lbUrls = data.urls;
lbNames = data.names; lbNames = data.names;
@@ -2427,8 +2429,26 @@
updateStudio(); updateStudio();
} }
// Reference badge number for filmstrip thumb i (0 = none).
// Generate tab → position in _sbRefIndices; Scenery tab → slot index in _sceneRefs.
function _filmstripRefBadge(i) {
if (_activeSidebarTab === 'scenery') {
const name = lbNames[i];
const slot = _sceneRefs.findIndex(r => r && r.kind === 'file' && r.name === name);
return slot >= 0 ? slot + 1 : 0;
}
const pos = _sbRefIndices.indexOf(i);
return pos >= 0 ? pos + 1 : 0;
}
function sbFilmstripClick(event, i) { function sbFilmstripClick(event, i) {
if (event.shiftKey) { if (event.shiftKey) {
event.preventDefault();
// Scenery tab uses positional slots (fills right→left); Generate uses _sbRefIndices.
if (_activeSidebarTab === 'scenery') {
sceneAssignRef(lbNames[i]); // re-renders panel + filmstrip
return;
}
const pos = _sbRefIndices.indexOf(i); const pos = _sbRefIndices.indexOf(i);
if (pos >= 0) { if (pos >= 0) {
_sbRefIndices.splice(pos, 1); _sbRefIndices.splice(pos, 1);
@@ -2439,7 +2459,6 @@
} }
updateStudio(); updateStudio();
if (_activeSidebarTab === 'generate') renderSidebarGenerate(); if (_activeSidebarTab === 'generate') renderSidebarGenerate();
event.preventDefault();
} else { } else {
lbNavTo(i); lbNavTo(i);
} }
@@ -2545,18 +2564,22 @@
// Film strip — always visible (override old display:none CSS) // Film strip — always visible (override old display:none CSS)
const strip = document.getElementById('lbVariantStrip'); const strip = document.getElementById('lbVariantStrip');
strip.style.display = 'flex'; strip.style.display = 'flex';
const sceneryActive = _activeSidebarTab === 'scenery';
strip.innerHTML = lbUrls.map((url, i) => { strip.innerHTML = lbUrls.map((url, i) => {
const n = lbNames[i]; const n = lbNames[i];
const pose = filePoses[n] || ''; const pose = filePoses[n] || '';
const act = i === lbIdx ? ' active' : ''; const act = i === lbIdx ? ' active' : '';
const refPos = _sbRefIndices.indexOf(i); // Badge: Generate uses _sbRefIndices order; Scenery uses positional slots.
const refCls = refPos >= 0 ? ' ref-selected' : ''; const badge = _filmstripRefBadge(i);
const refCls = badge > 0 ? ' ref-selected' : '';
const thumbSrc = isVideo(n) ? posterFor(url) : url; const thumbSrc = isVideo(n) ? posterFor(url) : url;
return '<div class="lb-var-thumb' + act + refCls + '" onclick="sbFilmstripClick(event,' + i + ')" title="Click to view · Shift+Click to add as ref">' // On the Scenery tab the thumbs are draggable onto the slots above.
const dragAttr = sceneryActive ? ' draggable="true" ondragstart="sceneFilmDragStart(event,' + i + ')"' : '';
return '<div class="lb-var-thumb' + act + refCls + '" onclick="sbFilmstripClick(event,' + i + ')"' + dragAttr + ' title="Click to view · Shift+Click to add as ref">'
+ '<img src="' + thumbSrc + '" loading="lazy" onerror="this.style.opacity=\'0.3\'">' + '<img src="' + thumbSrc + '" loading="lazy" onerror="this.style.opacity=\'0.3\'">'
+ (isVideo(n) ? '<div class="lb-var-play">▶</div>' : '') + (isVideo(n) ? '<div class="lb-var-play">▶</div>' : '')
+ (pose ? '<div class="lb-var-pose">' + escHtml(pose) + '</div>' : '') + (pose ? '<div class="lb-var-pose">' + escHtml(pose) + '</div>' : '')
+ (refPos >= 0 ? '<div class="lb-var-ref-badge">#' + (refPos + 1) + '</div>' : '') + (badge > 0 ? '<div class="lb-var-ref-badge">#' + badge + '</div>' : '')
+ '</div>'; + '</div>';
}).join(''); }).join('');
const active = strip.querySelector('.lb-var-thumb.active'); const active = strip.querySelector('.lb-var-thumb.active');
@@ -4522,8 +4545,15 @@
style="width:48px;height:48px;object-fit:contain;border-radius:4px;border:1px solid #333"> style="width:48px;height:48px;object-fit:contain;border-radius:4px;border:1px solid #333">
</div>` : ''}`; </div>` : ''}`;
html += '<div class="sb-sep"></div><div class="sb-label">Poses</div><div class="sb-poses-grid" id="sbPosesGrid">'; html += '<div class="sb-sep"></div><div class="sb-label">Poses</div>';
if (!availablePoses || Object.keys(availablePoses).length === 0) { const poseCount = availablePoses ? Object.keys(availablePoses).length : 0;
if (poseCount > 8) {
html += `<input type="text" id="sbPoseFilter" class="sb-input" placeholder="🔍 Filter poses…"
value="${escHtml(_sbPoseFilter)}" oninput="sbFilterPoses(this.value)"
style="margin-bottom:6px;font-size:11px;padding:5px 8px">`;
}
html += '<div class="sb-poses-grid" id="sbPosesGrid">';
if (!poseCount) {
html += '<div style="font-size:11px;color:#555;padding:6px 0">No poses loaded</div>'; html += '<div style="font-size:11px;color:#555;padding:6px 0">No poses loaded</div>';
} else { } else {
html += Object.entries(availablePoses).map(([name, entry]) => { html += Object.entries(availablePoses).map(([name, entry]) => {
@@ -4533,7 +4563,7 @@
if (entry?.beta) cls += ' beta'; if (entry?.beta) cls += ' beta';
const nSafe = name.replace(/'/g, "\\'"); const nSafe = name.replace(/'/g, "\\'");
const tip = String(entry?.text ?? entry).replace(/"/g,'&quot;'); const tip = String(entry?.text ?? entry).replace(/"/g,'&quot;');
return `<button class="${cls}" onclick="toggleSbPose('${nSafe}')" title="${tip}">${escHtml(name)}</button>`; return `<button class="${cls}" data-pose-name="${escHtml(name.toLowerCase())}" onclick="toggleSbPose('${nSafe}')" title="${tip}">${escHtml(name)}</button>`;
}).join(''); }).join('');
} }
html += `</div>`; html += `</div>`;
@@ -4605,13 +4635,13 @@
} }
html += `<div class="sb-label">Custom prompt</div> html += `<div class="sb-label">Custom prompt</div>
<div class="sb-prompt-wrap" style="position:relative;margin-bottom:10px"> <div class="sb-prompt-wrap" style="position:relative;margin-bottom:10px">
<textarea class="sb-input" id="sbGenPromptInput" rows="1" autocomplete="off" <textarea class="sb-input" id="sbGenPromptInput" rows="2" autocomplete="off"
placeholder="Prompt — overrides pose" placeholder="Prompt — overrides pose"
oninput="updateSbGenBtn();autoGrowPrompt(this);showPromptSuggest(this)" oninput="updateSbGenBtn();autoGrowPrompt(this);showPromptSuggest(this)"
onfocus="autoGrowPrompt(this);showPromptSuggest(this)" onfocus="autoGrowPrompt(this);showPromptSuggest(this)"
onblur="setTimeout(hidePromptSuggest,150)" onblur="setTimeout(()=>hidePromptSuggest(),150)"
onkeydown="if(event.key==='Escape')hidePromptSuggest()" 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> style="resize:none;max-height:320px;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 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>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"> <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
@@ -4626,6 +4656,7 @@
updateSbGenBtn(); updateSbGenBtn();
const promptEl = document.getElementById('sbGenPromptInput'); const promptEl = document.getElementById('sbGenPromptInput');
if (promptEl) autoGrowPrompt(promptEl); if (promptEl) autoGrowPrompt(promptEl);
if (_sbPoseFilter) sbFilterPoses(_sbPoseFilter); // re-apply after rebuild
} }
// Grow the custom-prompt textarea to fit its content (capped by max-height in CSS). // Grow the custom-prompt textarea to fit its content (capped by max-height in CSS).
@@ -4638,33 +4669,36 @@
// History autocomplete for the custom-prompt textarea. <datalist> can't bind to a // History autocomplete for the custom-prompt textarea. <datalist> can't bind to a
// <textarea>, so this reuses the same `batchPromptHistory` store that feeds the // <textarea>, so this reuses the same `batchPromptHistory` store that feeds the
// #promptHistory datalist and renders a small suggestion dropdown. // #promptHistory datalist and renders a small suggestion dropdown.
function showPromptSuggest(el) { // Prompt history dropdown — shared by the Generate and Scenery prompt textareas.
const box = document.getElementById('sbPromptSuggest'); // Pass the suggestion-box id so each textarea drives its own dropdown.
function showPromptSuggest(el, boxId = 'sbPromptSuggest') {
const box = document.getElementById(boxId);
if (!box) return; if (!box) return;
const hist = JSON.parse(localStorage.getItem('batchPromptHistory') || '[]'); const hist = JSON.parse(localStorage.getItem('batchPromptHistory') || '[]');
const q = (el.value || '').trim().toLowerCase(); const q = (el.value || '').trim().toLowerCase();
const matches = (q ? hist.filter(p => p.toLowerCase().includes(q) && p.toLowerCase() !== q) : hist).slice(0, 10); const matches = (q ? hist.filter(p => p.toLowerCase().includes(q) && p.toLowerCase() !== q) : hist).slice(0, 40);
if (!matches.length) { box.style.display = 'none'; return; } if (!matches.length) { box.style.display = 'none'; return; }
box.style.top = (el.offsetTop + el.offsetHeight) + 'px'; box.style.top = (el.offsetTop + el.offsetHeight) + 'px';
const inputId = el.id;
box.innerHTML = matches.map(p => box.innerHTML = matches.map(p =>
`<div class="prompt-suggest-item" onmousedown="event.preventDefault();pickPromptSuggest(this)" `<div class="prompt-suggest-item" onmousedown="event.preventDefault();pickPromptSuggest(this,'${inputId}','${boxId}')"
style="padding:6px 9px;font-size:11px;color:#ccc;cursor:pointer;border-bottom:1px solid #222;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" 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=''" onmouseover="this.style.background='#1f2937'" onmouseout="this.style.background=''"
data-val="${escHtml(p)}" title="${escHtml(p)}">${escHtml(p)}</div>`).join(''); data-val="${escHtml(p)}" title="${escHtml(p)}">${escHtml(p)}</div>`).join('');
box.style.display = 'block'; box.style.display = 'block';
} }
function hidePromptSuggest() { function hidePromptSuggest(boxId = 'sbPromptSuggest') {
const box = document.getElementById('sbPromptSuggest'); const box = document.getElementById(boxId);
if (box) box.style.display = 'none'; if (box) box.style.display = 'none';
} }
function pickPromptSuggest(item) { function pickPromptSuggest(item, inputId = 'sbGenPromptInput', boxId = 'sbPromptSuggest') {
const el = document.getElementById('sbGenPromptInput'); const el = document.getElementById(inputId);
if (!el) return; if (!el) return;
el.value = item.dataset.val; el.value = item.dataset.val;
hidePromptSuggest(); hidePromptSuggest(boxId);
updateSbGenBtn(); if (inputId === 'sbGenPromptInput') updateSbGenBtn();
autoGrowPrompt(el); autoGrowPrompt(el);
el.focus(); el.focus();
} }
@@ -4753,6 +4787,16 @@
renderSidebarGenerate(); renderSidebarGenerate();
} }
// Live client-side filter of the pose grid by name substring (no re-render).
function sbFilterPoses(q) {
_sbPoseFilter = q || '';
const needle = _sbPoseFilter.trim().toLowerCase();
document.querySelectorAll('#sbPosesGrid .sb-pose-btn').forEach(btn => {
const name = btn.dataset.poseName || '';
btn.style.display = (!needle || name.includes(needle)) ? '' : 'none';
});
}
function toggleSbAngle(name) { function toggleSbAngle(name) {
if (_sbSelectedAngles.has(name)) _sbSelectedAngles.delete(name); if (_sbSelectedAngles.has(name)) _sbSelectedAngles.delete(name);
else _sbSelectedAngles.add(name); else _sbSelectedAngles.add(name);
@@ -5342,40 +5386,59 @@
let _sceneVideo = null, _sceneDuration = 0, _sceneFrameBytes = null, _sceneJobPollTimer = null; let _sceneVideo = null, _sceneDuration = 0, _sceneFrameBytes = null, _sceneJobPollTimer = null;
let _sceneGridOpen = false; // whether the video picker grid is expanded let _sceneGridOpen = false; // whether the video picker grid is expanded
let _sceneExtraRef = null; // image3 — optional extra reference filename (from filmstrip) // 3 positional reference slots: 0=Background(image1), 1=Person(image2), 2=Extra(image3).
// Each entry is null | {kind:'bytes', data:<base64>} | {kind:'file', name:<filename>}.
// Backend contract: slot0→scene_bytes (bytes; a file here is fetched→bytes on submit),
// slot1→model_filename (must be a file), slot2→extra_filename (file, optional).
let _sceneRefs = [null, null, null];
let _scenePersonInit = false; // person auto-filled into slot1 once per group
let _sceneDrag = null; // active drag payload: {type:'slot',idx} | {type:'file',name}
function renderSidebarScenery() { function renderSidebarScenery() {
const panel = document.getElementById('sbPanelScenery'); const panel = document.getElementById('sbPanelScenery');
if (!panel) return; if (!panel) return;
const haveRef = _sceneVideo || _sceneFrameBytes; // Preserve a typed prompt across the frequent re-renders (slot changes).
// Auto-open the grid when nothing is chosen yet. const prevScenePrompt = document.getElementById('scenePromptInput')?.value || '';
// Auto-fill the Person slot (image2) once per group with the current image.
const personName = _fsModelFilename || lbNames[lbIdx] || '';
if (!_scenePersonInit && !_sceneRefs[1] && personName) {
_sceneRefs[1] = { kind: 'file', name: personName };
_scenePersonInit = true;
}
const bgRef = _sceneRefs[0];
const bgBytes = (bgRef && bgRef.kind === 'bytes') ? bgRef.data : null;
const haveRef = _sceneVideo || bgBytes;
// Auto-open the grid when no background source is chosen yet.
const gridOpen = _sceneGridOpen || !haveRef; const gridOpen = _sceneGridOpen || !haveRef;
// Person (image2) = current studio image; extra (image3) = _sceneExtraRef // --- Interactive 3-slot reference row (drag to reorder, ✕ to clear) ---
const personName = _fsModelFilename || lbNames[lbIdx] || ''; const SLOT_META = [
const personUrl = lbUrls[lbIdx] || ''; { n: 1, label: 'Background' },
const personThumb = personName ? (isVideo(personName) ? posterFor(personUrl) : personUrl) : ''; { n: 2, label: 'Person' },
const extraIdx = _sceneExtraRef ? lbNames.indexOf(_sceneExtraRef) : -1; { n: 3, label: 'Extra' },
const extraUrl = extraIdx >= 0 ? lbUrls[extraIdx] : ''; ];
const extraThumb = _sceneExtraRef ? (isVideo(_sceneExtraRef) ? posterFor(extraUrl) : extraUrl) : ''; const slotHtml = SLOT_META.map((m, i) => {
const ref = _sceneRefs[i];
// --- 3-slot reference summary (what feeds the multi-ref compose) --- const thumb = _sceneThumb(ref);
const slot = (cls, num, label, thumb) => ` const kind = ref ? (ref.kind === 'bytes' ? '📷' : '🖼') : '';
<div class="scene-slot ${thumb?'filled':'empty'}"> return `<div class="scene-slot ${ref ? 'filled' : 'empty'}" data-slot="${i}"
draggable="${ref ? 'true' : 'false'}"
ondragstart="sceneDragSlotStart(event,${i})"
ondragover="sceneDragOver(event)" ondragleave="sceneDragLeave(event)" ondrop="sceneDrop(event,${i})">
<div class="scene-slot-thumb">${thumb <div class="scene-slot-thumb">${thumb
? `<img src="${thumb}" onerror="this.style.opacity=.3">` ? `<img src="${thumb}" onerror="this.style.opacity=.3">`
: `<span class="ph">${num}</span>`}</div> : `<span class="ph">${m.n}</span>`}
<div class="scene-slot-cap"><b>Image ${num}</b>${label}</div> ${ref ? `<button class="scene-slot-x" title="Remove" onclick="sceneClearSlot(${i})">✕</button>` : ''}
${kind ? `<span class="scene-slot-kind">${kind}</span>` : ''}</div>
<div class="scene-slot-cap"><b>Image ${m.n}</b>${m.label}</div>
</div>`; </div>`;
let html = `<div class="sb-label">Compose: person + scene → one photo</div> }).join('');
<div class="scene-slots"> let html = `<div class="sb-label">References <span style="color:#555;font-weight:400;font-size:9px">· Shift+Click filmstrip (fills →) · drag to reorder</span></div>
${slot('bg','1','Background', _sceneFrameBytes ? `data:image/png;base64,${_sceneFrameBytes}` : '')} <div class="scene-slots">${slotHtml}</div>`;
${slot('person','2','Person', personThumb)}
${slot('extra','3','Extra (opt)', extraThumb)}
</div>`;
// --- IMAGE 1: Background scene (video scrub + capture, or upload) --- // --- Image 1 source: background scene (video scrub + capture, or upload) ---
html += `<div class="sb-label">Image 1 · Background scene</div>`; html += `<div class="sb-label">Image 1 source · background</div>`;
// --- Selected video: player + slider + capture (always at top) --- // --- Selected video: player + slider + capture (always at top) ---
if (_sceneVideo) { if (_sceneVideo) {
@@ -5420,56 +5483,47 @@
html += `<input type="file" id="sceneUploadInput" accept="image/*" style="display:none" onchange="sceneHandleUpload(event)"> html += `<input type="file" id="sceneUploadInput" accept="image/*" style="display:none" onchange="sceneHandleUpload(event)">
<button class="sb-btn" onclick="document.getElementById('sceneUploadInput').click()" style="width:100%;margin-bottom:10px">⬆ Upload image as background</button>`; <button class="sb-btn" onclick="document.getElementById('sceneUploadInput').click()" style="width:100%;margin-bottom:10px">⬆ Upload image as background</button>`;
// --- IMAGE 2: Person (auto — current studio image) --- // --- Person / Extra: filled via filmstrip Shift+Click (see slots above) ---
const person = _sceneRefs[1];
html += `<div class="sb-sep"></div> html += `<div class="sb-sep"></div>
<div class="sb-label">Image 2 · Person <span style="color:#555;font-weight:400;font-size:9px">· current image</span></div>`; <div style="font-size:10px;color:#666;line-height:1.5;margin-bottom:8px">
if (personThumb) { <b style="color:#999">Image 2 (Person)</b> &amp; <b style="color:#999">Image 3 (Extra)</b>:
html += `<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px"> Shift+Click a thumbnail in the bottom filmstrip to add it (fills the first open
<img src="${personThumb}" style="width:46px;height:46px;object-fit:cover;border-radius:5px;border:1px solid #2563eb" slot right→left). Drag the slots above to rearrange, or ✕ to clear.
onerror="this.style.opacity=.3"> </div>`;
<span style="font-size:10px;color:#888;word-break:break-all">${escHtml(personName)}</span> if (!person || person.kind !== 'file') {
</div>`; html += `<div style="font-size:11px;color:#a55;padding:0 0 8px">Image 2 needs a gallery image (Shift+Click the filmstrip).</div>`;
} else {
html += `<div style="font-size:11px;color:#a55;padding:4px 0 10px">No person image — open one from the gallery first.</div>`;
}
// --- IMAGE 3: Extra reference (optional, pick from this group) ---
html += `<div class="sb-sep"></div>
<div class="sb-label">Image 3 · Extra reference <span style="color:#555;font-weight:400;font-size:9px">· optional · click to toggle</span></div>`;
const pickNames = lbNames.filter(n => n && n !== personName);
if (pickNames.length) {
html += '<div class="scene-pick-row">';
pickNames.forEach(n => {
const idx = lbNames.indexOf(n);
const u = lbUrls[idx] || '';
const th = isVideo(n) ? posterFor(u) : u;
const sel = _sceneExtraRef === n;
const nSafe = n.replace(/'/g, "\\'");
html += `<div class="scene-pick ${sel?'sel':''}" title="${escHtml(n)}"
onclick="sceneToggleExtraRef('${nSafe}')">
<img src="${th}" loading="lazy" onerror="this.style.opacity=.3">
${sel?'<div class="badge3">3</div>':''}</div>`;
});
html += '</div>';
} else {
html += `<div style="font-size:11px;color:#555;padding:4px 0">No other images in this group.</div>`;
} }
// --- Prompt + generate --- // --- Prompt + generate ---
const canGen = !!bgRef && !!person && person.kind === 'file'
&& (!_sceneRefs[2] || _sceneRefs[2].kind === 'file');
html += `<div class="sb-sep"></div> html += `<div class="sb-sep"></div>
<div class="sb-label">Prompt (optional)</div> <div class="sb-label">Prompt (optional) <span style="color:#555;font-weight:400;font-size:9px">· history while typing</span></div>
<input type="text" class="sb-input" id="scenePromptInput" <div class="sb-prompt-wrap" style="position:relative;margin-bottom:10px">
placeholder="Auto: place person in scene…" style="margin-bottom:10px"> <textarea class="sb-input" id="scenePromptInput" rows="2" autocomplete="off"
placeholder="Auto: place person in scene… (type to search history)"
oninput="autoGrowPrompt(this);showPromptSuggest(this,'scenePromptSuggest')"
onfocus="autoGrowPrompt(this);showPromptSuggest(this,'scenePromptSuggest')"
onblur="setTimeout(()=>hidePromptSuggest('scenePromptSuggest'),150)"
onkeydown="if(event.key==='Escape')hidePromptSuggest('scenePromptSuggest')"
style="resize:none;max-height:300px;overflow-y:auto;line-height:1.4;font-family:inherit"></textarea>
<div id="scenePromptSuggest" style="display:none;position:absolute;left:0;right:0;z-index:50;max-height:240px;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"> <div style="display:flex;align-items:center;gap:8px">
<span id="sceneJobStatus" style="flex:1;font-size:11px;color:#888;display:none"> <span id="sceneJobStatus" style="flex:1;font-size:11px;color:#888;display:none">
<span class="sb-spinner"></span><span id="sceneJobText">Generating…</span> <span class="sb-spinner"></span><span id="sceneJobText">Generating…</span>
</span> </span>
<button class="sb-btn primary" id="sceneGenBtn" onclick="submitGenerateScenery()" <button class="sb-btn primary" id="sceneGenBtn" onclick="submitGenerateScenery()"
${(haveRef && personName)?'':'disabled'}>Create Scenery</button> ${canGen?'':'disabled'}>Create Scenery</button>
</div>`; </div>`;
panel.innerHTML = html; panel.innerHTML = html;
// Restore the preserved prompt text and size the textarea.
const spEl = document.getElementById('scenePromptInput');
if (spEl) { spEl.value = prevScenePrompt; autoGrowPrompt(spEl); }
if (_sceneVideo) { if (_sceneVideo) {
const vid = document.getElementById('sceneVideoEl'); const vid = document.getElementById('sceneVideoEl');
vid.src = `${API}/wireframe/${encodeURIComponent(_sceneVideo)}`; vid.src = `${API}/wireframe/${encodeURIComponent(_sceneVideo)}`;
@@ -5503,20 +5557,106 @@
renderSidebarScenery(); renderSidebarScenery();
} }
// Toggle image3 (extra reference) selection from the in-group picker. // ---- scenery reference-slot helpers ----
function sceneToggleExtraRef(name) {
_sceneExtraRef = (_sceneExtraRef === name) ? null : name; // Thumbnail (URL or data-URL) for a slot ref.
renderSidebarScenery(); function _sceneThumb(ref) {
if (!ref) return '';
if (ref.kind === 'bytes') return 'data:image/png;base64,' + ref.data;
const idx = lbNames.indexOf(ref.name);
const u = idx >= 0 ? lbUrls[idx] : (IMAGE_FOLDER + ref.name);
return isVideo(ref.name) ? posterFor(u) : u;
}
function _sceneUrlForName(name) {
const idx = lbNames.indexOf(name);
return idx >= 0 ? lbUrls[idx] : (IMAGE_FOLDER + name);
}
async function _fetchAsBase64(url) {
const r = await fetch(url);
const blob = await r.blob();
return await new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(String(fr.result).split(',')[1]);
fr.onerror = rej;
fr.readAsDataURL(blob);
});
}
// Refresh both the scenery panel and the filmstrip badges.
function _sceneRefresh() { renderSidebarScenery(); updateStudio(); }
// Assign a filmstrip file to the first empty slot scanning right→left ([2,1,0]).
// Shift+Click an already-assigned file removes it (toggle).
function sceneAssignRef(name) {
const at = _sceneRefs.findIndex(r => r && r.kind === 'file' && r.name === name);
if (at >= 0) { _sceneRefs[at] = null; _sceneRefresh(); return; }
for (const i of [2, 1, 0]) {
if (!_sceneRefs[i]) {
_sceneRefs[i] = { kind: 'file', name };
if (i === 0) _sceneFrameBytes = null; // a file now backs slot 0
_sceneRefresh();
return;
}
}
showToast('All 3 reference slots are full — ✕ one first', 'info');
}
function sceneClearSlot(i) {
const ref = _sceneRefs[i];
_sceneRefs[i] = null;
if (i === 0 && ref && ref.kind === 'bytes') _sceneFrameBytes = null;
if (i === 1) _scenePersonInit = true; // don't auto-refill after explicit clear
_sceneRefresh();
}
// ---- drag & drop between slots / from filmstrip ----
function sceneDragSlotStart(ev, i) {
if (!_sceneRefs[i]) { ev.preventDefault(); return; }
_sceneDrag = { type: 'slot', idx: i };
ev.dataTransfer.effectAllowed = 'move';
}
function sceneFilmDragStart(ev, i) {
_sceneDrag = { type: 'file', name: lbNames[i] };
ev.dataTransfer.effectAllowed = 'copy';
}
function sceneDragOver(ev) { ev.preventDefault(); ev.currentTarget.classList.add('drop-hover'); }
function sceneDragLeave(ev) { ev.currentTarget.classList.remove('drop-hover'); }
function sceneDrop(ev, dst) {
ev.preventDefault();
ev.currentTarget.classList.remove('drop-hover');
const drag = _sceneDrag; _sceneDrag = null;
if (!drag) return;
if (drag.type === 'file') {
// Dropping a filmstrip image onto a slot — remove any duplicate first.
const dup = _sceneRefs.findIndex(r => r && r.kind === 'file' && r.name === drag.name);
if (dup >= 0 && dup !== dst) _sceneRefs[dup] = null;
if (dst === 0 && _sceneRefs[0] && _sceneRefs[0].kind === 'bytes') _sceneFrameBytes = null;
_sceneRefs[dst] = { kind: 'file', name: drag.name };
} else {
const src = drag.idx;
if (src === dst) return;
const a = _sceneRefs[src], b = _sceneRefs[dst];
// Captured/uploaded bytes can only live in slot 0 (the background).
if ((a && a.kind === 'bytes' && dst !== 0) || (b && b.kind === 'bytes' && src !== 0)) {
showToast('The captured background can only sit in the Image 1 slot', 'info');
return;
}
_sceneRefs[src] = b; _sceneRefs[dst] = a;
_sceneFrameBytes = (_sceneRefs[0] && _sceneRefs[0].kind === 'bytes') ? _sceneRefs[0].data : null;
}
_sceneRefresh();
} }
async function sceneSelectVideo(v) { async function sceneSelectVideo(v) {
_sceneVideo = v; _sceneFrameBytes = null; _sceneVideo = v; _sceneFrameBytes = null;
if (_sceneRefs[0] && _sceneRefs[0].kind === 'bytes') _sceneRefs[0] = null;
_sceneGridOpen = false; // collapse the picker so the player is front-and-centre _sceneGridOpen = false; // collapse the picker so the player is front-and-centre
renderSidebarScenery(); renderSidebarScenery();
} }
function sceneReleaseFrame() { function sceneReleaseFrame() {
_sceneFrameBytes = null; _sceneFrameBytes = null;
if (_sceneRefs[0] && _sceneRefs[0].kind === 'bytes') _sceneRefs[0] = null;
renderSidebarScenery(); // re-render restores the live video + slider + capture button renderSidebarScenery(); // re-render restores the live video + slider + capture button
} }
@@ -5538,7 +5678,8 @@
} else { } else {
const d = await r.json(); const d = await r.json();
_sceneFrameBytes = d.frame_b64; _sceneFrameBytes = d.frame_b64;
renderSidebarScenery(); // show the frozen frame + "pick a different frame" _sceneRefs[0] = { kind: 'bytes', data: d.frame_b64 }; // background → slot 0
_sceneRefresh(); // show the frozen frame + update slot badge
} }
} catch (e) { } catch (e) {
showToast('Frame extract error: ' + e, 'error'); showToast('Frame extract error: ' + e, 'error');
@@ -5552,6 +5693,7 @@
const reader = new FileReader(); const reader = new FileReader();
reader.onload = e => { reader.onload = e => {
_sceneFrameBytes = e.target.result.split(',')[1]; _sceneFrameBytes = e.target.result.split(',')[1];
_sceneRefs[0] = { kind: 'bytes', data: _sceneFrameBytes }; // background → slot 0
_sceneVideo = null; _sceneVideo = null;
renderSidebarScenery(); renderSidebarScenery();
}; };
@@ -5559,9 +5701,11 @@
} }
async function submitGenerateScenery() { async function submitGenerateScenery() {
if (!_fsModelFilename) return; // Map positional slots → backend contract.
if (!_sceneVideo && !_sceneFrameBytes) { showToast('Pick a scene reference first', 'error'); return; } const bg = _sceneRefs[0], person = _sceneRefs[1], extra = _sceneRefs[2];
const sceneVid = document.getElementById('sceneVideoEl'); if (!bg) { showToast('Set a background (Image 1) — capture, upload, or Shift+Click', 'error'); return; }
if (!person || person.kind !== 'file') { showToast('Set a person image (Image 2) from the filmstrip', 'error'); return; }
if (extra && extra.kind !== 'file') { showToast('Image 3 must be a gallery image', 'error'); return; }
const prompt = (document.getElementById('scenePromptInput')?.value || '').trim() || null; const prompt = (document.getElementById('scenePromptInput')?.value || '').trim() || null;
const btn = document.getElementById('sceneGenBtn'); const btn = document.getElementById('sceneGenBtn');
const statusEl = document.getElementById('sceneJobStatus'); const statusEl = document.getElementById('sceneJobStatus');
@@ -5569,12 +5713,21 @@
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
if (statusEl) statusEl.style.display = 'inline-flex'; if (statusEl) statusEl.style.display = 'inline-flex';
if (textEl) textEl.textContent = 'Submitting…'; if (textEl) textEl.textContent = 'Submitting…';
if (prompt) savePromptHistory(prompt);
// slot0 → scene_bytes: bytes directly, or fetch a file-as-background to bytes.
let sceneBytes;
try {
sceneBytes = (bg.kind === 'bytes') ? bg.data : await _fetchAsBase64(_sceneUrlForName(bg.name));
} catch (e) {
showToast('Could not read background image: ' + e, 'error');
if (btn) btn.disabled = false; if (statusEl) statusEl.style.display = 'none'; return;
}
const payload = { const payload = {
model_filename: _fsModelFilename, // image2 (Picture 2) — person model_filename: person.name, // image2 (Picture 2) — person
scene_bytes: _sceneFrameBytes || null, // image1 (Picture 1) — background scene_bytes: sceneBytes, // image1 (Picture 1) — background
scene_video: _sceneVideo || null, scene_video: null,
scene_time: sceneVid ? sceneVid.currentTime : 0, scene_time: 0,
extra_filename: _sceneExtraRef || null, // image3 (Picture 3) — optional extra ref extra_filename: (extra && extra.kind === 'file') ? extra.name : null, // image3 (Picture 3)
prompt, prompt,
seed: -1, seed: -1,
}; };