diff --git a/backlog.md b/backlog.md index cf780d7..dc54edf 100644 --- a/backlog.md +++ b/backlog.md @@ -22,4 +22,8 @@ delete button gets disabled after a delete extract a frame from a clip (scenery) faceswap defaults nothing enabled, see jobs even when refresh -pose bestaat gewoon uit meerdere delen, camera, scenery + addition \ No newline at end of file +pose bestaat gewoon uit meerdere delen, camera, scenery + addition + +focus op head + sam2.hyria seg + mesh + +add photo to collection (zonder nieuwe groep) diff --git a/tour-comfy/bad-poses.md b/tour-comfy/bad-poses.md index da066ee..898ee6f 100644 --- a/tour-comfy/bad-poses.md +++ b/tour-comfy/bad-poses.md @@ -58,3 +58,20 @@ back flat on the ground, hips centered. head tilted back, throat exposed. Looking upward, not at camera. Vulnerable, displayed. Perfect anatomy, realistic + + +# Single Steel Bar — Behind Knees, Through Elbows, Prone Display +You are in black void, folded over a single solid steel bar. One polished steel rod passing behind your knees and through your bent elbows, holding your legs bent and your arms pinned behind your back simultaneously. +Your body draped face-down over this single bar, chest hanging, posterior elevated. Ankles cuffed together below the bar. +Wrists cuffed together above the bar. One bar, two cuffs. Your body folded into a tight prone package, dorsal plane fully presented. Floating examination light above. +Eyes looking sideways at camera, keeping your facial characteristics as reference photo. One bar, full posterior examination access. Realistic, anatomically precise. + +# Single Steel Pillory — Standing Bent, Neck and Wrists +You are bent forward at the waist in black void, locked into a single solid steel pillory. One heavy polished steel board, floating horizontally. Three holes: your neck locked in the center, your wrists locked to either side. The board holds you at hip height, torso parallel to the void floor. Your ankles cuffed together by a solid steel bar. No stand, no support — the pillory floats and you are fixed within it. Posterior fully exposed behind you. Floating examination light. Eyes looking forward at camera from beneath the board, keeping your facial characteristics as reference photo. One board, total bent immobilization. Realistic, anatomically precise. + +# Single Steel Hook — Suspended by the Waist, Arms and Legs Free +You are suspended in black void from a single solid steel hook. One polished steel curve descending from the darkness above, its blunt tip passing under a steel band cinched around your waist. Your body hangs horizontally, face down, arms and legs dangling free but separated by gravity. Ankles weighted by small steel cuffs, pulling your legs slightly apart. Wrists weighted by matching cuffs, pulling your arms forward. No other support. You hang from one hook, one band. Body suspended in full extension, all surfaces accessible. Floating examination light beneath you. Eyes looking at camera from your suspended position, keeping your facial characteristics as reference photo. One hook, full circumferential examination access. Realistic, anatomically precise. + + +# Single Steel Ring — Wrists and Ankles Behind, Kneeling Open +You are kneeling in black void. A single solid steel ring, polished and seamless, floating behind your back. Your wrists locked into the ring at the top. Your ankles locked into the ring at the bottom, legs spread wide by its diameter. No other material. One ring, four limbs converged behind you. Your body folded forward, chest toward the void floor, posterior elevated and fully exposed. A floating examination light illuminates your open dorsal plane. Full access for examination. Eyes looking back at camera over your shoulder, keeping your facial characteristics as reference photo. One ring, absolute exposure, minimal material. Realistic, anatomically precise. diff --git a/tour-comfy/car.html b/tour-comfy/car.html index 91dcf17..2676000 100644 --- a/tour-comfy/car.html +++ b/tour-comfy/car.html @@ -1675,6 +1675,7 @@ + @@ -1745,6 +1746,16 @@
+ +
+ + +
@@ -1791,7 +1802,9 @@ + Download + @@ -1821,6 +1834,7 @@ + @@ -2116,6 +2130,8 @@ let filePoses = {}; // filename → pose name let filePrompts = {}; // filename → generation prompt let fileHidden = {}; // filename → boolean (hidden from cycling) + let fileArchived = {}; // filename → boolean + let _archiveViewActive = false; // when true, show archived items only let fileHasBg = {}; // filename → boolean (has background, default true) let fileSourceRefs = {}; // filename → array of source filenames let fileHasClothing = {}; // filename → boolean|null (null = unknown) @@ -2134,6 +2150,10 @@ let _sbPoseEdits = {}; // poseName → edited text let _sbGenJobId = null; let _sbGenJobPollTimer= null; + let _followLatestGid = null; // set to gid after generation to auto-jump to newest image + let _slideshowActive = false; + let _slideshowTimer = null; + let _slideshowInterval= 4000; let _fsTrimVideo = null; function escHtml(s) { @@ -2239,6 +2259,36 @@ function closeStudio() { document.getElementById('studio').classList.remove('open'); document.body.style.overflow = ''; + stopSlideshow(); + } + + function toggleSlideshow() { + if (_slideshowActive) stopSlideshow(); + else startSlideshow(); + } + + function startSlideshow() { + if (_slideshowTimer) clearInterval(_slideshowTimer); + _slideshowActive = true; + const btn = document.getElementById('slideshowBtn'); + if (btn) { btn.textContent = '⏸ Pause'; btn.style.color = '#60a5fa'; } + _slideshowTimer = setInterval(() => { + if (lbUrls.length > 1) { lbIdx = (lbIdx + 1) % lbUrls.length; updateStudio(); } + }, _slideshowInterval); + } + + function stopSlideshow() { + _slideshowActive = false; + clearInterval(_slideshowTimer); + _slideshowTimer = null; + const btn = document.getElementById('slideshowBtn'); + if (btn) { btn.textContent = '▶ Slideshow'; btn.style.color = ''; } + } + + function updateSlideshowInterval() { + const sel = document.getElementById('slideshowInterval'); + if (sel) _slideshowInterval = parseInt(sel.value, 10); + if (_slideshowActive) startSlideshow(); // restart with new interval } function lbNav(dir) { @@ -2304,6 +2354,8 @@ if (faceswapBtn)faceswapBtn.style.display = !isVid ? '' : 'none'; const cropBtn = document.getElementById('lbCropBtn'); if (cropBtn) cropBtn.style.display = (!isVid && fileHasBg[fname] === false) ? '' : 'none'; + const archiveBtn = document.getElementById('lbArchiveBtn'); + if (archiveBtn) archiveBtn.textContent = fileArchived[fname] ? 'Restore' : 'Archive'; // Source refs const refs = fileSourceRefs[fname]; @@ -2521,6 +2573,60 @@ } } + async function lbArchive() { + const fname = lbNames[lbIdx]; + if (!fname) return; + const btn = document.getElementById('lbArchiveBtn'); + if (btn) btn.disabled = true; + try { + const endpoint = fileArchived[fname] ? 'unarchive' : 'archive'; + const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/${endpoint}`, { method: 'POST' }); + if (r.ok) { + fileArchived[fname] = !fileArchived[fname]; + const action = fileArchived[fname] ? 'Archived' : 'Restored'; + showToast(`${action}: ${fname}`, 'success'); + // Remove from current filmstrip view and navigate + lbUrls.splice(lbIdx, 1); + lbNames.splice(lbIdx, 1); + if (lbNames.length === 0) { + closeStudio(); + refreshNow(); + } else { + lbIdx = Math.min(lbIdx, lbNames.length - 1); + updateStudio(); + refreshNow(); + } + } else { + showToast('Archive failed', 'error'); + } + } catch (e) { showToast('Archive error: ' + e, 'error'); } + if (btn) btn.disabled = false; + } + + function toggleArchiveView() { + _archiveViewActive = !_archiveViewActive; + const btn = document.getElementById('archiveViewBtn'); + if (btn) { + btn.textContent = _archiveViewActive ? '📦 Back to Gallery' : '📦 Archive'; + btn.style.color = _archiveViewActive ? '#f59e0b' : ''; + } + // Show/hide archive banner + let banner = document.getElementById('archiveBanner'); + if (_archiveViewActive) { + if (!banner) { + banner = document.createElement('div'); + banner.id = 'archiveBanner'; + banner.style.cssText = 'background:#7c3aed22;border:1px solid #7c3aed;border-radius:6px;padding:8px 14px;font-size:12px;color:#a78bfa;margin:8px 0;text-align:center'; + banner.textContent = '📦 Viewing archived items — these are hidden from the main gallery'; + const gallery = document.getElementById('gallery'); + if (gallery) gallery.parentNode.insertBefore(banner, gallery); + } + } else { + if (banner) banner.remove(); + } + refreshNow(); + } + async function lbAutoCrop() { const fname = lbNames[lbIdx]; if (!fname) return; @@ -2539,6 +2645,120 @@ if (btn) { btn.disabled = false; btn.textContent = 'Crop'; } } + function startManualCrop() { + const fname = lbNames[lbIdx]; + if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to crop', 'info'); return; } + const viewer = document.getElementById('studioViewer'); + const img = document.getElementById('lbImg'); + if (!img || img.style.display === 'none') { showToast('No image displayed', 'info'); return; } + if (document.getElementById('cropCanvas')) return; // already active + + const canvas = document.createElement('canvas'); + canvas.id = 'cropCanvas'; + canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;cursor:crosshair;z-index:100;'; + viewer.appendChild(canvas); + + const bar = document.createElement('div'); + bar.id = 'cropBar'; + bar.style.cssText = 'position:absolute;bottom:0;left:0;right:0;display:flex;gap:8px;padding:8px;background:rgba(0,0,0,0.75);z-index:101;justify-content:flex-end;align-items:center;'; + bar.innerHTML = 'Drag to select crop area' + + '' + + ''; + viewer.appendChild(bar); + + const st = window._cropState = { canvas, viewer, img, fname, x1: 0, y1: 0, x2: 0, y2: 0, dragging: false }; + + function resize() { canvas.width = viewer.clientWidth; canvas.height = viewer.clientHeight; draw(); } + function draw() { + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + const w = Math.abs(st.x2 - st.x1), h = Math.abs(st.y2 - st.y1); + if (w < 2 || h < 2) return; + const rx = Math.min(st.x1, st.x2), ry = Math.min(st.y1, st.y2); + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.clearRect(rx, ry, w, h); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1.5; + ctx.strokeRect(rx + 0.5, ry + 0.5, w - 1, h - 1); + } + st.draw = draw; + + canvas.addEventListener('mousedown', e => { + const r = canvas.getBoundingClientRect(); + st.x1 = st.x2 = e.clientX - r.left; + st.y1 = st.y2 = e.clientY - r.top; + st.dragging = true; + const cb = document.getElementById('cropConfirmBtn'); + if (cb) cb.disabled = true; + }); + canvas.addEventListener('mousemove', e => { + if (!st.dragging) return; + const r = canvas.getBoundingClientRect(); + st.x2 = e.clientX - r.left; + st.y2 = e.clientY - r.top; + draw(); + }); + canvas.addEventListener('mouseup', () => { + st.dragging = false; + draw(); + const w = Math.abs(st.x2 - st.x1), h = Math.abs(st.y2 - st.y1); + const cb = document.getElementById('cropConfirmBtn'); + if (cb) cb.disabled = (w < 5 || h < 5); + }); + + resize(); + } + + function cancelManualCrop() { + document.getElementById('cropCanvas')?.remove(); + document.getElementById('cropBar')?.remove(); + window._cropState = null; + } + + async function confirmManualCrop() { + const st = window._cropState; + if (!st) return; + const { canvas, img, fname } = st; + + // Convert canvas rect → image pixel coords (account for object-fit: contain letterbox) + const cW = canvas.width, cH = canvas.height; + const iW = img.naturalWidth, iH = img.naturalHeight; + const scale = Math.min(cW / iW, cH / iH); + const dW = iW * scale, dH = iH * scale; + const offX = (cW - dW) / 2, offY = (cH - dH) / 2; + + const toImg = (cx, cy) => ({ + x: Math.round(Math.max(0, Math.min(iW, (cx - offX) / scale))), + y: Math.round(Math.max(0, Math.min(iH, (cy - offY) / scale))), + }); + const p1 = toImg(Math.min(st.x1, st.x2), Math.min(st.y1, st.y2)); + const p2 = toImg(Math.max(st.x1, st.x2), Math.max(st.y1, st.y2)); + if (p2.x - p1.x < 2 || p2.y - p1.y < 2) { showToast('Crop area too small', 'info'); return; } + + const btn = document.getElementById('cropConfirmBtn'); + if (btn) { btn.disabled = true; btn.textContent = 'Cropping…'; } + try { + const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/crop`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }), + }); + if (r.ok) { + showToast('Cropped', 'success'); + cancelManualCrop(); + lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now(); + updateStudio(); + } else { + showToast('Crop failed: ' + await r.text(), 'error'); + if (btn) { btn.disabled = false; btn.textContent = 'Crop'; } + } + } catch (e) { + showToast('Crop error: ' + e, 'error'); + if (btn) { btn.disabled = false; btn.textContent = 'Crop'; } + } + } + async function lbDuplicate() { const fname = lbNames[lbIdx]; if (!fname) return; @@ -2671,11 +2891,11 @@ } }); - function showToast(message, type = 'info') { + function showToast(message, type = 'info', duration = 3000) { const toast = document.getElementById('toast'); toast.textContent = message; toast.className = `toast ${type} show`; - setTimeout(() => toast.classList.remove('show'), 3000); + setTimeout(() => toast.classList.remove('show'), duration); } function formatTime(date) { @@ -2747,7 +2967,8 @@ // Primary: live list from API (always up to date, works after batch jobs) try { - const r = await fetch(`${API}/images`, { signal: AbortSignal.timeout(12000) }); + const url = _archiveViewActive ? `${API}/images?archived=true` : `${API}/images`; + const r = await fetch(url, { signal: AbortSignal.timeout(12000) }); if (r.ok) { const data = await r.json(); files = data.images.map(img => img.filename); @@ -2760,6 +2981,7 @@ if (img.group_name && img.group_id) groupNames[img.group_id] = img.group_name; fileSortOrders[img.filename] = img.sort_order ?? null; fileHidden[img.filename] = !!img.hidden; + fileArchived[img.filename] = !!img.archived; fileHasBg[img.filename] = img.has_background !== false; fileHasClothing[img.filename] = img.has_clothing ?? null; fileContentType[img.filename] = img.content_type || 'image'; @@ -2840,6 +3062,15 @@ const visibleIdx = names.map((n, i) => i).filter(i => !fileHidden[names[i]]); groupData.set(gid, { urls, names, visibleIdx }); + // Auto-follow: if a generation just finished for this group, jump to the last (newest) image + if (_followLatestGid === gid && _isStudioOpen() && lbCurrentGid === gid && names.length > 0) { + _followLatestGid = null; + lbIdx = names.length - 1; + lbUrls = urls; + lbNames = names; + updateStudio(); + } + const isNew = gf.some(f => newFileNames.has(f.cleanName)); const isRecent = gIdx < 3; const count = gf.length; @@ -3137,7 +3368,23 @@ const n = selectedFiles.size; document.getElementById('batchCount').textContent = `${n} group${n !== 1 ? 's' : ''} selected`; document.getElementById('processBtn').textContent = n > 0 ? `Process (${n})` : 'Process'; - document.getElementById('multiRefBtn').style.display = (n >= 2 && n <= 3) ? '' : 'none'; + const showMR = (n >= 2 && n <= 3); + document.getElementById('multiRefBtn').style.display = showMR ? '' : 'none'; + const legend = document.getElementById('multiRefLegend'); + if (legend) { + if (showMR) { + const names = [...selectedFiles].map((gid, i) => { + const data = groupData.get(gid); + const idx = selectedVariants.get(gid) ?? 0; + const fname = data?.names[idx] ?? gid; + return `ref${i+1}=${fname.split('/').pop().replace(/\?.*$/,'').slice(0,24)}`; + }); + legend.textContent = names.join(' · '); + legend.style.display = ''; + } else { + legend.style.display = 'none'; + } + } } function selectAll() { @@ -3226,6 +3473,12 @@ if (prompt) { prompts.push(prompt); poses.push(null); savePromptHistory(prompt); } poseEntries.forEach(e => { prompts.push(e.text); poses.push(e.name); }); + // Show which filename maps to which ref slot + const shortName = f => f.split('/').pop().replace(/\?.*$/, ''); + let refLabel = `Ref1: ${shortName(filenames[0])} Ref2: ${shortName(filenames[1])}`; + if (filenames[2]) refLabel += ` Ref3: ${shortName(filenames[2])}`; + showToast(refLabel, 'info', 5000); + document.getElementById('batchProgress').textContent = 'Submitting…'; try { const r = await fetch(`${API}/multi-ref`, { @@ -3697,8 +3950,12 @@ const job = await jr.json(); const finished = job.done + job.failed; textEl.textContent = `Generating… ${finished}/${total}`; - // Refresh UI as each task completes - if (job.done > prevDone) { prevDone = job.done; refreshNow(); } + // Refresh UI as each task completes; auto-follow to newest image + if (job.done > prevDone) { + prevDone = job.done; + _followLatestGid = lbCurrentGid; + refreshNow(); + } if (job.status === 'running') { pollGen(); return; } if (job.status === 'cancelled') { statusEl.style.display = 'none'; @@ -3715,6 +3972,7 @@ _fsSelectedPoses.clear(); _sbPoseEdits = {}; _sbGenJobId = null; + _followLatestGid = lbCurrentGid; refreshNow(); renderSidebarGenerate(); } else { @@ -3804,7 +4062,7 @@ const vSafe = v.replace(/'/g, "\\'"); const sel = _fsSelectedVideo === v ? ' selected' : ''; html += `
+ onmouseenter="const v=this.querySelector('video');v&&v.play().catch(()=>{})" onmouseleave="const v=this.querySelector('video');v&&v.pause()">
${escHtml(stem)}
@@ -3825,6 +4083,17 @@ FaceFusion not installed + +