diff --git a/backlog.md b/backlog.md index 0fff4a8..098dea7 100644 --- a/backlog.md +++ b/backlog.md @@ -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. introduce a like/dislike base system for images/group to determine the sort order (and more) -- would also dislike the pose \ No newline at end of file +- would also dislike the pose + +creating scenery should keep both video-frame + ref images as references, we only see 1 image now. diff --git a/tour-comfy/car.html b/tour-comfy/car.html index 152c7d8..6634257 100644 --- a/tour-comfy/car.html +++ b/tour-comfy/car.html @@ -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 '
' + // On the Scenery tab the thumbs are draggable onto the slots above. + const dragAttr = sceneryActive ? ' draggable="true" ondragstart="sceneFilmDragStart(event,' + i + ')"' : ''; + return '
' + '' + (isVideo(n) ? '
' : '') + (pose ? '
' + escHtml(pose) + '
' : '') - + (refPos >= 0 ? '
#' + (refPos + 1) + '
' : '') + + (badge > 0 ? '
#' + badge + '
' : '') + '
'; }).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">
` : ''}`; - html += '
Poses
'; - if (!availablePoses || Object.keys(availablePoses).length === 0) { + html += '
Poses
'; + const poseCount = availablePoses ? Object.keys(availablePoses).length : 0; + if (poseCount > 8) { + html += ``; + } + html += '
'; + if (!poseCount) { html += '
No poses loaded
'; } 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 ``; + return ``; }).join(''); } html += `
`; @@ -4605,13 +4635,13 @@ } html += `
Custom prompt
- + style="resize:none;max-height:320px;overflow-y:auto;line-height:1.4;font-family:inherit">${escHtml(prevPromptVal)}
@@ -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. can't bind to a // + +
+ ${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, };