reorder
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
""")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user