3348 lines
140 KiB
HTML
3348 lines
140 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Studio 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; }
|
||
|
||
/* --- lightbox variant strip --- */
|
||
#lbVariantStrip {
|
||
display: none;
|
||
flex-direction: row;
|
||
gap: 6px;
|
||
overflow-x: auto;
|
||
padding: 6px 2px 2px;
|
||
width: 100%;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #444 transparent;
|
||
}
|
||
.lb-var-thumb {
|
||
flex: 0 0 auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 2px;
|
||
cursor: pointer;
|
||
opacity: 0.5;
|
||
transition: opacity 0.15s;
|
||
max-width: 60px;
|
||
}
|
||
.lb-var-thumb:hover { opacity: 0.85; }
|
||
.lb-var-thumb.active { opacity: 1; outline: 2px solid #60a5fa; border-radius: 4px; }
|
||
.lb-var-thumb img {
|
||
width: 52px; height: 52px;
|
||
object-fit: cover;
|
||
border-radius: 4px;
|
||
display: block;
|
||
}
|
||
.lb-var-pose {
|
||
font-size: 7px;
|
||
color: #999;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 58px;
|
||
}
|
||
.lb-var-thumb { position: relative; }
|
||
.lb-var-play {
|
||
position: absolute;
|
||
top: 50%; left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
font-size: 13px;
|
||
color: #fff;
|
||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* --- group card grid overview --- */
|
||
.card-grid {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
grid-template-rows: 1fr 1fr;
|
||
gap: 2px;
|
||
background: #111;
|
||
}
|
||
.card-grid-thumb {
|
||
position: relative;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
}
|
||
.card-grid-thumb img {
|
||
width: 100%; height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.card-grid-thumb .thumb-pose {
|
||
position: absolute;
|
||
bottom: 0; left: 0; right: 0;
|
||
background: rgba(0,0,0,0.72);
|
||
font-size: 7px;
|
||
color: #ccc;
|
||
padding: 1px 3px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
text-align: center;
|
||
pointer-events: none;
|
||
}
|
||
.card-grid-more {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.55);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* --- privacy overlay: disguised as an innocuous AI study assistant --- */
|
||
.privacy-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 999999;
|
||
background: #f7f7f8;
|
||
color: #1f2328;
|
||
flex-direction: column;
|
||
cursor: default;
|
||
user-select: none;
|
||
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||
}
|
||
.priv-topbar {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 12px 18px;
|
||
border-bottom: 1px solid #e5e5e6;
|
||
background: #fff;
|
||
font-size: 14px; font-weight: 600; color: #2d2d2d;
|
||
}
|
||
.priv-topbar .priv-logo {
|
||
width: 26px; height: 26px; border-radius: 6px;
|
||
background: linear-gradient(135deg,#10a37f,#0d8a6c);
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: #fff; font-size: 14px; font-weight: 700;
|
||
}
|
||
.priv-topbar .priv-sub { font-weight: 400; color: #8a8a8a; font-size: 12px; margin-left: 2px; }
|
||
.priv-chat {
|
||
flex: 1; overflow-y: auto;
|
||
padding: 26px 0;
|
||
display: flex; flex-direction: column; align-items: center;
|
||
}
|
||
.priv-msg-row { width: 100%; padding: 18px 0; }
|
||
.priv-msg-row.assistant { background: #f0f4f9; border-top: 1px solid #ecedef; border-bottom: 1px solid #ecedef; }
|
||
.priv-msg-inner {
|
||
max-width: 720px; margin: 0 auto; padding: 0 24px;
|
||
display: flex; gap: 16px; align-items: flex-start;
|
||
}
|
||
.priv-avatar {
|
||
flex: 0 0 28px; width: 28px; height: 28px; border-radius: 4px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 12px; font-weight: 700; color: #fff;
|
||
}
|
||
.priv-avatar.user { background: #b4408a; }
|
||
.priv-avatar.bot { background: #10a37f; }
|
||
.priv-text { font-size: 15px; line-height: 1.7; color: #2d333a; }
|
||
.priv-text .eq { font-family: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace; background: #e8ecf1; padding: 1px 5px; border-radius: 4px; }
|
||
.priv-inputbar {
|
||
border-top: 1px solid #e5e5e6; background: #fff;
|
||
padding: 14px 0 22px;
|
||
}
|
||
.priv-inputbox {
|
||
max-width: 720px; margin: 0 auto; padding: 0 24px;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.priv-fakeinput {
|
||
flex: 1; border: 1px solid #d9d9e3; border-radius: 12px;
|
||
padding: 12px 16px; font-size: 14px; color: #9a9a9a;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||
}
|
||
.priv-send { width: 34px; height: 34px; border-radius: 8px; background: #10a37f; color:#fff; display:flex; align-items:center; justify-content:center; font-size:15px; }
|
||
.priv-hint { text-align:center; font-size: 11px; color: #b0b0b0; margin-top: 10px; }
|
||
|
||
/* --- 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; }
|
||
|
||
/* ---- professional UI polish ---- */
|
||
body { background: #0a0a0a; }
|
||
|
||
.header {
|
||
background: rgba(10,10,10,0.96);
|
||
border-bottom: 1px solid #1e1e1e;
|
||
padding: 14px 20px;
|
||
gap: 12px;
|
||
}
|
||
.header h1 { font-size: 16px; letter-spacing: -0.2px; }
|
||
.header h1 span { color: #555; font-weight: 400; font-size: 13px; margin-left: 4px; }
|
||
|
||
.btn {
|
||
background: #181818;
|
||
border: 1px solid #2a2a2a;
|
||
color: #aaa;
|
||
padding: 5px 12px;
|
||
border-radius: 7px;
|
||
font-size: 12px;
|
||
height: 30px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
}
|
||
.btn:hover { background: #222; border-color: #3a3a3a; color: #eee; }
|
||
.btn.primary { background: #1d4ed8; border-color: #2563eb; color: #fff; }
|
||
.btn.primary:hover { background: #2563eb; }
|
||
|
||
.prompt-input { background: #111; border-color: #2a2a2a; border-radius: 7px; }
|
||
.prompt-input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.2); }
|
||
|
||
.image-card {
|
||
background: #111;
|
||
border: 1px solid #1a1a1a;
|
||
border-radius: 14px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||
}
|
||
.image-card:hover {
|
||
border-color: #2e2e2e;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
|
||
transform: translateY(-3px);
|
||
}
|
||
.image-info { padding: 12px 14px; }
|
||
.image-name { font-size: 13px; }
|
||
.image-meta { font-size: 11px; }
|
||
|
||
.gallery { padding-top: 96px; gap: 16px; }
|
||
|
||
.batch-bar {
|
||
background: rgba(8,8,8,0.98);
|
||
border-top: 1px solid #1e1e1e;
|
||
}
|
||
.batch-prompt { background: #111; border-color: #2a2a2a; border-radius: 7px; }
|
||
|
||
/* ---- video card support ---- */
|
||
.image-wrapper video {
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
width: 100%; height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
border-radius: 0;
|
||
}
|
||
.video-play-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
z-index: 4;
|
||
background: rgba(0,0,0,0.25);
|
||
}
|
||
.image-card:hover .video-play-overlay { opacity: 1; }
|
||
.video-play-icon {
|
||
width: 42px; height: 42px;
|
||
background: rgba(255,255,255,0.88);
|
||
border-radius: 50%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
|
||
}
|
||
.badge.video { background: #6d28d9; }
|
||
.badge.fs { background: #0f766e; }
|
||
|
||
/* ---- faceswap modal ---- */
|
||
.faceswap-modal {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.88);
|
||
z-index: 600;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.faceswap-modal.open { display: flex; }
|
||
.faceswap-modal-inner {
|
||
background: #111;
|
||
border: 1px solid #252525;
|
||
border-radius: 16px;
|
||
padding: 22px 28px 24px;
|
||
max-width: 640px;
|
||
width: 92vw;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 24px 72px rgba(0,0,0,0.9);
|
||
}
|
||
/* ---- modal header ---- */
|
||
.fs-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.fs-model-thumb {
|
||
width: 52px; height: 52px;
|
||
object-fit: cover;
|
||
border-radius: 8px;
|
||
border: 1px solid #2a2a2a;
|
||
flex-shrink: 0;
|
||
}
|
||
.fs-header-info { min-width: 0; }
|
||
.faceswap-modal-title {
|
||
font-size: 15px;
|
||
font-weight: 650;
|
||
color: #fff;
|
||
margin-bottom: 2px;
|
||
}
|
||
.faceswap-modal-sub {
|
||
font-size: 11px;
|
||
color: #555;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
/* ---- tabs ---- */
|
||
.fs-tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
margin-bottom: 14px;
|
||
border-bottom: 1px solid #222;
|
||
}
|
||
.fs-tab {
|
||
background: none;
|
||
border: none;
|
||
color: #555;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
padding: 7px 16px;
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px;
|
||
transition: color 0.15s, border-color 0.15s;
|
||
}
|
||
.fs-tab:hover { color: #aaa; }
|
||
.fs-tab.active { color: #e4e4e7; border-bottom-color: #7c3aed; }
|
||
.fs-tab-content { display: none; }
|
||
.fs-tab-content.active { display: block; }
|
||
/* ---- poses grid (generate tab) ---- */
|
||
.fs-poses-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
padding: 2px 0 10px;
|
||
}
|
||
.fs-pose-btn {
|
||
padding: 5px 11px;
|
||
border-radius: 6px;
|
||
border: 1px solid #2a2a2a;
|
||
background: #18181b;
|
||
color: #888;
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: all 0.13s;
|
||
}
|
||
.fs-pose-btn:hover { border-color: #444; color: #ccc; }
|
||
.fs-pose-btn.done {
|
||
border-color: #14532d;
|
||
background: #052e16;
|
||
color: #4ade80;
|
||
}
|
||
.fs-pose-btn.done::before { content: '✓ '; opacity: 0.8; }
|
||
.fs-pose-btn.selected {
|
||
border-color: #6d28d9;
|
||
background: #1e1040;
|
||
color: #c4b5fd;
|
||
font-weight: 600;
|
||
}
|
||
.fs-pose-btn.done.selected { border-color: #6d28d9; background: #1e1040; color: #c4b5fd; }
|
||
.fs-prompt-row { margin-top: 10px; }
|
||
.fs-prompt-input {
|
||
width: 100%; box-sizing: border-box;
|
||
background: #18181b;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 7px;
|
||
color: #e4e4e7;
|
||
font-size: 12px;
|
||
padding: 8px 10px;
|
||
}
|
||
.fs-prompt-input:focus { outline: none; border-color: #7c3aed; }
|
||
.fs-prompt-input::placeholder { color: #3f3f46; }
|
||
/* ---- trim tool ---- */
|
||
.template-card .trim-btn {
|
||
position: absolute;
|
||
top: 5px; right: 5px;
|
||
width: 24px; height: 24px;
|
||
background: rgba(0,0,0,0.72);
|
||
border: 1px solid #333;
|
||
border-radius: 5px;
|
||
color: #888;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transition: opacity 0.15s, color 0.1s;
|
||
z-index: 3;
|
||
}
|
||
.template-card:hover .trim-btn { opacity: 1; }
|
||
.template-card .trim-btn:hover { color: #fff; background: rgba(0,0,0,0.92); }
|
||
.fs-trim-panel {
|
||
background: #0d0d10;
|
||
border: 1px solid #222;
|
||
border-radius: 10px;
|
||
padding: 14px 16px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.fs-trim-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
font-size: 12px;
|
||
color: #aaa;
|
||
font-weight: 600;
|
||
}
|
||
.fs-trim-close {
|
||
background: none; border: none;
|
||
color: #555; cursor: pointer;
|
||
font-size: 15px; line-height: 1;
|
||
}
|
||
.fs-trim-close:hover { color: #ccc; }
|
||
.fs-trim-form {
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
align-items: flex-end;
|
||
}
|
||
.fs-trim-form label {
|
||
display: flex; flex-direction: column; gap: 4px;
|
||
font-size: 11px; color: #555;
|
||
}
|
||
.fs-trim-form input[type=number],
|
||
.fs-trim-form input[type=text] {
|
||
background: #18181b;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 5px;
|
||
color: #e4e4e7;
|
||
font-size: 12px;
|
||
padding: 5px 8px;
|
||
width: 84px;
|
||
}
|
||
.fs-trim-form input[type=text] { width: 150px; }
|
||
.fs-trim-form input:focus { outline: none; border-color: #7c3aed; }
|
||
.fs-trim-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
margin-top: 10px;
|
||
}
|
||
.fs-trim-status { font-size: 11px; color: #666; margin-right: auto; }
|
||
.template-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 22px;
|
||
}
|
||
.template-card {
|
||
background: #181818;
|
||
border: 2px solid #222;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||
aspect-ratio: 16/9;
|
||
position: relative;
|
||
}
|
||
.template-card:hover {
|
||
border-color: #7c3aed;
|
||
transform: scale(1.03);
|
||
box-shadow: 0 4px 20px rgba(124,58,237,0.25);
|
||
}
|
||
.template-card.selected {
|
||
border-color: #22c55e;
|
||
box-shadow: 0 4px 20px rgba(34,197,94,0.2);
|
||
}
|
||
.template-card video {
|
||
width: 100%; height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
pointer-events: none;
|
||
}
|
||
.template-card-label {
|
||
position: absolute;
|
||
bottom: 0; left: 0; right: 0;
|
||
background: linear-gradient(transparent, rgba(0,0,0,0.8));
|
||
color: #ddd;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
padding: 12px 8px 5px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.template-empty {
|
||
color: #444;
|
||
font-size: 13px;
|
||
text-align: center;
|
||
padding: 30px;
|
||
grid-column: 1/-1;
|
||
}
|
||
.faceswap-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
}
|
||
.fs-job-status {
|
||
font-size: 12px;
|
||
color: #888;
|
||
margin-right: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.fs-spinner {
|
||
width: 12px; height: 12px;
|
||
border: 2px solid #333;
|
||
border-top-color: #7c3aed;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
flex-shrink: 0;
|
||
}
|
||
.fs-options {
|
||
display: flex;
|
||
gap: 18px;
|
||
padding: 8px 4px 0;
|
||
align-items: center;
|
||
}
|
||
.fs-opt-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: #aaa;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.fs-opt-label input[type=checkbox] {
|
||
accent-color: #7c3aed;
|
||
width: 14px; height: 14px;
|
||
cursor: pointer;
|
||
}
|
||
.fs-opt-label input[type=checkbox]:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.fs-opt-badge {
|
||
font-size: 10px;
|
||
padding: 1px 6px;
|
||
border-radius: 8px;
|
||
background: #27272a;
|
||
color: #666;
|
||
border: 1px solid #333;
|
||
}
|
||
|
||
/* ---- lb-info tighter layout ---- */
|
||
.lb-info {
|
||
padding: 8px 16px;
|
||
border-radius: 14px;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.lb-section-sep {
|
||
width: 1px; height: 18px;
|
||
background: rgba(255,255,255,0.15);
|
||
flex-shrink: 0;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="privacyOverlay" class="privacy-overlay" onclick="if(privacyMode)disablePrivacyOverlay()">
|
||
<div class="priv-topbar">
|
||
<div class="priv-logo">S</div>
|
||
Study Assistant <span class="priv-sub">· Algebra tutor</span>
|
||
</div>
|
||
<div class="priv-chat">
|
||
<div class="priv-msg-row">
|
||
<div class="priv-msg-inner">
|
||
<div class="priv-avatar user">You</div>
|
||
<div class="priv-text">A stamp collection has 3 times as many foreign stamps as domestic ones. If there are 48 stamps in total, how many are foreign?</div>
|
||
</div>
|
||
</div>
|
||
<div class="priv-msg-row assistant">
|
||
<div class="priv-msg-inner">
|
||
<div class="priv-avatar bot">AI</div>
|
||
<div class="priv-text">
|
||
Let <span class="eq">d</span> be the number of domestic stamps. Then the foreign stamps number <span class="eq">3d</span>.<br><br>
|
||
Since the whole collection is 48:<br>
|
||
<span class="eq">d + 3d = 48</span> → <span class="eq">4d = 48</span> → <span class="eq">d = 12</span>.<br><br>
|
||
So there are <span class="eq">3 × 12 = 36</span> foreign stamps (and 12 domestic).
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="priv-msg-row">
|
||
<div class="priv-msg-inner">
|
||
<div class="priv-avatar user">You</div>
|
||
<div class="priv-text">If I add 12 more foreign stamps to the collection, what's the new ratio of foreign to domestic?</div>
|
||
</div>
|
||
</div>
|
||
<div class="priv-msg-row assistant">
|
||
<div class="priv-msg-inner">
|
||
<div class="priv-avatar bot">AI</div>
|
||
<div class="priv-text">
|
||
New foreign total: <span class="eq">36 + 12 = 48</span>, with domestic unchanged at <span class="eq">12</span>.<br><br>
|
||
Ratio <span class="eq">foreign : domestic = 48 : 12</span>. Divide both by 12 → <span class="eq">4 : 1</span>.<br><br>
|
||
So the collection is now 4 foreign stamps for every 1 domestic stamp.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="priv-inputbar">
|
||
<div class="priv-inputbox">
|
||
<div class="priv-fakeinput">Ask a follow-up question…</div>
|
||
<div class="priv-send">➤</div>
|
||
</div>
|
||
<div class="priv-hint">Press P or click to resume</div>
|
||
</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>
|
||
Studio Monitor <span>AI</span>
|
||
</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>
|
||
<button class="btn" onclick="cleanupDb()" title="Remove DB records for deleted/missing files" style="font-size:11px;opacity:0.6">Clean DB</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="" />
|
||
<video id="lbVideo" style="display:none;max-width:88vw;max-height:88vh;border-radius:4px" controls autoplay muted loop></video>
|
||
<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()" title="Re-run tagger">Re-tag</button>
|
||
<button class="lb-btn" id="lbUndressBtn" style="font-size:11px;padding:3px 10px;background:#7c3aed;display:none" onclick="lbUndress()" title="Generate without clothing">Undress</button>
|
||
<button class="lb-btn" id="lbFaceswapBtn" style="font-size:11px;padding:3px 10px;background:#0f766e" onclick="lbFaceswap()" title="Faceswap into wireframe video">Faceswap</button>
|
||
<div class="lb-section-sep"></div>
|
||
<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 id="lbVariantStrip"></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 (2–3) 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>
|
||
|
||
<!-- Faceswap / Generate studio modal -->
|
||
<div id="faceswapModal" class="faceswap-modal" onclick="if(event.target===this)closeFaceswapModal()">
|
||
<div class="faceswap-modal-inner">
|
||
|
||
<!-- Header: model thumbnail + identity -->
|
||
<div class="fs-header">
|
||
<img id="fsModelThumb" class="fs-model-thumb" src="" alt="" style="display:none">
|
||
<div class="fs-header-info">
|
||
<div class="faceswap-modal-title">Studio</div>
|
||
<div class="faceswap-modal-sub" id="faceswapModalSub">Select a template or generate poses</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div class="fs-tabs">
|
||
<button class="fs-tab active" data-tab="swap" onclick="switchFsTab('swap')">Faceswap</button>
|
||
<button class="fs-tab" data-tab="gen" onclick="switchFsTab('gen')">Generate poses</button>
|
||
</div>
|
||
|
||
<!-- ── Tab: Faceswap ── -->
|
||
<div class="fs-tab-content active" id="fsContentSwap">
|
||
<!-- Trim panel (hidden until scissors clicked) -->
|
||
<div class="fs-trim-panel" id="fsTrimPanel" style="display:none">
|
||
<div class="fs-trim-header">
|
||
<span id="fsTrimVideoName">✂ Trim video</span>
|
||
<button class="fs-trim-close" onclick="closeTrimPanel()">✕</button>
|
||
</div>
|
||
<div class="fs-trim-form">
|
||
<label>Start (s)<input type="number" id="fsTrimStart" min="0" step="0.1" value="0"></label>
|
||
<label>End (s)<input type="number" id="fsTrimEnd" min="0" step="0.1" value="10"></label>
|
||
<label>Output name<input type="text" id="fsTrimName" placeholder="auto"></label>
|
||
</div>
|
||
<div class="fs-trim-actions">
|
||
<span class="fs-trim-status" id="fsTrimStatus"></span>
|
||
<button class="btn" onclick="closeTrimPanel()">Cancel</button>
|
||
<button class="btn primary" onclick="submitTrim()">Create clip ✂</button>
|
||
</div>
|
||
</div>
|
||
<div class="template-grid" id="templateGrid">
|
||
<div class="template-empty">Loading templates…</div>
|
||
</div>
|
||
<div class="fs-options">
|
||
<label class="fs-opt-label" title="Apply GFPGAN face restoration to each frame (recommended)">
|
||
<input type="checkbox" id="fsEnhance" checked>
|
||
<span>Enhance faces</span>
|
||
</label>
|
||
<label class="fs-opt-label" id="fsHairLabel" title="Use FaceFusion with ghost_3_256 model for higher quality (install_facefusion.sh required)">
|
||
<input type="checkbox" id="fsHair" disabled>
|
||
<span>FaceFusion</span>
|
||
<span class="fs-opt-badge" id="fsHairBadge">not installed</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Tab: Generate poses ── -->
|
||
<div class="fs-tab-content" id="fsContentGen">
|
||
<div class="fs-poses-grid" id="fsPosesGrid">
|
||
<div class="template-empty">Loading poses…</div>
|
||
</div>
|
||
<div class="fs-prompt-row">
|
||
<input type="text" id="fsPromptInput" class="fs-prompt-input"
|
||
placeholder="Custom prompt — overrides pose text if filled in"
|
||
oninput="updateFsActionBtn()">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Shared footer -->
|
||
<div class="faceswap-actions" style="margin-top:16px">
|
||
<span class="fs-job-status" id="fsJobStatus" style="display:none">
|
||
<span class="fs-spinner"></span>
|
||
<span id="fsJobText">Processing…</span>
|
||
</span>
|
||
<button class="btn" onclick="closeFaceswapModal()">Cancel</button>
|
||
<button class="btn primary" id="fsStartBtn" onclick="submitFsAction()" disabled>Start Faceswap</button>
|
||
</div>
|
||
</div>
|
||
</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 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() : '')); }
|
||
// 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.
|
||
function posterFor(url) {
|
||
if (!url) return url;
|
||
const q = url.indexOf('?');
|
||
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;
|
||
return path.replace(/\.[^.]+$/, '.jpg') + qs;
|
||
}
|
||
|
||
// 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 fileHasClothing = {}; // filename → boolean|null (null = unknown)
|
||
let fileContentType = {}; // filename → 'image'|'video'
|
||
let fileFaceswapSrcVideo = {}; // filename → source video name
|
||
let privacyMode = localStorage.getItem('privacyMode') === 'true';
|
||
|
||
// ---- faceswap state ----
|
||
let _fsModelFilename = null; // model image selected for faceswap
|
||
let _fsSelectedVideo = null; // chosen template video name
|
||
let _fsJobId = null;
|
||
let _fsJobPollTimer = null;
|
||
let availableVideos = []; // populated from /videos
|
||
let _fsActiveTab = 'swap';
|
||
let _fsSelectedPoses = new Set();
|
||
let _fsTrimVideo = null;
|
||
|
||
function escHtml(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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 lbNavTo(idx) {
|
||
lbIdx = idx;
|
||
updateLightbox();
|
||
}
|
||
|
||
function updateLightbox() {
|
||
const fname = lbNames[lbIdx];
|
||
const isVid = isVideo(fname) || fileContentType[fname] === 'video';
|
||
const lbImgEl = document.getElementById('lbImg');
|
||
const lbVideoEl = document.getElementById('lbVideo');
|
||
if (isVid) {
|
||
lbImgEl.style.display = 'none';
|
||
lbVideoEl.style.display = '';
|
||
lbVideoEl.poster = posterFor(lbUrls[lbIdx]);
|
||
lbVideoEl.src = lbUrls[lbIdx];
|
||
} else {
|
||
lbVideoEl.style.display = 'none';
|
||
lbVideoEl.src = '';
|
||
lbImgEl.style.display = '';
|
||
lbImgEl.src = lbUrls[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 / Undress / Faceswap — hide for video items
|
||
document.getElementById('lbNoBgBtn').style.display = (!isVid && fileHasBg[fname] !== false) ? '' : 'none';
|
||
document.getElementById('lbUndressBtn').style.display = (!isVid && fileHasClothing[fname] === true) ? '' : 'none';
|
||
document.getElementById('lbFaceswapBtn').style.display = !isVid ? '' : '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';
|
||
|
||
// Variant strip — thumbnails + pose labels for every image in the group
|
||
const strip = document.getElementById('lbVariantStrip');
|
||
if (multi) {
|
||
strip.style.display = 'flex';
|
||
strip.innerHTML = lbUrls.map((url, i) => {
|
||
const n = lbNames[i];
|
||
const pose = filePoses[n] || '';
|
||
const act = i === lbIdx ? ' active' : '';
|
||
const thumbSrc = isVideo(n) ? posterFor(url) : url;
|
||
return '<div class="lb-var-thumb' + act + '" onclick="lbNavTo(' + i + ')">'
|
||
+ '<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>' : '')
|
||
+ '</div>';
|
||
}).join('');
|
||
// Scroll active thumb into view
|
||
const active = strip.querySelector('.lb-var-thumb.active');
|
||
if (active) active.scrollIntoView({ block: 'nearest', inline: 'center' });
|
||
} else {
|
||
strip.style.display = '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(12000) });
|
||
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;
|
||
fileHasClothing[img.filename] = img.has_clothing ?? null;
|
||
fileContentType[img.filename] = img.content_type || 'image';
|
||
fileFaceswapSrcVideo[img.filename]= img.faceswap_source_video || null;
|
||
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 (!availableVideos.length) {
|
||
loadTemplateVideos();
|
||
}
|
||
|
||
if (fileObjects.length === 0) {
|
||
// Only show "no images" if gallery is currently empty (avoid flash on slow API)
|
||
if (!gallery.querySelector('.image-card')) {
|
||
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, '"');
|
||
const poses = [...new Set(gf.map(f => filePoses[f.cleanName]).filter(Boolean))];
|
||
const firstVisUrl = visibleIdx.length ? urls[visibleIdx[0]] : urls[0];
|
||
const firstVisName = visibleIdx.length ? names[visibleIdx[0]] : names[0];
|
||
const isVideoCard = isVideo(firstVisName) || fileContentType[firstVisName] === 'video';
|
||
const isFaceswap = !!fileFaceswapSrcVideo[firstVisName];
|
||
|
||
const mediaEl = isVideoCard
|
||
? `<video src="${firstVisUrl}" poster="${posterFor(firstVisUrl)}" autoplay muted loop playsinline preload="metadata"
|
||
onerror="this.style.opacity='0.2'" style="border-radius:0"></video>
|
||
<div class="video-play-overlay"><div class="video-play-icon">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="#111">
|
||
<polygon points="5,3 19,12 5,21"/>
|
||
</svg></div></div>`
|
||
: `<img src="${firstVisUrl}" alt="${label}" loading="${isRecent ? 'eager' : 'lazy'}" onerror="this.style.opacity='0.2'">`;
|
||
|
||
return `
|
||
<div class="image-card ${isNew ? 'new' : ''}" data-group="${safeGid}" onclick="handleCardClick(this)">
|
||
<div class="image-actions">
|
||
${!isVideoCard ? `<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>` : ''}
|
||
${mediaEl}
|
||
${vCount > 1 && !isVideoCard ? `<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>
|
||
${isVideoCard ? `<span class="badge video" style="font-size:9px">${isFaceswap ? 'Faceswap' : 'Video'}</span>` : ''}
|
||
${!isVideoCard && poses.length ? `<span class="badge" style="background:#78350f;font-size:9px">${escHtml(poses[0])}</span>` : ''}
|
||
${isRecent && !isVideoCard ? '<span class="badge recent">Latest</span>' : ''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
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, '"')}">
|
||
${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="${isVideo(data.names[i]) ? posterFor(url) : 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 2–3 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, '"')}"></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 = '🔒'; document.title = PRIV_TITLE; }
|
||
})();
|
||
|
||
// 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('faceswapModal').classList.contains('open')) {
|
||
closeFaceswapModal(); return;
|
||
}
|
||
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 — disguise the page as a study-chat on any focus loss.
|
||
const PRIV_TITLE = 'Study Assistant';
|
||
const REAL_TITLE = document.title; // "Studio Monitor"
|
||
|
||
function showPrivacyOverlay() {
|
||
const ov = document.getElementById('privacyOverlay');
|
||
ov.style.display = 'flex';
|
||
// Force a synchronous layout+style flush so the disguise is painted
|
||
// BEFORE the window manager grabs the minimize thumbnail — otherwise
|
||
// the first minimize leaks the real content. Text-only overlay = no
|
||
// async asset loads to wait on.
|
||
void ov.offsetHeight;
|
||
document.title = PRIV_TITLE;
|
||
}
|
||
|
||
// blur fires before the minimize snapshot in every browser we target, so
|
||
// this is the line of defense for the taskbar thumbnail.
|
||
window.addEventListener('blur', () => { if (privacyMode) showPrivacyOverlay(); });
|
||
window.addEventListener('pagehide', () => { if (privacyMode) showPrivacyOverlay(); });
|
||
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.hidden) {
|
||
if (privacyMode) showPrivacyOverlay();
|
||
} else {
|
||
// Don't auto-hide — user must click overlay or press P to dismiss
|
||
loadImages();
|
||
}
|
||
});
|
||
|
||
function togglePrivacyMode() {
|
||
privacyMode = !privacyMode;
|
||
localStorage.setItem('privacyMode', privacyMode);
|
||
const btn = document.getElementById('privacyBtn');
|
||
btn.textContent = privacyMode ? '🔒' : '🔓';
|
||
if (privacyMode) {
|
||
document.title = PRIV_TITLE; // keep the taskbar label discreet even while active
|
||
} else {
|
||
document.title = REAL_TITLE;
|
||
document.getElementById('privacyOverlay').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function disablePrivacyOverlay() {
|
||
// Resume work, but privacy mode stays armed — keep the title discreet.
|
||
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 cleanupDb() {
|
||
try {
|
||
const r = await fetch(`${API}/db/cleanup`, { method: 'POST' });
|
||
const d = await r.json();
|
||
showToast(`Cleaned ${d.removed} orphan record${d.removed !== 1 ? 's' : ''}`);
|
||
if (d.removed > 0) refreshNow();
|
||
} catch(e) {
|
||
showToast('Cleanup failed');
|
||
}
|
||
}
|
||
|
||
async function lbUndress() {
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname) return;
|
||
const btn = document.getElementById('lbUndressBtn');
|
||
btn.textContent = '...';
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/undress`, {method:'POST'});
|
||
if (!r.ok) { showToast('Undress failed', 'error'); return; }
|
||
showToast('Undress queued', 'success');
|
||
} catch(e) {
|
||
showToast('Undress error', 'error');
|
||
} finally {
|
||
btn.textContent = 'Undress';
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// ---- faceswap ----
|
||
|
||
async function loadTemplateVideos() {
|
||
try {
|
||
const r = await fetch(`${API}/videos`);
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
availableVideos = d.videos || [];
|
||
} catch (e) { availableVideos = []; }
|
||
}
|
||
|
||
function renderTemplateGrid() {
|
||
const grid = document.getElementById('templateGrid');
|
||
if (!availableVideos.length) {
|
||
grid.innerHTML = '<div class="template-empty">No wireframe videos found</div>';
|
||
return;
|
||
}
|
||
grid.innerHTML = availableVideos.map(v => {
|
||
const stem = v.replace(/\.[^.]+$/, '');
|
||
const vSafe = v.replace(/'/g, "\\'");
|
||
return `<div class="template-card ${_fsSelectedVideo === v ? 'selected' : ''}"
|
||
onclick="selectTemplate('${vSafe}')">
|
||
<video src="${API}/wireframe/${encodeURIComponent(v)}"
|
||
muted loop autoplay playsinline preload="metadata"
|
||
style="pointer-events:none;width:100%;height:100%;object-fit:cover"></video>
|
||
<div class="template-card-label">${escHtml(stem)}</div>
|
||
<button class="trim-btn" title="Trim video" onclick="event.stopPropagation();openTrimPanel('${vSafe}')">✂</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function selectTemplate(videoName) {
|
||
_fsSelectedVideo = videoName;
|
||
renderTemplateGrid();
|
||
updateFsActionBtn();
|
||
}
|
||
|
||
async function lbFaceswap() {
|
||
const data = groupData.get(lbCurrentGid);
|
||
if (!data) return;
|
||
_fsModelFilename = data.names[lbIdx];
|
||
_fsSelectedVideo = null;
|
||
_fsSelectedPoses = new Set();
|
||
_fsActiveTab = 'swap';
|
||
document.getElementById('fsJobStatus').style.display = 'none';
|
||
document.getElementById('faceswapModalSub').textContent =
|
||
`Face source: ${_fsModelFilename}`;
|
||
// Set model thumbnail
|
||
const thumb = document.getElementById('fsModelThumb');
|
||
const thumbUrl = data.urls[lbIdx];
|
||
if (thumbUrl) { thumb.src = thumbUrl; thumb.style.display = ''; }
|
||
else { thumb.style.display = 'none'; }
|
||
// Apply tab state
|
||
switchFsTab('swap');
|
||
if (!availableVideos.length) await loadTemplateVideos();
|
||
renderTemplateGrid();
|
||
renderFsPosesGrid();
|
||
document.getElementById('faceswapModal').classList.add('open');
|
||
// Browsers suppress autoplay in display:none elements; kick-start after show
|
||
requestAnimationFrame(() => {
|
||
document.querySelectorAll('#templateGrid video').forEach(v => v.play().catch(()=>{}));
|
||
});
|
||
// Check which backends are available
|
||
try {
|
||
const chk = await fetch(`${API}/faceswap/check`);
|
||
if (chk.ok) {
|
||
const s = await chk.json();
|
||
const hairCb = document.getElementById('fsHair');
|
||
const hairBdg = document.getElementById('fsHairBadge');
|
||
if (s.facefusion) {
|
||
hairCb.disabled = false;
|
||
hairBdg.style.display = 'none';
|
||
} else {
|
||
hairCb.disabled = true;
|
||
hairBdg.style.display = '';
|
||
hairBdg.textContent = 'run install_facefusion.sh';
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
function closeFaceswapModal() {
|
||
document.getElementById('faceswapModal').classList.remove('open');
|
||
clearTimeout(_fsJobPollTimer);
|
||
}
|
||
|
||
function switchFsTab(tab) {
|
||
_fsActiveTab = tab;
|
||
document.querySelectorAll('.fs-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||
document.getElementById('fsContentSwap').classList.toggle('active', tab === 'swap');
|
||
document.getElementById('fsContentGen').classList.toggle('active', tab === 'gen');
|
||
updateFsActionBtn();
|
||
}
|
||
|
||
function updateFsActionBtn() {
|
||
const btn = document.getElementById('fsStartBtn');
|
||
if (_fsActiveTab === 'swap') {
|
||
btn.disabled = !_fsSelectedVideo;
|
||
btn.textContent = 'Start Faceswap';
|
||
} else {
|
||
const hasPrompt = (document.getElementById('fsPromptInput')?.value || '').trim().length > 0;
|
||
btn.disabled = _fsSelectedPoses.size === 0 && !hasPrompt;
|
||
btn.textContent = `Generate (${_fsSelectedPoses.size + (hasPrompt ? 1 : 0)})`;
|
||
}
|
||
}
|
||
|
||
function renderFsPosesGrid() {
|
||
const grid = document.getElementById('fsPosesGrid');
|
||
if (!availablePoses || Object.keys(availablePoses).length === 0) {
|
||
grid.innerHTML = '<div class="template-empty">No poses loaded</div>';
|
||
return;
|
||
}
|
||
// Collect which poses are already generated for this model's group
|
||
const donePoses = new Set();
|
||
const gData = groupData.get(lbCurrentGid);
|
||
if (gData) {
|
||
gData.names.forEach(n => { if (filePoses[n]) donePoses.add(filePoses[n]); });
|
||
}
|
||
grid.innerHTML = Object.entries(availablePoses).map(([name, entry]) => {
|
||
const isDone = donePoses.has(name);
|
||
const isSel = _fsSelectedPoses.has(name);
|
||
let cls = 'fs-pose-btn';
|
||
if (isDone) cls += ' done';
|
||
if (isSel) cls += ' selected';
|
||
const nSafe = name.replace(/'/g, "\\'");
|
||
return `<button class="${cls}" onclick="toggleFsPose('${nSafe}')" title="${String(entry?.text ?? entry).replace(/"/g,'"')}">${escHtml(name)}</button>`;
|
||
}).join('');
|
||
}
|
||
|
||
function toggleFsPose(name) {
|
||
if (_fsSelectedPoses.has(name)) _fsSelectedPoses.delete(name);
|
||
else _fsSelectedPoses.add(name);
|
||
renderFsPosesGrid();
|
||
updateFsActionBtn();
|
||
}
|
||
|
||
function submitFsAction() {
|
||
if (_fsActiveTab === 'swap') submitFaceswap();
|
||
else submitFsGenerate();
|
||
}
|
||
|
||
async function submitFsGenerate() {
|
||
if (!_fsModelFilename) return;
|
||
const promptVal = (document.getElementById('fsPromptInput')?.value || '').trim();
|
||
const prompts = [];
|
||
const poses = [];
|
||
_fsSelectedPoses.forEach(name => {
|
||
poses.push(name);
|
||
prompts.push(availablePoses[name]?.text ?? availablePoses[name] ?? name);
|
||
});
|
||
if (promptVal) { prompts.push(promptVal); poses.push(null); }
|
||
if (prompts.length === 0) return;
|
||
const btn = document.getElementById('fsStartBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Submitting…';
|
||
document.getElementById('fsJobStatus').style.display = 'flex';
|
||
document.getElementById('fsJobText').textContent = `Queuing ${prompts.length} task(s)…`;
|
||
try {
|
||
const r = await fetch(`${API}/batch`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
filenames: [_fsModelFilename],
|
||
prompt: prompts,
|
||
poses,
|
||
seed: -1,
|
||
max_area: 0,
|
||
group_id: lbCurrentGid
|
||
}),
|
||
});
|
||
if (!r.ok) {
|
||
const err = await r.text();
|
||
document.getElementById('fsJobText').textContent = `Error: ${err}`;
|
||
btn.disabled = false; btn.textContent = 'Retry';
|
||
return;
|
||
}
|
||
const { job_id, total } = await r.json();
|
||
showToast(`Generating ${total} image${total !== 1 ? 's' : ''}…`, 'info');
|
||
document.getElementById('fsJobText').textContent = `Submitted ${total} task(s) — generating…`;
|
||
// Poll until done then refresh and close
|
||
const pollGen = () => {
|
||
clearTimeout(_fsJobPollTimer);
|
||
_fsJobPollTimer = setTimeout(async () => {
|
||
try {
|
||
const jr = await fetch(`${API}/batch/${job_id}`);
|
||
if (!jr.ok) { pollGen(); return; }
|
||
const job = await jr.json();
|
||
const done = job.done + job.failed;
|
||
document.getElementById('fsJobText').textContent = `Generating… ${done}/${total}`;
|
||
if (job.status === 'running') { pollGen(); return; }
|
||
if (job.status === 'done') {
|
||
showToast('Generation complete!', 'success');
|
||
closeFaceswapModal();
|
||
refreshNow();
|
||
} else {
|
||
document.getElementById('fsJobText').textContent = `Error: ${job.error || 'unknown'}`;
|
||
btn.disabled = false; btn.textContent = 'Retry';
|
||
}
|
||
} catch (e) { pollGen(); }
|
||
}, 2500);
|
||
};
|
||
pollGen();
|
||
} catch (e) {
|
||
document.getElementById('fsJobText').textContent = `API error: ${e}`;
|
||
btn.disabled = false; btn.textContent = 'Retry';
|
||
}
|
||
}
|
||
|
||
async function openTrimPanel(videoName) {
|
||
_fsTrimVideo = videoName;
|
||
const panel = document.getElementById('fsTrimPanel');
|
||
document.getElementById('fsTrimVideoName').textContent = `✂ Trim: ${videoName}`;
|
||
document.getElementById('fsTrimStatus').textContent = '';
|
||
document.getElementById('fsTrimName').value = '';
|
||
panel.style.display = '';
|
||
// Fetch duration to pre-fill end field
|
||
try {
|
||
const r = await fetch(`${API}/wireframe/duration/${encodeURIComponent(videoName)}`);
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
document.getElementById('fsTrimStart').value = 0;
|
||
document.getElementById('fsTrimEnd').value = Math.round(d.duration * 10) / 10;
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
function closeTrimPanel() {
|
||
document.getElementById('fsTrimPanel').style.display = 'none';
|
||
_fsTrimVideo = null;
|
||
}
|
||
|
||
async function submitTrim() {
|
||
if (!_fsTrimVideo) return;
|
||
const start = parseFloat(document.getElementById('fsTrimStart').value) || 0;
|
||
const end = parseFloat(document.getElementById('fsTrimEnd').value);
|
||
const name = document.getElementById('fsTrimName').value.trim() || null;
|
||
if (!end || end <= start) {
|
||
document.getElementById('fsTrimStatus').textContent = 'End must be > start';
|
||
return;
|
||
}
|
||
document.getElementById('fsTrimStatus').textContent = 'Trimming…';
|
||
try {
|
||
const r = await fetch(`${API}/wireframe/trim`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ video_name: _fsTrimVideo, start, end, output_name: name }),
|
||
});
|
||
if (!r.ok) {
|
||
const err = await r.text();
|
||
document.getElementById('fsTrimStatus').textContent = `Error: ${err}`;
|
||
return;
|
||
}
|
||
const d = await r.json();
|
||
document.getElementById('fsTrimStatus').textContent = `Created: ${d.output_name}`;
|
||
showToast(`Clip saved: ${d.output_name}`, 'success');
|
||
// Reload template list and close trim panel after short delay
|
||
availableVideos = [];
|
||
await loadTemplateVideos();
|
||
renderTemplateGrid();
|
||
setTimeout(closeTrimPanel, 1200);
|
||
} catch (e) {
|
||
document.getElementById('fsTrimStatus').textContent = `Error: ${e}`;
|
||
}
|
||
}
|
||
|
||
async function submitFaceswap() {
|
||
if (!_fsModelFilename || !_fsSelectedVideo) return;
|
||
const btn = document.getElementById('fsStartBtn');
|
||
const enhance = document.getElementById('fsEnhance').checked;
|
||
const hair = document.getElementById('fsHair').checked && !document.getElementById('fsHair').disabled;
|
||
btn.disabled = true;
|
||
btn.textContent = 'Starting…';
|
||
document.getElementById('fsJobStatus').style.display = 'flex';
|
||
const modeLabel = hair ? 'FaceFusion + hair' : (enhance ? 'insightface + enhance' : 'insightface');
|
||
document.getElementById('fsJobText').textContent = `Starting (${modeLabel})…`;
|
||
try {
|
||
const r = await fetch(`${API}/faceswap`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ model_filename: _fsModelFilename, video_name: _fsSelectedVideo, enhance, hair }),
|
||
});
|
||
if (!r.ok) {
|
||
const err = await r.text();
|
||
document.getElementById('fsJobText').textContent = `Error: ${err}`;
|
||
btn.disabled = false;
|
||
btn.textContent = 'Start Faceswap';
|
||
return;
|
||
}
|
||
const { job_id } = await r.json();
|
||
_fsJobId = job_id;
|
||
showToast(`Faceswap started (${_fsSelectedVideo})`, 'info');
|
||
pollFaceswapJob(job_id);
|
||
} catch (e) {
|
||
document.getElementById('fsJobText').textContent = `API error: ${e}`;
|
||
btn.disabled = false;
|
||
btn.textContent = 'Start Faceswap';
|
||
}
|
||
}
|
||
|
||
function pollFaceswapJob(job_id) {
|
||
clearTimeout(_fsJobPollTimer);
|
||
_fsJobPollTimer = setTimeout(async () => {
|
||
try {
|
||
const r = await fetch(`${API}/batch/${job_id}`);
|
||
if (!r.ok) { pollFaceswapJob(job_id); return; }
|
||
const job = await r.json();
|
||
const pct = job.total > 1 ? ` ${job.done}/${job.total} frames` : '';
|
||
if (job.status === 'running') {
|
||
document.getElementById('fsJobText').textContent = `Processing${pct}…`;
|
||
pollFaceswapJob(job_id);
|
||
} else if (job.status === 'done') {
|
||
document.getElementById('fsJobText').textContent = `Done! ${job.output || ''}`;
|
||
document.getElementById('fsStartBtn').textContent = 'Done ✓';
|
||
showToast(`Faceswap complete: ${job.output || ''}`, 'success');
|
||
setTimeout(() => {
|
||
closeFaceswapModal();
|
||
refreshNow();
|
||
}, 1800);
|
||
} else {
|
||
document.getElementById('fsJobText').textContent = `Error: ${job.error || 'unknown'}`;
|
||
document.getElementById('fsStartBtn').disabled = false;
|
||
document.getElementById('fsStartBtn').textContent = 'Retry';
|
||
showToast(`Faceswap failed: ${job.error || ''}`, 'error');
|
||
}
|
||
} catch (e) { pollFaceswapJob(job_id); }
|
||
}, 2500);
|
||
}
|
||
|
||
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> |