Files
qwen-image/tour-comfy/car.html
mike f1f693523b aa
2026-06-19 20:44:36 +02:00

2219 lines
92 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Image Monitor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
min-height: 100vh;
}
.header {
position: fixed;
top: 0; left: 0; right: 0;
background: rgba(15, 15, 15, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid #333;
padding: 16px 24px;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 18px;
font-weight: 600;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #888;
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #22c55e;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-dot.updating {
background: #f59e0b;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.controls {
display: flex;
gap: 12px;
align-items: center;
}
.btn {
background: #1a1a1a;
border: 1px solid #333;
color: #ccc;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn:hover {
background: #252525;
border-color: #444;
color: #fff;
}
.btn.primary {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.btn.primary:hover {
background: #3b82f6;
}
.btn.danger {
background: #991b1b;
border-color: #991b1b;
color: #fff;
}
.btn.danger:hover {
background: #b91c1c;
}
.image-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
z-index: 20;
}
.selectable .image-actions {
display: none !important;
}
.image-card:hover .image-actions {
opacity: 1;
}
.action-btn {
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
border: 1px solid rgba(255,255,255,0.1);
color: #fff;
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(0,0,0,0.8);
border-color: rgba(255,255,255,0.3);
}
.action-btn.delete:hover {
background: #991b1b;
border-color: #991b1b;
}
.prompt-bar {
display: flex;
gap: 8px;
align-items: center;
}
.prompt-input {
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
width: 320px;
outline: none;
transition: border-color 0.2s;
}
.prompt-input:focus {
border-color: #2563eb;
}
.prompt-input::placeholder {
color: #555;
}
.count {
font-size: 13px;
color: #666;
font-variant-numeric: tabular-nums;
}
.gallery {
padding: 100px 24px 40px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
max-width: 1800px;
margin: 0 auto;
}
.image-card {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, border-color 0.2s;
position: relative;
}
.image-card:hover {
transform: translateY(-2px);
border-color: #444;
}
.image-card.new {
border-color: #2563eb;
animation: highlight 1s ease;
}
@keyframes highlight {
from { box-shadow: 0 0 0 2px #2563eb; }
to { box-shadow: none; }
}
.image-wrapper {
position: relative;
padding-top: 133%; /* 4:3 portrait */
background: #111;
overflow: hidden;
}
.image-wrapper img {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
object-fit: contain;
transition: opacity 0.3s;
}
.variant-count {
position: absolute;
bottom: 8px; left: 8px;
background: rgba(0,0,0,0.65);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 2px 7px;
border-radius: 10px;
z-index: 5;
pointer-events: none;
}
.variant-dots {
position: absolute;
bottom: 8px; left: 50%; transform: translateX(-50%);
display: flex; gap: 4px;
z-index: 5;
}
.dot {
width: 6px; height: 6px;
border-radius: 50%;
background: rgba(255,255,255,0.35);
transition: background 0.2s;
}
.dot.active { background: #fff; }
.image-info {
padding: 14px 16px;
}
.image-name {
font-size: 14px;
font-weight: 500;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 6px;
}
.image-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #666;
}
.image-time {
display: flex;
align-items: center;
gap: 4px;
}
.badge {
background: #2563eb;
color: #fff;
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge.recent {
background: #22c55e;
}
.empty-state {
text-align: center;
padding: 120px 20px;
color: #555;
}
.empty-state svg {
width: 64px; height: 64px;
margin-bottom: 16px;
opacity: 0.3;
}
.empty-state h2 {
font-size: 20px;
color: #777;
margin-bottom: 8px;
}
.empty-state p {
font-size: 14px;
}
.toast {
position: fixed;
bottom: 24px; right: 24px;
background: #1a1a1a;
border: 1px solid #333;
padding: 12px 20px;
border-radius: 8px;
font-size: 13px;
color: #ccc;
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
z-index: 200;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success { border-left: 3px solid #22c55e; }
.toast.info { border-left: 3px solid #2563eb; }
.btn.active {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.image-card.selectable {
cursor: pointer;
user-select: none;
}
.image-card.selected {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37,99,235,0.35);
}
.select-indicator {
position: absolute;
top: 8px; left: 8px;
width: 22px; height: 22px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.5);
background: rgba(0,0,0,0.45);
display: none;
align-items: center;
justify-content: center;
z-index: 15;
font-size: 13px;
font-weight: 700;
color: #fff;
transition: background 0.15s, border-color 0.15s;
pointer-events: none;
}
.selectable .select-indicator { display: flex; }
.selected .select-indicator {
background: #2563eb;
border-color: #2563eb;
}
.selected .select-indicator::after { content: '✓'; }
.variant-picker {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.80);
z-index: 350;
align-items: center;
justify-content: center;
}
.variant-picker.open { display: flex; }
.variant-picker-inner {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 14px;
padding: 22px;
max-width: 88vw;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 40px rgba(0,0,0,0.7);
}
.variant-picker-title {
color: #ccc;
font-size: 14px;
font-weight: 600;
margin-bottom: 14px;
}
.variant-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
}
.variant-thumb {
cursor: pointer;
border-radius: 8px;
overflow: hidden;
border: 2px solid transparent;
aspect-ratio: 3/4;
background: #111;
transition: border-color 0.15s, transform 0.15s;
}
.variant-thumb:hover { border-color: #2563eb; transform: scale(1.03); }
.variant-thumb.selected { border-color: #22c55e; }
.variant-thumb.hidden-img { opacity: 0.38; }
.variant-thumb.hidden-img::after {
content: '🚫';
position: absolute;
top: 4px; right: 4px;
font-size: 14px;
line-height: 1;
}
.variant-thumb { position: relative; }
.lb-hidden-badge {
position: absolute;
top: 12px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.7);
color: #f87171;
font-size: 12px;
font-weight: 600;
padding: 3px 12px;
border-radius: 20px;
pointer-events: none;
display: none;
}
.lightbox.img-hidden .lb-hidden-badge { display: block; }
.lightbox.img-hidden #lbImg { opacity: 0.45; }
.variant-thumb img {
width: 100%; height: 100%;
object-fit: contain;
display: block;
}
.variant-picker-actions {
display: flex;
gap: 8px;
margin-top: 16px;
justify-content: flex-end;
}
.batch-bar {
position: fixed;
bottom: 0; left: 0; right: 0;
background: rgba(12,12,12,0.97);
backdrop-filter: blur(12px);
border-top: 1px solid #2a2a2a;
padding: 14px 24px;
display: flex;
align-items: center;
gap: 12px;
z-index: 100;
transform: translateY(100%);
transition: transform 0.25s ease;
}
.batch-bar.visible { transform: translateY(0); }
.pose-menu {
position: absolute;
bottom: 100%;
left: 24px;
right: 24px;
background: #1a1a1a;
border: 1px solid #333;
border-bottom: none;
border-radius: 8px 8px 0 0;
padding: 16px;
display: none;
flex-wrap: wrap;
gap: 8px;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 -10px 20px rgba(0,0,0,0.5);
}
.pose-menu.open {
display: flex;
}
.pose-item {
background: #2a2a2a;
border: 1px solid #444;
color: #ccc;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.pose-item:hover {
background: #3a3a3a;
color: #fff;
}
.pose-item.active {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.batch-count {
font-size: 13px;
color: #888;
min-width: 80px;
white-space: nowrap;
}
.batch-prompt-wrap { flex: 1; position: relative; }
.batch-prompt {
width: 100%;
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
padding: 7px 12px;
border-radius: 6px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.batch-prompt:focus { border-color: #2563eb; }
.batch-prompt::placeholder { color: #555; }
.batch-progress {
font-size: 12px;
color: #666;
min-width: 90px;
text-align: right;
white-space: nowrap;
}
/* Lightbox */
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.93);
z-index: 500;
align-items: center;
justify-content: center;
gap: 16px;
}
.lightbox.open { display: flex; }
.lightbox img {
max-width: 88vw;
max-height: 88vh;
object-fit: contain;
border-radius: 4px;
display: block;
}
.lb-btn {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
color: #fff;
border-radius: 8px;
cursor: pointer;
font-size: 20px;
padding: 10px 16px;
transition: background 0.2s;
flex-shrink: 0;
}
.lb-btn:hover { background: rgba(255,255,255,0.18); }
.lb-close {
position: absolute;
top: 16px; right: 20px;
font-size: 16px;
padding: 6px 12px;
}
.lb-info {
position: absolute;
bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; gap: 16px; align-items: center;
background: rgba(0,0,0,0.8);
padding: 10px 20px;
border-radius: 20px;
font-size: 14px;
color: white;
white-space: nowrap;
}
#lbGroupNameInput, #lbNameInput {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
color: white;
padding: 5px 10px;
border-radius: 4px;
width: 150px;
font-size: 13px;
}
#lbGroupNameInput { border-color: rgba(255,255,255,0.35); }
#lbClipDesc {
font-size: 12px;
color: #aaa;
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upload-zone {
display: flex;
align-items: center;
gap: 10px;
margin-left: 20px;
padding-left: 20px;
border-left: 1px solid #333;
}
#uploadInput { display: none; }
/* --- privacy overlay --- */
.privacy-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 999999;
background: #000;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
cursor: pointer;
user-select: none;
}
.privacy-overlay .priv-icon { font-size: 52px; opacity: 0.25; }
.privacy-overlay .priv-msg { color: #222; font-size: 13px; }
/* --- preferred star badge in variant picker --- */
.variant-thumb.preferred::before {
content: '★';
position: absolute;
top: 3px; left: 5px;
font-size: 14px;
color: #f59e0b;
text-shadow: 0 0 4px #000;
pointer-events: none;
z-index: 2;
}
.set-preferred-btn {
position: absolute;
bottom: 3px; right: 3px;
background: rgba(0,0,0,0.7);
color: #999;
border: none;
border-radius: 3px;
font-size: 10px;
padding: 1px 4px;
cursor: pointer;
z-index: 2;
}
.set-preferred-btn:hover { color: #f59e0b; }
</style>
</head>
<body>
<div id="privacyOverlay" class="privacy-overlay" onclick="if(privacyMode)disablePrivacyOverlay()">
<div class="priv-icon">🔒</div>
<div class="priv-msg">Privacy mode — click to unlock</div>
</div>
<div class="header">
<h1>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
Live Image Monitor
</h1>
<div class="prompt-bar">
<input class="prompt-input" id="promptInput" type="text" placeholder="prompt..." onkeydown="if(event.key==='Enter')setPrompt()" />
<button class="btn primary" onclick="setPrompt()">Set Prompt</button>
</div>
<div class="controls">
<span class="count" id="count">0 images</span>
<button class="btn" onclick="setIntervalTime(30)">30s</button>
<button class="btn" onclick="setIntervalTime(120)">2m</button>
<button class="btn primary" onclick="refreshNow()">Refresh Now</button>
<button class="btn" id="selectBtn" onclick="toggleSelectMode()">Select</button>
<button class="btn" id="privacyBtn" onclick="togglePrivacyMode()" title="Privacy mode — hides content on focus loss (P)">🔓</button>
</div>
<div class="status">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Auto-refresh: 2m</span>
</div>
<div class="upload-zone">
<input type="file" id="uploadInput" multiple onchange="handleUpload(event)" />
<button class="btn" onclick="document.getElementById('uploadInput').click()" title="Or paste image from clipboard (Ctrl+V)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
Upload
</button>
</div>
</div>
<div class="gallery" id="gallery">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<h2>No images found</h2>
<p>Place this HTML file in your image folder, or configure the path below.</p>
</div>
</div>
<div class="toast" id="toast"></div>
<div id="lightbox" class="lightbox" onclick="if(event.target===this)closeLightbox()">
<button class="lb-btn lb-close" onclick="closeLightbox()"></button>
<button class="lb-btn" id="lbPrev" onclick="lbNav(-1)"></button>
<img id="lbImg" src="" alt="" />
<div class="lb-hidden-badge">Hidden from preview</div>
<button class="lb-btn" id="lbNext" onclick="lbNav(1)"></button>
<div class="lb-info">
<input type="text" id="lbGroupNameInput" placeholder="Group name…" onblur="updateGroupName()" onkeydown="if(event.key==='Enter')this.blur()" style="font-weight:600;color:#fff;border-color:#444" title="Group name (applies to all images in this group)" />
<input type="text" id="lbNameInput" placeholder="Image name…" onblur="updateName()" onkeydown="if(event.key==='Enter')this.blur()" />
<span id="lbClipDesc"></span>
<span id="lbPoseTag" style="font-size:11px;color:#f59e0b;display:none"></span>
<span id="lbCounter"></span>
<button class="lb-btn" id="lbMoveUp" style="font-size:13px;padding:3px 8px;display:none" onclick="lbMoveInGroup(-1)" title="Move earlier in group"></button>
<button class="lb-btn" id="lbMoveDown" style="font-size:13px;padding:3px 8px;display:none" onclick="lbMoveInGroup(1)" title="Move later in group"></button>
<button class="lb-btn" id="lbPreferredBtn" style="font-size:11px;padding:3px 8px;display:none;color:#f59e0b" onclick="lbSetPreferred()" title="Set as preferred reference for this group">★ Preferred</button>
<button class="lb-btn" id="lbEyeBtn" style="font-size:15px;padding:3px 8px" onclick="lbToggleHidden()" title="Hide/show this image in group cycling">👁</button>
<button class="lb-btn" style="font-size:11px;padding:3px 10px" onclick="tagCurrentImage()">Tag</button>
<button class="lb-btn" id="lbExtract" style="font-size:11px;padding:3px 10px" onclick="extractCurrentImage()">Extract</button>
<button class="lb-btn" id="lbNoBgBtn" style="font-size:11px;padding:3px 10px;background: #334155" onclick="lbRemoveBg()">No BG</button>
<button class="lb-btn" style="font-size:11px;padding:3px 10px;background: #991b1b" onclick="lbDelete()">Delete</button>
<div id="lbSourceRefs" style="display:none;align-items:center;gap:4px;flex-wrap:wrap;margin-top:4px;width:100%"></div>
</div>
</div>
<div class="batch-bar" id="batchBar">
<div id="poseMenu" class="pose-menu"></div>
<span class="batch-count" id="batchCount">0 selected</span>
<div class="batch-prompt-wrap">
<input class="batch-prompt" id="batchPromptInput" list="promptHistory"
placeholder="temporal prompt..."
onkeydown="if(event.key==='Enter')processSelected()" />
<datalist id="promptHistory"></datalist>
</div>
<button class="btn" id="poseBtn" onclick="togglePoseMenu()">Poses</button>
<button class="btn" onclick="selectAll()">All</button>
<button class="btn" onclick="deselectAll()">None</button>
<button class="btn" id="groupBtn" onclick="groupSelected()">Group</button>
<button class="btn" id="multiRefBtn" onclick="processMultiRef()" style="display:none" title="Use selected (23) as combined references → single output">Multi-ref</button>
<button class="btn primary" id="processBtn" onclick="processSelected()">Process</button>
<span class="batch-progress" id="batchProgress"></span>
<button class="btn" onclick="toggleSelectMode()">Done</button>
</div>
<div class="variant-picker" id="variantPicker" onclick="if(event.target===this)closeVariantPicker()">
<div class="variant-picker-inner">
<div class="variant-picker-title" id="variantPickerTitle">Select reference image</div>
<div class="variant-picker-grid" id="variantPickerGrid"></div>
<div class="variant-picker-actions">
<button class="btn danger" id="variantPickerDeselect" style="display:none" onclick="deselectGroupFromPicker()">Deselect</button>
<button class="btn" onclick="closeVariantPicker()">Cancel</button>
</div>
</div>
</div>
<script>
// ============================================
// CONFIGURATION - EDIT THIS SECTION
// ============================================
// Set this to your image folder path relative to this HTML file
// Examples: './' (same folder), './images/', '../screenshots/'
const IMAGE_FOLDER = './';
// --- HYDRATION_START ---
const PRELOADED_IMAGES = [
"20260618_052008_20260618_051426_test_group.png",
"20260618_052009_3_20260618_051918_test_conflict.png",
"20260618_051958_2_20260618_051918_test_conflict.png",
"20260618_051946_1_20260618_051918_test_conflict.png",
"20260618_051935_0_20260618_051918_test_conflict.png",
"20260618_051918_test_conflict.png",
"20260618_051753_8_20260618_051435_test_group.png",
"20260618_051742_8_20260618_051426_test_group.png",
"20260618_051731_7_20260618_051435_test_group.png",
"20260618_051721_7_20260618_051426_test_group.png",
"20260618_051711_6_20260618_051435_test_group.png",
"20260618_051629_20260618_051435_test_group.png",
"20260618_051650_6_20260618_051426_test_group.png",
"20260618_051638_5_20260618_051435_test_group.png",
"20260618_051628_5_20260618_051426_test_group.png",
"20260618_051618_4_20260618_051435_test_group.png",
"20260618_051607_4_20260618_051426_test_group.png",
"20260618_051556_3_20260618_051435_test_group.png",
"20260618_051546_3_20260618_051426_test_group.png",
"20260618_051535_2_20260618_051435_test_group.png",
"20260618_051526_2_20260618_051426_test_group.png",
"20260618_051514_1_20260618_051435_test_group.png",
"20260618_051503_1_20260618_051426_test_group.png",
"20260618_051453_0_20260618_051435_test_group.png",
"20260618_051442_0_20260618_051426_test_group.png",
"20260618_051052_9_20260618_050846_img_4.png",
"20260618_051040_8_20260618_050846_img_4.png",
"20260618_051029_7_20260618_050846_img_4.png",
"20260618_051017_6_20260618_050846_img_4.png",
"20260618_051006_5_20260618_050846_img_4.png",
"20260618_050955_4_20260618_050846_img_4.png",
"20260618_050929_20260618_050846_img_4.png",
"20260618_050935_3_20260618_050846_img_4.png",
"20260618_050923_2_20260618_050846_img_4.png",
"20260618_050913_1_20260618_050846_img_4.png",
"20260618_050902_0_20260618_050846_img_4.png",
"20260618_045516_1_20260618_045450_test_clipboard.png",
"20260618_015217_20260618_010710_p12-2.png",
"20260618_015207_20260618_010732_p10.png",
"20260618_015156_20260618_010700_Pasted image (4).png",
"20260618_015145_20260618_010627_jb3.png",
"20260618_015135_20260618_011424_20260618_004356_bb_01.png",
"20260618_015124_20260618_010617_20260615_153426_img_12.png",
"20260618_015113_20260618_004952_jb.png",
"20260618_015102_20260616_022543_img_50.png",
"20260618_015052_20260618_004407_20260616_002456_test123.jpeg",
"20260618_015041_20260618_004313_20260616_021938_20160903_200935.jpg",
"20260618_015030_20260618_004858_p12.png",
"20260618_015019_20260618_010638_b1.png",
"20260618_015008_20260618_011445_20260618_004450_20260617_074413_img_73.png",
"20260618_013512_Pasted image (9).png",
"20260618_013502_kk563t.png",
"20260618_013452_out4.png",
"20260618_011633_t159zr-1.png",
"20260618_011622_jb1.png",
"20260618_011612_20260616_003803_img_14.png",
"20260618_011601_20260616_014057_img_29.png",
"20260618_011550_20260616_023209_img_51.png",
"20260618_011539_20260617_133129_img_77.png",
"20260618_011528_20260617_074613_img_75.png",
"20260618_011517_20260618_002014_20260616_020035_img_36.png",
"20260618_011507_20260617_134615_img_86.png",
"20260618_011456_20260618_004439_20260617_005512_img_60.png",
"20260618_011445_20260618_004450_20260617_074413_img_73.png",
"20260618_011434_20260618_004847_20260615_150812_img_19_2.png",
"20260618_011424_20260618_004356_bb_01.png",
"20260618_010732_p10.png",
"20260618_010721_Pasted image (2).png",
"20260618_010710_p12-2.png",
"20260618_010700_Pasted image (4).png",
"20260618_010649_20260615_150340_test.png",
"20260618_010638_b1.png",
"20260618_010627_jb3.png",
"20260618_010617_20260615_153426_img_12.png",
"20260618_004952_jb.png",
"20260618_004941_out7.png",
"20260618_004930_20260617_134041_img_84.png",
"20260618_004919_kbk99v.png",
"20260618_004909_p1.png",
"20260618_004858_p12.png",
"20260618_004847_20260615_150812_img_19_2.png",
"20260618_004512_20260617_215245_img_87.png",
"20260618_004501_20260617_134041_img_84.png",
"20260618_004450_20260617_074413_img_73.png",
"20260618_004439_20260617_005512_img_60.png",
"20260618_004428_20260616_015949_img_37.png",
"20260618_004417_20260616_004250_img_18.png",
"20260618_004407_20260616_002456_test123.jpeg",
"20260618_004356_bb_01.png",
"20260618_004345_p1.png",
"20260618_004334_Pasted image (3).png",
"20260618_004324_jb.png",
"20260618_004313_20260616_021938_20160903_200935.jpg",
"20260618_004003_jb.png",
"20260618_003952_20260616_021938_20160903_200935.jpg",
"20260616_021938_20160903_200935.jpg",
"20260618_002606_20260616_021938_20160903_200935.jpg",
"20260618_002036_20260616_015949_img_37.png",
"20260618_002025_20260616_020020_img_35.png",
"20260618_002014_20260616_020035_img_36.png",
"20260618_000914_img_88.png",
"20260617_215245_img_87.png",
"20260617_134615_img_86.png",
"20260617_134229_img_83.png",
"20260617_134119_img_85.png",
"20260617_134041_img_84.png",
"20260617_133917_img_82.png",
"20260617_133832_img_81.png",
"20260617_133519_img_80.png",
"20260617_133411_img_79.png",
"20260617_133154_img_78.png",
"20260617_133129_img_77.png",
"20260617_132851_img_76.png",
"20260617_074613_img_75.png",
"20260617_074556_img_74.png",
"20260617_074413_img_73.png",
"20260617_074223_img_72.png",
"20260617_015946_img_71.png",
"20260617_015728_img_70.png",
"20260617_015611_img_69.png",
"20260617_015007_img_68.png",
"20260617_014351_img_66.png",
"20260617_013327_img_67.png",
"20260617_013231_img_68.png",
"20260617_013211_img_65.png",
"20260617_013150_img_66.png",
"20260617_013111_img_63.png",
"20260617_013035_img_64.png",
"20260617_012709_img_62.png",
"20260617_011132_img_61.png",
"20260617_005512_img_60.png",
"20260617_005200_img_59.png",
"20260617_005040_img_56.png",
"20260617_005026_img_55.png",
"20260617_005008_img_54.png",
"20260617_004942_img_53.png",
"20260617_004814_img_57.png",
"20260616_023306_img_52.png",
"20260616_023209_img_51.png",
"20260616_022543_img_50.png",
"20260616_022349_img_48.png",
"20260616_021235_img_47.png",
"20260616_021214_img_46.png",
"20260616_021150_img_44.png",
"20260616_021116_img_43.png",
"20260616_021056_img_45.png",
"20260616_021003_img_41.png",
"20260616_020908_img_40.png",
"20260616_020403_img_39.png",
"20260616_020059_img_38.png",
"20260616_020035_img_36.png",
"20260616_020020_img_35.png",
"20260616_015949_img_37.png",
"20260616_015919_img_33.png",
"20260616_015850_img_34.png",
"20260616_015341_img_32.png",
"20260616_014757_img_31.png",
"20260616_014225_img_30.png",
"20260616_014057_img_29.png",
"20260616_013755_img_28.png",
"20260616_013603_img_27.png",
"20260616_013013_img_26.png",
"20260616_012939_img_25.png",
"20260616_011823_imgxxxx.png",
"20260616_011447_img_24.png",
"20260616_010228_img_22.png",
"20260616_005752_img_21.png",
"20260616_005727_img_19.png",
"20260616_005202_img_20.png",
"20260616_004250_img_18.png",
"20260616_004220_img_17.png",
"20260616_004001_img_16.png",
"20260616_003916_img_15.png",
"20260616_003803_img_14.png",
"20260616_003629_img_13.png",
"20260616_003548_img.png",
"20260616_002456_test123.jpeg",
"20260616_002302_image.png",
"20260615_155756_img_6v1.png",
"20260615_155354_others.jpeg",
"20260615_154852_other.jpeg",
"20260615_154333_other.jpeg",
"20260615_153749_img_11.png",
"20260615_153426_img_12.png",
"20260615_153125_img_10.png",
"20260615_152826_img.png",
"20260615_152252_imgxxx.png",
"20260615_151829_img_92.png",
"20260615_151614_img_93.png",
"20260615_150812_img_19_2.png",
"20260615_150340_test.png",
"20260615_145017_img_93.png",
"img_9.png",
"b1.png",
"jb3.png",
"jb.png",
"jb1.png",
"img.png",
"out7.png",
"out5.png",
"out4.png",
"out3.png",
"out2.png",
"out.png",
"bb_01.png",
"tp236b.png",
"pass-1.png",
"t159zr-1.png",
"kbk99v.png",
"kk563t.png",
"Pasted image (9).png",
"Pasted image (7).png",
"Pasted image (5).png",
"Pasted image (4).png",
"Pasted image (3).png",
"Pasted image (2).png",
"Pasted image.png",
"pa01.png",
"pa0.png",
"p13.png",
"p12-2.png",
"p12.png",
"p11.png",
"p10.png",
"p1.png",
"img_2.png",
"img_3.png"
];
// --- HYDRATION_END ---
// Supported image extensions
const EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
// Auto-refresh interval in seconds (default: 120 = 2 minutes)
let REFRESH_INTERVAL = 120;
// ============================================
let autoRefreshTimer = null;
let knownFiles = new Set();
let currentFiles = [];
let currentGroups = [];
const groupData = new Map(); // gid → { urls[], names[] }
const cycleTimers = new Map(); // gid → intervalId
const cycleIdx = new Map(); // gid → current index
let imageNames = {}; // filename → human name (from /names)
let clipDescriptions = {}; // filename → description
let customGroups = {}; // filename → groupId (from /groups)
let groupNames = {}; // group_id → group display name
let fileSortOrders = {}; // filename → sort_order (integer or null)
let filePoses = {}; // filename → pose name
let filePrompts = {}; // filename → generation prompt
let fileHidden = {}; // filename → boolean (hidden from cycling)
let fileHasBg = {}; // filename → boolean (has background, default true)
let fileSourceRefs = {}; // filename → array of source filenames
let privacyMode = localStorage.getItem('privacyMode') === 'true';
function getBaseName(name) {
return name.replace(/^(\d{8}_\d{6}_)+/, '');
}
function groupFiles(fileObjects) {
const map = new Map(); // gid → files[]
for (const f of fileObjects) {
const custom = customGroups[f.cleanName];
let gid;
if (custom) {
gid = custom.startsWith('solo:') ? f.cleanName : custom;
} else {
gid = getBaseName(f.cleanName);
}
if (!map.has(gid)) map.set(gid, []);
map.get(gid).push(f);
}
// Sort each group: by explicit sort_order first, then by filename length
for (const files of map.values()) {
files.sort((a, b) => {
const oa = fileSortOrders[a.cleanName] ?? 9999;
const ob = fileSortOrders[b.cleanName] ?? 9999;
if (oa !== ob) return oa - ob;
return a.cleanName.length - b.cleanName.length;
});
}
return [...map.entries()].map(([gid, files]) => ({ gid, files }));
}
function groupDisplayName(group) {
// 1. Explicit group_name set via UI
if (groupNames[group.gid]) return groupNames[group.gid];
// 2. Human name tagged for any file in the group
for (const f of group.files) {
if (imageNames[f.cleanName]) return imageNames[f.cleanName];
}
return group.gid;
}
function clearCycleTimers() {
cycleTimers.forEach(id => clearInterval(id));
cycleTimers.clear();
cycleIdx.clear();
}
function startGroupCycle(base) {
cycleIdx.set(base, 0); // index into visibleIdx array
const id = setInterval(() => {
const data = groupData.get(base);
if (!data || !data.visibleIdx || data.visibleIdx.length < 2) { clearInterval(id); cycleTimers.delete(base); return; }
const card = [...document.querySelectorAll('.image-card')].find(c => c.dataset.group === base);
if (!card) { clearInterval(id); cycleTimers.delete(base); return; }
const nextSlot = (cycleIdx.get(base) + 1) % data.visibleIdx.length;
cycleIdx.set(base, nextSlot);
const realIdx = data.visibleIdx[nextSlot];
const img = card.querySelector('img');
if (img) img.src = data.urls[realIdx];
card.querySelectorAll('.dot').forEach((d, i) => d.classList.toggle('active', i === nextSlot));
}, 1800);
cycleTimers.set(base, id);
}
// --- lightbox ---
let lbUrls = [], lbNames = [], lbIdx = 0;
function openLightbox(gid, startIdx) {
const data = groupData.get(gid);
if (!data) return;
lbCurrentGid = gid;
lbUrls = data.urls;
lbNames = data.names;
lbIdx = startIdx !== undefined ? startIdx : (cycleIdx.get(gid) || 0);
updateLightbox();
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
}
function lbNav(dir) {
lbIdx = (lbIdx + dir + lbUrls.length) % lbUrls.length;
updateLightbox();
}
function updateLightbox() {
document.getElementById('lbImg').src = lbUrls[lbIdx];
const fname = lbNames[lbIdx];
const groupNameInput = document.getElementById('lbGroupNameInput');
groupNameInput.value = groupNames[lbCurrentGid] || '';
groupNameInput.placeholder = 'Group name…';
const nameInput = document.getElementById('lbNameInput');
nameInput.value = imageNames[fname] || '';
nameInput.placeholder = fname;
document.getElementById('lbClipDesc').textContent = clipDescriptions[fname] || '';
const pose = filePoses[fname];
const poseTag = document.getElementById('lbPoseTag');
if (pose) { poseTag.textContent = '⛷ ' + pose; poseTag.style.display = ''; }
else { poseTag.style.display = 'none'; }
const hidden = !!fileHidden[fname];
const lb = document.getElementById('lightbox');
lb.classList.toggle('img-hidden', hidden);
document.getElementById('lbEyeBtn').textContent = hidden ? '🚫' : '👁';
document.getElementById('lbEyeBtn').title = hidden ? 'Show in cycling' : 'Hide from cycling';
// No BG button — hide when image already has no background
document.getElementById('lbNoBgBtn').style.display = fileHasBg[fname] !== false ? '' : 'none';
// Source refs — show small clickable thumbnails
const refs = fileSourceRefs[fname];
const refsDiv = document.getElementById('lbSourceRefs');
if (refs && refs.length > 0) {
refsDiv.style.display = 'flex';
refsDiv.innerHTML = `<span style="font-size:10px;color:#666">ref:</span>` +
refs.map(r => `<img src="${IMAGE_FOLDER + r}" loading="lazy"
style="width:26px;height:26px;object-fit:cover;border-radius:3px;cursor:pointer;opacity:0.75;border:1px solid #333"
onclick="openLightbox('${getBaseName(r).replace(/'/g,"\\'")}'); closeLightbox();"
title="${r}">`).join('');
} else {
refsDiv.style.display = 'none';
}
const multi = lbUrls.length > 1;
document.getElementById('lbCounter').textContent = multi ? `${lbIdx + 1} / ${lbUrls.length}` : '';
document.getElementById('lbPrev').style.display = multi ? '' : 'none';
document.getElementById('lbNext').style.display = multi ? '' : 'none';
document.getElementById('lbExtract').style.display= multi ? '' : 'none';
document.getElementById('lbMoveUp').style.display = multi ? '' : 'none';
document.getElementById('lbMoveDown').style.display= multi ? '' : 'none';
// ★ Preferred — shown when not already the first image
const prefBtn = document.getElementById('lbPreferredBtn');
prefBtn.style.display = multi && lbIdx > 0 ? '' : 'none';
}
async function updateName() {
const fname = lbNames[lbIdx];
const newName = document.getElementById('lbNameInput').value;
if (newName === (imageNames[fname] || '')) return;
try {
const r = await fetch(`${API}/names/${fname}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
if (r.ok) {
imageNames[fname] = newName;
showToast(`Name updated`);
}
} catch (e) {
showToast(`Failed to update name: ${e}`, 'error');
}
}
async function updateGroupName() {
const gid = lbCurrentGid;
if (!gid) return;
const newName = document.getElementById('lbGroupNameInput').value.trim();
if (newName === (groupNames[gid] || '')) return;
try {
const r = await fetch(`${API}/group-names/${encodeURIComponent(gid)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
if (r.ok) {
if (newName) groupNames[gid] = newName;
else delete groupNames[gid];
showToast('Group name updated');
// Refresh gallery labels without full reload
document.querySelectorAll('.image-card').forEach(card => {
if (card.dataset.group === gid) {
const group = currentGroups.find(g => g.gid === gid);
if (group) card.querySelector('.image-name').textContent = groupDisplayName(group);
}
});
}
} catch (e) {
showToast(`Failed: ${e}`, 'error');
}
}
async function lbMoveInGroup(dir) {
const gid = lbCurrentGid;
const data = groupData.get(gid);
if (!data || data.names.length < 2) return;
const newIdx = lbIdx + dir;
if (newIdx < 0 || newIdx >= data.names.length) return;
// Swap in local arrays
[data.names[lbIdx], data.names[newIdx]] = [data.names[newIdx], data.names[lbIdx]];
[data.urls[lbIdx], data.urls[newIdx]] = [data.urls[newIdx], data.urls[lbIdx]];
// Update sort orders locally
data.names.forEach((fn, i) => { fileSortOrders[fn] = i; });
// Persist to API
try {
await fetch(`${API}/groups/${encodeURIComponent(gid)}/order`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames: data.names })
});
} catch (e) { /* non-fatal */ }
lbIdx = newIdx;
lbUrls = data.urls;
lbNames = data.names;
updateLightbox();
}
async function lbToggleHidden() {
const fname = lbNames[lbIdx];
const newHidden = !fileHidden[fname];
try {
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/hidden`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: newHidden }),
});
if (!r.ok) { showToast('Failed to update visibility', 'error'); return; }
fileHidden[fname] = newHidden;
// Update visibleIdx for this group
const data = groupData.get(lbCurrentGid);
if (data) {
data.visibleIdx = data.names.map((n, i) => i).filter(i => !fileHidden[data.names[i]]);
// Restart or stop cycle accordingly
const timer = cycleTimers.get(lbCurrentGid);
if (timer) { clearInterval(timer); cycleTimers.delete(lbCurrentGid); }
if (data.visibleIdx.length > 1) startGroupCycle(lbCurrentGid);
// Update card count badge
const card = [...document.querySelectorAll('.image-card')].find(c => c.dataset.group === lbCurrentGid);
if (card) {
const vc = card.querySelector('.variant-count');
if (vc) {
const vn = data.visibleIdx.length;
const tot = data.names.length;
if (vn > 1) {
vc.innerHTML = `${vn}×${tot !== vn ? `<span style="opacity:.5;font-size:9px"> /${tot}</span>` : ''}`;
} else {
vc.remove();
}
}
}
}
showToast(newHidden ? 'Hidden from cycling' : 'Visible in cycling');
updateLightbox();
} catch (e) {
showToast('API not reachable', 'error');
}
}
async function lbDelete() {
const fname = lbNames[lbIdx];
if (!confirm(`Are you sure you want to delete this image? (${fname})`)) return;
try {
const r = await fetch(`${API}/images/${fname}`, { method: 'DELETE' });
if (r.ok) {
showToast(`Deleted ${fname}`, 'success');
closeLightbox();
refreshNow();
} else {
showToast(`Failed to delete ${fname}`, 'error');
}
} catch (e) {
showToast(`Failed to delete: ${e}`, 'error');
}
}
async function lbRemoveBg() {
const fname = lbNames[lbIdx];
showToast(`Removing background for ${fname}...`);
try {
const r = await fetch(`${API}/remove-background/${fname}`, { method: 'POST' });
if (r.ok) {
showToast(`Background removed for ${fname}`, 'success');
// Refresh current image in lightbox by adding timestamp
const img = document.getElementById('lbImg');
img.src = img.src.split('?')[0] + '?t=' + Date.now();
refreshNow();
} else {
showToast(`Failed to remove background for ${fname}`, 'error');
}
} catch (e) {
showToast(`Failed to remove background: ${e}`, 'error');
}
}
async function deleteGroup(el, event) {
if (event) event.stopPropagation();
const gid = el.closest('.image-card').dataset.group;
const data = groupData.get(gid);
const count = data ? data.names.length : 0;
const msg = count > 1
? `Are you sure you want to delete ENTIRE GROUP: ${gid}? (${count} images)`
: `Are you sure you want to delete this image?`;
if (!confirm(msg)) return;
try {
const r = await fetch(`${API}/groups/${gid}`, { method: 'DELETE' });
if (r.ok) {
showToast(`Group ${gid} deleted`, 'success');
refreshNow();
} else {
showToast(`Failed to delete group ${gid}`, 'error');
}
} catch (e) {
showToast(`Failed to delete group: ${e}`, 'error');
}
}
async function removeBgGroup(el, event) {
if (event) event.stopPropagation();
const gid = el.closest('.image-card').dataset.group;
showToast(`Removing background for group ${gid}...`);
try {
const r = await fetch(`${API}/remove-background/group/${gid}`, { method: 'POST' });
if (r.ok) {
showToast(`Background removal started for group ${gid}`, 'info');
} else {
showToast(`Failed to start background removal for group ${gid}`, 'error');
}
} catch (e) {
showToast(`Failed to remove group background: ${e}`, 'error');
}
}
async function handleUpload(eventOrFiles) {
const files = eventOrFiles.target ? eventOrFiles.target.files : eventOrFiles;
if (!files || files.length === 0) return;
const prompt = document.getElementById('promptInput').value;
showToast(`Uploading ${files.length} items...`);
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
if (prompt) formData.append('prompts', prompt);
// If it's a pasted file, it might not have a useful name
const fileName = file.name || 'clipboard.png';
try {
const r = await fetch(`${API}/upload`, {
method: 'POST',
body: formData
});
if (r.ok) {
showToast(`Upload successful: ${fileName}`);
} else {
showToast(`Upload failed for ${fileName}`, 'error');
}
} catch (e) {
showToast(`Error uploading ${fileName}: ${e}`, 'error');
}
}
// Trigger refresh
refreshNow();
}
// --- clipboard paste support ---
window.addEventListener('paste', async (e) => {
const items = e.clipboardData.items;
const files = [];
for (const item of items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
files.push(blob);
}
}
if (files.length > 0) {
handleUpload(files);
}
});
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => toast.classList.remove('show'), 3000);
}
function formatTime(date) {
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'Just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return date.toLocaleDateString();
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// Try to get file info using HEAD requests (works with some local servers)
async function getFileInfo(filename) {
try {
const response = await fetch(IMAGE_FOLDER + filename, { method: 'HEAD' });
const lastMod = response.headers.get('last-modified');
const size = response.headers.get('content-length');
return {
modified: lastMod ? new Date(lastMod) : new Date(),
size: size ? parseInt(size) : 0
};
} catch {
return { modified: new Date(), size: 0 };
}
}
async function scanFolder() {
// For local file:// protocol, we can't list directories.
// This works when served via HTTP or you can manually list files.
// METHOD 1: If you have a simple file server, it might return directory listing
try {
const response = await fetch(IMAGE_FOLDER);
const text = await response.text();
// Try to parse common directory listing formats
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const links = Array.from(doc.querySelectorAll('a'))
.map(a => a.getAttribute('href'))
.filter(href => href && EXTENSIONS.some(ext => href.toLowerCase().endsWith(ext)))
.map(href => href.split('/').pop()); // Just the filename
if (links.length > 0) return links;
} catch (e) {
// Directory listing not available
}
// METHOD 2: Manual file list (fallback)
// If you can't use a server, list your files here or use the manual input below
return null;
}
async function loadImages() {
const gallery = document.getElementById('gallery');
const statusDot = document.getElementById('statusDot');
statusDot.classList.add('updating');
let files = null;
// Primary: live list from API (always up to date, works after batch jobs)
try {
const r = await fetch(`${API}/images`, { signal: AbortSignal.timeout(3000) });
if (r.ok) {
const data = await r.json();
files = data.images.map(img => img.filename);
// Hydrate metadata cache
data.images.forEach(img => {
if (img.name) imageNames[img.filename] = img.name;
if (img.clip_description)clipDescriptions[img.filename] = img.clip_description;
if (img.pose) filePoses[img.filename] = img.pose;
if (img.prompt) filePrompts[img.filename] = img.prompt;
if (img.group_name && img.group_id) groupNames[img.group_id] = img.group_name;
fileSortOrders[img.filename] = img.sort_order ?? null;
fileHidden[img.filename] = !!img.hidden;
fileHasBg[img.filename] = img.has_background !== false;
if (img.source_refs) {
try { fileSourceRefs[img.filename] = JSON.parse(img.source_refs); } catch(e) {}
}
// Pre-populate customGroups so grouping works immediately on first render
if (img.group_id) customGroups[img.filename] = img.group_id;
});
}
} catch (e) { /* API not reachable, fall through */ }
// Fallback: static list hydrated at page-load time
if (!files && typeof PRELOADED_IMAGES !== 'undefined' && PRELOADED_IMAGES.length > 0) {
files = PRELOADED_IMAGES;
}
if (!files) files = await scanFolder();
if (!files) files = await discoverImages();
const t = Date.now();
const fileObjects = files.map(name => ({
url: IMAGE_FOLDER + name + '?t=' + t,
cleanName: name.split('?')[0]
}));
const newFileNames = new Set(fileObjects.filter(f => !knownFiles.has(f.cleanName)).map(f => f.cleanName));
newFileNames.forEach(n => knownFiles.add(n));
currentFiles = fileObjects;
if (availablePoses && Object.keys(availablePoses).length === 0) {
loadPoses();
}
if (fileObjects.length === 0) {
gallery.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<h2>No images found</h2>
<p>Place images in the output folder and refresh.</p>
</div>`;
statusDot.classList.remove('updating');
return;
}
// Refresh names, custom groups, and group names before grouping
try {
const [nr, gr, gnr] = await Promise.all([
fetch(`${API}/names`, { signal: AbortSignal.timeout(2000) }),
fetch(`${API}/groups`, { signal: AbortSignal.timeout(2000) }),
fetch(`${API}/group-names`, { signal: AbortSignal.timeout(2000) }),
]);
if (nr.ok) imageNames = await nr.json();
if (gr.ok) customGroups = await gr.json();
if (gnr.ok) groupNames = await gnr.json();
} catch (e) { /* non-fatal */ }
clearCycleTimers();
groupData.clear();
currentGroups = groupFiles(fileObjects);
gallery.innerHTML = currentGroups.map((group, gIdx) => {
const { gid, files: gf } = group;
const urls = gf.map(f => f.url);
const names = gf.map(f => f.cleanName);
// Track which indices are visible (not hidden)
const visibleIdx = names.map((n, i) => i).filter(i => !fileHidden[names[i]]);
groupData.set(gid, { urls, names, visibleIdx });
const isNew = gf.some(f => newFileNames.has(f.cleanName));
const isRecent = gIdx < 3;
const count = gf.length;
const vCount = visibleIdx.length;
const label = groupDisplayName(group);
const safeGid = gid.replace(/"/g, '&quot;');
const poses = [...new Set(gf.map(f => filePoses[f.cleanName]).filter(Boolean))];
// First visible image (fallback to first overall)
const firstVisUrl = visibleIdx.length ? urls[visibleIdx[0]] : urls[0];
return `
<div class="image-card ${isNew ? 'new' : ''}" data-group="${safeGid}" onclick="handleCardClick(this)">
<div class="image-actions">
<div class="action-btn" title="Remove Background for Group" onclick="removeBgGroup(this, event)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v4m0 12v4M2 12h4m12 0h4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
</svg>
</div>
<div class="action-btn delete" title="Delete Entire Group" onclick="deleteGroup(this, event)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18m-2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</div>
</div>
<div class="image-wrapper">
<div class="select-indicator"></div>
${vCount > 1 ? `<div class="variant-count">${vCount}×${count !== vCount ? `<span style="opacity:.5;font-size:9px"> /${count}</span>` : ''}</div>` : ''}
<img src="${firstVisUrl}" alt="${label}" loading="lazy"
onerror="this.style.opacity='0.2'">
${vCount > 1 ? `<div class="variant-dots">${visibleIdx.map((_, i) => `<span class="dot${i === 0 ? ' active' : ''}"></span>`).join('')}</div>` : ''}
</div>
<div class="image-info">
<div class="image-name" title="${label}">${label}</div>
<div class="image-meta">
<span class="image-time">#${String(currentGroups.length - gIdx).padStart(3, '0')}</span>
${poses.length ? `<span class="badge" style="background:#78350f;font-size:9px">${poses[0]}</span>` : ''}
${isRecent ? '<span class="badge recent">Latest</span>' : ''}
</div>
</div>
</div>`;
}).join('');
// Start cycling for groups with 2+ visible images
currentGroups.forEach(g => {
const data = groupData.get(g.gid);
if (data && data.visibleIdx && data.visibleIdx.length > 1) startGroupCycle(g.gid);
});
updateCardSelection();
document.getElementById('count').textContent = `${currentGroups.length} group${currentGroups.length !== 1 ? 's' : ''} · ${fileObjects.length} images`;
if (newFileNames.size > 0) showToast(`${newFileNames.size} new image${newFileNames.size !== 1 ? 's' : ''} detected`, 'success');
statusDot.classList.remove('updating');
}
// Discover images by trying common patterns (fallback for file:// protocol)
async function discoverImages() {
const found = [];
// Try to find images by testing if they exist
// This is a best-effort approach for local file access
// If you know your naming pattern, add it here:
const patterns = [];
// Try to extract from page if there are any references
const images = document.querySelectorAll('img[src]');
images.forEach(img => {
const src = img.getAttribute('src');
if (src && EXTENSIONS.some(ext => src.toLowerCase().includes(ext))) {
found.push(src.split('/').pop().split('?')[0]);
}
});
// Remove duplicates
return [...new Set(found)];
}
function setIntervalTime(seconds) {
REFRESH_INTERVAL = seconds;
document.getElementById('statusText').textContent = `Auto-refresh: ${seconds < 60 ? seconds + 's' : (seconds / 60) + 'm'}`;
clearInterval(autoRefreshTimer);
autoRefreshTimer = setInterval(refreshNow, REFRESH_INTERVAL * 1000);
showToast(`Refresh interval set to ${seconds < 60 ? seconds + ' seconds' : (seconds / 60) + ' minutes'}`, 'info');
}
function refreshNow() {
loadImages();
}
const API = 'http://127.0.0.1:8500';
// --- selection state ---
let selectionMode = false;
const selectedFiles = new Set();
const selectedVariants = new Map(); // gid -> chosen variant index
const selectedPoses = new Set();
let availablePoses = {};
async function loadPoses() {
try {
const r = await fetch(`${API}/poses`);
if (r.ok) {
availablePoses = await r.json();
renderPoseMenu();
}
} catch (e) { console.error("Failed to load poses", e); }
}
function renderPoseMenu() {
const menu = document.getElementById('poseMenu');
menu.innerHTML = Object.entries(availablePoses).map(([name, entry]) => {
const text = entry?.text ?? entry; // handle legacy string format
const beta = entry?.beta ?? false;
return `<div class="pose-item ${selectedPoses.has(name) ? 'active' : ''}"
onclick="togglePose('${name.replace(/'/g, "\\'")}')" title="${String(text).replace(/"/g, '&quot;')}">
${name}${beta ? ' <span style="font-size:9px;background:#78350f;color:#fbbf24;padding:1px 4px;border-radius:3px;vertical-align:middle">β</span>' : ''}
</div>`;
}).join('');
}
function togglePose(name) {
if (selectedPoses.has(name)) selectedPoses.delete(name);
else selectedPoses.add(name);
renderPoseMenu();
updateBatchBar();
}
function togglePoseMenu() {
document.getElementById('poseMenu').classList.toggle('open');
document.getElementById('poseBtn').classList.toggle('active');
}
// --- variant picker ---
let _pickerGid = null;
function showVariantPicker(gid) {
const data = groupData.get(gid);
if (!data) return;
_pickerGid = gid;
const isSelected = selectedFiles.has(gid);
const chosen = selectedVariants.get(gid);
document.getElementById('variantPickerTitle').textContent =
`Pick reference image — ${data.urls.length} variants`;
document.getElementById('variantPickerGrid').innerHTML = data.urls.map((url, i) => {
const isHidden = fileHidden[data.names[i]];
const isPreferred = i === 0;
const prefClass = isPreferred ? ' preferred' : '';
const prefBtn = !isPreferred
? `<button class="set-preferred-btn" onclick="event.stopPropagation();setAsPreferred('${data.names[i].replace(/'/g,"\\'")}',this)" title="Set as preferred">★</button>`
: '';
return `<div class="variant-thumb${chosen === i ? ' selected' : ''}${isHidden ? ' hidden-img' : ''}${prefClass}"
onclick="selectVariant(${i})" title="${isHidden ? '🚫 Hidden — ' : ''}${isPreferred ? '★ Preferred — ' : ''}Variant ${i + 1}">
<img src="${url}" loading="lazy">
${prefBtn}
</div>`;
}).join('');
const deselectBtn = document.getElementById('variantPickerDeselect');
deselectBtn.style.display = isSelected ? '' : 'none';
document.getElementById('variantPicker').classList.add('open');
}
function selectVariant(idx) {
const gid = _pickerGid;
if (!gid) return;
selectedFiles.add(gid);
selectedVariants.set(gid, idx);
// Stop cycling and freeze on chosen variant
const timer = cycleTimers.get(gid);
if (timer) { clearInterval(timer); cycleTimers.delete(gid); }
const card = [...document.querySelectorAll('.image-card')].find(c => c.dataset.group === gid);
if (card) {
const data = groupData.get(gid);
if (data) {
const img = card.querySelector('img');
if (img) img.src = data.urls[idx];
card.querySelectorAll('.dot').forEach((d, i) => d.classList.toggle('active', i === idx));
}
}
closeVariantPicker();
updateCardSelection();
updateBatchBar();
}
function deselectGroupFromPicker() {
const gid = _pickerGid;
if (!gid) return;
selectedFiles.delete(gid);
selectedVariants.delete(gid);
_restartCycleIfNeeded(gid);
closeVariantPicker();
updateCardSelection();
updateBatchBar();
}
function closeVariantPicker() {
document.getElementById('variantPicker').classList.remove('open');
_pickerGid = null;
}
function toggleSelectMode() {
selectionMode = !selectionMode;
if (!selectionMode) {
selectedFiles.forEach(gid => _restartCycleIfNeeded(gid));
selectedFiles.clear();
selectedVariants.clear();
}
document.getElementById('selectBtn').classList.toggle('active', selectionMode);
const bar = document.getElementById('batchBar');
bar.classList.toggle('visible', selectionMode);
document.querySelector('.gallery').style.paddingBottom = selectionMode ? '90px' : '40px';
updateCardSelection();
updateBatchBar();
}
function handleCardClick(card) {
const gid = card.dataset.group;
if (selectionMode) {
const data = groupData.get(gid);
if (data && data.urls.length > 1) {
showVariantPicker(gid);
} else {
toggleFile(gid);
}
} else {
openLightbox(gid);
}
}
function toggleFile(base) {
if (selectedFiles.has(base)) {
selectedFiles.delete(base);
selectedVariants.delete(base);
_restartCycleIfNeeded(base);
} else {
selectedFiles.add(base);
}
updateCardSelection();
updateBatchBar();
}
function _restartCycleIfNeeded(gid) {
const data = groupData.get(gid);
if (data && data.visibleIdx && data.visibleIdx.length > 1 && !cycleTimers.has(gid)) {
startGroupCycle(gid);
}
}
function updateCardSelection() {
document.querySelectorAll('.image-card').forEach(card => {
const base = card.dataset.group;
card.classList.toggle('selectable', selectionMode);
card.classList.toggle('selected', selectionMode && selectedFiles.has(base));
});
}
function updateBatchBar() {
const n = selectedFiles.size;
document.getElementById('batchCount').textContent = `${n} group${n !== 1 ? 's' : ''} selected`;
document.getElementById('processBtn').textContent = n > 0 ? `Process (${n})` : 'Process';
document.getElementById('multiRefBtn').style.display = (n >= 2 && n <= 3) ? '' : 'none';
}
function selectAll() {
currentGroups.forEach(g => selectedFiles.add(g.gid));
updateCardSelection();
updateBatchBar();
}
function deselectAll() {
selectedFiles.forEach(gid => _restartCycleIfNeeded(gid));
selectedFiles.clear();
selectedVariants.clear();
updateCardSelection();
updateBatchBar();
}
// --- batch processing ---
let pollTimer = null;
async function processSelected() {
const prompt = document.getElementById('batchPromptInput').value.trim();
const poseEntries = [...selectedPoses].map(p => ({ name: p, text: availablePoses[p]?.text ?? availablePoses[p] }));
if (!prompt && poseEntries.length === 0) { showToast('Enter a prompt or select a pose', 'info'); return; }
if (selectedFiles.size === 0) { showToast('No images selected', 'info'); return; }
// Build parallel prompt+pose arrays
const prompts = [];
const poses = [];
if (prompt) { prompts.push(prompt); poses.push(null); savePromptHistory(prompt); }
poseEntries.forEach(e => { prompts.push(e.text ?? e); poses.push(e.name); });
// Send the chosen (or newest) file from each selected group
const filenames = [...selectedFiles].flatMap(base => {
const data = groupData.get(base);
if (!data) return [];
const idx = selectedVariants.get(base) ?? 0;
return [data.names[idx]];
});
// Try to use a common group_id if all selected files belong to the same group
let group_id = null;
const groups = new Set();
[...selectedFiles].forEach(base => {
const custom = customGroups[groupData.get(base)?.names[0]];
if (custom) groups.add(custom);
else groups.add(base);
});
if (groups.size === 1) group_id = [...groups][0];
document.getElementById('batchProgress').textContent = 'Submitting…';
try {
const r = await fetch(`${API}/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames, prompt: prompts, poses, seed: -1, max_area: 0, group_id }),
});
if (!r.ok) { showToast('Batch failed to start', 'info'); document.getElementById('batchProgress').textContent = ''; return; }
const { job_id, total } = await r.json();
showToast(`Processing ${total} task${total !== 1 ? 's' : ''}`, 'info');
pollJob(job_id, total);
} catch (e) {
showToast('API not reachable', 'info');
document.getElementById('batchProgress').textContent = '';
}
}
async function processMultiRef() {
const n = selectedFiles.size;
if (n < 2 || n > 3) { showToast('Select 23 images for multi-ref', 'info'); return; }
const prompt = document.getElementById('batchPromptInput').value.trim();
const poseEntries = [...selectedPoses].map(p => ({ name: p, text: availablePoses[p]?.text ?? availablePoses[p] }));
if (!prompt && poseEntries.length === 0) { showToast('Enter a prompt or select a pose', 'info'); return; }
// Resolve chosen variant for each selected group (first selected = primary/image1)
const filenames = [...selectedFiles].map(base => {
const data = groupData.get(base);
if (!data) return null;
const idx = selectedVariants.get(base) ?? 0;
return data.names[idx];
}).filter(Boolean);
const prompts = [];
const poses = [];
if (prompt) { prompts.push(prompt); poses.push(null); savePromptHistory(prompt); }
poseEntries.forEach(e => { prompts.push(e.text); poses.push(e.name); });
document.getElementById('batchProgress').textContent = 'Submitting…';
try {
const r = await fetch(`${API}/multi-ref`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames, prompt: prompts, poses, seed: -1, max_area: 0 }),
});
if (!r.ok) { showToast('Multi-ref failed to start', 'info'); document.getElementById('batchProgress').textContent = ''; return; }
const { job_id, total } = await r.json();
showToast(`Multi-ref: ${total} task${total !== 1 ? 's' : ''}`, 'info');
pollJob(job_id, total);
} catch (e) {
showToast('API not reachable', 'info');
document.getElementById('batchProgress').textContent = '';
}
}
function pollJob(job_id, total) {
clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
try {
const r = await fetch(`${API}/batch/${job_id}`);
if (!r.ok) return;
const job = await r.json();
const done = job.done + job.failed;
document.getElementById('batchProgress').textContent = `${done}/${total}`;
if (job.status === 'running') {
pollJob(job_id, total);
} else {
const msg = job.failed > 0
? `Done: ${job.done} ok, ${job.failed} failed`
: `Done: ${job.done} processed`;
document.getElementById('batchProgress').textContent = msg;
showToast(msg, 'success');
loadImages();
}
} catch (e) { /* ignore transient errors */ pollJob(job_id, total); }
}, 2000);
}
// --- prompt history (localStorage) ---
function loadPromptHistory() {
const hist = JSON.parse(localStorage.getItem('batchPromptHistory') || '[]');
const dl = document.getElementById('promptHistory');
dl.innerHTML = hist.map(p => `<option value="${p.replace(/"/g, '&quot;')}"></option>`).join('');
}
function savePromptHistory(prompt) {
let hist = JSON.parse(localStorage.getItem('batchPromptHistory') || '[]');
hist = [prompt, ...hist.filter(p => p !== prompt)].slice(0, 20);
localStorage.setItem('batchPromptHistory', JSON.stringify(hist));
loadPromptHistory();
}
// --- tagging ---
let lbCurrentGid = null;
async function tagCurrentImage() {
const data = groupData.get(lbCurrentGid);
if (!data) return;
const filename = data.names[lbIdx];
const btn = document.querySelector('#lightbox .lb-btn[onclick="tagCurrentImage()"]');
if (btn) btn.textContent = '…';
try {
const r = await fetch(`${API}/tag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, threshold: 0.35, max_tags: 8 }),
});
if (r.ok) {
const { name } = await r.json();
imageNames[filename] = name;
document.getElementById('lbName').textContent = name;
showToast(`Tagged: ${name}`, 'success');
// Update the card label too
const card = [...document.querySelectorAll('.image-card')].find(c => c.dataset.group === lbCurrentGid);
if (card) card.querySelector('.image-name').textContent = name;
} else {
const err = await r.text();
showToast(`Tag failed: ${err}`, 'info');
}
} catch (e) { showToast('API not reachable', 'info'); }
if (btn) btn.textContent = 'Tag';
}
async function extractCurrentImage() {
const data = groupData.get(lbCurrentGid);
if (!data || data.names.length <= 1) { showToast('Already standalone', 'info'); return; }
const filename = data.names[lbIdx];
try {
const r = await fetch(`${API}/groups/extract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename }),
});
if (r.ok) {
customGroups[filename] = `solo:${filename}`;
showToast('Extracted to own group', 'success');
closeLightbox();
await loadImages();
}
} catch (e) { showToast('API not reachable', 'info'); }
}
async function groupSelected() {
if (selectedFiles.size < 2) { showToast('Select 2+ groups to merge', 'info'); return; }
// Collect all filenames from selected groups
const filenames = [...selectedFiles].flatMap(gid => {
const data = groupData.get(gid);
return data ? data.names : [];
});
try {
const r = await fetch(`${API}/groups/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames }),
});
if (r.ok) {
const { group_id } = await r.json();
filenames.forEach(f => { customGroups[f] = group_id; });
showToast(`Merged into group ${group_id}`, 'success');
deselectAll();
await loadImages();
}
} catch (e) { showToast('API not reachable', 'info'); }
}
async function loadCurrentPrompt() {
try {
const r = await fetch(`${API}/config`);
if (r.ok) {
const conf = await r.json();
document.getElementById('promptInput').value = conf.prompt || '';
}
} catch (e) {
// API not reachable yet
}
}
async function setPrompt() {
const prompt = document.getElementById('promptInput').value.trim();
if (!prompt) return;
try {
const r = await fetch(`${API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
if (r.ok) {
showToast('Prompt updated', 'success');
} else {
showToast('Failed to update prompt', 'info');
}
} catch (e) {
showToast('API not reachable', 'info');
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadCurrentPrompt();
loadPromptHistory();
loadImages();
autoRefreshTimer = setInterval(refreshNow, REFRESH_INTERVAL * 1000);
// Privacy mode init — apply stored state on load
(function applyPrivacyInit() {
const btn = document.getElementById('privacyBtn');
if (privacyMode) btn.textContent = '🔒';
})();
// Lightbox + variant picker + select-mode keyboard nav
document.addEventListener('keydown', e => {
// P — toggle privacy mode (only when no text input focused)
const tag = document.activeElement && document.activeElement.tagName;
if ((e.key === 'p' || e.key === 'P') && tag !== 'INPUT' && tag !== 'TEXTAREA') {
togglePrivacyMode(); return;
}
if (e.key === 'Escape') {
if (document.getElementById('variantPicker').classList.contains('open')) {
closeVariantPicker(); return;
}
if (document.getElementById('lightbox').classList.contains('open')) {
closeLightbox(); return;
}
if (selectionMode) {
toggleSelectMode(); return;
}
}
if (!document.getElementById('lightbox').classList.contains('open')) return;
if (e.key === 'ArrowLeft') lbNav(-1);
if (e.key === 'ArrowRight') lbNav(1);
});
});
// Privacy overlay — show when window loses focus, hide on regain
window.addEventListener('blur', () => {
if (privacyMode) document.getElementById('privacyOverlay').style.display = 'flex';
});
window.addEventListener('focus', () => {
document.getElementById('privacyOverlay').style.display = 'none';
});
// Handle visibility change — refresh when tab becomes active; privacy on hide
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (privacyMode) document.getElementById('privacyOverlay').style.display = 'flex';
} else {
document.getElementById('privacyOverlay').style.display = 'none';
loadImages();
}
});
function togglePrivacyMode() {
privacyMode = !privacyMode;
localStorage.setItem('privacyMode', privacyMode);
const btn = document.getElementById('privacyBtn');
btn.textContent = privacyMode ? '🔒' : '🔓';
if (!privacyMode) document.getElementById('privacyOverlay').style.display = 'none';
}
function disablePrivacyOverlay() {
document.getElementById('privacyOverlay').style.display = 'none';
}
async function setAsPreferred(filename, btn) {
try {
const r = await fetch(`/images/${encodeURIComponent(filename)}/set-preferred`, {method:'POST'});
if (!r.ok) { console.error('set-preferred failed', r.status); return; }
const data = await r.json();
// Update sort orders locally
const gid = data.group_id;
fileSortOrders[filename] = 0;
// Re-open picker to reflect new order
showVariantPicker(filename);
} catch(e) { console.error(e); }
}
async function lbSetPreferred() {
const fname = lbNames[lbIdx];
if (!fname) return;
try {
const r = await fetch(`/images/${encodeURIComponent(fname)}/set-preferred`, {method:'POST'});
if (!r.ok) return;
const data = await r.json();
fileSortOrders[fname] = 0;
// Move to front in both parallel arrays so lbIdx=0 lands on the promoted image
const url = lbUrls[lbIdx];
const otherUrls = lbUrls.filter((_, i) => i !== lbIdx);
const otherNames = lbNames.filter((_, i) => i !== lbIdx);
lbUrls = [url, ...otherUrls];
lbNames = [fname, ...otherNames];
lbIdx = 0;
updateLightbox();
} catch(e) { console.error(e); }
}
</script>
</body>
</html>