From d522b2a2676e21d73c959dc61030abfbce3964f6 Mon Sep 17 00:00:00 2001 From: mike Date: Sun, 28 Jun 2026 03:33:02 +0200 Subject: [PATCH] reorder --- backlog.md | 9 +- tour-comfy/car.html | 500 +++++++++++++++++++++++++++++++++++++++-- tour-comfy/database.py | 93 +++++++- tour-comfy/edit_api.py | 328 +++++++++++++++++++++++++++ tour-comfy/poses.md | 12 +- 5 files changed, 914 insertions(+), 28 deletions(-) diff --git a/backlog.md b/backlog.md index 3921cfe..7ac7282 100644 --- a/backlog.md +++ b/backlog.md @@ -63,9 +63,12 @@ Scenery: - The UI wants to car.html:2817 GET file:///mnt/zim/tour-comfy/output/_turntable/up_34e48ffb/turntable.jpg?t=1782562827857 net::ERR_FILE_NOT_FOUND, often want to show the wireframe - pose - ✅ Fixed: statically serve turntable and orbit preview files from filesystem, not via server (avoid GET /output/... 304 Not Modified log spam) - Check face similarity in a group of images. -- implement a tag-system for images, will be used to filter, group, sort and search images. Obvious tags are VISIBLE, ARCHIVED, LIKE, DISLIKE, RATED, GROUP_ANCHOR, GROUP_MEMBER, BACKGROUND etc.. -- mark image as source -- introduce multiple filmstrips. . +- ✅ Fixed: implement a tag-system for images, will be used to filter, group, sort and search images. Obvious tags are VISIBLE, ARCHIVED, LIKE, DISLIKE, RATED, GROUP_ANCHOR, GROUP_MEMBER, BACKGROUND etc.. +- ✅ Fixed: mark image as source (with dedicated 'S' keybind) +- ✅ Fixed: introduce multiple filmstrips (Group, Hidden, Source, Archived with drag-and-drop & bulk selection actions). +- samengestelde outpaint featurue lijkt niet goed te werken, but individual steps work perfect. +- regenerate a single orbit - pose +- when generating a known pose, we should know roughly the image ratio. Make a pose-table, when a pose is generated store the wireframe and meta data ## refine - when refresh page, we lose track of current jobs running. diff --git a/tour-comfy/car.html b/tour-comfy/car.html index 3b99cfb..99b2afd 100644 --- a/tour-comfy/car.html +++ b/tour-comfy/car.html @@ -726,6 +726,29 @@ .lb-var-thumb.active { opacity: 1; outline: 2px solid #60a5fa; border-radius: 4px; } .lb-var-thumb.ref-selected { opacity: 1; outline: 2px solid #f97316; border-radius: 4px; } .lb-var-thumb.active.ref-selected { outline: 2px solid #f97316; } + .lb-var-thumb.selected { + opacity: 1 !important; + outline: 2px solid #3b82f6 !important; + box-shadow: 0 0 8px rgba(59,130,246,0.6); + border-radius: 4px; + } + .lb-var-thumb-check { + position: absolute; + top: 2px; + right: 2px; + background: #3b82f6; + color: white; + border-radius: 50%; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: bold; + z-index: 5; + pointer-events: none; + } .lb-var-ref-badge { position: absolute; top: 2px; left: 2px; background: #f97316; color: #fff; font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 3px; line-height: 1.4; pointer-events: none; z-index: 2; } .lb-var-thumb img { width: 52px; height: 52px; @@ -1364,6 +1387,40 @@ align-items: center; } + .fs-tab { + background: #222; + border: 1px solid #333; + color: #888; + font-size: 11px; + padding: 3px 10px; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; + } + .fs-tab.active { + background: #3b82f6; + border-color: #3b82f6; + color: #fff; + font-weight: bold; + } + .fs-tab:hover:not(.active) { + background: #2d2d2d; + border-color: #444; + color: #fff; + } + .fs-action-btn { + background: #1e3a8a; + border: 1px solid #3b82f6; + color: #fff; + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + cursor: pointer; + } + .fs-action-btn:hover { + background: #3b82f6; + } + /* ===================== SIDEBAR ===================== */ .studio-sidebar { flex-shrink: 0; width: 320px; min-width: 320px; @@ -1989,6 +2046,25 @@ + +
+
+ + + + +
+
+ + +
+
@@ -2042,7 +2118,9 @@ + +
Actions
@@ -2227,6 +2305,24 @@ // --- HYDRATION_START --- const PRELOADED_IMAGES = [ + "20260618_053802_image.png_face.png", + "20260628_032232_dup_20260618_053932_7_20260618_053802_image.nobg.nobg.nobg.png", + "20260628_032232_dup_20260618_053932_7_20260618_053802_image.nobg.nobg.png", + "pass-1.png_face.png", + "20260628_031641_dup_20260628_031119_pad_pass-1.nobg.png", + "20260628_031119_pad_pass-1.nobg.png", + "20260628_031316_pad_pass-1.png", + "20260628_031119_pad_pass-1.png", + "pa01.png_face.png", + "20260628_030838_pa01.png", + "20260628_030808_pa01.png", + "20260628_030624_pa01.png", + "20260628_030532_pa01.png", + "20260628_030241_pa01.png", + "20260628_030115_pa01.png", + "kk563t.png_face.png", + "20260628_025504_pad_pass-1.png", + "up_e25ad102_face.png", "20260628_020853_pad_20260628_015651_img_12.nobg.png", "20260628_020642_pad_20260628_015651_img_12.nobg.png", "20260628_020610_pad_20260628_015651_img_12.nobg.png", @@ -4605,7 +4701,6 @@ "_turntable/b1.png/views/view_021_315deg.png", "_turntable/b1.png/views/view_020_300deg.png", "_turntable/b1.png/views/view_019_285deg.png", - "_turntable/up_e25ad102/turntable.mp4", "_turntable/up_e25ad102/views/view_011_330deg.png", "_turntable/up_e25ad102/views/view_010_300deg.png", "_turntable/up_e25ad102/views/view_009_270deg.png", @@ -4640,11 +4735,10 @@ "20260626_053719_pad_20260619_224737_img_14.nobg.nobg.png", "20260626_053754_pad_20260619_224737_img_14.png", "20260626_053719_pad_20260619_224737_img_14.png", - "20260618_053802_image.png_face.png", "20260626_043734_7_20260618_053802_image.nobg.nobg.png", "20260626_043915_crop_20260626_043734_7_20260618_053802_image.nobg.nobg.png", "20260626_043734_7_20260618_053802_image.nobg.png", - "20260626_043529_pad_20260618_053932_7_20260618_053802_image.nobg.png", + "20260628_032038_dup_20260618_053932_7_20260618_053802_image.nobg.png", "20260618_053932_7_20260618_053802_image.nobg.png", "20260626_041550_sc_pad_20260626_034830_jb.nobg.png", "20260626_041539_sc_pad_20260626_034830_jb.nobg.png", @@ -4776,8 +4870,8 @@ "20260625_025228_dup_20260625_024438_dup_20260624_165109_0_20260622_091731_image.nobg.png", "20260625_025156_dup_20260624_165109_0_20260622_091731_image.nobg.png", "20260625_025115_dup_20260624_165109_0_20260622_091731_image.nobg.png", - "20260625_024438_dup_20260624_165109_0_20260622_091731_image.nobg.png", "20260625_025335_dup_20260625_024438_dup_20260624_165109_0_20260622_091731_image.nobg.png", + "20260625_024438_dup_20260624_165109_0_20260622_091731_image.nobg.png", "20260625_023536_dup_20260624_165109_0_20260622_091731_image.nobg.png", "20260624_165109_0_20260622_091731_image.nobg.png", "20260625_023536_dup_20260624_165109_0_20260622_091731_image.png", @@ -4791,10 +4885,9 @@ "20260625_021910_image.png", "20260625_021702_sc_image.png", "20260625_021615_sc_image.png", - "20260625_020212_dup_20260625_015711_sc_image.png", "20260625_015711_sc_image.png", + "20260625_020212_dup_20260625_015711_sc_image.png", "20260625_015711_sc_image.nobg.png", - "up_e25ad102_face.png", "20260625_015900_sc_image.png", "20260625_015413_sc_image.png", "20260625_015213_image.png", @@ -4980,8 +5073,8 @@ "20260624_183010_0_20260624_182958_image.png", "20260624_182958_image.png", "20260624_174900_image.png", - "20260624_174943_dup_20260624_173115_image.png", "20260624_175711_dup_20260624_174943_dup_20260624_173115_image.png", + "20260624_174943_dup_20260624_173115_image.png", "20260624_173115_image.png", "20260624_174757_image.png", "20260624_174442_image.png", @@ -5007,8 +5100,8 @@ "20260624_164240_crop_20260624_162342_1_20260622_093058_image.png", "20260624_164218_crop_20260624_162601_mr_0_20260622_093058_image.nobg.png", "20260624_164059_dup_20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png", - "20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png", "20260624_164116_dup_20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png", + "20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png", "20260624_163634_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png", "20260624_162510_image.png", "20260624_162408_image.png", @@ -6459,7 +6552,6 @@ "20260618_171628_image.png", "20260618_171624_0_20260618_171448_image.png", "20260618_171448_image.png", - "20260618_134543_8_20260618_134348_image.png", "20260618_134532_7_20260618_134348_image.png", "20260618_134520_6_20260618_134348_image.png", "20260618_134508_5_20260618_134348_image.png", @@ -6560,7 +6652,6 @@ "20260618_054252_1_20260618_054230_image.png", "20260618_054241_0_20260618_054230_image.png", "20260618_054230_image.png", - "20260618_053932_7_20260618_053802_image.png", "20260618_053920_6_20260618_053802_image.png", "20260618_053909_5_20260618_053802_image.png", "20260618_053857_4_20260618_053802_image.png", @@ -6710,7 +6801,10 @@ // Supported extensions const EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.mp4', '.mov', '.webm']; const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.webm', '.avi', '.mkv']); - function isVideo(name) { return VIDEO_EXTENSIONS.has((name.split('.').pop() ? '.'+name.split('.').pop().toLowerCase() : '')); } + function isVideo(name) { + if (!name || typeof name !== 'string') return false; + return VIDEO_EXTENSIONS.has((name.split('.').pop() ? '.'+name.split('.').pop().toLowerCase() : '')); + } // Videos can't render as an thumbnail over file://, so the backend // writes a sibling ".jpg" poster. Map a video URL → its poster URL // (preserving the ?t= cache-buster). Non-video URLs pass through unchanged. @@ -6720,6 +6814,16 @@ const path = q === -1 ? url : url.slice(0, q); const qs = q === -1 ? '' : url.slice(q); if (!/\.(mp4|mov|webm|avi|mkv)$/i.test(path)) return url; + + // For turntable/orbit videos, use the first view frame as the poster/thumbnail instead of a non-existent .jpg + if (path.includes('_turntable/')) { + const lastSlash = path.lastIndexOf('/'); + if (lastSlash !== -1) { + const dir = path.substring(0, lastSlash); + return dir + '/views/view_000_000deg.png' + qs; + } + } + return path.replace(/\.[^.]+$/, '.jpg') + qs; } @@ -6747,6 +6851,8 @@ let filePrompts = {}; // filename → generation prompt let fileHidden = {}; // filename → boolean (hidden from cycling) let fileArchived = {}; // filename → boolean + let fileTags = {}; // filename → array of strings + let fileIsSource = {}; // 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 @@ -6873,6 +6979,242 @@ // --- studio view (replaces lightbox) --- let lbUrls = [], lbNames = [], lbIdx = 0; + let _activeFilmstripTab = 'group'; // 'group', 'hidden', 'source', 'archived' + let _multiSelectModeActive = false; + const _selectedFilenames = new Set(); + + function switchFilmstripTab(tab) { + _activeFilmstripTab = tab; + + // Highlight tab + ['Group', 'Hidden', 'Source', 'Archived'].forEach(t => { + const el = document.getElementById(`fsTab${t}`); + if (el) el.classList.toggle('active', t.toLowerCase() === tab); + }); + + // Reset selection when switching tabs + _selectedFilenames.clear(); + updateMultiSelectUI(); + + const currentImg = lbNames[lbIdx]; + + if (tab === 'group') { + const data = groupData.get(lbCurrentGid); + if (data) { + lbUrls = data.urls; + lbNames = data.names; + } + } else { + const matched = []; + knownFiles.forEach(fname => { + const isArchived = !!fileArchived[fname]; + const isHidden = !!fileHidden[fname]; + const isSource = !!fileIsSource[fname]; + + if (tab === 'hidden' && isHidden && !isArchived) { + matched.push(fname); + } else if (tab === 'source' && isSource && !isArchived) { + matched.push(fname); + } else if (tab === 'archived' && isArchived) { + matched.push(fname); + } + }); + + // Sort matching files alphabetically (descending time-like sort) + matched.sort((a, b) => b.localeCompare(a)); + + lbNames = matched; + lbUrls = matched.map(n => IMAGE_FOLDER + n); + } + + let newIdx = lbNames.indexOf(currentImg); + if (newIdx === -1) newIdx = 0; + + lbIdx = newIdx; + updateStudio(); + } + + function toggleMultiSelectMode() { + _multiSelectModeActive = !_multiSelectModeActive; + const btn = document.getElementById('multiSelectToggleBtn'); + if (btn) { + btn.textContent = _multiSelectModeActive ? 'Cancel Multi' : 'Multi-select'; + btn.style.background = _multiSelectModeActive ? '#1e3a8a' : '#222'; + } + _selectedFilenames.clear(); + updateMultiSelectUI(); + updateStudio(); // Re-render thumbnails to show checkmarks + } + + function updateMultiSelectUI() { + const actionsEl = document.getElementById('multiSelectActions'); + const countEl = document.getElementById('multiSelectCount'); + if (actionsEl && countEl) { + if (_multiSelectModeActive && _selectedFilenames.size > 0) { + actionsEl.style.display = 'flex'; + countEl.textContent = `${_selectedFilenames.size} selected`; + } else { + actionsEl.style.display = 'none'; + } + } + } + + async function bulkMoveSelected(targetStrip) { + if (_selectedFilenames.size === 0) return; + const list = Array.from(_selectedFilenames); + try { + const r = await fetch(`${API}/images/bulk-move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filenames: list, target_strip: targetStrip }) + }); + if (r.ok) { + showToast(`Moved ${list.length} images to ${targetStrip}`); + + // Update local cache immediately + list.forEach(fname => { + if (targetStrip === 'VISIBLE') { + fileArchived[fname] = false; + fileHidden[fname] = false; + if (fileTags[fname]) { + fileTags[fname] = fileTags[fname].filter(t => t !== 'ARCHIVED' && t !== 'HIDDEN'); + if (!fileTags[fname].includes('VISIBLE')) fileTags[fname].push('VISIBLE'); + } + } else if (targetStrip === 'HIDDEN') { + fileArchived[fname] = false; + fileHidden[fname] = true; + if (fileTags[fname]) { + fileTags[fname] = fileTags[fname].filter(t => t !== 'ARCHIVED' && t !== 'VISIBLE'); + if (!fileTags[fname].includes('HIDDEN')) fileTags[fname].push('HIDDEN'); + } + } else if (targetStrip === 'ARCHIVED') { + fileArchived[fname] = true; + fileHidden[fname] = false; + if (fileTags[fname]) { + fileTags[fname] = fileTags[fname].filter(t => t !== 'HIDDEN' && t !== 'VISIBLE'); + if (!fileTags[fname].includes('ARCHIVED')) fileTags[fname].push('ARCHIVED'); + } + } else if (targetStrip === 'SOURCE') { + fileIsSource[fname] = true; + if (fileTags[fname] && !fileTags[fname].includes('SOURCE')) { + fileTags[fname].push('SOURCE'); + } + } + }); + + toggleMultiSelectMode(); + switchFilmstripTab(_activeFilmstripTab); + refreshNow(); + } else { + const err = await r.text(); + showToast(`Failed to move: ${err}`, 'error'); + } + } catch (e) { + showToast(`Network error: ${e}`, 'error'); + } + } + + function filmstripThumbDragStart(event, fname) { + event.dataTransfer.setData('text/plain', fname); + event.dataTransfer.effectAllowed = 'move'; + } + + async function filmstripTabDrop(event, targetStrip) { + event.preventDefault(); + const fname = event.dataTransfer.getData('text/plain'); + if (!fname) return; + + try { + const r = await fetch(`${API}/images/bulk-move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filenames: [fname], target_strip: targetStrip }) + }); + if (r.ok) { + showToast(`Moved ${fname} to ${targetStrip}`); + + if (targetStrip === 'VISIBLE') { + fileArchived[fname] = false; + fileHidden[fname] = false; + if (fileTags[fname]) { + fileTags[fname] = fileTags[fname].filter(t => t !== 'ARCHIVED' && t !== 'HIDDEN'); + if (!fileTags[fname].includes('VISIBLE')) fileTags[fname].push('VISIBLE'); + } + } else if (targetStrip === 'HIDDEN') { + fileArchived[fname] = false; + fileHidden[fname] = true; + if (fileTags[fname]) { + fileTags[fname] = fileTags[fname].filter(t => t !== 'ARCHIVED' && t !== 'VISIBLE'); + if (!fileTags[fname].includes('HIDDEN')) fileTags[fname].push('HIDDEN'); + } + } else if (targetStrip === 'ARCHIVED') { + fileArchived[fname] = true; + fileHidden[fname] = false; + if (fileTags[fname]) { + fileTags[fname] = fileTags[fname].filter(t => t !== 'HIDDEN' && t !== 'VISIBLE'); + if (!fileTags[fname].includes('ARCHIVED')) fileTags[fname].push('ARCHIVED'); + } + } else if (targetStrip === 'SOURCE') { + fileIsSource[fname] = true; + if (fileTags[fname] && !fileTags[fname].includes('SOURCE')) { + fileTags[fname].push('SOURCE'); + } + } + + switchFilmstripTab(_activeFilmstripTab); + refreshNow(); + } + } catch (e) { + showToast(`Drag-drop move failed: ${e}`, 'error'); + } + } + + async function toggleSourceKeybind() { + const fname = lbNames[lbIdx]; + if (!fname) return; + const newSource = !fileIsSource[fname]; + try { + const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/source`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: newSource }) + }); + if (r.ok) { + fileIsSource[fname] = newSource; + if (newSource) { + if (fileTags[fname] && !fileTags[fname].includes('SOURCE')) fileTags[fname].push('SOURCE'); + } else { + if (fileTags[fname]) fileTags[fname] = fileTags[fname].filter(t => t !== 'SOURCE'); + } + showToast(newSource ? 'Marked as SOURCE' : 'Unmarked as SOURCE'); + updateStudio(); + refreshNow(); + } + } catch (err) { + showToast(`Failed to toggle source: ${err}`, 'error'); + } + } + + async function estimate21PlusKeybind() { + const fname = lbNames[lbIdx]; + if (!fname) return; + showToast('Estimating 21+ tag...', 'info'); + try { + const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/estimate-21plus`, { + method: 'POST' + }); + if (r.ok) { + const data = await r.json(); + const is21 = !!data['21+']; + fileTags[fname] = data.tags; + showToast(is21 ? 'Tagged as 21+' : 'Not tagged as 21+'); + updateStudio(); + refreshNow(); + } + } catch (err) { + showToast(`Failed to estimate: ${err}`, 'error'); + } + } let _activeSidebarTab = localStorage.getItem('studioSidebarTab') || 'info'; // Maps fname → nobg sidecar URL; viewer shows sidecar while this entry exists const _nobgSidecars = new Map(); @@ -6896,6 +7238,16 @@ lbCurrentGid = gid; _lastSelectedGid = gid; localStorage.setItem('lastSelectedGid', gid); + + _activeFilmstripTab = 'group'; + ['Group', 'Hidden', 'Source', 'Archived'].forEach(t => { + const el = document.getElementById(`fsTab${t}`); + if (el) el.classList.toggle('active', t.toLowerCase() === 'group'); + }); + _multiSelectModeActive = false; + _selectedFilenames.clear(); + updateMultiSelectUI(); + lbUrls = data.urls; lbNames = data.names; lbIdx = startIdx !== undefined ? startIdx : (cycleIdx.get(gid) || 0); @@ -7297,6 +7649,18 @@ } function sbFilmstripClick(event, i) { + const fname = lbNames[i]; + if (_multiSelectModeActive) { + event.preventDefault(); + if (_selectedFilenames.has(fname)) { + _selectedFilenames.delete(fname); + } else { + _selectedFilenames.add(fname); + } + updateMultiSelectUI(); + updateStudio(); + return; + } if (event.shiftKey) { event.preventDefault(); // Scenery tab uses positional slots (fills right→left); Generate uses _sbRefIndices. @@ -7327,6 +7691,74 @@ const lbImgEl = document.getElementById('lbImg'); const lbVideoEl = document.getElementById('lbVideo'); + if (!fname) { + lbImgEl.style.display = 'none'; + lbImgEl.src = ''; + lbVideoEl.style.display = 'none'; + lbVideoEl.src = ''; + const dimEl = document.getElementById('lbImgDims'); + if (dimEl) dimEl.textContent = ''; + + document.getElementById('lbCounter').textContent = ''; + const prevBtn = document.getElementById('lbPrev'); + const nextBtn = document.getElementById('lbNext'); + if (prevBtn) prevBtn.style.visibility = 'hidden'; + if (nextBtn) nextBtn.style.visibility = 'hidden'; + + const extractBtn = document.getElementById('lbExtract'); + if (extractBtn) extractBtn.style.display = 'none'; + const moveUp = document.getElementById('lbMoveUp'); + const moveDown = document.getElementById('lbMoveDown'); + if (moveUp) moveUp.style.display = 'none'; + if (moveDown) moveDown.style.display = 'none'; + + const prefBtn = document.getElementById('lbPreferredBtn'); + if (prefBtn) prefBtn.style.display = 'none'; + + const groupNameInput = document.getElementById('lbGroupNameInput'); + if (groupNameInput) groupNameInput.value = ''; + + const nameInput = document.getElementById('lbNameInput'); + if (nameInput) { nameInput.value = ''; nameInput.placeholder = ''; } + + const clipEl = document.getElementById('lbClipDesc'); + if (clipEl) clipEl.textContent = 'No images in this tab.'; + + const genPromptWrap = document.getElementById('lbGenPromptWrap'); + if (genPromptWrap) genPromptWrap.style.display = 'none'; + + const poseTag = document.getElementById('lbPoseTag'); + if (poseTag) poseTag.style.display = 'none'; + + const noBgBtn = document.getElementById('lbNoBgBtn'); + const undressBtn = document.getElementById('lbUndressBtn'); + const faceswapBtn = document.getElementById('lbFaceswapBtn'); + if (noBgBtn) noBgBtn.style.display = 'none'; + if (undressBtn) undressBtn.style.display = 'none'; + if (faceswapBtn)faceswapBtn.style.display = 'none'; + const cropBtn = document.getElementById('lbCropBtn'); + if (cropBtn) cropBtn.style.display = 'none'; + const invAlphaBtn = document.getElementById('lbInvertAlphaBtn'); + if (invAlphaBtn) invAlphaBtn.style.display = 'none'; + const poseBtn = document.getElementById('lbPoseBtn'); + if (poseBtn) poseBtn.style.display = 'none'; + + const refsDiv = document.getElementById('lbSourceRefs'); + if (refsDiv) refsDiv.style.display = 'none'; + + const strip = document.getElementById('lbVariantStrip'); + if (strip) strip.innerHTML = '
No images in this strip
'; + + updateSbAngleBar(); + + const faceBook = document.getElementById('lbFaceBook'); + if (faceBook) faceBook.style.display = 'none'; + + updatePadPreview(); + toggleCheckerboard(false); + return; + } + lbImgEl.onload = () => { const dimEl = document.getElementById('lbImgDims'); if (dimEl) dimEl.textContent = `${lbImgEl.naturalWidth} x ${lbImgEl.naturalHeight}`; @@ -7402,6 +7834,21 @@ const eyeBtn = document.getElementById('lbEyeBtn'); if (eyeBtn) { eyeBtn.textContent = hidden ? '🚫 Hidden' : '👁 Visibility'; eyeBtn.title = hidden ? 'Show in cycling' : 'Hide from cycling'; } + const sourceBtn = document.getElementById('lbSourceBtn'); + if (sourceBtn) { + const isSrc = !!fileIsSource[fname]; + sourceBtn.style.color = isSrc ? '#f59e0b' : ''; + sourceBtn.style.borderColor = isSrc ? '#f59e0b' : ''; + sourceBtn.textContent = isSrc ? '★ Source (Yes)' : '★ Source'; + } + const estimateBtn = document.getElementById('lbEstimate21Btn'); + if (estimateBtn) { + const is21 = fileTags[fname] && fileTags[fname].includes('21+'); + estimateBtn.style.color = is21 ? '#ef4444' : ''; + estimateBtn.style.borderColor = is21 ? '#ef4444' : ''; + estimateBtn.textContent = is21 ? '🔞 21+ (Yes)' : '🔞 21+'; + } + const noBgBtn = document.getElementById('lbNoBgBtn'); const undressBtn = document.getElementById('lbUndressBtn'); const faceswapBtn = document.getElementById('lbFaceswapBtn'); @@ -7460,17 +7907,23 @@ const n = lbNames[i]; const pose = filePoses[n] || ''; const act = i === lbIdx ? ' active' : ''; + const isSel = _selectedFilenames.has(n); + const selCls = isSel ? ' 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; - // On the Scenery tab the thumbs are draggable onto the slots above. - const dragAttr = sceneryActive ? ' draggable="true" ondragstart="sceneFilmDragStart(event,' + i + ')"' : ''; - return '
' + // On the Scenery tab the thumbs are draggable onto the slots above. Otherwise draggable for strip drop targets + const dragAttr = sceneryActive + ? ' draggable="true" ondragstart="sceneFilmDragStart(event,' + i + ')"' + : ' draggable="true" ondragstart="filmstripThumbDragStart(event,\'' + n.replace(/'/g, "\\'") + '\')"'; + const titleStr = _multiSelectModeActive ? 'Click to select' : 'Click to view · Shift+Click to add as ref'; + return '
' + '' + (isVideo(n) ? '
' : '') + (pose ? '
' + escHtml(pose) + '
' : '') + (badge > 0 ? '
#' + badge + '
' : '') + + (isSel ? '
' : '') + '
'; }).join(''); const active = strip.querySelector('.lb-var-thumb.active'); @@ -9391,6 +9844,8 @@ fileSortOrders[img.filename] = img.sort_order ?? null; fileHidden[img.filename] = !!img.hidden; fileArchived[img.filename] = !!img.archived; + fileTags[img.filename] = img.tags || []; + fileIsSource[img.filename] = !!img.is_source; fileHasBg[img.filename] = img.has_background !== false; fileHasClothing[img.filename] = img.has_clothing ?? null; fileContentType[img.filename] = img.content_type || 'image'; @@ -9419,9 +9874,9 @@ if (r.ok) { const data = await r.json(); let imgs = data.images || []; + _hydrateImageMeta(imgs); // Hydrate everything first! imgs = _archiveViewActive ? imgs.filter(i => i.archived) : imgs.filter(i => !i.archived); files = imgs.map(img => img.filename); - _hydrateImageMeta(imgs); _staticOk = true; } } catch (e) { /* fall through */ } @@ -9429,12 +9884,13 @@ // Fallback: live API (handles first startup before static file is written) if (!files) { try { - const url = _archiveViewActive ? `${API}/images?archived=true` : `${API}/images`; + const url = `${API}/images?archived=true`; const r = await fetch(url, { signal: AbortSignal.timeout(12000) }); if (r.ok) { const data = await r.json(); - files = data.images.map(img => img.filename); - _hydrateImageMeta(data.images); + _hydrateImageMeta(data.images); // Hydrate everything first! + let imgs = data.images.filter(i => _archiveViewActive ? i.archived : !i.archived); + files = imgs.map(img => img.filename); } } catch (e) { /* API not reachable, fall through */ } } @@ -10200,6 +10656,14 @@ lbSetPreferred(); e.preventDefault(); } + if ((e.key === 's' || e.key === 'S') && tag !== 'INPUT' && tag !== 'TEXTAREA') { + toggleSourceKeybind(); + e.preventDefault(); + } + if ((e.key === 'e' || e.key === 'E') && tag !== 'INPUT' && tag !== 'TEXTAREA') { + estimate21PlusKeybind(); + e.preventDefault(); + } if (e.key === 'Delete') { lbArchive(); e.preventDefault(); diff --git a/tour-comfy/database.py b/tour-comfy/database.py index e3765d8..4eec61d 100644 --- a/tour-comfy/database.py +++ b/tour-comfy/database.py @@ -119,6 +119,87 @@ def migrate_schema(): finally: cur.close() _put_db_connection(conn) + try: + initialize_tags_in_db() + except Exception as e: + print(f"[db] initialize_tags_in_db error: {e}") + +def initialize_tags_in_db(): + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute(""" + SELECT filename, archived, hidden, is_source, sort_order, has_background, tags + FROM person + """) + rows = cur.fetchall() + for filename, archived, hidden, is_source, sort_order, has_background, tags_val in rows: + tags_list = [] + if tags_val: + if isinstance(tags_val, str): + try: + tags_list = json.loads(tags_val) + except Exception: + tags_list = [] + elif isinstance(tags_val, list): + tags_list = tags_val + + is_archived = bool(archived) if archived is not None else False + is_hidden = bool(hidden) if hidden is not None else False + is_src = bool(is_source) if is_source is not None else False + has_bg = bool(has_background) if has_background is not None else True + + def add_tag_if_missing(tag): + if tag not in tags_list: + tags_list.append(tag) + + if is_archived: + add_tag_if_missing("ARCHIVED") + if "VISIBLE" in tags_list: + tags_list.remove("VISIBLE") + else: + if not is_hidden: + add_tag_if_missing("VISIBLE") + if "ARCHIVED" in tags_list: + tags_list.remove("ARCHIVED") + + if is_hidden: + add_tag_if_missing("HIDDEN") + if "VISIBLE" in tags_list: + tags_list.remove("VISIBLE") + else: + if "HIDDEN" in tags_list: + tags_list.remove("HIDDEN") + + if is_src: + add_tag_if_missing("SOURCE") + else: + if "SOURCE" in tags_list: + tags_list.remove("SOURCE") + + if sort_order == 0: + add_tag_if_missing("GROUP_ANCHOR") + if "GROUP_MEMBER" in tags_list: + tags_list.remove("GROUP_MEMBER") + else: + add_tag_if_missing("GROUP_MEMBER") + if "GROUP_ANCHOR" in tags_list: + tags_list.remove("GROUP_ANCHOR") + + if not has_bg: + add_tag_if_missing("BACKGROUND_REMOVED") + if "BACKGROUND" in tags_list: + tags_list.remove("BACKGROUND") + else: + add_tag_if_missing("BACKGROUND") + if "BACKGROUND_REMOVED" in tags_list: + tags_list.remove("BACKGROUND_REMOVED") + + cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags_list), filename)) + conn.commit() + finally: + cur.close() + _put_db_connection(conn) def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None, embedding=None, clip_description=None, prompt=None, pose=None, @@ -201,6 +282,16 @@ def set_hidden(filename, hidden: bool): cur.close() _put_db_connection(conn) +def set_person_tags(filename, tags): + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags) if tags is not None else None, filename)) + conn.commit() + finally: + cur.close() + _put_db_connection(conn) + def get_person(filename): conn = get_db_connection() cur = conn.cursor() @@ -224,7 +315,7 @@ def list_persons(include_archived=False): cur.execute(f""" SELECT filename, name, group_id, clip_description, prompt, pose, sort_order, group_name, hidden, has_background, source_refs, - has_clothing, content_type, faceswap_source_video, archived, is_source + has_clothing, content_type, faceswap_source_video, archived, is_source, tags FROM person {where} """) diff --git a/tour-comfy/edit_api.py b/tour-comfy/edit_api.py index d03afaa..72059b7 100644 --- a/tour-comfy/edit_api.py +++ b/tour-comfy/edit_api.py @@ -1300,6 +1300,17 @@ def _write_all_static() -> None: if is_archived: archived_count += 1 + tags_val = p[16] + tags_list = [] + if tags_val: + if isinstance(tags_val, str): + try: + tags_list = json.loads(tags_val) + except Exception: + tags_list = [] + elif isinstance(tags_val, list): + tags_list = tags_val + db_images.append({ "filename": p[0], "name": p[1], @@ -1317,6 +1328,7 @@ def _write_all_static() -> None: "faceswap_source_video": p[13], "archived": is_archived, "is_source": bool(p[15]) if p[15] else False, + "tags": tags_list, }) print(f"[static] write_all: {len(db_images)} total images, {archived_count} archived") try: @@ -2132,6 +2144,11 @@ def generate_video(req: VideoStitchRequest): if r.returncode != 0: raise HTTPException(500, f"ffmpeg error: {r.stderr.decode(errors='replace')[:600]}") + try: + _make_video_poster(out_path) + except Exception as pe: + print(f"[generate-video] poster extraction error: {pe}") + # Register in DB so it shows up in the gallery person = database.get_person(req.filenames[0]) group_id = person[1] if person and person[1] else naming.get_base_name(req.filenames[0]) @@ -2774,6 +2791,317 @@ def extract_face_endpoint(filename: str): return {"status": "queued", "filename": filename} +class ImageTagsRequest(BaseModel): + tags: list[str] + +class TagActionRequest(BaseModel): + action: str # "add" or "remove" or "toggle" + tag: str + +class BulkMoveRequest(BaseModel): + filenames: list[str] + target_strip: str + +def estimate_nsfw_21plus(filename: str) -> bool: + """Run WD tagger and return True if image is 21+ (NSFW/sensitive/explicit/nudity).""" + try: + output_dir = _load_output_dir() + fpath = os.path.join(output_dir, filename) + if not os.path.exists(fpath): + return False + + pil_img = Image.open(fpath) + # Run tagger with 0.2 threshold to capture sensitive/explicit content + tags = _run_tagger(pil_img, threshold=0.2) + + for t in tags: + tag_name = t["tag"].lower().replace("_", " ") + # Check rating category + if t["cat"] == 9 and tag_name in ("explicit", "questionable", "sensitive"): + if t["score"] >= 0.25: + return True + # Check other explicit keywords in name + if any(nsfw_word in tag_name for nsfw_word in ("nude", "naked", "breasts", "pussy", "nipples", "ass", "panties", "underwear", "lingerie", "sex", "erotic")): + if t["score"] >= 0.3: + return True + return False + except Exception as e: + print(f"[nsfw-estimator] Error estimating for {filename}: {e}") + return False + +@app.post("/images/{filename:path}/tags") +def set_image_tags(filename: str, req: ImageTagsRequest): + person = database.get_person(filename) + if not person: + raise HTTPException(404, "Image not found") + + tags_list = list(set([str(t).upper().strip() for t in req.tags if t])) + + archived = "ARCHIVED" in tags_list + hidden = "HIDDEN" in tags_list + is_source = "SOURCE" in tags_list + + if archived: + if "VISIBLE" in tags_list: + tags_list.remove("VISIBLE") + else: + if not hidden: + if "VISIBLE" not in tags_list: + tags_list.append("VISIBLE") + + if hidden: + if "VISIBLE" in tags_list: + tags_list.remove("VISIBLE") + else: + if not archived: + if "VISIBLE" not in tags_list: + tags_list.append("VISIBLE") + + conn = database.get_db_connection() + cur = conn.cursor() + try: + cur.execute(""" + UPDATE person + SET tags = %s, + archived = %s, + hidden = %s, + is_source = %s + WHERE filename = %s + """, (json.dumps(tags_list), archived, hidden, is_source, filename)) + conn.commit() + finally: + cur.close() + database._put_db_connection(conn) + + _invalidate_static() + return {"filename": filename, "tags": tags_list} + +@app.post("/images/{filename:path}/tag-action") +def tag_action_endpoint(filename: str, req: TagActionRequest): + person = database.get_person(filename) + if not person: + raise HTTPException(404, "not found") + + tags_val = person[2] + tags_list = [] + if tags_val: + if isinstance(tags_val, str): + try: + tags_list = json.loads(tags_val) + except Exception: + tags_list = [] + elif isinstance(tags_val, list): + tags_list = tags_val + + action = req.action.lower() + tag_name = req.tag.upper().strip() + + if action == "add": + if tag_name not in tags_list: + tags_list.append(tag_name) + elif action == "remove": + if tag_name in tags_list: + tags_list.remove(tag_name) + elif action == "toggle": + if tag_name in tags_list: + tags_list.remove(tag_name) + else: + tags_list.append(tag_name) + + if tag_name == "LIKE" and "LIKE" in tags_list: + if "DISLIKE" in tags_list: + tags_list.remove("DISLIKE") + elif tag_name == "DISLIKE" and "DISLIKE" in tags_list: + if "LIKE" in tags_list: + tags_list.remove("LIKE") + + archived = "ARCHIVED" in tags_list + hidden = "HIDDEN" in tags_list + is_source = "SOURCE" in tags_list + + if archived: + if "VISIBLE" in tags_list: + tags_list.remove("VISIBLE") + else: + if not hidden: + if "VISIBLE" not in tags_list: + tags_list.append("VISIBLE") + + if hidden: + if "VISIBLE" in tags_list: + tags_list.remove("VISIBLE") + else: + if not archived: + if "VISIBLE" not in tags_list: + tags_list.append("VISIBLE") + + conn = database.get_db_connection() + cur = conn.cursor() + try: + cur.execute(""" + UPDATE person + SET tags = %s, + archived = %s, + hidden = %s, + is_source = %s + WHERE filename = %s + """, (json.dumps(tags_list), archived, hidden, is_source, filename)) + conn.commit() + finally: + cur.close() + database._put_db_connection(conn) + + _invalidate_static() + return {"filename": filename, "tags": tags_list} + +@app.post("/images/{filename:path}/source") +def set_image_source(filename: str, body: dict): + is_source = bool(body.get("source", False)) + person = database.get_person(filename) + if not person: + raise HTTPException(404, "not found") + + tags_val = person[2] + tags_list = [] + if tags_val: + if isinstance(tags_val, str): + try: + tags_list = json.loads(tags_val) + except Exception: + tags_list = [] + elif isinstance(tags_val, list): + tags_list = tags_val + + if is_source: + if "SOURCE" not in tags_list: + tags_list.append("SOURCE") + else: + if "SOURCE" in tags_list: + tags_list.remove("SOURCE") + + conn = database.get_db_connection() + cur = conn.cursor() + try: + cur.execute("UPDATE person SET is_source = %s, tags = %s WHERE filename = %s", (is_source, json.dumps(tags_list), filename)) + conn.commit() + finally: + cur.close() + database._put_db_connection(conn) + + _invalidate_static() + return {"filename": filename, "is_source": is_source, "tags": tags_list} + +@app.post("/images/{filename:path}/estimate-21plus") +def estimate_image_21plus(filename: str): + is_21plus = estimate_nsfw_21plus(filename) + person = database.get_person(filename) + if not person: + raise HTTPException(404, "not found") + + tags_val = person[2] + tags_list = [] + if tags_val: + if isinstance(tags_val, str): + try: + tags_list = json.loads(tags_val) + except Exception: + tags_list = [] + elif isinstance(tags_val, list): + tags_list = tags_val + + if is_21plus: + if "21+" not in tags_list: + tags_list.append("21+") + else: + if "21+" in tags_list: + tags_list.remove("21+") + + conn = database.get_db_connection() + cur = conn.cursor() + try: + cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags_list), filename)) + conn.commit() + finally: + cur.close() + database._put_db_connection(conn) + + _invalidate_static() + return {"filename": filename, "21+": is_21plus, "tags": tags_list} + +@app.post("/images/bulk-move") +def bulk_move_endpoint(req: BulkMoveRequest): + if not req.filenames: + return {"status": "ok", "moved": 0} + + target = req.target_strip.upper().strip() + conn = database.get_db_connection() + cur = conn.cursor() + try: + for filename in req.filenames: + cur.execute("SELECT tags, archived, hidden, is_source FROM person WHERE filename = %s", (filename,)) + row = cur.fetchone() + if not row: + continue + tags_val, archived, hidden, is_source = row + tags_list = [] + if tags_val: + if isinstance(tags_val, str): + try: + tags_list = json.loads(tags_val) + except Exception: + tags_list = [] + elif isinstance(tags_val, list): + tags_list = tags_val + + if target == "VISIBLE": + archived = False + hidden = False + if "VISIBLE" not in tags_list: + tags_list.append("VISIBLE") + if "ARCHIVED" in tags_list: + tags_list.remove("ARCHIVED") + if "HIDDEN" in tags_list: + tags_list.remove("HIDDEN") + elif target == "HIDDEN": + archived = False + hidden = True + if "HIDDEN" not in tags_list: + tags_list.append("HIDDEN") + if "VISIBLE" in tags_list: + tags_list.remove("VISIBLE") + if "ARCHIVED" in tags_list: + tags_list.remove("ARCHIVED") + elif target == "ARCHIVED": + archived = True + hidden = False + if "ARCHIVED" not in tags_list: + tags_list.append("ARCHIVED") + if "VISIBLE" in tags_list: + tags_list.remove("VISIBLE") + if "HIDDEN" in tags_list: + tags_list.remove("HIDDEN") + elif target == "SOURCE": + is_source = True + if "SOURCE" not in tags_list: + tags_list.append("SOURCE") + + cur.execute(""" + UPDATE person + SET tags = %s, + archived = %s, + hidden = %s, + is_source = %s + WHERE filename = %s + """, (json.dumps(tags_list), archived, hidden, is_source, filename)) + conn.commit() + finally: + cur.close() + database._put_db_connection(conn) + + _invalidate_static() + return {"status": "ok", "moved": len(req.filenames)} + + class FaceSimilarRequest(BaseModel): group_id: str limit: int = 12 diff --git a/tour-comfy/poses.md b/tour-comfy/poses.md index 55e7cac..b85a3e7 100644 --- a/tour-comfy/poses.md +++ b/tour-comfy/poses.md @@ -55,20 +55,20 @@ Keep full characteristics of reference image. # Three-quarter (18) -You are in a black empty void, transparent background. +You are in a black empty void. masterpiece, high quality, detailed, detailed skin. Head-on a right-front. Detailed full-nude-body. Top-to-toe photo. -female teenage, hyper realistic. +female teenage, hyper-realistic. Anatomically precise. Keep characteristics of reference image. # Three-quarter -You are in a black empty void. -high quality, masterpiece, detailed, detailed skin. -Head-on a right-front view. Top-to-toe. -detailed full-nude-body, female portrait, photorealistic, transparent background. +You are in a black empty void. +high quality, masterpiece, detailed, detailed skin. +Head-on a right-front view. +Top-to-toe. detailed full-nude-body, female portrait, hyper-realistic. Keep characteristics of reference image. # Head-on undress (18)