New _apply_transparency_black_bg function (called when bg_removal=sam2): 1. Threshold — any pixel with max-channel > 25 = person. Finds the person's exact bounding box without any model confusion. 2. SAM2 with tight person bbox — feeds SAM2 the person-specific box instead of the full frame. SAM2 now segments within the person area for clean sub-pixel edges. 3. Coverage sanity — accepts SAM2 only if coverage is within ±30pp of the threshold estimate; rejects inverted-mask failures. 4. Threshold mask fallback — if SAM2 errors or diverges, uses the threshold mask with Gaussian edge blur (r=2). Test result: Person RGB mean (146, 101, 86) — correct skin tones. 74.5% transparent background, 24% opaque person. ✓ Test results validated: • rembg path: perfect cutout (hair bun, earring, sneakers, clean edges) • SAM2-on-black path: complete silhouette mask at 74% coverage — full body, shoes and hair included, no holes To switch to SAM2 mode: "bg_removal": "sam2" in config.json. No restart needed — the config is read per-request.
4899 lines
225 KiB
HTML
4899 lines
225 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;
|
||
will-change: transform;
|
||
}
|
||
.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;
|
||
will-change: transform;
|
||
}
|
||
.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;
|
||
}
|
||
|
||
.sb-camera-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 4px; margin-bottom: 8px; }
|
||
.sb-angle-btn {
|
||
padding: 5px 2px; border-radius: 5px;
|
||
border: 1px solid #2a2a2a; background: #18181b;
|
||
color: #777; font-size: 11px; cursor: pointer; user-select: none;
|
||
text-align: center; transition: all 0.12s;
|
||
}
|
||
.sb-angle-btn:hover { border-color: #444; color: #ccc; }
|
||
.sb-angle-btn.selected { border-color: #0e7490; background: #082f49; color: #7dd3fc; font-weight: 600; }
|
||
#studioAngleBar {
|
||
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
|
||
display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s;
|
||
z-index: 10; pointer-events: none;
|
||
}
|
||
.studio-viewer:hover #studioAngleBar { opacity: 1; pointer-events: all; }
|
||
#studioAngleBar button {
|
||
font-size: 15px; background: rgba(0,0,0,0.65); border: 1px solid rgba(255,255,255,0.18);
|
||
border-radius: 4px; color: #fff; width: 34px; height: 34px; cursor: pointer; line-height: 1;
|
||
transition: background 0.12s; position: relative;
|
||
}
|
||
#studioAngleBar button:hover { background: rgba(255,255,255,0.18); }
|
||
#studioAngleBar button[title]:hover::after {
|
||
content: attr(title); position: absolute; bottom: 38px; left: 50%; transform: translateX(-50%);
|
||
background: rgba(0,0,0,0.8); color: #fff; font-size: 10px; padding: 2px 6px;
|
||
border-radius: 3px; white-space: nowrap; pointer-events: none;
|
||
}
|
||
|
||
/* 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 this system and what does it run on?</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 is a self-hosted AI image studio for generative editing, multi-view character rendering, background removal, face swapping, and group-based asset management. It runs on a local RTX A6000 GPU (CUDA 12.4, 48 GB VRAM) using the <strong>Qwen 2.5 VL GGUF</strong> model (Rapid-NSFW-v23 Q8_0) via ComfyUI. The frontend is a single-page browser app (car.html) talking to a FastAPI backend on :8500.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="priv-msg-row">
|
||
<div class="priv-msg-inner">
|
||
<div class="priv-avatar user">You</div>
|
||
<div class="priv-text">Walk me through the image generation flow.</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">
|
||
When a generation is triggered the pipeline is:<br><br>
|
||
<strong>1. Prompt cleaning</strong> — if the prompt contains "transparent background", that keyword is stripped so Qwen renders a clean solid-BG image instead of producing washed-out halos.<br><br>
|
||
<strong>2. ComfyUI queue</strong> — the reference image is uploaded to ComfyUI (:8188) and injected into <em>image1</em>. An optional wireframe frame extracted from a pose-guide video goes into <em>image2</em>. The workflow graph (workflow_qwen_edit.json) is patched with the prompt, dimensions, seed, and sampler params, then submitted via POST /prompt.<br><br>
|
||
<strong>3. Qwen inference</strong> — the GGUF model (≈8 GB VRAM), text encoder (FP8), and VAE decode the latent into a PNG.<br><br>
|
||
<strong>4. SAM2 post-processing</strong> — if transparent was requested, SAM2 point-prompt segmentation removes the background using 12 foreground seed points (hair → shoes, shoulder / hip lateral spread) and 7 background points (corners + edge midpoints). A coverage sanity check (5–92 %) falls back to rembg if the mask is implausible.
|
||
</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 main API endpoints?</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 FastAPI service (:8500) exposes:<br><br>
|
||
<strong>GET /images</strong> — full image list with PostgreSQL metadata (group, prompt, pose, sort order, archived flag).<br>
|
||
<strong>POST /upload</strong> — add an image; with group_id + skip_poses it silently joins an existing group without triggering pose generation.<br>
|
||
<strong>POST /batch</strong> — async multi-prompt generation job (filenames, prompts, poses, wireframe_ref). Poll progress via GET /jobs/{id}.<br>
|
||
<strong>POST /faceswap</strong> — insightface video face swap with optional GFPGAN restoration.<br>
|
||
<strong>POST /remove-background-sam/{f}</strong> — SAM2 BG removal to a .nobg.png sidecar.<br>
|
||
<strong>POST /images/{f}/set-preferred</strong> — moves image to group slot 0 and queues face extraction in the background.<br>
|
||
<strong>GET /wireframe/frame/{name}?t=</strong> — extract a frame at normalised time t∈[0,1] from a wireframe pose video.
|
||
</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 the face-book and multi-view workflow 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">
|
||
<strong>Face-book:</strong> marking an image as "preferred" (star button) moves it to sort_order=0 in the group and triggers a background task. insightface detects the largest face, adds 50 % padding on the sides and 200 % headroom above, and saves a <em>{group_id}_face.png</em> crop. This appears as a 72 px thumbnail in the studio Info tab for fast face-reference lookup.<br><br>
|
||
<strong>Multi-view / camera angles:</strong> the Generate tab offers 12 camera angle presets — 8 absolute (Front, ¾ Left/Right, Side, Back, High, Low) and 4 relative (±45°, ±90°). Each fires a /batch request with the angle prompt injected and pose=null. The wireframe pose guide (a .mp4 video scrubbed to any frame) is passed as image2 in the Qwen workflow to constrain body layout without ControlNet — the controlnet model folder is empty by design.<br><br>
|
||
<strong>Group management:</strong> all images belong to groups stored in PostgreSQL. Clipboard paste (Ctrl+V) while a group is open offers "add to this group — no poses" or "new group with pose generation". Archive hides images from the default view without deleting them.
|
||
</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>
|
||
|
||
<!-- Inject build timestamp and math art -->
|
||
<script>
|
||
// Add build timestamp to privacy screen
|
||
function injectBuildTime() {
|
||
try {
|
||
const now = new Date();
|
||
const timestamp = now.toLocaleString('en-US', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
timeZoneName: 'short'
|
||
});
|
||
|
||
// Add timestamp to the privacy screen title
|
||
const titleElement = document.querySelector('.priv-sub');
|
||
if (titleElement) {
|
||
titleElement.innerHTML += ` • Built: ${timestamp}`;
|
||
}
|
||
|
||
} catch (e) {
|
||
console.error('Error injecting build time or math art:', e);
|
||
}
|
||
}
|
||
|
||
// Call the function when page loads
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', injectBuildTime);
|
||
} else {
|
||
injectBuildTime();
|
||
}
|
||
</script>
|
||
|
||
</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="archiveViewBtn" onclick="toggleArchiveView()" title="View archived items">📦 Archive</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 id="studioAngleBar"></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>
|
||
<!-- Slideshow controls -->
|
||
<div id="slideshowBar" style="display:flex;align-items:center;gap:6px;padding:4px 8px;background:#111;border-top:1px solid #1a1a1a;flex-shrink:0">
|
||
<button onclick="toggleSlideshow()" id="slideshowBtn" style="background:none;border:1px solid #333;color:#888;font-size:11px;padding:2px 8px;border-radius:4px;cursor:pointer">▶ Slideshow</button>
|
||
<select id="slideshowInterval" onchange="updateSlideshowInterval()" style="background:#111;border:1px solid #333;color:#888;font-size:11px;padding:2px 4px;border-radius:4px">
|
||
<option value="2000">2s</option>
|
||
<option value="4000" selected>4s</option>
|
||
<option value="8000">8s</option>
|
||
<option value="15000">15s</option>
|
||
</select>
|
||
</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 id="lbGenPromptWrap" style="display:none;margin-bottom:8px">
|
||
<div class="sb-label" style="margin-bottom:3px">Generation prompt</div>
|
||
<div id="lbGenPrompt" style="font-size:11px;color:#888;line-height:1.4;word-break:break-word"></div>
|
||
</div>
|
||
<div id="lbFaceBook" style="display:none;margin-bottom:8px">
|
||
<div class="sb-label" style="margin-bottom:4px">Face reference</div>
|
||
<img id="lbFaceThumb" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid #333;cursor:pointer" onclick="window.open(this.src,'_blank')" title="Face crop — click to view full">
|
||
</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>
|
||
<button class="sb-btn" id="lbCropBtn" style="display:none" onclick="lbAutoCrop()" title="Crop away transparent border">Crop</button>
|
||
<button class="sb-btn" id="lbDuplicateBtn" onclick="lbDuplicate()" title="Duplicate image into same group">Duplicate</button>
|
||
<button class="sb-btn" id="lbManualCropBtn" onclick="startManualCrop()" title="Drag to crop image">Crop…</button>
|
||
<a class="sb-btn" id="lbDownloadBtn" download style="text-decoration:none">Download</a>
|
||
<button class="sb-btn" id="lbArchiveBtn" onclick="lbArchive()" style="color:#f59e0b" title="Move to archive (recoverable)">Archive</button>
|
||
<button class="sb-btn danger" id="lbDeleteBtn" onclick="lbDeleteArm()" title="Click once to arm, then again to confirm">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>
|
||
<span id="multiRefLegend" style="display:none;font-size:10px;color:#888;white-space:nowrap"></span>
|
||
<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 fileArchived = {}; // filename → boolean
|
||
let _archiveViewActive = false; // when true, show archived items only
|
||
let fileHasBg = {}; // filename → boolean (has background, default true)
|
||
let fileSourceRefs = {}; // filename → array of source filenames
|
||
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 _sbSelectedAngles = new Set(); // selected camera angle names
|
||
let _sbWireframeRef = ''; // wireframe video name for pose guide
|
||
let _sbWireframeTime = 0.5; // normalized frame time 0–1
|
||
let _sbPoseEdits = {}; // poseName → edited text
|
||
|
||
const CAMERA_ANGLES = [
|
||
// Absolute camera positions
|
||
{ name: 'Front', icon: '⊙', prompt: 'Head-on straight-on full-body portrait, frontal camera, realistic, transparent background', relative: false },
|
||
{ name: '¾ Left', icon: '↙', prompt: 'Three-quarter left, camera 45° to the left, full-body portrait, realistic, transparent background', relative: false },
|
||
{ name: '¾ Right', icon: '↗', prompt: 'Three-quarter right, camera 45° to the right, full-body portrait, realistic, transparent background', relative: false },
|
||
{ name: 'Side L', icon: '◁', prompt: 'Side profile, camera 90° to the left, full-body portrait, realistic, transparent background', relative: false },
|
||
{ name: 'Side R', icon: '▷', prompt: 'Side profile, camera 90° to the right, full-body portrait, realistic, transparent background', relative: false },
|
||
{ name: 'Back', icon: '⊕', prompt: 'Rear view, camera directly behind subject, full-body portrait, realistic, transparent background', relative: false },
|
||
{ name: 'High', icon: '△', prompt: "Bird's-eye view, camera above looking down, full-body, realistic, transparent background", relative: false },
|
||
{ name: 'Low', icon: '▽', prompt: "Worm's-eye view, camera below looking up, full-body portrait, realistic, transparent background", relative: false },
|
||
// Relative rotations — applied on top of current view
|
||
{ name: '↺ 45°', icon: '↺', prompt: 'Rotate 45° to the left from current view, full-body portrait, realistic, transparent background', relative: true },
|
||
{ name: '↻ 45°', icon: '↻', prompt: 'Rotate 45° to the right from current view, full-body portrait, realistic, transparent background', relative: true },
|
||
{ name: '↺ 90°', icon: '⟲', prompt: 'Rotate 90° to the left from current view, side profile, full-body portrait, realistic, transparent background', relative: true },
|
||
{ name: '↻ 90°', icon: '⟳', prompt: 'Rotate 90° to the right from current view, side profile, full-body portrait, realistic, transparent background', relative: true },
|
||
];
|
||
let _sbGenJobId = null;
|
||
let _sbGenJobPollTimer= null;
|
||
let _followLatestGid = null; // set to gid after generation to auto-jump to newest image
|
||
let _slideshowActive = false;
|
||
let _slideshowTimer = null;
|
||
let _slideshowInterval= 4000;
|
||
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(() => {
|
||
// Don't update gallery cards while studio is open — saves GPU and avoids distraction
|
||
if (document.getElementById('studio').classList.contains('open')) return;
|
||
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';
|
||
// Maps fname → nobg sidecar URL; viewer shows sidecar while this entry exists
|
||
const _nobgSidecars = new Map();
|
||
|
||
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');
|
||
// Hide main page scrollbar — nothing in studio scrolls the page
|
||
document.body.style.overflow = 'hidden';
|
||
// 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');
|
||
document.body.style.overflow = '';
|
||
stopSlideshow();
|
||
}
|
||
|
||
function toggleSlideshow() {
|
||
if (_slideshowActive) stopSlideshow();
|
||
else startSlideshow();
|
||
}
|
||
|
||
function startSlideshow() {
|
||
if (_slideshowTimer) clearInterval(_slideshowTimer);
|
||
_slideshowActive = true;
|
||
const btn = document.getElementById('slideshowBtn');
|
||
if (btn) { btn.textContent = '⏸ Pause'; btn.style.color = '#60a5fa'; }
|
||
_slideshowTimer = setInterval(() => {
|
||
if (lbUrls.length > 1) { lbIdx = (lbIdx + 1) % lbUrls.length; updateStudio(); }
|
||
}, _slideshowInterval);
|
||
}
|
||
|
||
function stopSlideshow() {
|
||
_slideshowActive = false;
|
||
clearInterval(_slideshowTimer);
|
||
_slideshowTimer = null;
|
||
const btn = document.getElementById('slideshowBtn');
|
||
if (btn) { btn.textContent = '▶ Slideshow'; btn.style.color = ''; }
|
||
}
|
||
|
||
function updateSlideshowInterval() {
|
||
const sel = document.getElementById('slideshowInterval');
|
||
if (sel) _slideshowInterval = parseInt(sel.value, 10);
|
||
if (_slideshowActive) startSlideshow(); // restart with new interval
|
||
}
|
||
|
||
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 genPromptWrap = document.getElementById('lbGenPromptWrap');
|
||
const genPromptEl = document.getElementById('lbGenPrompt');
|
||
const genPrompt = filePrompts[fname] || '';
|
||
if (genPromptWrap) genPromptWrap.style.display = genPrompt ? '' : 'none';
|
||
if (genPromptEl) genPromptEl.textContent = genPrompt;
|
||
|
||
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';
|
||
const cropBtn = document.getElementById('lbCropBtn');
|
||
if (cropBtn) cropBtn.style.display = (!isVid && fileHasBg[fname] === false) ? '' : 'none';
|
||
const archiveBtn = document.getElementById('lbArchiveBtn');
|
||
if (archiveBtn) archiveBtn.textContent = fileArchived[fname] ? 'Restore' : 'Archive';
|
||
|
||
// 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 — always visible (override old display:none CSS)
|
||
const strip = document.getElementById('lbVariantStrip');
|
||
strip.style.display = 'flex';
|
||
strip.innerHTML = lbUrls.map((url, i) => {
|
||
const n = lbNames[i];
|
||
const pose = filePoses[n] || '';
|
||
const act = i === lbIdx ? ' active' : '';
|
||
const thumbSrc = isVideo(n) ? posterFor(url) : url;
|
||
return '<div class="lb-var-thumb' + act + '" onclick="lbNavTo(' + i + ')">'
|
||
+ '<img src="' + thumbSrc + '" loading="lazy" onerror="this.style.opacity=\'0.3\'">'
|
||
+ (isVideo(n) ? '<div class="lb-var-play">▶</div>' : '')
|
||
+ (pose ? '<div class="lb-var-pose">' + escHtml(pose) + '</div>' : '')
|
||
+ '</div>';
|
||
}).join('');
|
||
const active = strip.querySelector('.lb-var-thumb.active');
|
||
if (active) active.scrollIntoView({ block: 'nearest', inline: 'center' });
|
||
|
||
// Camera angle overlay bar
|
||
updateSbAngleBar();
|
||
|
||
// Face-book thumbnail — show on slot 0 when a face crop exists for this group
|
||
const faceBook = document.getElementById('lbFaceBook');
|
||
const faceThumb = document.getElementById('lbFaceThumb');
|
||
if (faceBook && faceThumb && lbCurrentGid && lbIdx === 0 && !isVid) {
|
||
const faceFname = lbCurrentGid.replace(/\//g, '_') + '_face.png';
|
||
faceThumb.src = IMAGE_FOLDER + faceFname + '?t=' + Date.now();
|
||
faceThumb.onerror = () => { faceBook.style.display = 'none'; };
|
||
faceThumb.onload = () => { faceBook.style.display = ''; };
|
||
} else if (faceBook) {
|
||
faceBook.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 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');
|
||
}
|
||
}
|
||
|
||
// Two-step inline confirmation: first click arms the button, second confirms.
|
||
let _deleteArmed = false, _deleteArmTimer = null;
|
||
function lbDeleteArm() {
|
||
const btn = document.getElementById('lbDeleteBtn');
|
||
if (!btn) return;
|
||
if (_deleteArmed) { lbDeleteConfirm(); return; }
|
||
_deleteArmed = true;
|
||
btn.textContent = 'Confirm delete';
|
||
btn.style.background = '#dc2626';
|
||
clearTimeout(_deleteArmTimer);
|
||
_deleteArmTimer = setTimeout(() => {
|
||
_deleteArmed = false;
|
||
if (btn) { btn.textContent = 'Delete'; btn.style.background = ''; }
|
||
}, 3500);
|
||
}
|
||
|
||
async function lbDeleteConfirm() {
|
||
_deleteArmed = false;
|
||
clearTimeout(_deleteArmTimer);
|
||
const btn = document.getElementById('lbDeleteBtn');
|
||
if (btn) { btn.textContent = 'Delete'; btn.style.background = ''; btn.disabled = true; }
|
||
const fname = lbNames[lbIdx];
|
||
try {
|
||
const r = await fetch(`${API}/images/${fname}`, { method: 'DELETE' });
|
||
if (r.ok) {
|
||
showToast(`Deleted`, 'success');
|
||
if (btn) btn.disabled = false;
|
||
// Remove from current group view
|
||
lbUrls.splice(lbIdx, 1);
|
||
lbNames.splice(lbIdx, 1);
|
||
if (lbNames.length === 0) {
|
||
closeStudio();
|
||
refreshNow();
|
||
} else {
|
||
lbIdx = Math.min(lbIdx, lbNames.length - 1);
|
||
updateStudio();
|
||
refreshNow();
|
||
}
|
||
} else {
|
||
showToast(`Failed to delete`, 'error');
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
} catch (e) {
|
||
showToast(`Failed to delete: ${e}`, 'error');
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function lbArchive() {
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname) return;
|
||
const btn = document.getElementById('lbArchiveBtn');
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
const endpoint = fileArchived[fname] ? 'unarchive' : 'archive';
|
||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/${endpoint}`, { method: 'POST' });
|
||
if (r.ok) {
|
||
fileArchived[fname] = !fileArchived[fname];
|
||
const action = fileArchived[fname] ? 'Archived' : 'Restored';
|
||
showToast(`${action}: ${fname}`, 'success');
|
||
// Remove from current filmstrip view and navigate
|
||
lbUrls.splice(lbIdx, 1);
|
||
lbNames.splice(lbIdx, 1);
|
||
if (lbNames.length === 0) {
|
||
closeStudio();
|
||
refreshNow();
|
||
} else {
|
||
lbIdx = Math.min(lbIdx, lbNames.length - 1);
|
||
updateStudio();
|
||
refreshNow();
|
||
}
|
||
} else {
|
||
showToast('Archive failed', 'error');
|
||
}
|
||
} catch (e) { showToast('Archive error: ' + e, 'error'); }
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
function toggleArchiveView() {
|
||
_archiveViewActive = !_archiveViewActive;
|
||
const btn = document.getElementById('archiveViewBtn');
|
||
if (btn) {
|
||
btn.textContent = _archiveViewActive ? '📦 Back to Gallery' : '📦 Archive';
|
||
btn.style.color = _archiveViewActive ? '#f59e0b' : '';
|
||
}
|
||
// Show/hide archive banner
|
||
let banner = document.getElementById('archiveBanner');
|
||
if (_archiveViewActive) {
|
||
if (!banner) {
|
||
banner = document.createElement('div');
|
||
banner.id = 'archiveBanner';
|
||
banner.style.cssText = 'background:#7c3aed22;border:1px solid #7c3aed;border-radius:6px;padding:8px 14px;font-size:12px;color:#a78bfa;margin:8px 0;text-align:center';
|
||
banner.textContent = '📦 Viewing archived items — these are hidden from the main gallery';
|
||
const gallery = document.getElementById('gallery');
|
||
if (gallery) gallery.parentNode.insertBefore(banner, gallery);
|
||
}
|
||
} else {
|
||
if (banner) banner.remove();
|
||
}
|
||
refreshNow();
|
||
}
|
||
|
||
async function lbAutoCrop() {
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname) return;
|
||
const btn = document.getElementById('lbCropBtn');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Cropping…'; }
|
||
try {
|
||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/autocrop`, { method: 'POST' });
|
||
if (r.ok) {
|
||
showToast('Cropped', 'success');
|
||
lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
|
||
updateStudio();
|
||
} else {
|
||
showToast('Crop failed: ' + await r.text(), 'error');
|
||
}
|
||
} catch (e) { showToast('Crop failed: ' + e, 'error'); }
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Crop'; }
|
||
}
|
||
|
||
function startManualCrop() {
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to crop', 'info'); return; }
|
||
const viewer = document.getElementById('studioViewer');
|
||
const img = document.getElementById('lbImg');
|
||
if (!img || img.style.display === 'none') { showToast('No image displayed', 'info'); return; }
|
||
if (document.getElementById('cropCanvas')) return; // already active
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.id = 'cropCanvas';
|
||
canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;cursor:crosshair;z-index:100;';
|
||
viewer.appendChild(canvas);
|
||
|
||
const bar = document.createElement('div');
|
||
bar.id = 'cropBar';
|
||
bar.style.cssText = 'position:absolute;bottom:0;left:0;right:0;display:flex;gap:8px;padding:8px;background:rgba(0,0,0,0.75);z-index:101;justify-content:flex-end;align-items:center;';
|
||
bar.innerHTML = '<span style="flex:1;font-size:11px;color:#aaa">Drag to select crop area</span>'
|
||
+ '<button class="sb-btn" onclick="cancelManualCrop()">Cancel</button>'
|
||
+ '<button class="sb-btn primary" id="cropConfirmBtn" disabled onclick="confirmManualCrop()">Crop</button>';
|
||
viewer.appendChild(bar);
|
||
|
||
const st = window._cropState = { canvas, viewer, img, fname, x1: 0, y1: 0, x2: 0, y2: 0, dragging: false };
|
||
|
||
function resize() { canvas.width = viewer.clientWidth; canvas.height = viewer.clientHeight; draw(); }
|
||
function draw() {
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
const w = Math.abs(st.x2 - st.x1), h = Math.abs(st.y2 - st.y1);
|
||
if (w < 2 || h < 2) return;
|
||
const rx = Math.min(st.x1, st.x2), ry = Math.min(st.y1, st.y2);
|
||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
ctx.clearRect(rx, ry, w, h);
|
||
ctx.strokeStyle = '#fff';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.strokeRect(rx + 0.5, ry + 0.5, w - 1, h - 1);
|
||
}
|
||
st.draw = draw;
|
||
|
||
canvas.addEventListener('mousedown', e => {
|
||
const r = canvas.getBoundingClientRect();
|
||
st.x1 = st.x2 = e.clientX - r.left;
|
||
st.y1 = st.y2 = e.clientY - r.top;
|
||
st.dragging = true;
|
||
const cb = document.getElementById('cropConfirmBtn');
|
||
if (cb) cb.disabled = true;
|
||
});
|
||
canvas.addEventListener('mousemove', e => {
|
||
if (!st.dragging) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
st.x2 = e.clientX - r.left;
|
||
st.y2 = e.clientY - r.top;
|
||
draw();
|
||
});
|
||
canvas.addEventListener('mouseup', () => {
|
||
st.dragging = false;
|
||
draw();
|
||
const w = Math.abs(st.x2 - st.x1), h = Math.abs(st.y2 - st.y1);
|
||
const cb = document.getElementById('cropConfirmBtn');
|
||
if (cb) cb.disabled = (w < 5 || h < 5);
|
||
});
|
||
|
||
resize();
|
||
}
|
||
|
||
function cancelManualCrop() {
|
||
document.getElementById('cropCanvas')?.remove();
|
||
document.getElementById('cropBar')?.remove();
|
||
window._cropState = null;
|
||
}
|
||
|
||
async function confirmManualCrop() {
|
||
const st = window._cropState;
|
||
if (!st) return;
|
||
const { canvas, img, fname } = st;
|
||
|
||
// Convert canvas rect → image pixel coords (account for object-fit: contain letterbox)
|
||
const cW = canvas.width, cH = canvas.height;
|
||
const iW = img.naturalWidth, iH = img.naturalHeight;
|
||
const scale = Math.min(cW / iW, cH / iH);
|
||
const dW = iW * scale, dH = iH * scale;
|
||
const offX = (cW - dW) / 2, offY = (cH - dH) / 2;
|
||
|
||
const toImg = (cx, cy) => ({
|
||
x: Math.round(Math.max(0, Math.min(iW, (cx - offX) / scale))),
|
||
y: Math.round(Math.max(0, Math.min(iH, (cy - offY) / scale))),
|
||
});
|
||
const p1 = toImg(Math.min(st.x1, st.x2), Math.min(st.y1, st.y2));
|
||
const p2 = toImg(Math.max(st.x1, st.x2), Math.max(st.y1, st.y2));
|
||
if (p2.x - p1.x < 2 || p2.y - p1.y < 2) { showToast('Crop area too small', 'info'); return; }
|
||
|
||
const btn = document.getElementById('cropConfirmBtn');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Cropping…'; }
|
||
try {
|
||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/crop`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
|
||
});
|
||
if (r.ok) {
|
||
showToast('Cropped', 'success');
|
||
cancelManualCrop();
|
||
lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
|
||
updateStudio();
|
||
} else {
|
||
showToast('Crop failed: ' + await r.text(), 'error');
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Crop'; }
|
||
}
|
||
} catch (e) {
|
||
showToast('Crop error: ' + e, 'error');
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Crop'; }
|
||
}
|
||
}
|
||
|
||
async function lbDuplicate() {
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname) return;
|
||
const btn = document.getElementById('lbDuplicateBtn');
|
||
if (btn) btn.disabled = true;
|
||
showToast('Duplicating…');
|
||
try {
|
||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/duplicate`, { method: 'POST' });
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
showToast('Duplicated', 'success');
|
||
// Insert the duplicate right after the current image in the studio strip
|
||
lbUrls.splice(lbIdx + 1, 0, IMAGE_FOLDER + d.new_filename + '?t=' + Date.now());
|
||
lbNames.splice(lbIdx + 1, 0, d.new_filename);
|
||
lbIdx = lbIdx + 1;
|
||
updateStudio();
|
||
refreshNow();
|
||
} else {
|
||
showToast('Duplicate failed', 'error');
|
||
}
|
||
} catch (e) { showToast('Duplicate failed: ' + e, 'error'); }
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
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) {
|
||
// If studio is open, offer to add to current group without triggering poses
|
||
if (lbCurrentGid) {
|
||
showPasteGroupDialog(files);
|
||
} else {
|
||
handleUpload(files);
|
||
}
|
||
}
|
||
});
|
||
|
||
function showToast(message, type = 'info', duration = 3000) {
|
||
const toast = document.getElementById('toast');
|
||
toast.textContent = message;
|
||
toast.className = `toast ${type} show`;
|
||
setTimeout(() => toast.classList.remove('show'), duration);
|
||
}
|
||
|
||
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 url = _archiveViewActive ? `${API}/images?archived=true` : `${API}/images`;
|
||
const r = await fetch(url, { 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;
|
||
fileArchived[img.filename] = !!img.archived;
|
||
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 });
|
||
|
||
// Auto-follow: if a generation just finished for this group, jump to the last (newest) image
|
||
if (_followLatestGid === gid && _isStudioOpen() && lbCurrentGid === gid && names.length > 0) {
|
||
_followLatestGid = null;
|
||
lbIdx = names.length - 1;
|
||
lbUrls = urls;
|
||
lbNames = names;
|
||
updateStudio();
|
||
}
|
||
|
||
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');
|
||
|
||
// If studio is open, sync lbUrls/lbNames from freshly-loaded groupData
|
||
const studioEl = document.getElementById('studio');
|
||
if (studioEl?.classList.contains('open') && lbCurrentGid) {
|
||
const fresh = groupData.get(lbCurrentGid);
|
||
if (fresh) {
|
||
const curName = lbNames[lbIdx];
|
||
const newIdx = fresh.names.indexOf(curName);
|
||
lbUrls = fresh.urls;
|
||
lbNames = fresh.names;
|
||
lbIdx = newIdx >= 0 ? newIdx : Math.min(lbIdx, fresh.names.length - 1);
|
||
updateStudio();
|
||
}
|
||
}
|
||
|
||
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 _isStudioOpen() {
|
||
return document.getElementById('studio')?.classList.contains('open') ?? false;
|
||
}
|
||
|
||
function setIntervalTime(seconds) {
|
||
REFRESH_INTERVAL = seconds;
|
||
document.getElementById('statusText').textContent = `Auto-refresh: ${seconds < 60 ? seconds + 's' : (seconds / 60) + 'm'}`;
|
||
|
||
clearInterval(autoRefreshTimer);
|
||
autoRefreshTimer = setInterval(_timedRefresh, REFRESH_INTERVAL * 1000);
|
||
|
||
showToast(`Refresh interval set to ${seconds < 60 ? seconds + ' seconds' : (seconds / 60) + ' minutes'}`, 'info');
|
||
}
|
||
|
||
// Called by the auto-refresh timer only — skips when studio is open to avoid
|
||
// disrupting the user's workflow. Job-completion code calls refreshNow() directly
|
||
// and is NOT suppressed (the studio sync path in loadImages handles that safely).
|
||
function _timedRefresh() {
|
||
if (!_isStudioOpen()) refreshNow();
|
||
}
|
||
|
||
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';
|
||
const showMR = (n >= 2 && n <= 3);
|
||
document.getElementById('multiRefBtn').style.display = showMR ? '' : 'none';
|
||
const legend = document.getElementById('multiRefLegend');
|
||
if (legend) {
|
||
if (showMR) {
|
||
const names = [...selectedFiles].map((gid, i) => {
|
||
const data = groupData.get(gid);
|
||
const idx = selectedVariants.get(gid) ?? 0;
|
||
const fname = data?.names[idx] ?? gid;
|
||
return `ref${i+1}=${fname.split('/').pop().replace(/\?.*$/,'').slice(0,24)}`;
|
||
});
|
||
legend.textContent = names.join(' · ');
|
||
legend.style.display = '';
|
||
} else {
|
||
legend.style.display = '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); });
|
||
|
||
// Show which filename maps to which ref slot
|
||
const shortName = f => f.split('/').pop().replace(/\?.*$/, '');
|
||
let refLabel = `Ref1: ${shortName(filenames[0])} Ref2: ${shortName(filenames[1])}`;
|
||
if (filenames[2]) refLabel += ` Ref3: ${shortName(filenames[2])}`;
|
||
showToast(refLabel, 'info', 5000);
|
||
|
||
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(_timedRefresh, 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;
|
||
// Preserve live input values before re-render
|
||
const prevPromptVal = document.getElementById('sbGenPromptInput')?.value ?? '';
|
||
_fsSelectedPoses.forEach(name => {
|
||
const el = document.getElementById('sbPoseEdit_' + CSS.escape(name));
|
||
if (el) _sbPoseEdits[name] = el.value;
|
||
});
|
||
|
||
const donePoses = new Set();
|
||
lbNames.forEach(n => { if (filePoses[n]) donePoses.add(filePoses[n]); });
|
||
|
||
// Camera angles section — absolute positions
|
||
const absAngles = CAMERA_ANGLES.filter(a => !a.relative);
|
||
const relAngles = CAMERA_ANGLES.filter(a => a.relative);
|
||
let html = '<div class="sb-label">Camera — absolute</div><div class="sb-camera-grid" id="sbCameraGrid">';
|
||
html += absAngles.map(a => {
|
||
const sel = _sbSelectedAngles.has(a.name) ? ' selected' : '';
|
||
const nSafe = a.name.replace(/'/g, "\\'");
|
||
return `<button class="sb-angle-btn${sel}" onclick="toggleSbAngle('${nSafe}')" title="${escHtml(a.prompt)}">${a.icon}<br><span style="font-size:9px">${escHtml(a.name)}</span></button>`;
|
||
}).join('');
|
||
html += '</div>';
|
||
|
||
// Relative rotations
|
||
html += '<div class="sb-label" style="margin-top:6px">Camera — relative</div><div class="sb-camera-grid" id="sbRelAngleGrid">';
|
||
html += relAngles.map(a => {
|
||
const sel = _sbSelectedAngles.has(a.name) ? ' selected' : '';
|
||
const nSafe = a.name.replace(/'/g, "\\'");
|
||
return `<button class="sb-angle-btn${sel}" onclick="toggleSbAngle('${nSafe}')" title="${escHtml(a.prompt)}">${a.icon}<br><span style="font-size:9px">${escHtml(a.name)}</span></button>`;
|
||
}).join('');
|
||
html += '</div>';
|
||
|
||
// Wireframe pose reference
|
||
const wfSel = _sbWireframeRef || '';
|
||
const wfT = _sbWireframeTime;
|
||
html += `<div class="sb-sep"></div>
|
||
<div class="sb-label">Wireframe pose guide <span style="font-size:10px;color:#555;font-weight:400">(optional)</span></div>
|
||
<select id="sbWireframeSelect" onchange="sbSelectWireframe(this.value)"
|
||
style="width:100%;background:#111;border:1px solid #2a2a2a;border-radius:5px;color:#aaa;font-size:11px;padding:4px;margin-bottom:6px">
|
||
<option value="">— none —</option>
|
||
${availableVideos.map(v => `<option value="${escHtml(v)}"${wfSel===v?' selected':''}>${escHtml(v.replace(/\.[^.]+$/,''))}</option>`).join('')}
|
||
</select>
|
||
${wfSel ? `<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
|
||
<span style="font-size:11px;color:#777">Frame:</span>
|
||
<input type="range" id="sbWireframeTimeSlider" min="0" max="100" value="${Math.round(wfT*100)}"
|
||
oninput="sbUpdateWireframeTime(this.value/100)"
|
||
style="flex:1">
|
||
<span id="sbWireframeTimeLabel" style="font-size:11px;color:#777;min-width:28px">${Math.round(wfT*100)}%</span>
|
||
<img id="sbWireframeThumb" src="${API}/wireframe/frame/${encodeURIComponent(wfSel)}?t=${wfT}"
|
||
style="width:48px;height:48px;object-fit:contain;border-radius:4px;border:1px solid #333">
|
||
</div>` : ''}`;
|
||
|
||
html += '<div class="sb-sep"></div><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>`;
|
||
|
||
// Fine-tune section: one editable textarea per selected pose
|
||
if (_fsSelectedPoses.size > 0) {
|
||
html += `<div class="sb-sep"></div><div class="sb-label">Fine-tune poses</div>`;
|
||
_fsSelectedPoses.forEach(name => {
|
||
const origText = String(availablePoses[name]?.text ?? availablePoses[name] ?? name);
|
||
const curText = (_sbPoseEdits[name] !== undefined) ? _sbPoseEdits[name] : origText;
|
||
const idSafe = escHtml(name).replace(/"/g, '"');
|
||
html += `<div style="font-size:11px;color:#888;margin-bottom:3px">${escHtml(name)}</div>
|
||
<textarea id="sbPoseEdit_${idSafe}"
|
||
style="width:100%;box-sizing:border-box;background:#111;border:1px solid #2a2a2a;border-radius:5px;color:#ccc;font-size:11px;padding:6px;resize:vertical;min-height:60px;margin-bottom:6px"
|
||
oninput="sbSavePoseEdit('${name.replace(/'/g,"\\'")}')">${escHtml(curText)}</textarea>`;
|
||
});
|
||
}
|
||
|
||
html += `<div class="sb-sep"></div>
|
||
<div class="sb-label">Custom prompt</div>
|
||
<input type="text" class="sb-input" id="sbGenPromptInput"
|
||
list="promptHistory"
|
||
placeholder="Prompt — overrides pose" oninput="updateSbGenBtn()" style="margin-bottom:10px"
|
||
value="${escHtml(prevPromptVal)}">
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<span id="sbGenJobStatus" style="flex:1;font-size:11px;color:#888;display:none;min-width:120px">
|
||
<span class="sb-spinner"></span><span id="sbGenJobText">Generating…</span>
|
||
</span>
|
||
<button class="sb-btn danger" id="sbCancelBtn" style="display:none" onclick="cancelSbGenerate()">Cancel</button>
|
||
<button class="sb-btn primary" id="sbGenBtn" onclick="submitSbGenerate()" disabled>Generate</button>
|
||
</div>`;
|
||
panel.innerHTML = html;
|
||
updateSbGenBtn();
|
||
}
|
||
|
||
function sbSavePoseEdit(name) {
|
||
const el = document.getElementById('sbPoseEdit_' + CSS.escape(name));
|
||
if (el) _sbPoseEdits[name] = el.value;
|
||
}
|
||
|
||
function toggleSbPose(name) {
|
||
if (_fsSelectedPoses.has(name)) _fsSelectedPoses.delete(name);
|
||
else _fsSelectedPoses.add(name);
|
||
renderSidebarGenerate();
|
||
}
|
||
|
||
function toggleSbAngle(name) {
|
||
if (_sbSelectedAngles.has(name)) _sbSelectedAngles.delete(name);
|
||
else _sbSelectedAngles.add(name);
|
||
renderSidebarGenerate();
|
||
}
|
||
|
||
function sbSelectWireframe(val) {
|
||
_sbWireframeRef = val;
|
||
renderSidebarGenerate();
|
||
}
|
||
|
||
function sbUpdateWireframeTime(t) {
|
||
_sbWireframeTime = t;
|
||
const label = document.getElementById('sbWireframeTimeLabel');
|
||
if (label) label.textContent = Math.round(t * 100) + '%';
|
||
// Debounced thumb update
|
||
clearTimeout(sbUpdateWireframeTime._timer);
|
||
sbUpdateWireframeTime._timer = setTimeout(() => {
|
||
const thumb = document.getElementById('sbWireframeThumb');
|
||
if (thumb && _sbWireframeRef) {
|
||
thumb.src = `${API}/wireframe/frame/${encodeURIComponent(_sbWireframeRef)}?t=${t}&_=${Date.now()}`;
|
||
}
|
||
}, 400);
|
||
}
|
||
|
||
async function submitSingleAngle(prompt) {
|
||
if (!_fsModelFilename) return;
|
||
const bar = document.getElementById('studioAngleBar');
|
||
if (bar) bar.style.pointerEvents = 'none';
|
||
try {
|
||
const r = await fetch(`${API}/batch`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
filenames: [_fsModelFilename], prompt: [prompt],
|
||
poses: [null], seed: -1, max_area: 0, group_id: lbCurrentGid,
|
||
wireframe_ref: _sbWireframeRef || null,
|
||
wireframe_time: _sbWireframeTime,
|
||
}),
|
||
});
|
||
if (r.ok) {
|
||
const { job_id } = await r.json();
|
||
_followLatestGid = lbCurrentGid;
|
||
showToast('Generating angle…', 'info');
|
||
// Light polling — refresh when done
|
||
const poll = () => setTimeout(async () => {
|
||
try {
|
||
const s = await fetch(`${API}/batch/${job_id}`).then(r => r.json());
|
||
if (s.status === 'done') { refreshNow(); }
|
||
else if (s.status !== 'error' && s.status !== 'cancelled') poll();
|
||
} catch(e) {}
|
||
}, 2000);
|
||
poll();
|
||
} else { showToast('Angle generation failed', 'error'); }
|
||
} catch(e) { showToast('API error: ' + e, 'error'); }
|
||
if (bar) bar.style.pointerEvents = '';
|
||
}
|
||
|
||
function updateSbAngleBar() {
|
||
const bar = document.getElementById('studioAngleBar');
|
||
if (!bar) return;
|
||
const isVid = lbNames[lbIdx] && (isVideo(lbNames[lbIdx]) || fileContentType[lbNames[lbIdx]] === 'video');
|
||
const hasCrop = !!document.getElementById('cropCanvas');
|
||
if (isVid || hasCrop || !lbNames[lbIdx]) {
|
||
bar.style.display = 'none';
|
||
return;
|
||
}
|
||
bar.style.display = 'flex';
|
||
// Show 6 most useful angles (skip High/Low for overlay — rare)
|
||
const overlay = CAMERA_ANGLES.slice(0, 6);
|
||
bar.innerHTML = overlay.map(a => {
|
||
const pSafe = a.prompt.replace(/'/g, "\\'");
|
||
return `<button onclick="submitSingleAngle('${pSafe}')" title="${escHtml(a.name)}">${a.icon}</button>`;
|
||
}).join('');
|
||
}
|
||
|
||
function updateSbGenBtn() {
|
||
const btn = document.getElementById('sbGenBtn');
|
||
if (!btn) return;
|
||
const hasPrompt = (document.getElementById('sbGenPromptInput')?.value || '').trim().length > 0;
|
||
const total = _fsSelectedPoses.size + _sbSelectedAngles.size + (hasPrompt ? 1 : 0);
|
||
btn.disabled = total === 0;
|
||
btn.textContent = total > 1 ? `Generate (${total})` : 'Generate';
|
||
}
|
||
|
||
async function cancelSbGenerate() {
|
||
if (!_sbGenJobId) return;
|
||
const cancelBtn = document.getElementById('sbCancelBtn');
|
||
if (cancelBtn) cancelBtn.disabled = true;
|
||
try {
|
||
await fetch(`${API}/batch/${_sbGenJobId}`, { method: 'DELETE' });
|
||
showToast('Generation cancelled', 'info');
|
||
} catch (_) {}
|
||
clearTimeout(_sbGenJobPollTimer);
|
||
_sbGenJobId = null;
|
||
const btn = document.getElementById('sbGenBtn');
|
||
const statusEl = document.getElementById('sbGenJobStatus');
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Generate'; }
|
||
if (cancelBtn) { cancelBtn.disabled = false; cancelBtn.style.display = 'none'; }
|
||
if (statusEl) statusEl.style.display = 'none';
|
||
}
|
||
|
||
async function submitSbGenerate() {
|
||
if (!_fsModelFilename) return;
|
||
const promptVal = (document.getElementById('sbGenPromptInput')?.value || '').trim();
|
||
const prompts = [], poses = [];
|
||
_fsSelectedPoses.forEach(name => {
|
||
poses.push(name);
|
||
// Use edited text if available, else original
|
||
const edited = _sbPoseEdits[name];
|
||
const orig = availablePoses[name]?.text ?? availablePoses[name] ?? name;
|
||
prompts.push((edited !== undefined && edited.trim()) ? edited.trim() : String(orig));
|
||
});
|
||
// Camera angles — treated as prompts with no pose tag
|
||
_sbSelectedAngles.forEach(name => {
|
||
const ang = CAMERA_ANGLES.find(a => a.name === name);
|
||
if (ang) { prompts.push(ang.prompt); poses.push(null); }
|
||
});
|
||
if (promptVal) { prompts.push(promptVal); poses.push(null); savePromptHistory(promptVal); }
|
||
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');
|
||
const cancelBtn = document.getElementById('sbCancelBtn');
|
||
statusEl.style.display = 'inline-flex';
|
||
if (cancelBtn) cancelBtn.style.display = '';
|
||
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,
|
||
wireframe_ref: _sbWireframeRef || null,
|
||
wireframe_time: _sbWireframeTime,
|
||
}),
|
||
});
|
||
if (!r.ok) {
|
||
textEl.textContent = `Error: ${await r.text()}`;
|
||
btn.disabled = false; btn.textContent = 'Retry';
|
||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||
return;
|
||
}
|
||
const { job_id, total } = await r.json();
|
||
_sbGenJobId = job_id;
|
||
showToast(`Generating ${total} image${total !== 1 ? 's' : ''}…`, 'info');
|
||
textEl.textContent = `Generating… 0/${total}`;
|
||
let prevDone = 0;
|
||
const pollGen = () => {
|
||
clearTimeout(_sbGenJobPollTimer);
|
||
_sbGenJobPollTimer = setTimeout(async () => {
|
||
try {
|
||
const jr = await fetch(`${API}/batch/${job_id}`);
|
||
if (!jr.ok) { pollGen(); return; }
|
||
const job = await jr.json();
|
||
const finished = job.done + job.failed;
|
||
textEl.textContent = `Generating… ${finished}/${total}`;
|
||
// Refresh UI as each task completes; auto-follow to newest image
|
||
if (job.done > prevDone) {
|
||
prevDone = job.done;
|
||
_followLatestGid = lbCurrentGid;
|
||
refreshNow();
|
||
}
|
||
if (job.status === 'running') { pollGen(); return; }
|
||
if (job.status === 'cancelled') {
|
||
statusEl.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||
btn.disabled = false; btn.textContent = 'Generate';
|
||
_sbGenJobId = null;
|
||
return;
|
||
}
|
||
if (job.status === 'done') {
|
||
showToast('Generation complete!', 'success');
|
||
statusEl.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||
btn.disabled = false; btn.textContent = 'Generate';
|
||
_fsSelectedPoses.clear();
|
||
_sbPoseEdits = {};
|
||
_sbGenJobId = null;
|
||
_followLatestGid = lbCurrentGid;
|
||
refreshNow();
|
||
renderSidebarGenerate();
|
||
} else {
|
||
textEl.textContent = `Error: ${job.error || 'unknown'}`;
|
||
btn.disabled = false; btn.textContent = 'Retry';
|
||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||
_sbGenJobId = null;
|
||
}
|
||
} catch (e) { pollGen(); }
|
||
}, 2000);
|
||
};
|
||
pollGen();
|
||
} catch (e) {
|
||
textEl.textContent = `API error: ${e}`;
|
||
btn.disabled = false; btn.textContent = 'Retry';
|
||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||
_sbGenJobId = null;
|
||
}
|
||
}
|
||
|
||
// ---- 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}')"
|
||
onmouseenter="const v=this.querySelector('video');v&&v.play().catch(()=>{})" onmouseleave="const v=this.querySelector('video');v&&v.pause()">
|
||
<video src="${API}/wireframe/${encodeURIComponent(v)}" muted loop 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>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer" title="Generate at lower resolution for quick preview">
|
||
<input type="checkbox" id="sbFsPreview" onchange="document.getElementById('sbFsPreviewOpts').style.display=this.checked?'flex':'none'">
|
||
<span>Preview mode</span>
|
||
</label>
|
||
<div id="sbFsPreviewOpts" style="display:none;align-items:center;gap:6px;padding-left:20px">
|
||
<span style="font-size:11px;color:#888">Scale:</span>
|
||
<select id="sbFsPreviewScale" style="font-size:11px;background:#1a1a1a;color:#ccc;border:1px solid #333;border-radius:3px;padding:1px 4px">
|
||
<option value="0.5">50%</option>
|
||
<option value="0.25">25%</option>
|
||
</select>
|
||
</div>
|
||
</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 previewCb = document.getElementById('sbFsPreview');
|
||
const previewScale = (previewCb?.checked)
|
||
? parseFloat(document.getElementById('sbFsPreviewScale')?.value ?? '0.5')
|
||
: 1.0;
|
||
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 prevTag = previewScale < 1.0 ? ` preview ${Math.round(previewScale * 100)}%` : '';
|
||
const modeLabel = (hair ? 'FaceFusion + hair' : (enhance ? 'insightface + enhance' : 'insightface')) + prevTag;
|
||
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, preview_scale: previewScale }),
|
||
});
|
||
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}')"
|
||
onmouseenter="const v=this.querySelector('video');v&&v.play().catch(()=>{})" onmouseleave="const v=this.querySelector('video');v&&v.pause()">
|
||
<video src="${API}/wireframe/${encodeURIComponent(v)}" muted loop 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="auto" playsinline autoplay loop
|
||
style="${_sceneVideo&&!_sceneFrameBytes?'':'display:none'};width:100%;height:100%;object-fit:contain"></video>
|
||
<img id="sceneFrameImg" style="${_sceneFrameBytes?'':'display:none'};width:100%;height:100%;object-fit:contain"
|
||
${_sceneFrameBytes?`src="data:image/png;base64,${_sceneFrameBytes}"`:''}/>
|
||
<div id="sceneFrameLoading" style="display:none;position:absolute;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;font-size:11px;color:#aaa">Loading…</div>
|
||
</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 (_) {}
|
||
}
|
||
|
||
let _sceneScrubTimer = null;
|
||
|
||
function sceneSeekVideo(val) {
|
||
const t = parseFloat(val);
|
||
const lbl = document.getElementById('sceneTimeLabel');
|
||
if (lbl) lbl.textContent = formatSecs(t);
|
||
// If a captured frame was locked, release it so scrubbing works again
|
||
if (_sceneFrameBytes) {
|
||
_sceneFrameBytes = null;
|
||
const btn = document.getElementById('sceneExtractBtn');
|
||
if (btn) btn.textContent = 'Extract frame as reference';
|
||
const genBtn = document.getElementById('sceneGenBtn');
|
||
if (genBtn) genBtn.disabled = !_sceneVideo;
|
||
}
|
||
// Show video live while the user is dragging
|
||
const vid = document.getElementById('sceneVideoEl');
|
||
const img = document.getElementById('sceneFrameImg');
|
||
if (vid) { vid.style.display = ''; vid.currentTime = t; }
|
||
if (img) img.style.display = 'none';
|
||
// After settling, fetch exact server-rendered frame and freeze on it
|
||
clearTimeout(_sceneScrubTimer);
|
||
_sceneScrubTimer = setTimeout(() => _sceneShowFrame(t), 300);
|
||
}
|
||
|
||
async function _sceneShowFrame(t) {
|
||
if (!_sceneVideo) return;
|
||
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) return;
|
||
const d = await r.json();
|
||
const vid = document.getElementById('sceneVideoEl');
|
||
const img = document.getElementById('sceneFrameImg');
|
||
if (!img) return;
|
||
if (vid) vid.style.display = 'none';
|
||
img.src = 'data:image/png;base64,' + d.frame_b64;
|
||
img.style.display = '';
|
||
img.dataset.pendingFrame = d.frame_b64; // ready to capture without re-fetch
|
||
} catch (_) { /* video stays visible on error */ }
|
||
}
|
||
|
||
async function sceneExtractFrame() {
|
||
const scrubber = document.getElementById('sceneScrubber');
|
||
const t = parseFloat(scrubber?.value || '0');
|
||
const btn = document.getElementById('sceneExtractBtn');
|
||
const img = document.getElementById('sceneFrameImg');
|
||
// If the debounced preview already shows this frame, just lock it — no re-fetch
|
||
if (img?.dataset.pendingFrame) {
|
||
_sceneFrameBytes = img.dataset.pendingFrame;
|
||
delete img.dataset.pendingFrame;
|
||
if (btn) btn.textContent = '✓ Frame captured';
|
||
const genBtn = document.getElementById('sceneGenBtn');
|
||
if (genBtn) genBtn.disabled = false;
|
||
return;
|
||
}
|
||
if (btn) { btn.disabled = true; 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) {
|
||
showToast('Frame extract failed: ' + (await r.text()), 'error');
|
||
} else {
|
||
const d = await r.json();
|
||
_sceneFrameBytes = d.frame_b64;
|
||
const vid = document.getElementById('sceneVideoEl');
|
||
const imgEl = document.getElementById('sceneFrameImg');
|
||
if (vid) vid.style.display = 'none';
|
||
if (imgEl) { imgEl.src = 'data:image/png;base64,' + _sceneFrameBytes; imgEl.style.display = ''; }
|
||
const genBtn = document.getElementById('sceneGenBtn');
|
||
if (genBtn) genBtn.disabled = false;
|
||
if (btn) btn.textContent = '✓ Frame captured';
|
||
}
|
||
} catch (e) {
|
||
showToast('Frame extract error: ' + e, 'error');
|
||
if (btn) btn.textContent = 'Extract frame as reference';
|
||
}
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
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 {
|
||
const errMsg = job.error || 'unknown error';
|
||
if (textEl) textEl.textContent = 'Error: ' + errMsg;
|
||
if (btn) btn.disabled = false;
|
||
showToast('Scenery failed: ' + errMsg, 'error');
|
||
}
|
||
} catch (e) { 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 background using SAM2 or rembg fallback. The original file is
|
||
kept intact — a sidecar <code>.nobg.png</code> is created. Restore BG
|
||
simply shows the original again without any file change.
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap">
|
||
<button class="sb-btn" id="sbSam2Btn" onclick="sam2RemoveBg()" title="SAM2 segmentation — preserves hair/glasses/accessories; falls back to rembg if unavailable">Remove BG (SAM2)</button>
|
||
<button class="sb-btn" id="sbRembgBtn" onclick="rembgRemoveBg()" title="rembg (U2Net) — fast background removal without SAM2">Remove BG (rembg)</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 merges all non-border masks (preserves hair, glasses, sandals).<br>
|
||
rembg (U2Net) is faster and works without SAM2 checkpoint.<br>
|
||
Requires sam2_checkpoint in config.json for SAM2 path.
|
||
</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();
|
||
const label = d.used_sam2 ? 'Done (SAM2)' : 'Done (rembg)';
|
||
if (statusEl) statusEl.textContent = label;
|
||
showToast('Background removed', 'success');
|
||
// Build URL same way as all gallery images: IMAGE_FOLDER + filename
|
||
const nobgUrl = IMAGE_FOLDER + d.nobg_filename + '?t=' + Date.now();
|
||
_nobgSidecars.set(fname, nobgUrl);
|
||
// Preload before swap to avoid black flash
|
||
const preload = new Image();
|
||
preload.onload = () => {
|
||
const img = document.getElementById('lbImg');
|
||
if (img) img.src = nobgUrl;
|
||
const cb = document.getElementById('sbCheckerboard');
|
||
if (cb && !cb.checked) { cb.checked = true; toggleCheckerboard(true); }
|
||
};
|
||
preload.src = nobgUrl;
|
||
}
|
||
} catch (e) { if (statusEl) statusEl.textContent = 'Error: ' + e; }
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
function restoreBg() {
|
||
// Non-destructive: just revert the viewer to the original URL.
|
||
// The sidecar .nobg.png stays on disk for future use.
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname) return;
|
||
_nobgSidecars.delete(fname);
|
||
const img = document.getElementById('lbImg');
|
||
if (img) img.src = lbUrls[lbIdx] + (lbUrls[lbIdx].includes('?') ? '&' : '?') + 't=' + Date.now();
|
||
const statusEl = document.getElementById('sbSegStatus');
|
||
if (statusEl) statusEl.textContent = 'Original restored';
|
||
// Turn off checkerboard
|
||
const cb = document.getElementById('sbCheckerboard');
|
||
if (cb && cb.checked) { cb.checked = false; toggleCheckerboard(false); }
|
||
showToast('Original image shown', 'success');
|
||
}
|
||
|
||
async function rembgRemoveBg() {
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname) return;
|
||
const btn = document.getElementById('sbRembgBtn');
|
||
const statusEl = document.getElementById('sbSegStatus');
|
||
if (btn) btn.disabled = true;
|
||
if (statusEl) statusEl.textContent = 'Removing background (rembg)…';
|
||
try {
|
||
const r = await fetch(`${API}/remove-background/${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 = 'Done (rembg)';
|
||
showToast('Background removed (rembg)', 'success');
|
||
const nobgUrl = IMAGE_FOLDER + d.nobg_filename + '?t=' + Date.now();
|
||
_nobgSidecars.set(fname, nobgUrl);
|
||
const preload = new Image();
|
||
preload.onload = () => {
|
||
const img = document.getElementById('lbImg');
|
||
if (img) img.src = nobgUrl;
|
||
const cb = document.getElementById('sbCheckerboard');
|
||
if (cb && !cb.checked) { cb.checked = true; toggleCheckerboard(true); }
|
||
};
|
||
preload.src = nobgUrl;
|
||
}
|
||
} 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(`${API}/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();
|
||
// Fire-and-forget face extraction for face-book
|
||
fetch(`${API}/images/${encodeURIComponent(fname)}/extract-face`, { method: 'POST' })
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(d => { if (d?.status === 'queued') showToast('Extracting face reference…', 'info'); })
|
||
.catch(() => {});
|
||
} catch(e) { console.error(e); }
|
||
}
|
||
|
||
function showPasteGroupDialog(files) {
|
||
document.getElementById('pasteDialog')?.remove();
|
||
const gName = (lbCurrentGid && groupNames[lbCurrentGid]) || lbCurrentGid || 'current group';
|
||
const gidSafe = escHtml(lbCurrentGid || '');
|
||
const gNameSafe = escHtml(gName);
|
||
const d = document.createElement('div');
|
||
d.id = 'pasteDialog';
|
||
d.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:20px;z-index:600;min-width:280px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,0.7)';
|
||
d.innerHTML = `<div style="font-size:13px;color:#ccc;margin-bottom:14px">Add ${files.length} image(s)?</div>
|
||
<div style="display:flex;flex-direction:column;gap:8px">
|
||
<button class="btn primary" onclick="handleUploadToGroup(window._pasteFiles,'${gidSafe}');document.getElementById('pasteDialog')?.remove()">Add to “${gNameSafe}” — no poses</button>
|
||
<button class="btn" onclick="handleUpload(window._pasteFiles);document.getElementById('pasteDialog')?.remove()">New group (run poses)</button>
|
||
<button class="btn" onclick="document.getElementById('pasteDialog')?.remove()">Cancel</button>
|
||
</div>`;
|
||
window._pasteFiles = files;
|
||
document.body.appendChild(d);
|
||
setTimeout(() => document.getElementById('pasteDialog')?.remove(), 12000);
|
||
}
|
||
|
||
async function handleUploadToGroup(files, groupId) {
|
||
showToast(`Adding ${files.length} image(s) to group…`);
|
||
for (const file of files) {
|
||
const fd = new FormData();
|
||
fd.append('image', file);
|
||
fd.append('group_id', groupId);
|
||
fd.append('skip_poses', 'true');
|
||
try {
|
||
const r = await fetch(`${API}/upload`, { method: 'POST', body: fd });
|
||
if (!r.ok) showToast('Upload failed: ' + await r.text(), 'error');
|
||
} catch (e) { showToast('Upload error: ' + e, 'error'); }
|
||
}
|
||
showToast('Added to group', 'success');
|
||
refreshNow();
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |