This commit is contained in:
mike
2026-06-28 03:33:02 +02:00
parent 6ad11fc6c0
commit d522b2a267
5 changed files with 914 additions and 28 deletions

View File

@@ -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.

View File

@@ -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 @@
</div>
<button class="studio-nav-btn" id="lbNext" onclick="lbNav(1)"></button>
</div>
<!-- Film strip selector tabs -->
<div class="filmstrip-selector-tabs" style="display:flex;gap:4px;padding:4px 8px;background:#151515;border-top:1px solid #1a1a1a;align-items:center;justify-content:space-between;flex-shrink:0">
<div style="display:flex;gap:4px">
<button onclick="switchFilmstripTab('group')" class="fs-tab active" id="fsTabGroup" ondragover="event.preventDefault()" ondrop="filmstripTabDrop(event, 'VISIBLE')">Group Variants</button>
<button onclick="switchFilmstripTab('hidden')" class="fs-tab" id="fsTabHidden" ondragover="event.preventDefault()" ondrop="filmstripTabDrop(event, 'HIDDEN')">Hidden</button>
<button onclick="switchFilmstripTab('source')" class="fs-tab" id="fsTabSource" ondragover="event.preventDefault()" ondrop="filmstripTabDrop(event, 'SOURCE')">Source</button>
<button onclick="switchFilmstripTab('archived')" class="fs-tab" id="fsTabArchived" ondragover="event.preventDefault()" ondrop="filmstripTabDrop(event, 'ARCHIVED')">Archived</button>
</div>
<div style="display:flex;gap:6px;align-items:center" id="multiSelectControls">
<button onclick="toggleMultiSelectMode()" id="multiSelectToggleBtn" style="background:#222;border:1px solid #333;color:#888;font-size:11px;padding:2px 8px;border-radius:4px;cursor:pointer">Multi-select</button>
<div id="multiSelectActions" style="display:none;align-items:center;gap:4px">
<span id="multiSelectCount" style="font-size:11px;color:#aaa">0 selected</span>
<button onclick="bulkMoveSelected('VISIBLE')" class="fs-action-btn">Move to Group</button>
<button onclick="bulkMoveSelected('HIDDEN')" class="fs-action-btn">Move to Hidden</button>
<button onclick="bulkMoveSelected('SOURCE')" class="fs-action-btn">Move to Source</button>
<button onclick="bulkMoveSelected('ARCHIVED')" class="fs-action-btn">Move to Archived</button>
</div>
</div>
</div>
<!-- Film strip / variant thumbnails -->
<div class="studio-filmstrip" id="lbVariantStrip"></div>
<!-- Slideshow controls -->
@@ -2042,7 +2118,9 @@
<button class="sb-btn" id="lbMoveUp" style="display:none" onclick="lbMoveInGroup(-1)" title="Move earlier in group">↑ Earlier</button>
<button class="sb-btn" id="lbMoveDown" style="display:none" onclick="lbMoveInGroup(1)" title="Move later in group">↓ Later</button>
<button class="sb-btn" id="lbPreferredBtn" style="display:none;color:#f59e0b" onclick="lbSetPreferred()" title="Set as preferred reference">★ Preferred</button>
<button class="sb-btn" id="lbSourceBtn" onclick="toggleSourceKeybind()" title="Toggle mark as SOURCE (Shortcut: S)">★ Source</button>
<button class="sb-btn" id="lbEyeBtn" onclick="lbToggleHidden()" title="Hide/show in cycling">👁 Visibility</button>
<button class="sb-btn" id="lbEstimate21Btn" onclick="estimate21PlusKeybind()" title="Detect adult 21+ tags (Shortcut: E)">🔞 21+</button>
</div>
<div class="sb-sep"></div>
<div class="sb-label">Actions</div>
@@ -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 <img> thumbnail over file://, so the backend
// writes a sibling "<stem>.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 = '<div style="color:#666;font-size:11px;padding:12px;width:100%;text-align:center">No images in this strip</div>';
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 '<div class="lb-var-thumb' + act + refCls + '" onclick="sbFilmstripClick(event,' + i + ')"' + dragAttr + ' title="Click to view · Shift+Click to add as ref">'
// 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 '<div class="lb-var-thumb' + act + refCls + selCls + '" onclick="sbFilmstripClick(event,' + i + ')"' + dragAttr + ' title="' + titleStr + '">'
+ '<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>' : '')
+ (badge > 0 ? '<div class="lb-var-ref-badge">#' + badge + '</div>' : '')
+ (isSel ? '<div class="lb-var-thumb-check">✓</div>' : '')
+ '</div>';
}).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();

View File

@@ -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}
""")

View File

@@ -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

View File

@@ -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)