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