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

@@ -54,3 +54,5 @@ generating poses themself should use the adviced dimensions rather than the base
introduce a like/dislike base system for images/group to determine the sort order (and more)
- 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;
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-slot {
flex: 1; min-width: 0; background: #141414; border: 1px solid #262626;
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.drop-hover { border-color: #60a5fa; background: #11203f; }
.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;
}
.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-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 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-checker-hint { font-size: 10px; color: #444; margin-top: 6px; }
@@ -2243,6 +2239,7 @@
let availableVideos = []; // populated from /videos
let _fsActiveTab = 'swap';
let _fsSelectedPoses = new Set();
let _sbPoseFilter = ''; // live pose-name filter query (Generate tab)
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)
@@ -2356,7 +2353,12 @@
function openStudio(gid, startIdx) {
const data = groupData.get(gid);
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;
lbUrls = data.urls;
lbNames = data.names;
@@ -2427,8 +2429,26 @@
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) {
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);
if (pos >= 0) {
_sbRefIndices.splice(pos, 1);
@@ -2439,7 +2459,6 @@
}
updateStudio();
if (_activeSidebarTab === 'generate') renderSidebarGenerate();
event.preventDefault();
} else {
lbNavTo(i);
}
@@ -2545,18 +2564,22 @@
// Film strip — always visible (override old display:none CSS)
const strip = document.getElementById('lbVariantStrip');
strip.style.display = 'flex';
const sceneryActive = _activeSidebarTab === 'scenery';
strip.innerHTML = lbUrls.map((url, i) => {
const n = lbNames[i];
const pose = filePoses[n] || '';
const act = i === lbIdx ? ' active' : '';
const refPos = _sbRefIndices.indexOf(i);
const refCls = refPos >= 0 ? ' ref-selected' : '';
// Badge: Generate uses _sbRefIndices order; Scenery uses positional slots.
const badge = _filmstripRefBadge(i);
const refCls = badge > 0 ? ' ref-selected' : '';
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\'">'
+ (isVideo(n) ? '<div class="lb-var-play">▶</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>';
}).join('');
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">
</div>` : ''}`;
html += '<div class="sb-sep"></div><div class="sb-label">Poses</div><div class="sb-poses-grid" id="sbPosesGrid">';
if (!availablePoses || Object.keys(availablePoses).length === 0) {
html += '<div class="sb-sep"></div><div class="sb-label">Poses</div>';
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>';
} else {
html += Object.entries(availablePoses).map(([name, entry]) => {
@@ -4533,7 +4563,7 @@
if (entry?.beta) cls += ' beta';
const nSafe = name.replace(/'/g, "\\'");
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('');
}
html += `</div>`;
@@ -4605,13 +4635,13 @@
}
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"
<textarea class="sb-input" id="sbGenPromptInput" rows="2" autocomplete="off"
placeholder="Prompt — overrides pose"
oninput="updateSbGenBtn();autoGrowPrompt(this);showPromptSuggest(this)"
onfocus="autoGrowPrompt(this);showPromptSuggest(this)"
onblur="setTimeout(hidePromptSuggest,150)"
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>
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>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
@@ -4626,6 +4656,7 @@
updateSbGenBtn();
const promptEl = document.getElementById('sbGenPromptInput');
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).
@@ -4638,33 +4669,36 @@
// 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');
// Prompt history dropdown — shared by the Generate and Scenery prompt textareas.
// 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;
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);
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; }
box.style.top = (el.offsetTop + el.offsetHeight) + 'px';
const inputId = el.id;
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"
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');
function hidePromptSuggest(boxId = 'sbPromptSuggest') {
const box = document.getElementById(boxId);
if (box) box.style.display = 'none';
}
function pickPromptSuggest(item) {
const el = document.getElementById('sbGenPromptInput');
function pickPromptSuggest(item, inputId = 'sbGenPromptInput', boxId = 'sbPromptSuggest') {
const el = document.getElementById(inputId);
if (!el) return;
el.value = item.dataset.val;
hidePromptSuggest();
updateSbGenBtn();
hidePromptSuggest(boxId);
if (inputId === 'sbGenPromptInput') updateSbGenBtn();
autoGrowPrompt(el);
el.focus();
}
@@ -4753,6 +4787,16 @@
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) {
if (_sbSelectedAngles.has(name)) _sbSelectedAngles.delete(name);
else _sbSelectedAngles.add(name);
@@ -5342,40 +5386,59 @@
let _sceneVideo = null, _sceneDuration = 0, _sceneFrameBytes = null, _sceneJobPollTimer = null;
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() {
const panel = document.getElementById('sbPanelScenery');
if (!panel) return;
const haveRef = _sceneVideo || _sceneFrameBytes;
// Auto-open the grid when nothing is chosen yet.
// Preserve a typed prompt across the frequent re-renders (slot changes).
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;
// Person (image2) = current studio image; extra (image3) = _sceneExtraRef
const personName = _fsModelFilename || lbNames[lbIdx] || '';
const personUrl = lbUrls[lbIdx] || '';
const personThumb = personName ? (isVideo(personName) ? posterFor(personUrl) : personUrl) : '';
const extraIdx = _sceneExtraRef ? lbNames.indexOf(_sceneExtraRef) : -1;
const extraUrl = extraIdx >= 0 ? lbUrls[extraIdx] : '';
const extraThumb = _sceneExtraRef ? (isVideo(_sceneExtraRef) ? posterFor(extraUrl) : extraUrl) : '';
// --- 3-slot reference summary (what feeds the multi-ref compose) ---
const slot = (cls, num, label, thumb) => `
<div class="scene-slot ${thumb?'filled':'empty'}">
// --- Interactive 3-slot reference row (drag to reorder, ✕ to clear) ---
const SLOT_META = [
{ n: 1, label: 'Background' },
{ n: 2, label: 'Person' },
{ n: 3, label: 'Extra' },
];
const slotHtml = SLOT_META.map((m, i) => {
const ref = _sceneRefs[i];
const thumb = _sceneThumb(ref);
const kind = ref ? (ref.kind === 'bytes' ? '📷' : '🖼') : '';
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
? `<img src="${thumb}" onerror="this.style.opacity=.3">`
: `<span class="ph">${num}</span>`}</div>
<div class="scene-slot-cap"><b>Image ${num}</b>${label}</div>
: `<span class="ph">${m.n}</span>`}
${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>`;
let html = `<div class="sb-label">Compose: person + scene → one photo</div>
<div class="scene-slots">
${slot('bg','1','Background', _sceneFrameBytes ? `data:image/png;base64,${_sceneFrameBytes}` : '')}
${slot('person','2','Person', personThumb)}
${slot('extra','3','Extra (opt)', extraThumb)}
</div>`;
}).join('');
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>
<div class="scene-slots">${slotHtml}</div>`;
// --- IMAGE 1: Background scene (video scrub + capture, or upload) ---
html += `<div class="sb-label">Image 1 · Background scene</div>`;
// --- Image 1 source: background scene (video scrub + capture, or upload) ---
html += `<div class="sb-label">Image 1 source · background</div>`;
// --- Selected video: player + slider + capture (always at top) ---
if (_sceneVideo) {
@@ -5420,56 +5483,47 @@
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>`;
// --- 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>
<div class="sb-label">Image 2 · Person <span style="color:#555;font-weight:400;font-size:9px">· current image</span></div>`;
if (personThumb) {
html += `<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
<img src="${personThumb}" style="width:46px;height:46px;object-fit:cover;border-radius:5px;border:1px solid #2563eb"
onerror="this.style.opacity=.3">
<span style="font-size:10px;color:#888;word-break:break-all">${escHtml(personName)}</span>
</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>`;
<div style="font-size:10px;color:#666;line-height:1.5;margin-bottom:8px">
<b style="color:#999">Image 2 (Person)</b> &amp; <b style="color:#999">Image 3 (Extra)</b>:
Shift+Click a thumbnail in the bottom filmstrip to add it (fills the first open
slot right→left). Drag the slots above to rearrange, or ✕ to clear.
</div>`;
if (!person || person.kind !== 'file') {
html += `<div style="font-size:11px;color:#a55;padding:0 0 8px">Image 2 needs a gallery image (Shift+Click the filmstrip).</div>`;
}
// --- Prompt + generate ---
const canGen = !!bgRef && !!person && person.kind === 'file'
&& (!_sceneRefs[2] || _sceneRefs[2].kind === 'file');
html += `<div class="sb-sep"></div>
<div class="sb-label">Prompt (optional)</div>
<input type="text" class="sb-input" id="scenePromptInput"
placeholder="Auto: place person in scene…" style="margin-bottom:10px">
<div class="sb-label">Prompt (optional) <span style="color:#555;font-weight:400;font-size:9px">· history while typing</span></div>
<div class="sb-prompt-wrap" style="position:relative;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">
<span id="sceneJobStatus" style="flex:1;font-size:11px;color:#888;display:none">
<span class="sb-spinner"></span><span id="sceneJobText">Generating…</span>
</span>
<button class="sb-btn primary" id="sceneGenBtn" onclick="submitGenerateScenery()"
${(haveRef && personName)?'':'disabled'}>Create Scenery</button>
${canGen?'':'disabled'}>Create Scenery</button>
</div>`;
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) {
const vid = document.getElementById('sceneVideoEl');
vid.src = `${API}/wireframe/${encodeURIComponent(_sceneVideo)}`;
@@ -5503,20 +5557,106 @@
renderSidebarScenery();
}
// Toggle image3 (extra reference) selection from the in-group picker.
function sceneToggleExtraRef(name) {
_sceneExtraRef = (_sceneExtraRef === name) ? null : name;
renderSidebarScenery();
// ---- scenery reference-slot helpers ----
// Thumbnail (URL or data-URL) for a slot ref.
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) {
_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
renderSidebarScenery();
}
function sceneReleaseFrame() {
_sceneFrameBytes = null;
if (_sceneRefs[0] && _sceneRefs[0].kind === 'bytes') _sceneRefs[0] = null;
renderSidebarScenery(); // re-render restores the live video + slider + capture button
}
@@ -5538,7 +5678,8 @@
} else {
const d = await r.json();
_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) {
showToast('Frame extract error: ' + e, 'error');
@@ -5552,6 +5693,7 @@
const reader = new FileReader();
reader.onload = e => {
_sceneFrameBytes = e.target.result.split(',')[1];
_sceneRefs[0] = { kind: 'bytes', data: _sceneFrameBytes }; // background → slot 0
_sceneVideo = null;
renderSidebarScenery();
};
@@ -5559,9 +5701,11 @@
}
async function submitGenerateScenery() {
if (!_fsModelFilename) return;
if (!_sceneVideo && !_sceneFrameBytes) { showToast('Pick a scene reference first', 'error'); return; }
const sceneVid = document.getElementById('sceneVideoEl');
// Map positional slots → backend contract.
const bg = _sceneRefs[0], person = _sceneRefs[1], extra = _sceneRefs[2];
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 btn = document.getElementById('sceneGenBtn');
const statusEl = document.getElementById('sceneJobStatus');
@@ -5569,12 +5713,21 @@
if (btn) btn.disabled = true;
if (statusEl) statusEl.style.display = 'inline-flex';
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 = {
model_filename: _fsModelFilename, // image2 (Picture 2) — person
scene_bytes: _sceneFrameBytes || null, // image1 (Picture 1) — background
scene_video: _sceneVideo || null,
scene_time: sceneVid ? sceneVid.currentTime : 0,
extra_filename: _sceneExtraRef || null, // image3 (Picture 3) — optional extra ref
model_filename: person.name, // image2 (Picture 2) — person
scene_bytes: sceneBytes, // image1 (Picture 1) — background
scene_video: null,
scene_time: 0,
extra_filename: (extra && extra.kind === 'file') ? extra.name : null, // image3 (Picture 3)
prompt,
seed: -1,
};