4035 lines
175 KiB
HTML
4035 lines
175 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; }
|
||
.studio.st-hidden .lb-hidden-badge { display: block; }
|
||
.studio.st-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: #0a0a0a;
|
||
color: #e0e0e0;
|
||
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 #333;
|
||
background: #1a1a1a;
|
||
font-size: 14px; font-weight: 600; color: #e0e0e0;
|
||
}
|
||
.priv-topbar .priv-logo {
|
||
width: 26px; height: 26px; border-radius: 6px;
|
||
background: linear-gradient(135deg,#2563eb,#1d4ed8);
|
||
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: #1a1a1a; border-top: 1px solid #333; border-bottom: 1px solid #333; }
|
||
.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: #8b5cf6; }
|
||
.priv-avatar.bot { background: #2563eb; }
|
||
.priv-text { font-size: 15px; line-height: 1.7; color: #e0e0e0; }
|
||
.priv-text .eq { font-family: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace; background: #2d333a; padding: 1px 5px; border-radius: 4px; }
|
||
.priv-inputbar {
|
||
border-top: 1px solid #333; background: #1a1a1a;
|
||
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 #333; border-radius: 12px;
|
||
padding: 12px 16px; font-size: 14px; color: #888;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||
background: #151515;
|
||
}
|
||
.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;
|
||
}
|
||
|
||
/* ===================== STUDIO VIEW ===================== */
|
||
.studio {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: #0a0a0a;
|
||
z-index: 500;
|
||
flex-direction: column;
|
||
}
|
||
.studio.open { display: flex; }
|
||
|
||
.studio-topbar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 14px;
|
||
background: #0f0f0f;
|
||
border-bottom: 1px solid #1a1a1a;
|
||
height: 44px;
|
||
}
|
||
.studio-topbar-left { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||
.studio-topbar-center{ flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; overflow: hidden; }
|
||
.studio-topbar-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||
|
||
.studio-body {
|
||
flex: 1; min-height: 0;
|
||
display: flex; overflow: hidden;
|
||
}
|
||
.studio-main {
|
||
flex: 1; min-width: 0;
|
||
display: flex; flex-direction: column; overflow: hidden;
|
||
}
|
||
.studio-viewer-row {
|
||
flex: 1; min-height: 0;
|
||
display: flex; align-items: center; gap: 6px; padding: 10px 6px;
|
||
}
|
||
.studio-viewer {
|
||
flex: 1; min-width: 0; min-height: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
position: relative; height: 100%; border-radius: 6px; overflow: hidden;
|
||
}
|
||
.studio-viewer.show-checker {
|
||
background-color: #888;
|
||
background-image:
|
||
linear-gradient(45deg,#555 25%,transparent 25%),
|
||
linear-gradient(-45deg,#555 25%,transparent 25%),
|
||
linear-gradient(45deg,transparent 75%,#555 75%),
|
||
linear-gradient(-45deg,transparent 75%,#555 75%);
|
||
background-size: 16px 16px;
|
||
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||
}
|
||
#lbImg {
|
||
max-width: 100%; max-height: 100%;
|
||
object-fit: contain; border-radius: 4px;
|
||
cursor: zoom-in; transition: transform 0.25s; display: block;
|
||
}
|
||
#lbImg.zoomed { cursor: zoom-out; transform: scale(2.2); transform-origin: center; }
|
||
#lbVideo { max-width: 100%; max-height: 100%; border-radius: 4px; }
|
||
.studio-nav-btn {
|
||
flex-shrink: 0; background: rgba(255,255,255,0.06);
|
||
border: 1px solid rgba(255,255,255,0.1); color: #fff;
|
||
border-radius: 8px; cursor: pointer; font-size: 26px;
|
||
width: 38px; height: 56px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: background 0.2s; user-select: none;
|
||
}
|
||
.studio-nav-btn:hover { background: rgba(255,255,255,0.14); }
|
||
|
||
/* Film strip */
|
||
.studio-filmstrip {
|
||
flex-shrink: 0; height: 76px;
|
||
display: flex; flex-direction: row; gap: 5px;
|
||
overflow-x: auto; padding: 8px 10px;
|
||
background: #0d0d0d; border-top: 1px solid #1a1a1a;
|
||
scrollbar-width: thin; scrollbar-color: #333 transparent;
|
||
align-items: center;
|
||
}
|
||
|
||
/* ===================== SIDEBAR ===================== */
|
||
.studio-sidebar {
|
||
flex-shrink: 0; width: 320px; min-width: 320px;
|
||
display: flex; flex-direction: column;
|
||
background: #0d0d10; border-left: 1px solid #1a1a1a;
|
||
transition: width 0.2s ease, min-width 0.2s ease;
|
||
overflow: hidden; position: relative;
|
||
}
|
||
.studio-sidebar.collapsed { width: 32px; min-width: 32px; }
|
||
|
||
.sidebar-toggle-btn {
|
||
position: absolute; top: 9px; left: 5px;
|
||
width: 22px; height: 22px;
|
||
background: #1a1a1a; border: 1px solid #2a2a2a;
|
||
border-radius: 4px; color: #666; font-size: 11px;
|
||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||
z-index: 2; transition: color 0.15s;
|
||
}
|
||
.sidebar-toggle-btn:hover { color: #fff; border-color: #444; }
|
||
|
||
.sidebar-inner {
|
||
flex: 1; min-height: 0;
|
||
display: flex; flex-direction: column;
|
||
padding-top: 36px; overflow: hidden;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.studio-sidebar.collapsed .sidebar-inner { opacity: 0; pointer-events: none; }
|
||
|
||
.sidebar-tabs {
|
||
display: flex; border-bottom: 1px solid #1a1a1a;
|
||
overflow-x: auto; flex-shrink: 0; scrollbar-width: none;
|
||
}
|
||
.sidebar-tabs::-webkit-scrollbar { display: none; }
|
||
.sb-tab {
|
||
background: none; border: none; color: #555;
|
||
font-size: 11px; font-weight: 500; padding: 6px 10px;
|
||
cursor: pointer; border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px; white-space: nowrap;
|
||
transition: color 0.15s, border-color 0.15s;
|
||
}
|
||
.sb-tab:hover { color: #999; }
|
||
.sb-tab.active { color: #e4e4e7; border-bottom-color: #2563eb; }
|
||
|
||
.sb-panels { flex: 1; min-height: 0; overflow: hidden; position: relative; }
|
||
.sb-panel {
|
||
display: none; flex-direction: column;
|
||
height: 100%; overflow-y: auto;
|
||
padding: 12px 13px;
|
||
scrollbar-width: thin; scrollbar-color: #2a2a2a transparent;
|
||
}
|
||
.sb-panel.active { display: flex; }
|
||
|
||
.sb-label {
|
||
font-size: 10px; color: #4a4a4a; font-weight: 600;
|
||
text-transform: uppercase; letter-spacing: 0.6px;
|
||
margin-bottom: 5px;
|
||
}
|
||
.sb-input {
|
||
width: 100%; box-sizing: border-box;
|
||
background: #18181b; border: 1px solid #2a2a2a;
|
||
border-radius: 6px; color: #e4e4e7;
|
||
font-size: 12px; padding: 7px 10px; outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.sb-input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.15); }
|
||
.sb-input::placeholder { color: #3f3f46; }
|
||
.sb-sep { height: 1px; background: #1a1a1a; margin: 10px 0; flex-shrink: 0; }
|
||
.sb-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 4px; }
|
||
.sb-btn {
|
||
background: #18181b; border: 1px solid #2a2a2a;
|
||
color: #999; font-size: 11px; padding: 5px 9px;
|
||
border-radius: 6px; cursor: pointer;
|
||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
text-decoration: none;
|
||
}
|
||
.sb-btn:hover { background: #252525; color: #eee; border-color: #3a3a3a; }
|
||
.sb-btn.primary { background: #1d4ed8; border-color: #2563eb; color: #fff; }
|
||
.sb-btn.primary:hover { background: #2563eb; }
|
||
.sb-btn.danger { background: #7f1d1d; border-color: #991b1b; color: #fca5a5; }
|
||
.sb-btn.danger:hover { background: #991b1b; }
|
||
.sb-btn.teal { background: #0f4c46; border-color: #0f766e; color: #99f6e4; }
|
||
.sb-btn.teal:hover { background: #0f766e; }
|
||
.sb-btn.purple { background: #3b0764; border-color: #6d28d9; color: #c4b5fd; }
|
||
.sb-btn.purple:hover { background: #6d28d9; }
|
||
.sb-btn:disabled { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
|
||
.sb-status { font-size: 11px; color: #555; margin: 4px 0; }
|
||
.sb-status.ok { color: #4ade80; }
|
||
.sb-status.err { color: #f87171; }
|
||
.sb-spinner {
|
||
width: 10px; height: 10px; border: 2px solid #333;
|
||
border-top-color: #2563eb; border-radius: 50%;
|
||
animation: spin 0.8s linear infinite; display: inline-block;
|
||
margin-right: 4px; vertical-align: middle;
|
||
}
|
||
.sb-source-refs { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
||
.sb-source-refs img {
|
||
width: 28px; height: 28px; object-fit: cover; border-radius: 3px;
|
||
cursor: pointer; opacity: 0.7; border: 1px solid #333;
|
||
transition: opacity 0.15s, border-color 0.15s;
|
||
}
|
||
.sb-source-refs img:hover { opacity: 1; border-color: #555; }
|
||
|
||
/* poses grid in Generate tab */
|
||
.sb-poses-grid { display: flex; flex-wrap: wrap; gap: 5px; padding-bottom: 8px; }
|
||
.sb-pose-btn {
|
||
padding: 4px 10px; border-radius: 5px;
|
||
border: 1px solid #2a2a2a; background: #18181b;
|
||
color: #777; font-size: 11px; cursor: pointer; user-select: none;
|
||
transition: all 0.12s;
|
||
}
|
||
.sb-pose-btn:hover { border-color: #444; color: #ccc; }
|
||
.sb-pose-btn.done { border-color: #14532d; background: #052e16; color: #4ade80; }
|
||
.sb-pose-btn.done::before { content: '✓ '; opacity: 0.8; }
|
||
.sb-pose-btn.selected { border-color: #6d28d9; background: #1e1040; color: #c4b5fd; font-weight: 600; }
|
||
.sb-pose-btn.beta-badge::after {
|
||
content: 'β'; font-size: 8px; background: #78350f; color: #fbbf24;
|
||
padding: 1px 3px; border-radius: 2px; margin-left: 3px; vertical-align: middle;
|
||
}
|
||
|
||
/* template grid (faceswap / scenery) */
|
||
.sb-template-grid {
|
||
display: grid; grid-template-columns: 1fr 1fr;
|
||
gap: 6px; margin-bottom: 8px;
|
||
}
|
||
.sb-template-card {
|
||
background: #181818; border: 2px solid #1e1e1e;
|
||
border-radius: 7px; overflow: hidden; cursor: pointer;
|
||
aspect-ratio: 16/9; position: relative;
|
||
transition: border-color 0.15s, transform 0.13s;
|
||
}
|
||
.sb-template-card:hover { border-color: #7c3aed; transform: scale(1.02); }
|
||
.sb-template-card.selected { border-color: #22c55e; }
|
||
.sb-template-card video { width:100%; height:100%; object-fit:cover; pointer-events:none; }
|
||
.sb-template-label {
|
||
position: absolute; bottom: 0; left: 0; right: 0;
|
||
background: linear-gradient(transparent,rgba(0,0,0,0.82));
|
||
color: #ccc; font-size: 9px; padding: 10px 5px 3px;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none;
|
||
}
|
||
.sb-template-trim-btn {
|
||
position: absolute; top: 3px; right: 3px;
|
||
width: 20px; height: 20px;
|
||
background: rgba(0,0,0,0.7); border: 1px solid #333;
|
||
border-radius: 4px; color: #888; font-size: 11px;
|
||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||
opacity: 0; transition: opacity 0.15s, color 0.1s;
|
||
}
|
||
.sb-template-card:hover .sb-template-trim-btn { opacity: 1; }
|
||
.sb-template-trim-btn:hover { color: #fff; background: rgba(0,0,0,0.92); }
|
||
|
||
/* faceswap options */
|
||
.sb-opt-label {
|
||
display: flex; align-items: center; gap: 6px;
|
||
font-size: 11px; color: #888; cursor: pointer; user-select: none;
|
||
}
|
||
.sb-opt-label input[type=checkbox] { accent-color: #7c3aed; width: 13px; height: 13px; cursor: pointer; }
|
||
.sb-opt-label input[type=checkbox]:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.sb-opt-badge {
|
||
font-size: 9px; padding: 1px 5px; border-radius: 7px;
|
||
background: #27272a; color: #555; border: 1px solid #333;
|
||
}
|
||
|
||
/* ===================== CLIP TIMELINE ===================== */
|
||
.clt-wrapper {
|
||
background: #0d0d10; border: 1px solid #1e1e1e;
|
||
border-radius: 8px; overflow: hidden; margin: 8px 0;
|
||
}
|
||
.clt-video-preview {
|
||
width: 100%; aspect-ratio: 16/9; background: #000; display: block;
|
||
}
|
||
.clt-body { padding: 10px 12px 12px; }
|
||
.clt-track {
|
||
position: relative; height: 32px; background: #1a1a1a;
|
||
border-radius: 4px; cursor: pointer; user-select: none;
|
||
margin-bottom: 6px; overflow: visible;
|
||
}
|
||
.clt-bg { position: absolute; inset: 0; background: #222; border-radius: 4px; }
|
||
.clt-range {
|
||
position: absolute; top: 0; bottom: 0;
|
||
background: rgba(37,99,235,0.3); pointer-events: none;
|
||
}
|
||
.clt-handle {
|
||
position: absolute; top: 0; bottom: 0; width: 10px;
|
||
background: #2563eb; border-radius: 3px; cursor: ew-resize;
|
||
transform: translateX(-50%); z-index: 2;
|
||
display: flex; align-items: center; justify-content: center;
|
||
touch-action: none;
|
||
}
|
||
.clt-handle::after {
|
||
content: ''; width: 2px; height: 14px;
|
||
background: rgba(255,255,255,0.55); border-radius: 1px;
|
||
}
|
||
.clt-handle.end { background: #7c3aed; }
|
||
.clt-playhead {
|
||
position: absolute; top: -3px; bottom: -3px; width: 2px;
|
||
background: #f59e0b; pointer-events: none; z-index: 1;
|
||
transform: translateX(-50%);
|
||
}
|
||
.clt-labels {
|
||
display: flex; justify-content: space-between;
|
||
font-size: 10px; color: #555; margin-bottom: 8px;
|
||
}
|
||
.clt-name { font-size: 10px; color: #444; margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
|
||
/* ===================== SCENERY TAB ===================== */
|
||
.scene-frame-preview {
|
||
width: 100%; aspect-ratio: 16/9; background: #111;
|
||
border-radius: 6px; overflow: hidden; display: none;
|
||
margin-bottom: 8px; position: relative;
|
||
}
|
||
.scene-frame-preview img { width:100%; height:100%; object-fit:contain; display:block; }
|
||
.scene-scrubber { width: 100%; accent-color: #2563eb; margin: 4px 0; cursor: pointer; }
|
||
|
||
/* ===================== SEGMENT TAB ===================== */
|
||
.segment-checker-hint { font-size: 10px; color: #444; margin-top: 6px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="privacyOverlay" class="privacy-overlay" onclick="if(privacyMode)disablePrivacyOverlay()">
|
||
<div class="priv-topbar">
|
||
<div class="priv-logo">S</div>
|
||
AI Image Processing Assistant <span class="priv-sub">· Qwen-Image-Edit Rapid-AIO v23</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">What is the purpose of this system?</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">
|
||
This system implements a headless image editing API using the Qwen-Rapid-NSFW-v23 model on an RTX A6000 GPU. It provides an HTTP service that accepts image and prompt inputs, processes edits using ComfyUI backend, and returns edited PNG outputs.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="priv-msg-row">
|
||
<div class="priv-msg-inner">
|
||
<div class="priv-avatar user">You</div>
|
||
<div class="priv-text">How does it work?</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">
|
||
The system consists of two main components: a ComfyUI backend (running on port 8188) that executes the Qwen models and a FastAPI service (running on port 8500) that provides HTTP API endpoints. When you send an image and prompt to the API, it forwards the request to ComfyUI which processes the image using the Qwen-Rapid-NSFW-v23 model with specific workflow parameters.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="priv-msg-row">
|
||
<div class="priv-msg-inner">
|
||
<div class="priv-avatar user">You</div>
|
||
<div class="priv-text">What are the key components?</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">
|
||
Key components include:<br><br>
|
||
- Qwen model: Qwen-Rapid-NSFW-v23_Q8_0.gguf<br>
|
||
- Text encoder: qwen_2.5_vl_7b_fp8_scaled.safetensors<br>
|
||
- VAE decoder: qwen_image_vae.safetensors<br>
|
||
- Workflow execution engine using ComfyUI<br><br>
|
||
The system uses a specialized workflow (workflow_qwen_edit.json) that defines the processing pipeline with nodes for loading the model, text encoder, VAE, input image, positive/negative prompts, latent generation, sampling, and output saving.
|
||
</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 • Qwen-Image-Edit Rapid-AIO v23</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>
|
||
|
||
<!-- Studio view (replaces old lightbox + faceswap modal) -->
|
||
<div id="studio" class="studio">
|
||
<!-- Topbar -->
|
||
<div class="studio-topbar">
|
||
<div class="studio-topbar-left">
|
||
<button class="btn" onclick="closeStudio()" style="font-size:11px;padding:4px 10px">✕ Gallery</button>
|
||
</div>
|
||
<div class="studio-topbar-center">
|
||
<input type="text" id="lbGroupNameInput"
|
||
style="background:transparent;border:none;border-bottom:1px solid #2a2a2a;color:#fff;font-size:13px;font-weight:600;padding:2px 5px;min-width:80px;max-width:200px;outline:none"
|
||
placeholder="Group name…"
|
||
onblur="updateGroupName()" onkeydown="if(event.key==='Enter')this.blur()"
|
||
title="Group name (applies to all images in this group)" />
|
||
<span id="lbPoseTag" style="font-size:11px;color:#f59e0b;display:none"></span>
|
||
<span id="lbCounter" style="font-size:12px;color:#555"></span>
|
||
</div>
|
||
<div class="studio-topbar-right">
|
||
<button class="btn" id="sidebarToggleTopBtn" onclick="toggleSidebar()" style="font-size:11px;padding:4px 10px" title="Toggle toolbox">Tools</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Body: main viewer + sidebar -->
|
||
<div class="studio-body">
|
||
|
||
<!-- Main area: viewer + filmstrip -->
|
||
<div class="studio-main">
|
||
<div class="studio-viewer-row">
|
||
<button class="studio-nav-btn" id="lbPrev" onclick="lbNav(-1)">‹</button>
|
||
<div class="studio-viewer" id="studioViewer">
|
||
<img id="lbImg" src="" alt="" onclick="toggleImageZoom(this)" />
|
||
<video id="lbVideo" style="display:none;max-width:100%;max-height:100%;border-radius:4px" controls autoplay muted loop></video>
|
||
<div class="lb-hidden-badge">Hidden from preview</div>
|
||
</div>
|
||
<button class="studio-nav-btn" id="lbNext" onclick="lbNav(1)">›</button>
|
||
</div>
|
||
<!-- Film strip / variant thumbnails -->
|
||
<div class="studio-filmstrip" id="lbVariantStrip"></div>
|
||
</div>
|
||
|
||
<!-- Sidebar toolbox -->
|
||
<div id="studioSidebar" class="studio-sidebar">
|
||
<button class="sidebar-toggle-btn" id="sidebarInnerToggle" onclick="toggleSidebar()" title="Collapse/expand toolbox">⊟</button>
|
||
<div id="sidebarInner" class="sidebar-inner">
|
||
<div class="sidebar-tabs">
|
||
<button class="sb-tab active" data-tab="info" onclick="switchSidebarTab('info')">Info</button>
|
||
<button class="sb-tab" data-tab="generate" onclick="switchSidebarTab('generate')">Generate</button>
|
||
<button class="sb-tab" data-tab="faceswap" onclick="switchSidebarTab('faceswap')">Faceswap</button>
|
||
<button class="sb-tab" data-tab="scenery" onclick="switchSidebarTab('scenery')">Scenery</button>
|
||
<button class="sb-tab" data-tab="segment" onclick="switchSidebarTab('segment')">Segment</button>
|
||
</div>
|
||
<div class="sb-panels">
|
||
<!-- Info panel: static HTML with existing lb* IDs so updateStudio() just works -->
|
||
<div class="sb-panel active" id="sbPanelInfo">
|
||
<div class="sb-label">Image name</div>
|
||
<input type="text" class="sb-input" id="lbNameInput"
|
||
placeholder="Image name…"
|
||
onblur="updateName()" onkeydown="if(event.key==='Enter')this.blur()"
|
||
style="margin-bottom:8px">
|
||
<div id="lbClipDesc" class="sb-status" style="margin-bottom:6px;font-size:11px;color:#555"></div>
|
||
<div class="sb-sep"></div>
|
||
<div class="sb-label">Order & visibility</div>
|
||
<div class="sb-actions" style="margin-bottom:8px">
|
||
<button class="sb-btn" id="lbMoveUp" style="display:none" onclick="lbMoveInGroup(-1)" title="Move earlier in group">↑ Earlier</button>
|
||
<button class="sb-btn" id="lbMoveDown" style="display:none" onclick="lbMoveInGroup(1)" title="Move later in group">↓ Later</button>
|
||
<button class="sb-btn" id="lbPreferredBtn" style="display:none;color:#f59e0b" onclick="lbSetPreferred()" title="Set as preferred reference">★ Preferred</button>
|
||
<button class="sb-btn" id="lbEyeBtn" onclick="lbToggleHidden()" title="Hide/show in cycling">👁 Visibility</button>
|
||
</div>
|
||
<div class="sb-sep"></div>
|
||
<div class="sb-label">Actions</div>
|
||
<div class="sb-actions" style="margin-bottom:8px">
|
||
<button class="sb-btn" onclick="tagCurrentImage()" title="Re-run tagger">Re-tag</button>
|
||
<button class="sb-btn purple" id="lbUndressBtn" style="display:none" onclick="lbUndress()">Undress</button>
|
||
<button class="sb-btn teal" id="lbFaceswapBtn" onclick="lbFaceswap()">Faceswap →</button>
|
||
</div>
|
||
<div class="sb-actions" style="margin-bottom:8px">
|
||
<button class="sb-btn" id="lbExtract" style="display:none" onclick="extractCurrentImage()">Extract</button>
|
||
<button class="sb-btn" id="lbNoBgBtn" onclick="lbRemoveBg()">No BG</button>
|
||
<a class="sb-btn" id="lbDownloadBtn" download style="text-decoration:none">Download</a>
|
||
<button class="sb-btn danger" onclick="lbDelete()">Delete</button>
|
||
</div>
|
||
<div id="lbSourceRefs" class="sb-source-refs" style="display:none"></div>
|
||
</div>
|
||
<!-- Other panels: rendered dynamically -->
|
||
<div class="sb-panel" id="sbPanelGenerate"></div>
|
||
<div class="sb-panel" id="sbPanelFaceswap"></div>
|
||
<div class="sb-panel" id="sbPanelScenery"></div>
|
||
<div class="sb-panel" id="sbPanelSegment"></div>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
|
||
|
||
|
||
<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);
|
||
}
|
||
|
||
// --- studio view (replaces lightbox) ---
|
||
let lbUrls = [], lbNames = [], lbIdx = 0;
|
||
let _activeSidebarTab = localStorage.getItem('studioSidebarTab') || 'info';
|
||
|
||
function openLightbox(gid, startIdx) { // kept for compat
|
||
openStudio(gid, startIdx);
|
||
}
|
||
function closeLightbox() { closeStudio(); }
|
||
|
||
function openStudio(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);
|
||
updateStudio();
|
||
document.getElementById('studio').classList.add('open');
|
||
// Restore sidebar state
|
||
const sidebar = document.getElementById('studioSidebar');
|
||
const btn = document.getElementById('sidebarInnerToggle');
|
||
const wasCollapsed = localStorage.getItem('studioSidebarCollapsed') === '1';
|
||
sidebar.classList.toggle('collapsed', wasCollapsed);
|
||
if (btn) btn.textContent = wasCollapsed ? '⊞' : '⊟';
|
||
// Render current tab
|
||
switchSidebarTab(_activeSidebarTab, true);
|
||
}
|
||
|
||
function closeStudio() {
|
||
document.getElementById('studio').classList.remove('open');
|
||
}
|
||
|
||
function lbNav(dir) {
|
||
lbIdx = (lbIdx + dir + lbUrls.length) % lbUrls.length;
|
||
updateStudio();
|
||
}
|
||
|
||
function lbNavTo(idx) {
|
||
lbIdx = idx;
|
||
updateStudio();
|
||
}
|
||
|
||
function updateStudio() {
|
||
const fname = lbNames[lbIdx];
|
||
_fsModelFilename = fname; // keep faceswap/scenery/segment in sync
|
||
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.classList.remove('zoomed');
|
||
lbImgEl.src = lbUrls[lbIdx];
|
||
}
|
||
|
||
const groupNameInput = document.getElementById('lbGroupNameInput');
|
||
groupNameInput.value = groupNames[lbCurrentGid] || '';
|
||
|
||
const nameInput = document.getElementById('lbNameInput');
|
||
if (nameInput) { nameInput.value = imageNames[fname] || ''; nameInput.placeholder = fname; }
|
||
|
||
const clipEl = document.getElementById('lbClipDesc');
|
||
if (clipEl) clipEl.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 studio = document.getElementById('studio');
|
||
studio.classList.toggle('st-hidden', hidden);
|
||
const eyeBtn = document.getElementById('lbEyeBtn');
|
||
if (eyeBtn) { eyeBtn.textContent = hidden ? '🚫 Hidden' : '👁 Visibility'; eyeBtn.title = hidden ? 'Show in cycling' : 'Hide from cycling'; }
|
||
|
||
const noBgBtn = document.getElementById('lbNoBgBtn');
|
||
const undressBtn = document.getElementById('lbUndressBtn');
|
||
const faceswapBtn = document.getElementById('lbFaceswapBtn');
|
||
if (noBgBtn) noBgBtn.style.display = (!isVid && fileHasBg[fname] !== false) ? '' : 'none';
|
||
if (undressBtn) undressBtn.style.display = (!isVid && fileHasClothing[fname] === true) ? '' : 'none';
|
||
if (faceswapBtn)faceswapBtn.style.display = !isVid ? '' : 'none';
|
||
|
||
// Source refs
|
||
const refs = fileSourceRefs[fname];
|
||
const refsDiv = document.getElementById('lbSourceRefs');
|
||
if (refsDiv) {
|
||
if (refs && refs.length > 0) {
|
||
refsDiv.style.display = 'flex';
|
||
refsDiv.innerHTML = refs.map(r =>
|
||
`<img src="${IMAGE_FOLDER + r}" loading="lazy" title="${r}"
|
||
onclick="openStudio('${getBaseName(r).replace(/'/g,"\\'")}');">`
|
||
).join('');
|
||
} else {
|
||
refsDiv.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Download link
|
||
const dlBtn = document.getElementById('lbDownloadBtn');
|
||
if (dlBtn) { dlBtn.href = lbUrls[lbIdx]; dlBtn.setAttribute('download', fname); }
|
||
|
||
const multi = lbUrls.length > 1;
|
||
document.getElementById('lbCounter').textContent = multi ? `${lbIdx + 1} / ${lbUrls.length}` : '';
|
||
const prevBtn = document.getElementById('lbPrev');
|
||
const nextBtn = document.getElementById('lbNext');
|
||
if (prevBtn) prevBtn.style.visibility = multi ? '' : 'hidden';
|
||
if (nextBtn) nextBtn.style.visibility = multi ? '' : 'hidden';
|
||
const extractBtn = document.getElementById('lbExtract');
|
||
if (extractBtn) extractBtn.style.display = multi ? '' : 'none';
|
||
const moveUp = document.getElementById('lbMoveUp');
|
||
const moveDown = document.getElementById('lbMoveDown');
|
||
if (moveUp) moveUp.style.display = multi ? '' : 'none';
|
||
if (moveDown) moveDown.style.display = multi ? '' : 'none';
|
||
const prefBtn = document.getElementById('lbPreferredBtn');
|
||
if (prefBtn) prefBtn.style.display = multi && lbIdx > 0 ? '' : 'none';
|
||
|
||
// Film strip
|
||
const strip = document.getElementById('lbVariantStrip');
|
||
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('');
|
||
const active = strip.querySelector('.lb-var-thumb.active');
|
||
if (active) active.scrollIntoView({ block: 'nearest', inline: 'center' });
|
||
}
|
||
|
||
// Alias for old callsites
|
||
function updateLightbox() { updateStudio(); }
|
||
|
||
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; }
|
||
})();
|
||
|
||
// Studio + variant picker + select-mode keyboard nav
|
||
document.addEventListener('keydown', e => {
|
||
const tag = document.activeElement && document.activeElement.tagName;
|
||
if ((e.key === 'p' || e.key === 'P') && tag !== 'INPUT' && tag !== 'TEXTAREA') {
|
||
togglePrivacyMode(); return;
|
||
}
|
||
if (e.key === 'Escape') {
|
||
if (document.getElementById('variantPicker').classList.contains('open')) {
|
||
closeVariantPicker(); return;
|
||
}
|
||
if (document.getElementById('studio').classList.contains('open')) {
|
||
closeStudio(); return;
|
||
}
|
||
if (selectionMode) {
|
||
toggleSelectMode(); return;
|
||
}
|
||
}
|
||
if (!document.getElementById('studio').classList.contains('open')) return;
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||
if (e.key === 'ArrowLeft') { lbNav(-1); e.preventDefault(); }
|
||
if (e.key === 'ArrowRight') { lbNav(1); e.preventDefault(); }
|
||
if (e.key === ' ') {
|
||
const vid = document.getElementById('lbVideo');
|
||
if (vid && vid.style.display !== 'none') {
|
||
vid.paused ? vid.play() : vid.pause();
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT') { lbToggleHidden(); }
|
||
});
|
||
});
|
||
|
||
// Privacy overlay — disguise the page as a study-chat on any focus loss.
|
||
const PRIV_TITLE = 'AI Image Processing 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;
|
||
}
|
||
}
|
||
|
||
// ---- sidebar & studio ----
|
||
|
||
function toggleSidebar() {
|
||
const sidebar = document.getElementById('studioSidebar');
|
||
const btn = document.getElementById('sidebarInnerToggle');
|
||
const collapsed = sidebar.classList.toggle('collapsed');
|
||
if (btn) btn.textContent = collapsed ? '⊞' : '⊟';
|
||
localStorage.setItem('studioSidebarCollapsed', collapsed ? '1' : '0');
|
||
}
|
||
|
||
function switchSidebarTab(tab, initial) {
|
||
_activeSidebarTab = tab;
|
||
localStorage.setItem('studioSidebarTab', tab);
|
||
document.querySelectorAll('#studioSidebar .sb-tab').forEach(t =>
|
||
t.classList.toggle('active', t.dataset.tab === tab)
|
||
);
|
||
const panelId = 'sbPanel' + tab.charAt(0).toUpperCase() + tab.slice(1);
|
||
document.querySelectorAll('#studioSidebar .sb-panel').forEach(p =>
|
||
p.classList.toggle('active', p.id === panelId)
|
||
);
|
||
if (tab === 'generate') renderSidebarGenerate();
|
||
if (tab === 'faceswap') renderSidebarFaceswap();
|
||
if (tab === 'scenery') renderSidebarScenery();
|
||
if (tab === 'segment') renderSidebarSegment();
|
||
}
|
||
|
||
function toggleImageZoom() {
|
||
document.getElementById('lbImg')?.classList.toggle('zoomed');
|
||
}
|
||
|
||
// ---- sidebar panels ----
|
||
|
||
function renderSidebarGenerate() {
|
||
const panel = document.getElementById('sbPanelGenerate');
|
||
if (!panel) return;
|
||
const donePoses = new Set();
|
||
const gData = groupData.get(lbCurrentGid);
|
||
if (gData) gData.names.forEach(n => { if (filePoses[n]) donePoses.add(filePoses[n]); });
|
||
|
||
let html = '<div class="sb-label">Poses</div><div class="sb-poses-grid" id="sbPosesGrid">';
|
||
if (!availablePoses || Object.keys(availablePoses).length === 0) {
|
||
html += '<div style="font-size:11px;color:#555;padding:6px 0">No poses loaded</div>';
|
||
} else {
|
||
html += Object.entries(availablePoses).map(([name, entry]) => {
|
||
let cls = 'sb-pose-btn';
|
||
if (donePoses.has(name)) cls += ' done';
|
||
if (_fsSelectedPoses.has(name)) cls += ' selected';
|
||
if (entry?.beta) cls += ' beta';
|
||
const nSafe = name.replace(/'/g, "\\'");
|
||
const tip = String(entry?.text ?? entry).replace(/"/g,'"');
|
||
return `<button class="${cls}" onclick="toggleSbPose('${nSafe}')" title="${tip}">${escHtml(name)}</button>`;
|
||
}).join('');
|
||
}
|
||
html += `</div>
|
||
<div class="sb-sep"></div>
|
||
<div class="sb-label">Custom prompt</div>
|
||
<input type="text" class="sb-input" id="sbGenPromptInput"
|
||
placeholder="Prompt — overrides pose" oninput="updateSbGenBtn()" style="margin-bottom:10px">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<span id="sbGenJobStatus" style="flex:1;font-size:11px;color:#888;display:none">
|
||
<span class="sb-spinner"></span><span id="sbGenJobText">Generating…</span>
|
||
</span>
|
||
<button class="sb-btn primary" id="sbGenBtn" onclick="submitSbGenerate()" disabled>Generate</button>
|
||
</div>`;
|
||
panel.innerHTML = html;
|
||
updateSbGenBtn();
|
||
}
|
||
|
||
function toggleSbPose(name) {
|
||
if (_fsSelectedPoses.has(name)) _fsSelectedPoses.delete(name);
|
||
else _fsSelectedPoses.add(name);
|
||
renderSidebarGenerate();
|
||
}
|
||
|
||
function updateSbGenBtn() {
|
||
const btn = document.getElementById('sbGenBtn');
|
||
if (!btn) return;
|
||
const hasPrompt = (document.getElementById('sbGenPromptInput')?.value || '').trim().length > 0;
|
||
btn.disabled = _fsSelectedPoses.size === 0 && !hasPrompt;
|
||
const n = _fsSelectedPoses.size + (hasPrompt ? 1 : 0);
|
||
btn.textContent = n > 1 ? `Generate (${n})` : 'Generate';
|
||
}
|
||
|
||
async function submitSbGenerate() {
|
||
if (!_fsModelFilename) return;
|
||
const promptVal = (document.getElementById('sbGenPromptInput')?.value || '').trim();
|
||
const prompts = [], 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('sbGenBtn');
|
||
btn.disabled = true; btn.textContent = 'Submitting…';
|
||
const statusEl = document.getElementById('sbGenJobStatus');
|
||
const textEl = document.getElementById('sbGenJobText');
|
||
statusEl.style.display = 'inline-flex';
|
||
textEl.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) {
|
||
textEl.textContent = `Error: ${await r.text()}`;
|
||
btn.disabled = false; btn.textContent = 'Retry'; return;
|
||
}
|
||
const { job_id, total } = await r.json();
|
||
showToast(`Generating ${total} image${total !== 1 ? 's' : ''}…`, 'info');
|
||
textEl.textContent = `Generating… 0/${total}`;
|
||
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();
|
||
textEl.textContent = `Generating… ${job.done + job.failed}/${total}`;
|
||
if (job.status === 'running') { pollGen(); return; }
|
||
if (job.status === 'done') {
|
||
showToast('Generation complete!', 'success');
|
||
statusEl.style.display = 'none';
|
||
btn.disabled = false; btn.textContent = 'Generate';
|
||
_fsSelectedPoses.clear();
|
||
refreshNow();
|
||
} else {
|
||
textEl.textContent = `Error: ${job.error || 'unknown'}`;
|
||
btn.disabled = false; btn.textContent = 'Retry';
|
||
}
|
||
} catch (e) { pollGen(); }
|
||
}, 2500);
|
||
};
|
||
pollGen();
|
||
} catch (e) {
|
||
textEl.textContent = `API error: ${e}`;
|
||
btn.disabled = false; btn.textContent = 'Retry';
|
||
}
|
||
}
|
||
|
||
// ---- faceswap sidebar tab ----
|
||
|
||
async function loadTemplateVideos() {
|
||
try {
|
||
const r = await fetch(`${API}/videos`);
|
||
if (!r.ok) return;
|
||
availableVideos = (await r.json()).videos || [];
|
||
} catch (_) { availableVideos = []; }
|
||
}
|
||
|
||
async function lbFaceswap() {
|
||
const data = groupData.get(lbCurrentGid);
|
||
if (!data) return;
|
||
_fsModelFilename = data.names[lbIdx];
|
||
_fsSelectedVideo = null;
|
||
if (!availableVideos.length) await loadTemplateVideos();
|
||
switchSidebarTab('faceswap');
|
||
// Expand sidebar if collapsed
|
||
document.getElementById('studioSidebar')?.classList.remove('collapsed');
|
||
document.getElementById('sidebarInnerToggle').textContent = '⊟';
|
||
}
|
||
|
||
function renderSidebarFaceswap() {
|
||
const panel = document.getElementById('sbPanelFaceswap');
|
||
if (!panel) return;
|
||
|
||
if (_fsTrimVideo) {
|
||
// Clip timeline view
|
||
const vSafe = _fsTrimVideo.replace(/'/g, "\\'");
|
||
panel.innerHTML = `
|
||
<div style="font-size:11px;color:#aaa;margin-bottom:6px;word-break:break-all">✂ ${escHtml(_fsTrimVideo)}</div>
|
||
<div class="clt-wrapper">
|
||
<video class="clt-video-preview" id="cltVideoPreview" muted preload="metadata"
|
||
src="${API}/wireframe/${encodeURIComponent(_fsTrimVideo)}"></video>
|
||
<div class="clt-track" id="cltTrack">
|
||
<div class="clt-bg"></div>
|
||
<div class="clt-range" id="cltRange"></div>
|
||
<div class="clt-handle" id="cltHandleStart"></div>
|
||
<div class="clt-handle end" id="cltHandleEnd"></div>
|
||
<div class="clt-playhead" id="cltPlayhead"></div>
|
||
</div>
|
||
<div class="clt-labels">
|
||
<span id="cltStartLabel">0:00</span>
|
||
<span id="cltEndLabel">0:00</span>
|
||
</div>
|
||
<input type="text" class="sb-input" id="cltOutputName"
|
||
placeholder="Output name (auto)" style="margin:6px 0">
|
||
<div id="cltStatus" style="font-size:11px;color:#aaa;min-height:16px"></div>
|
||
<div style="display:flex;gap:6px;margin-top:8px">
|
||
<button class="sb-btn" onclick="closeTrimPanel()">← Back</button>
|
||
<button class="sb-btn" onclick="cltPreviewRange()">▶ Preview</button>
|
||
<button class="sb-btn primary" onclick="submitTrimFromTimeline()">✂ Create clip</button>
|
||
</div>
|
||
</div>`;
|
||
requestAnimationFrame(() => initClipTimeline());
|
||
return;
|
||
}
|
||
|
||
// Template grid view
|
||
let html = '<div class="sb-label">Template videos</div>';
|
||
if (!availableVideos.length) {
|
||
html += '<div style="font-size:11px;color:#555;padding:6px 0">No wireframe videos found</div>';
|
||
} else {
|
||
html += '<div class="sb-template-grid">';
|
||
availableVideos.forEach(v => {
|
||
const stem = v.replace(/\.[^.]+$/, '');
|
||
const vSafe = v.replace(/'/g, "\\'");
|
||
const sel = _fsSelectedVideo === v ? ' selected' : '';
|
||
html += `<div class="sb-template-card${sel}" onclick="sbSelectTemplate('${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="sb-template-label">${escHtml(stem)}</div>
|
||
<button class="sb-template-trim-btn" title="Trim video"
|
||
onclick="event.stopPropagation();openTrimPanel('${vSafe}')">✂</button>
|
||
</div>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
html += `<div class="sb-sep"></div>
|
||
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:10px">
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer">
|
||
<input type="checkbox" id="sbFsEnhance" checked>
|
||
<span>Enhance faces</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer" title="Requires install_facefusion.sh">
|
||
<input type="checkbox" id="sbFsHair" disabled>
|
||
<span>FaceFusion</span>
|
||
<span id="sbFsHairBadge" style="font-size:10px;color:#f87;background:#2a1a1a;padding:1px 5px;border-radius:3px">not installed</span>
|
||
</label>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<span id="sbFsJobStatus" style="flex:1;font-size:11px;color:#888;display:none">
|
||
<span class="sb-spinner"></span><span id="sbFsJobText">Processing…</span>
|
||
</span>
|
||
<button class="sb-btn primary" id="sbFsStartBtn" onclick="submitFaceswap()"
|
||
${_fsSelectedVideo ? '' : 'disabled'}>Start Faceswap</button>
|
||
</div>`;
|
||
|
||
panel.innerHTML = html;
|
||
requestAnimationFrame(() => {
|
||
panel.querySelectorAll('.sb-template-card video').forEach(v => v.play().catch(()=>{}));
|
||
});
|
||
// Check FaceFusion availability
|
||
fetch(`${API}/faceswap/check`).then(r => r.json()).then(s => {
|
||
const hairCb = document.getElementById('sbFsHair');
|
||
const hairBdg = document.getElementById('sbFsHairBadge');
|
||
if (!hairCb) return;
|
||
if (s.facefusion) { hairCb.disabled = false; hairBdg.style.display = 'none'; }
|
||
else { hairCb.disabled = true; hairBdg.style.display = ''; }
|
||
}).catch(()=>{});
|
||
}
|
||
|
||
function sbSelectTemplate(videoName) {
|
||
_fsSelectedVideo = videoName;
|
||
renderSidebarFaceswap();
|
||
}
|
||
|
||
function openTrimPanel(videoName) {
|
||
_fsTrimVideo = videoName;
|
||
renderSidebarFaceswap();
|
||
}
|
||
|
||
function closeTrimPanel() {
|
||
_fsTrimVideo = null;
|
||
renderSidebarFaceswap();
|
||
}
|
||
|
||
async function submitFaceswap() {
|
||
if (!_fsModelFilename || !_fsSelectedVideo) return;
|
||
const enhance = document.getElementById('sbFsEnhance')?.checked ?? true;
|
||
const hairEl = document.getElementById('sbFsHair');
|
||
const hair = (hairEl?.checked && !hairEl?.disabled) ?? false;
|
||
const btn = document.getElementById('sbFsStartBtn');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; }
|
||
const statusEl = document.getElementById('sbFsJobStatus');
|
||
const textEl = document.getElementById('sbFsJobText');
|
||
if (statusEl) statusEl.style.display = 'inline-flex';
|
||
const modeLabel = hair ? 'FaceFusion + hair' : (enhance ? 'insightface + enhance' : 'insightface');
|
||
if (textEl) textEl.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) {
|
||
if (textEl) textEl.textContent = `Error: ${await r.text()}`;
|
||
if (btn) { 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) {
|
||
if (textEl) textEl.textContent = `API error: ${e}`;
|
||
if (btn) { 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` : '';
|
||
const textEl = document.getElementById('sbFsJobText');
|
||
const btn = document.getElementById('sbFsStartBtn');
|
||
if (job.status === 'running') {
|
||
if (textEl) textEl.textContent = `Processing${pct}…`;
|
||
pollFaceswapJob(job_id);
|
||
} else if (job.status === 'done') {
|
||
if (textEl) textEl.textContent = `Done! ${job.output || ''}`;
|
||
if (btn) btn.textContent = 'Done ✓';
|
||
showToast(`Faceswap complete: ${job.output || ''}`, 'success');
|
||
setTimeout(() => { refreshNow(); }, 1800);
|
||
} else {
|
||
if (textEl) textEl.textContent = `Error: ${job.error || 'unknown'}`;
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Retry'; }
|
||
showToast(`Faceswap failed: ${job.error || ''}`, 'error');
|
||
}
|
||
} catch (e) { pollFaceswapJob(job_id); }
|
||
}, 2500);
|
||
}
|
||
|
||
// ---- clip timeline ----
|
||
let _cltDuration = 0, _cltStart = 0, _cltEnd = 0, _cltDragging = null;
|
||
|
||
function initClipTimeline() {
|
||
const video = document.getElementById('cltVideoPreview');
|
||
const track = document.getElementById('cltTrack');
|
||
if (!video || !track) return;
|
||
video.addEventListener('loadedmetadata', () => {
|
||
_cltDuration = video.duration || 10;
|
||
_cltStart = 0;
|
||
_cltEnd = _cltDuration;
|
||
updateCltUI();
|
||
});
|
||
video.addEventListener('timeupdate', () => {
|
||
if (_cltDragging || _cltDuration <= 0) return;
|
||
const ph = document.getElementById('cltPlayhead');
|
||
if (ph) ph.style.left = (video.currentTime / _cltDuration * 100) + '%';
|
||
});
|
||
track.addEventListener('pointerdown', e => {
|
||
const rect = track.getBoundingClientRect();
|
||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||
const startPct = _cltDuration > 0 ? _cltStart / _cltDuration : 0;
|
||
const endPct = _cltDuration > 0 ? _cltEnd / _cltDuration : 1;
|
||
if (e.target.id === 'cltHandleStart' || (e.target.id !== 'cltHandleEnd' && Math.abs(pct - startPct) <= Math.abs(pct - endPct))) {
|
||
_cltDragging = 'start';
|
||
} else if (e.target.id === 'cltHandleEnd' || Math.abs(pct - endPct) < Math.abs(pct - startPct)) {
|
||
_cltDragging = 'end';
|
||
} else {
|
||
if (video) video.currentTime = pct * _cltDuration;
|
||
return;
|
||
}
|
||
track.setPointerCapture(e.pointerId);
|
||
e.preventDefault();
|
||
});
|
||
track.addEventListener('pointermove', e => {
|
||
if (!_cltDragging || _cltDuration <= 0) return;
|
||
const rect = track.getBoundingClientRect();
|
||
const t = Math.max(0, Math.min(_cltDuration, (e.clientX - rect.left) / rect.width * _cltDuration));
|
||
if (_cltDragging === 'start') _cltStart = Math.min(t, _cltEnd - 0.1);
|
||
else _cltEnd = Math.max(t, _cltStart + 0.1);
|
||
updateCltUI();
|
||
if (video) video.currentTime = _cltDragging === 'start' ? _cltStart : _cltEnd;
|
||
});
|
||
track.addEventListener('pointerup', e => {
|
||
_cltDragging = null;
|
||
track.releasePointerCapture(e.pointerId);
|
||
});
|
||
}
|
||
|
||
function updateCltUI() {
|
||
if (_cltDuration <= 0) return;
|
||
const sp = (_cltStart / _cltDuration) * 100;
|
||
const ep = (_cltEnd / _cltDuration) * 100;
|
||
const sh = document.getElementById('cltHandleStart');
|
||
const eh = document.getElementById('cltHandleEnd');
|
||
const rn = document.getElementById('cltRange');
|
||
const sl = document.getElementById('cltStartLabel');
|
||
const el = document.getElementById('cltEndLabel');
|
||
if (sh) sh.style.left = sp + '%';
|
||
if (eh) eh.style.left = ep + '%';
|
||
if (rn) { rn.style.left = sp + '%'; rn.style.width = (ep - sp) + '%'; }
|
||
if (sl) sl.textContent = formatSecs(_cltStart);
|
||
if (el) el.textContent = formatSecs(_cltEnd);
|
||
}
|
||
|
||
function formatSecs(s) {
|
||
const m = Math.floor(s / 60), sec = (s % 60).toFixed(1);
|
||
return m + ':' + (parseFloat(sec) < 10 ? '0' : '') + sec;
|
||
}
|
||
|
||
function cltPreviewRange() {
|
||
const video = document.getElementById('cltVideoPreview');
|
||
if (!video || _cltDuration <= 0) return;
|
||
video.currentTime = _cltStart;
|
||
video.play();
|
||
const stop = () => { if (video.currentTime >= _cltEnd) { video.pause(); video.currentTime = _cltStart; } };
|
||
video.addEventListener('timeupdate', stop);
|
||
setTimeout(() => video.removeEventListener('timeupdate', stop), (_cltEnd - _cltStart + 2) * 1000);
|
||
}
|
||
|
||
async function submitTrimFromTimeline() {
|
||
if (!_fsTrimVideo) return;
|
||
const name = (document.getElementById('cltOutputName')?.value || '').trim() || null;
|
||
const statusEl = document.getElementById('cltStatus');
|
||
if (_cltEnd <= _cltStart) { if (statusEl) statusEl.textContent = 'End must be > start'; return; }
|
||
if (statusEl) statusEl.textContent = 'Trimming…';
|
||
try {
|
||
const r = await fetch(`${API}/wireframe/trim`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ video_name: _fsTrimVideo, start: _cltStart, end: _cltEnd, output_name: name }),
|
||
});
|
||
if (!r.ok) { if (statusEl) statusEl.textContent = 'Error: ' + await r.text(); return; }
|
||
const d = await r.json();
|
||
showToast('Clip saved: ' + d.output_name, 'success');
|
||
availableVideos = [];
|
||
await loadTemplateVideos();
|
||
closeTrimPanel();
|
||
} catch (e) { if (statusEl) statusEl.textContent = 'Error: ' + e; }
|
||
}
|
||
|
||
// ---- scenery sidebar tab ----
|
||
let _sceneVideo = null, _sceneDuration = 0, _sceneFrameBytes = null, _sceneJobPollTimer = null;
|
||
|
||
function renderSidebarScenery() {
|
||
const panel = document.getElementById('sbPanelScenery');
|
||
if (!panel) return;
|
||
let html = '<div class="sb-label">Background reference</div>';
|
||
if (availableVideos.length) {
|
||
html += '<div class="sb-template-grid">';
|
||
availableVideos.forEach(v => {
|
||
const stem = v.replace(/\.[^.]+$/, '');
|
||
const vSafe = v.replace(/'/g, "\\'");
|
||
const sel = _sceneVideo === v ? ' selected' : '';
|
||
html += `<div class="sb-template-card${sel}" onclick="sceneSelectVideo('${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="sb-template-label">${escHtml(stem)}</div>
|
||
</div>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
html += `<div class="scene-frame-preview" id="sceneFramePreview" style="${_sceneVideo||_sceneFrameBytes?'':'display:none'}">
|
||
<video id="sceneVideoEl" muted preload="metadata"
|
||
style="${_sceneVideo?'':'display:none'};width:100%;height:100%;object-fit:contain"></video>
|
||
<img id="sceneFrameImg" style="${_sceneFrameBytes&&!_sceneVideo?'':'display:none'};width:100%;height:100%;object-fit:contain"
|
||
${_sceneFrameBytes?`src="data:image/png;base64,${_sceneFrameBytes}"`:''}/>
|
||
</div>
|
||
<input type="range" class="scene-scrubber" id="sceneScrubber"
|
||
min="0" max="${_sceneDuration||100}" value="0" step="0.1"
|
||
oninput="sceneSeekVideo(this.value)"
|
||
style="${_sceneVideo?'':'display:none'}">
|
||
<div id="sceneTimeLabel" style="font-size:10px;color:#555;margin-bottom:4px;${_sceneVideo?'':'display:none'}">0:00</div>
|
||
<button class="sb-btn" id="sceneExtractBtn" onclick="sceneExtractFrame()"
|
||
style="${_sceneVideo?'':'display:none'};margin-bottom:8px">Extract frame as reference</button>
|
||
<div class="sb-sep"></div>
|
||
<div class="sb-label">Or upload image</div>
|
||
<input type="file" id="sceneUploadInput" accept="image/*" style="display:none" onchange="sceneHandleUpload(event)">
|
||
<button class="sb-btn" onclick="document.getElementById('sceneUploadInput').click()" style="margin-bottom:10px">Upload image</button>
|
||
<div class="sb-sep"></div>
|
||
<div class="sb-label">Prompt (optional)</div>
|
||
<input type="text" class="sb-input" id="scenePromptInput"
|
||
placeholder="Auto: place person in scene…" style="margin-bottom:10px">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<span id="sceneJobStatus" style="flex:1;font-size:11px;color:#888;display:none">
|
||
<span class="sb-spinner"></span><span id="sceneJobText">Generating…</span>
|
||
</span>
|
||
<button class="sb-btn primary" id="sceneGenBtn" onclick="submitGenerateScenery()"
|
||
${(_sceneVideo||_sceneFrameBytes)?'':'disabled'}>Generate Scenery</button>
|
||
</div>`;
|
||
panel.innerHTML = html;
|
||
if (_sceneVideo) {
|
||
const vid = document.getElementById('sceneVideoEl');
|
||
vid.src = `${API}/wireframe/${encodeURIComponent(_sceneVideo)}`;
|
||
if (_sceneDuration) {
|
||
const scrubber = document.getElementById('sceneScrubber');
|
||
if (scrubber) { scrubber.max = _sceneDuration; scrubber.step = Math.max(0.05, _sceneDuration / 500); }
|
||
}
|
||
}
|
||
requestAnimationFrame(() => {
|
||
panel.querySelectorAll('.sb-template-card video').forEach(v => v.play().catch(()=>{}));
|
||
});
|
||
}
|
||
|
||
async function sceneSelectVideo(v) {
|
||
_sceneVideo = v; _sceneFrameBytes = null;
|
||
renderSidebarScenery();
|
||
try {
|
||
const r = await fetch(`${API}/wireframe/duration/${encodeURIComponent(v)}`);
|
||
if (r.ok) {
|
||
_sceneDuration = (await r.json()).duration || 0;
|
||
const scrubber = document.getElementById('sceneScrubber');
|
||
if (scrubber) { scrubber.max = _sceneDuration; scrubber.step = Math.max(0.05, _sceneDuration / 500); }
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
function sceneSeekVideo(val) {
|
||
const vid = document.getElementById('sceneVideoEl');
|
||
const lbl = document.getElementById('sceneTimeLabel');
|
||
if (vid) vid.currentTime = parseFloat(val);
|
||
if (lbl) lbl.textContent = formatSecs(parseFloat(val));
|
||
}
|
||
|
||
async function sceneExtractFrame() {
|
||
const scrubber = document.getElementById('sceneScrubber');
|
||
const t = parseFloat(scrubber?.value || '0');
|
||
const btn = document.getElementById('sceneExtractBtn');
|
||
if (btn) btn.textContent = 'Extracting…';
|
||
try {
|
||
const r = await fetch(`${API}/wireframe/frame`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ video_name: _sceneVideo, time: t }),
|
||
});
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
_sceneFrameBytes = d.frame_b64;
|
||
const img = document.getElementById('sceneFrameImg');
|
||
if (img) { img.src = 'data:image/png;base64,' + _sceneFrameBytes; img.style.display = ''; }
|
||
const genBtn = document.getElementById('sceneGenBtn');
|
||
if (genBtn) genBtn.disabled = false;
|
||
}
|
||
} catch (_) {}
|
||
if (btn) btn.textContent = 'Extract frame as reference';
|
||
}
|
||
|
||
function sceneHandleUpload(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = e => {
|
||
_sceneFrameBytes = e.target.result.split(',')[1];
|
||
_sceneVideo = null;
|
||
renderSidebarScenery();
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
async function submitGenerateScenery() {
|
||
if (!_fsModelFilename) return;
|
||
if (!_sceneVideo && !_sceneFrameBytes) { showToast('Pick a scene reference first', 'error'); return; }
|
||
const scrubber = document.getElementById('sceneScrubber');
|
||
const prompt = (document.getElementById('scenePromptInput')?.value || '').trim() || null;
|
||
const btn = document.getElementById('sceneGenBtn');
|
||
const statusEl = document.getElementById('sceneJobStatus');
|
||
const textEl = document.getElementById('sceneJobText');
|
||
if (btn) btn.disabled = true;
|
||
if (statusEl) statusEl.style.display = 'inline-flex';
|
||
if (textEl) textEl.textContent = 'Submitting…';
|
||
const payload = {
|
||
model_filename: _fsModelFilename,
|
||
scene_bytes: _sceneFrameBytes || null,
|
||
scene_video: _sceneVideo || null,
|
||
scene_time: parseFloat(scrubber?.value || '0'),
|
||
prompt,
|
||
seed: -1,
|
||
};
|
||
try {
|
||
const r = await fetch(`${API}/generate-scenery`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!r.ok) {
|
||
if (textEl) textEl.textContent = 'Error: ' + await r.text();
|
||
if (btn) btn.disabled = false; return;
|
||
}
|
||
const { job_id } = await r.json();
|
||
showToast('Scenery generation started…', 'info');
|
||
if (textEl) textEl.textContent = 'Generating…';
|
||
const pollScene = () => {
|
||
_sceneJobPollTimer = setTimeout(async () => {
|
||
try {
|
||
const jr = await fetch(`${API}/batch/${job_id}`);
|
||
if (!jr.ok) { pollScene(); return; }
|
||
const job = await jr.json();
|
||
if (job.status === 'running') { if (textEl) textEl.textContent = 'Generating…'; pollScene(); return; }
|
||
if (job.status === 'done') {
|
||
showToast('Scenery generated!', 'success');
|
||
if (statusEl) statusEl.style.display = 'none';
|
||
if (btn) btn.disabled = false;
|
||
refreshNow();
|
||
} else {
|
||
if (textEl) textEl.textContent = 'Error: ' + (job.error || 'unknown');
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
} catch (_) { pollScene(); }
|
||
}, 2500);
|
||
};
|
||
pollScene();
|
||
} catch (e) {
|
||
if (textEl) textEl.textContent = 'API error: ' + e;
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// ---- segment (SAM2 / rembg) sidebar tab ----
|
||
|
||
function renderSidebarSegment() {
|
||
const panel = document.getElementById('sbPanelSegment');
|
||
if (!panel) return;
|
||
panel.innerHTML = `
|
||
<div style="font-size:11px;color:#666;margin-bottom:12px;line-height:1.6">
|
||
Remove the background using SAM2 (when available) or rembg fallback.
|
||
The result is a transparent PNG. Restore BG composites alpha back to white.
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap">
|
||
<button class="sb-btn" id="sbSam2Btn" onclick="sam2RemoveBg()">Remove BG</button>
|
||
<button class="sb-btn" id="sbRestoreBgBtn" onclick="restoreBg()">Restore BG</button>
|
||
</div>
|
||
<div id="sbSegStatus" style="font-size:11px;color:#aaa;min-height:16px;margin-bottom:10px"></div>
|
||
<div class="sb-sep"></div>
|
||
<label style="display:flex;align-items:center;gap:8px;font-size:12px;cursor:pointer;margin-bottom:6px">
|
||
<input type="checkbox" id="sbCheckerboard" onchange="toggleCheckerboard(this.checked)">
|
||
<span>Checkerboard (show transparency)</span>
|
||
</label>
|
||
<div style="font-size:10px;color:#555;line-height:1.4">
|
||
SAM2 segments the largest foreground subject.<br>
|
||
Requires sam2_checkpoint in config.json.
|
||
</div>`;
|
||
// Check SAM2 availability
|
||
fetch(`${API}/sam2/check`).then(r => r.json()).then(d => {
|
||
const btn = document.getElementById('sbSam2Btn');
|
||
if (btn) btn.title = d.sam2 ? 'SAM2 available' : 'SAM2 not available — will use rembg fallback';
|
||
}).catch(()=>{});
|
||
}
|
||
|
||
async function sam2RemoveBg() {
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname) return;
|
||
const btn = document.getElementById('sbSam2Btn');
|
||
const statusEl = document.getElementById('sbSegStatus');
|
||
if (btn) btn.disabled = true;
|
||
if (statusEl) statusEl.textContent = 'Removing background…';
|
||
try {
|
||
const r = await fetch(`${API}/remove-background-sam/${encodeURIComponent(fname)}`, { method: 'POST' });
|
||
if (!r.ok) {
|
||
if (statusEl) statusEl.textContent = 'Error: ' + await r.text();
|
||
} else {
|
||
const d = await r.json();
|
||
if (statusEl) statusEl.textContent = d.used_sam2 ? 'Done (SAM2)' : 'Done (rembg)';
|
||
showToast('Background removed', 'success');
|
||
const img = document.getElementById('lbImg');
|
||
if (img) { img.src = img.src.split('?')[0] + '?t=' + Date.now(); }
|
||
}
|
||
} catch (e) { if (statusEl) statusEl.textContent = 'Error: ' + e; }
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
async function restoreBg() {
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname) return;
|
||
const btn = document.getElementById('sbRestoreBgBtn');
|
||
const statusEl = document.getElementById('sbSegStatus');
|
||
if (btn) btn.disabled = true;
|
||
if (statusEl) statusEl.textContent = 'Restoring…';
|
||
try {
|
||
const r = await fetch(`${API}/restore-background/${encodeURIComponent(fname)}`, { method: 'POST' });
|
||
if (!r.ok) {
|
||
if (statusEl) statusEl.textContent = 'Error: ' + await r.text();
|
||
} else {
|
||
if (statusEl) statusEl.textContent = 'Background restored';
|
||
showToast('Background restored', 'success');
|
||
const img = document.getElementById('lbImg');
|
||
if (img) { img.src = img.src.split('?')[0] + '?t=' + Date.now(); }
|
||
}
|
||
} catch (e) { if (statusEl) statusEl.textContent = 'Error: ' + e; }
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
function toggleCheckerboard(on) {
|
||
const viewer = document.getElementById('studioViewer');
|
||
if (viewer) viewer.classList.toggle('show-checker', on);
|
||
}
|
||
|
||
// ---- set preferred ----
|
||
|
||
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;
|
||
fileSortOrders[fname] = 0;
|
||
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;
|
||
updateStudio();
|
||
} catch(e) { console.error(e); }
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |