}.
+ // 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) => `
-
+ // --- 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 `
${thumb
? `
`
- : `
${num} `}
-
Image ${num} ${label}
+ : `
${m.n} `}
+ ${ref ? `
✕ ` : ''}
+ ${kind ? `
${kind} ` : ''}
+
Image ${m.n} ${m.label}
`;
- let html = `Compose: person + scene → one photo
-
- ${slot('bg','1','Background', _sceneFrameBytes ? `data:image/png;base64,${_sceneFrameBytes}` : '')}
- ${slot('person','2','Person', personThumb)}
- ${slot('extra','3','Extra (opt)', extraThumb)}
-
`;
+ }).join('');
+ let html = `References · Shift+Click filmstrip (fills →) · drag to reorder
+ ${slotHtml}
`;
- // --- IMAGE 1: Background scene (video scrub + capture, or upload) ---
- html += `Image 1 · Background scene
`;
+ // --- Image 1 source: background scene (video scrub + capture, or upload) ---
+ html += `Image 1 source · background
`;
// --- Selected video: player + slider + capture (always at top) ---
if (_sceneVideo) {
@@ -5420,56 +5483,47 @@
html += `
⬆ Upload image as background `;
- // --- IMAGE 2: Person (auto — current studio image) ---
+ // --- Person / Extra: filled via filmstrip Shift+Click (see slots above) ---
+ const person = _sceneRefs[1];
html += `
- Image 2 · Person · current image
`;
- if (personThumb) {
- html += `
-
-
${escHtml(personName)}
-
`;
- } else {
- html += `No person image — open one from the gallery first.
`;
- }
-
- // --- IMAGE 3: Extra reference (optional, pick from this group) ---
- html += `
- Image 3 · Extra reference · optional · click to toggle
`;
- const pickNames = lbNames.filter(n => n && n !== personName);
- if (pickNames.length) {
- html += '';
- 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 += `
-
- ${sel?'
3
':''}
`;
- });
- html += '
';
- } else {
- html += `No other images in this group.
`;
+
+ Image 2 (Person) & Image 3 (Extra) :
+ 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.
+
`;
+ if (!person || person.kind !== 'file') {
+ html += `Image 2 needs a gallery image (Shift+Click the filmstrip).
`;
}
// --- Prompt + generate ---
+ const canGen = !!bgRef && !!person && person.kind === 'file'
+ && (!_sceneRefs[2] || _sceneRefs[2].kind === 'file');
html += `
- Prompt (optional)
-
+ Prompt (optional) · history while typing
+
Generating…
Create Scenery
+ ${canGen?'':'disabled'}>Create Scenery
`;
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,
};