6319 lines
306 KiB
HTML
6319 lines
306 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.ref-selected { opacity: 1; outline: 2px solid #f97316; border-radius: 4px; }
|
||
.lb-var-thumb.active.ref-selected { outline: 2px solid #f97316; }
|
||
.lb-var-ref-badge { position: absolute; top: 2px; left: 2px; background: #f97316; color: #fff; font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 3px; line-height: 1.4; pointer-events: none; z-index: 2; }
|
||
.lb-var-thumb img {
|
||
width: 52px; height: 52px;
|
||
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.done { border-color: #14532d; background: #052e16; color: #4ade80; position: relative; }
|
||
.sb-angle-btn.done::after { content: '✓'; position: absolute; top: 1px; right: 3px; font-size: 9px; color: #4ade80; opacity: 0.9; }
|
||
.sb-angle-btn.selected { border-color: #0e7490; background: #082f49; color: #7dd3fc; font-weight: 600; }
|
||
|
||
/* Generate panel: sticky footer (custom prompt + Generate) and selected-ref thumbnails */
|
||
.sb-gen-footer {
|
||
position: sticky; bottom: 0; margin: 8px -13px -12px; padding: 10px 13px;
|
||
background: #0d0d10; border-top: 1px solid #1f1f24;
|
||
}
|
||
.sb-gen-refs { display: flex; gap: 5px; margin-bottom: 8px; flex-wrap: wrap; }
|
||
.sb-gen-refs .sb-gen-ref { position: relative; width: 40px; height: 40px; flex-shrink: 0; }
|
||
.sb-gen-refs .sb-gen-ref img {
|
||
width: 40px; height: 40px; object-fit: cover; border-radius: 4px;
|
||
border: 1px solid #3d2008; background: #111;
|
||
}
|
||
.sb-gen-refs .sb-gen-ref-badge {
|
||
position: absolute; top: -4px; left: -4px; background: #f97316; color: #111;
|
||
font-size: 9px; font-weight: 700; border-radius: 50%; width: 15px; height: 15px;
|
||
display: flex; align-items: center; justify-content: center; line-height: 1;
|
||
}
|
||
.sb-gen-refs .sb-gen-ref-x {
|
||
position: absolute; top: -4px; right: -4px; background: #dc2626; color: #fff;
|
||
font-size: 11px; font-weight: 700; border-radius: 50%; width: 15px; height: 15px;
|
||
display: none; align-items: center; justify-content: center; line-height: 1;
|
||
}
|
||
.sb-gen-refs .sb-gen-ref:hover .sb-gen-ref-x { display: flex; }
|
||
.sb-gen-refs .sb-gen-ref:hover img { border-color: #dc2626; }
|
||
#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,
|
||
.sb-template-card img { width:100%; height:100%; object-fit:cover; pointer-events:none; }
|
||
.sb-template-card video { position:absolute; inset:0; }
|
||
.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;
|
||
z-index: 2;
|
||
}
|
||
.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: 6px 0 2px; cursor: pointer; height: 18px; }
|
||
.scene-scrubber:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.scene-captured-badge {
|
||
position: absolute; top: 6px; left: 6px;
|
||
background: rgba(34,197,94,0.9); color: #04210f;
|
||
border-radius: 5px; padding: 2px 8px; font-size: 11px; font-weight: 600;
|
||
}
|
||
/* 3 interactive reference slots (image1 / image2 / image3) */
|
||
.scene-slots { display: flex; gap: 6px; margin-bottom: 12px; }
|
||
.scene-slot {
|
||
flex: 1; min-width: 0; background: #141414; border: 1px solid #262626;
|
||
border-radius: 7px; overflow: hidden; text-align: center;
|
||
}
|
||
.scene-slot.filled { border-color: #2563eb; cursor: grab; }
|
||
.scene-slot.empty { border-style: dashed; }
|
||
.scene-slot.drop-hover { border-color: #60a5fa; background: #11203f; }
|
||
.scene-slot-thumb {
|
||
width: 100%; aspect-ratio: 1/1; background: #0c0c0c; position: relative;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.scene-slot-thumb img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
|
||
.scene-slot-thumb .ph { font-size: 18px; color: #333; }
|
||
.scene-slot-x {
|
||
position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; line-height: 14px;
|
||
background: rgba(0,0,0,0.7); border: 1px solid #444; border-radius: 4px;
|
||
color: #ddd; font-size: 10px; cursor: pointer; padding: 0;
|
||
}
|
||
.scene-slot-x:hover { background: #b91c1c; color: #fff; }
|
||
.scene-slot-kind { position: absolute; bottom: 1px; left: 2px; font-size: 10px; }
|
||
.scene-slot-cap { font-size: 8.5px; color: #888; padding: 3px 2px 2px; line-height: 1.25; }
|
||
.scene-slot-cap b { color: #bbb; display: block; font-size: 9px; }
|
||
|
||
/* ===================== 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>
|
||
<div style="display:flex;gap:8px;align-items:flex-start">
|
||
<img id="lbFaceThumb" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid #333;cursor:pointer;flex-shrink:0" onclick="window.open(this.src,'_blank')" title="Face crop — click to view full">
|
||
<button class="sb-btn" onclick="lbFindSimilarFaces()" title="Find groups with matching faces">Similar faces</button>
|
||
</div>
|
||
</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="lbInvertAlphaBtn" onclick="lbInvertAlpha()" title="Invert transparency — fixes when the wrong segment was kept">Invert α</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>
|
||
<button class="sb-btn" id="lbRotateLeftBtn" onclick="lbRotate(-90)" title="Rotate 90° counter-clockwise">⟲ 90°</button>
|
||
<button class="sb-btn" id="lbRotateRightBtn" onclick="lbRotate(90)" title="Rotate 90° clockwise">⟳ 90°</button>
|
||
<button class="sb-btn" id="lbPoseBtn" onclick="lbTogglePose()" title="Preview body pose skeleton">Pose</button>
|
||
<button class="sb-btn" id="lbMakeVideoBtn" onclick="lbMakeVideo()" title="Stitch group images (or Shift+Click selection) into a short MP4">Make video</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 _sbPoseFilter = ''; // live pose-name filter query (Generate tab)
|
||
let _sbSelectedAngles = new Set(); // selected camera angle names
|
||
let _sbRefIndices = []; // ordered filmstrip indices selected as multi-ref (#1, #2, #3)
|
||
let _sbRefMode = 'combine'; // with 2–3 refs: 'combine' → 1 multi-ref output; 'each' → one edit per ref (cross product)
|
||
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;
|
||
if (lbCurrentGid !== gid) { // clear refs when switching groups
|
||
_sbRefIndices = [];
|
||
_sceneRefs = [null, null, null];
|
||
_scenePersonInit = false;
|
||
_sceneFrameBytes = null;
|
||
}
|
||
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();
|
||
// Release every streaming/decoding video so closing the studio frees memory
|
||
// and stops background network streams (template clips + scenery preview).
|
||
_tplStopAll();
|
||
['sceneVideoEl', 'lbVideo', 'cltVideoPreview'].forEach(id => {
|
||
const v = document.getElementById(id);
|
||
if (v) { v.pause(); v.removeAttribute('src'); v.load(); }
|
||
});
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// Reference badge number for filmstrip thumb i (0 = none).
|
||
// Generate tab → position in _sbRefIndices; Scenery tab → slot index in _sceneRefs.
|
||
function _filmstripRefBadge(i) {
|
||
if (_activeSidebarTab === 'scenery') {
|
||
const name = lbNames[i];
|
||
const slot = _sceneRefs.findIndex(r => r && r.kind === 'file' && r.name === name);
|
||
return slot >= 0 ? slot + 1 : 0;
|
||
}
|
||
const pos = _sbRefIndices.indexOf(i);
|
||
return pos >= 0 ? pos + 1 : 0;
|
||
}
|
||
|
||
function sbFilmstripClick(event, i) {
|
||
if (event.shiftKey) {
|
||
event.preventDefault();
|
||
// Scenery tab uses positional slots (fills right→left); Generate uses _sbRefIndices.
|
||
if (_activeSidebarTab === 'scenery') {
|
||
sceneAssignRef(lbNames[i]); // re-renders panel + filmstrip
|
||
return;
|
||
}
|
||
const pos = _sbRefIndices.indexOf(i);
|
||
if (pos >= 0) {
|
||
_sbRefIndices.splice(pos, 1);
|
||
} else if (_sbRefIndices.length < 3) {
|
||
_sbRefIndices.push(i);
|
||
} else {
|
||
showToast('Max 3 references — Shift+Click a selected one to remove it', 'info');
|
||
}
|
||
updateStudio();
|
||
if (_activeSidebarTab === 'generate') renderSidebarGenerate();
|
||
} else {
|
||
lbNavTo(i);
|
||
}
|
||
}
|
||
|
||
function updateStudio() {
|
||
const fname = lbNames[lbIdx];
|
||
if (document.getElementById('poseCanvas')) _removePoseOverlay(); // skeleton is per-image
|
||
document.getElementById('poseResults')?.remove();
|
||
_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';
|
||
// Invert α is only meaningful on a transparent image (after background removal).
|
||
const invAlphaBtn = document.getElementById('lbInvertAlphaBtn');
|
||
if (invAlphaBtn) invAlphaBtn.style.display = (!isVid && fileHasBg[fname] === false) ? '' : 'none';
|
||
const poseBtn = document.getElementById('lbPoseBtn');
|
||
if (poseBtn) poseBtn.style.display = isVid ? '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';
|
||
const sceneryActive = _activeSidebarTab === 'scenery';
|
||
strip.innerHTML = lbUrls.map((url, i) => {
|
||
const n = lbNames[i];
|
||
const pose = filePoses[n] || '';
|
||
const act = i === lbIdx ? ' active' : '';
|
||
// Badge: Generate uses _sbRefIndices order; Scenery uses positional slots.
|
||
const badge = _filmstripRefBadge(i);
|
||
const refCls = badge > 0 ? ' ref-selected' : '';
|
||
const thumbSrc = isVideo(n) ? posterFor(url) : url;
|
||
// On the Scenery tab the thumbs are draggable onto the slots above.
|
||
const dragAttr = sceneryActive ? ' draggable="true" ondragstart="sceneFilmDragStart(event,' + i + ')"' : '';
|
||
return '<div class="lb-var-thumb' + act + refCls + '" onclick="sbFilmstripClick(event,' + i + ')"' + dragAttr + ' title="Click to view · Shift+Click to add as ref">'
|
||
+ '<img src="' + thumbSrc + '" loading="lazy" onerror="this.style.opacity=\'0.3\'">'
|
||
+ (isVideo(n) ? '<div class="lb-var-play">▶</div>' : '')
|
||
+ (pose ? '<div class="lb-var-pose">' + escHtml(pose) + '</div>' : '')
|
||
+ (badge > 0 ? '<div class="lb-var-ref-badge">#' + badge + '</div>' : '')
|
||
+ '</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) {
|
||
loadFaceThumb(lbCurrentGid);
|
||
} 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();
|
||
// Add a small delay to allow static regeneration to complete before refresh
|
||
setTimeout(refreshNow, 500);
|
||
} else {
|
||
lbIdx = Math.min(lbIdx, lbNames.length - 1);
|
||
updateStudio();
|
||
// Add a small delay to allow static regeneration to complete before refresh
|
||
setTimeout(refreshNow, 500);
|
||
}
|
||
} 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();
|
||
// Add a small delay to allow static regeneration to complete before refresh
|
||
setTimeout(refreshNow, 500);
|
||
} else {
|
||
lbIdx = Math.min(lbIdx, lbNames.length - 1);
|
||
updateStudio();
|
||
// Add a small delay to allow static regeneration to complete before refresh
|
||
setTimeout(refreshNow, 500);
|
||
}
|
||
} 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'; }
|
||
}
|
||
|
||
async function lbRotate(degrees) {
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to rotate', 'info'); return; }
|
||
const btns = [document.getElementById('lbRotateLeftBtn'), document.getElementById('lbRotateRightBtn')];
|
||
btns.forEach(b => { if (b) b.disabled = true; });
|
||
try {
|
||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/rotate`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ degrees }),
|
||
});
|
||
if (r.ok) {
|
||
showToast('Rotated', 'success');
|
||
lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
|
||
updateStudio();
|
||
} else {
|
||
showToast('Rotate failed: ' + await r.text(), 'error');
|
||
}
|
||
} catch (e) { showToast('Rotate failed: ' + e, 'error'); }
|
||
btns.forEach(b => { if (b) b.disabled = false; });
|
||
}
|
||
|
||
// --- body-pose skeleton preview (interactive) ---
|
||
const POSE_MIN_SCORE = 0.3;
|
||
const POSE_KP_COLORS = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#a855f7'];
|
||
|
||
function _removePoseOverlay() {
|
||
document.getElementById('poseCanvas')?.remove();
|
||
document.getElementById('poseToolbar')?.remove();
|
||
window._poseState = null;
|
||
const btn = document.getElementById('lbPoseBtn');
|
||
if (btn) btn.classList.remove('primary');
|
||
}
|
||
|
||
async function lbTogglePose() {
|
||
if (document.getElementById('poseCanvas')) { _removePoseOverlay(); return; }
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image', 'info'); return; }
|
||
const btn = document.getElementById('lbPoseBtn');
|
||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||
try {
|
||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/pose`, { method: 'POST' });
|
||
if (r.status === 501) {
|
||
showToast('Pose estimator not installed on the server (pip install rtmlib onnxruntime)', 'info', 6000);
|
||
} else if (r.ok) {
|
||
const d = await r.json();
|
||
if (!d.people || !d.people.length) showToast('No person detected', 'info');
|
||
else { _initPoseOverlay(d, fname); showToast(`Pose (${d.backend}) — drag joints to edit`, 'success'); }
|
||
} else {
|
||
showToast('Pose failed: ' + await r.text(), 'error');
|
||
}
|
||
} catch (e) { showToast('Pose failed: ' + e, 'error'); }
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Pose'; }
|
||
}
|
||
|
||
function _initPoseOverlay(data, fname) {
|
||
_removePoseOverlay();
|
||
const viewer = document.getElementById('studioViewer');
|
||
const img = document.getElementById('lbImg');
|
||
if (!viewer || !img) return;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.id = 'poseCanvas';
|
||
canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;cursor:grab;z-index:90;';
|
||
viewer.appendChild(canvas);
|
||
canvas.width = viewer.clientWidth;
|
||
canvas.height = viewer.clientHeight;
|
||
|
||
// object-fit:contain letterbox mapping image px ↔ canvas px.
|
||
const iW = data.width, iH = data.height;
|
||
const scale = Math.min(canvas.width / iW, canvas.height / iH);
|
||
const st = window._poseState = {
|
||
fname, canvas, ctx: canvas.getContext('2d'),
|
||
people: data.people.map(p => p.map(k => k.slice())), // deep copy (editable)
|
||
skeleton: data.skeleton, width: iW, height: iH,
|
||
scale, offX: (canvas.width - iW * scale) / 2, offY: (canvas.height - iH * scale) / 2,
|
||
drag: null,
|
||
};
|
||
|
||
// Floating toolbar (top-left so it doesn't fight the crop bar on the right).
|
||
const bar = document.createElement('div');
|
||
bar.id = 'poseToolbar';
|
||
bar.style.cssText = 'position:absolute;top:8px;left:8px;display:flex;gap:6px;z-index:102;';
|
||
bar.innerHTML =
|
||
'<button class="sb-btn" onclick="lbPoseReset()" title="Re-detect the original pose">Reset</button>'
|
||
+ '<button class="sb-btn primary" onclick="lbFindSimilarPose()" title="Find library images in this pose">Similar pose</button>'
|
||
+ '<div style="position:relative;display:inline-block">'
|
||
+ ' <button class="sb-btn" id="gestureMenuBtn" onclick="toggleGestureMenu(event)" title="Apply a gesture preset to the skeleton">Gesture ▾</button>'
|
||
+ ' <div id="gestureMenuPanel" style="display:none;position:absolute;top:100%;left:0;z-index:200;background:#18181b;border:1px solid #2a2a2a;border-radius:6px;padding:4px;box-shadow:0 6px 20px rgba(0,0,0,.7);min-width:150px;margin-top:4px"></div>'
|
||
+ '</div>'
|
||
+ '<button class="sb-btn" onclick="lbPoseFromVideo()" title="Pick a pose from a wireframe video frame">From video</button>'
|
||
+ '<button class="sb-btn" id="poseGenBtn" onclick="lbGenerateWithPose()" title="Generate the person in this pose (skeleton → Qwen image2 guide)">Generate ▸</button>';
|
||
viewer.appendChild(bar);
|
||
_buildGestureMenu();
|
||
|
||
canvas.addEventListener('mousedown', _posePointerDown);
|
||
canvas.addEventListener('mousemove', _posePointerMove);
|
||
window.addEventListener('mouseup', _posePointerUp);
|
||
_drawPose();
|
||
const btn = document.getElementById('lbPoseBtn');
|
||
if (btn) btn.classList.add('primary');
|
||
}
|
||
|
||
// --- Gesture presets: normalized [x,y,conf] per COCO-17 keypoint ---
|
||
// Defined in [0,1] image space for a portrait-oriented standing figure.
|
||
// Applied to the actual image by scaling to image pixel dimensions.
|
||
const GESTURE_PRESETS = [
|
||
{ name: 'T-pose', desc: 'Arms fully extended sideways',
|
||
kpts: [[.50,.07,1],[.47,.065,1],[.53,.065,1],[.43,.08,1],[.57,.08,1],
|
||
[.25,.22,1],[.75,.22,1],[.10,.22,1],[.90,.22,1],[-.02,.22,1],[1.02,.22,1],
|
||
[.40,.52,1],[.60,.52,1],[.40,.72,1],[.60,.72,1],[.40,.93,1],[.60,.93,1]] },
|
||
{ name: 'Wave right', desc: 'Right arm raised and waving',
|
||
kpts: [[.50,.07,1],[.47,.065,1],[.53,.065,1],[.43,.08,1],[.57,.08,1],
|
||
[.35,.22,1],[.65,.22,1],[.20,.38,1],[.78,.10,1],[.22,.52,1],[.70,.04,1],
|
||
[.40,.52,1],[.60,.52,1],[.40,.72,1],[.60,.72,1],[.40,.93,1],[.60,.93,1]] },
|
||
{ name: 'Arms raised', desc: 'Both arms raised above head',
|
||
kpts: [[.50,.07,1],[.47,.065,1],[.53,.065,1],[.43,.08,1],[.57,.08,1],
|
||
[.35,.22,1],[.65,.22,1],[.22,.08,1],[.78,.08,1],[.18,.02,1],[.82,.02,1],
|
||
[.40,.52,1],[.60,.52,1],[.40,.72,1],[.60,.72,1],[.40,.93,1],[.60,.93,1]] },
|
||
{ name: 'Hands on hips', desc: 'Hands resting on hips',
|
||
kpts: [[.50,.07,1],[.47,.065,1],[.53,.065,1],[.43,.08,1],[.57,.08,1],
|
||
[.35,.22,1],[.65,.22,1],[.28,.40,1],[.72,.40,1],[.35,.52,1],[.65,.52,1],
|
||
[.38,.52,1],[.62,.52,1],[.38,.72,1],[.62,.72,1],[.38,.93,1],[.62,.93,1]] },
|
||
{ name: 'Turn left 45°', desc: 'Body turned ~45° to the left',
|
||
kpts: [[.45,.07,1],[.44,.065,1],[.50,.065,1],[.41,.08,1],[.52,.08,1],
|
||
[.30,.22,1],[.58,.22,1],[.18,.38,1],[.65,.30,1],[.12,.52,1],[.70,.22,1],
|
||
[.36,.52,1],[.58,.52,1],[.36,.72,1],[.58,.72,1],[.36,.93,1],[.58,.93,1]] },
|
||
{ name: 'Turn right 45°', desc: 'Body turned ~45° to the right',
|
||
kpts: [[.55,.07,1],[.50,.065,1],[.56,.065,1],[.48,.08,1],[.59,.08,1],
|
||
[.42,.22,1],[.70,.22,1],[.35,.30,1],[.82,.38,1],[.30,.22,1],[.88,.52,1],
|
||
[.42,.52,1],[.64,.52,1],[.42,.72,1],[.64,.72,1],[.42,.93,1],[.64,.93,1]] },
|
||
];
|
||
|
||
function _buildGestureMenu() {
|
||
const panel = document.getElementById('gestureMenuPanel');
|
||
if (!panel) return;
|
||
panel.innerHTML = GESTURE_PRESETS.map((g, i) =>
|
||
`<div style="padding:5px 10px;cursor:pointer;font-size:11px;border-radius:4px;white-space:nowrap"
|
||
title="${escHtml(g.desc)}"
|
||
onmouseenter="this.style.background='#2a2a2a'" onmouseleave="this.style.background=''"
|
||
onclick="applyGesturePreset(${i})">${escHtml(g.name)}</div>`
|
||
).join('');
|
||
}
|
||
|
||
function toggleGestureMenu(e) {
|
||
e.stopPropagation();
|
||
const p = document.getElementById('gestureMenuPanel');
|
||
if (!p) return;
|
||
const open = p.style.display !== 'none';
|
||
p.style.display = open ? 'none' : 'block';
|
||
if (!open) {
|
||
const close = (ev) => { if (!p.contains(ev.target)) { p.style.display='none'; document.removeEventListener('click',close); } };
|
||
setTimeout(() => document.addEventListener('click', close), 0);
|
||
}
|
||
}
|
||
|
||
function applyGesturePreset(idx) {
|
||
const st = window._poseState;
|
||
if (!st || !st.people.length) { showToast('No pose overlay active — click Pose first', 'info'); return; }
|
||
const g = GESTURE_PRESETS[idx];
|
||
const W = st.width, H = st.height;
|
||
st.people[0] = g.kpts.map(([nx, ny, c]) => [nx * W, ny * H, c]);
|
||
st._gestureName = g.name; // remember for generation
|
||
_drawPose();
|
||
document.getElementById('gestureMenuPanel').style.display = 'none';
|
||
showToast(`Gesture: ${g.name} — click Generate ▸ to create`, 'success');
|
||
}
|
||
|
||
function _drawPose() {
|
||
const st = window._poseState; if (!st) return;
|
||
const { ctx, canvas, scale, offX, offY } = st;
|
||
const cx = x => offX + x * scale, cy = y => offY + y * scale;
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
st.people.forEach((kpts, pi) => {
|
||
const color = POSE_KP_COLORS[pi % POSE_KP_COLORS.length];
|
||
ctx.lineWidth = 3; ctx.strokeStyle = color;
|
||
st.skeleton.forEach(([a, b]) => {
|
||
const pa = kpts[a], pb = kpts[b];
|
||
if (!pa || !pb || pa[2] < POSE_MIN_SCORE || pb[2] < POSE_MIN_SCORE) return;
|
||
ctx.beginPath(); ctx.moveTo(cx(pa[0]), cy(pa[1])); ctx.lineTo(cx(pb[0]), cy(pb[1])); ctx.stroke();
|
||
});
|
||
kpts.forEach((p, ki) => {
|
||
if (!p || p[2] < POSE_MIN_SCORE) return;
|
||
const hot = st.drag && st.drag.pi === pi && st.drag.ki === ki;
|
||
ctx.beginPath(); ctx.arc(cx(p[0]), cy(p[1]), hot ? 7 : 5, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#fff'; ctx.fill();
|
||
ctx.lineWidth = 2; ctx.strokeStyle = color; ctx.stroke();
|
||
});
|
||
});
|
||
}
|
||
|
||
function _poseHitTest(mx, my) {
|
||
const st = window._poseState; if (!st) return null;
|
||
const cx = x => st.offX + x * st.scale, cy = y => st.offY + y * st.scale;
|
||
let best = null, bestD = 14 * 14; // ~14px grab radius
|
||
st.people.forEach((kpts, pi) => kpts.forEach((p, ki) => {
|
||
if (!p || p[2] < POSE_MIN_SCORE) return;
|
||
const dx = mx - cx(p[0]), dy = my - cy(p[1]), d = dx * dx + dy * dy;
|
||
if (d < bestD) { bestD = d; best = { pi, ki }; }
|
||
}));
|
||
return best;
|
||
}
|
||
|
||
function _poseEvtXY(e) {
|
||
const r = window._poseState.canvas.getBoundingClientRect();
|
||
return [e.clientX - r.left, e.clientY - r.top];
|
||
}
|
||
function _posePointerDown(e) {
|
||
const st = window._poseState; if (!st) return;
|
||
const [mx, my] = _poseEvtXY(e);
|
||
const hit = _poseHitTest(mx, my);
|
||
if (hit) { st.drag = hit; st.canvas.style.cursor = 'grabbing'; _drawPose(); e.preventDefault(); }
|
||
}
|
||
function _posePointerMove(e) {
|
||
const st = window._poseState; if (!st) return;
|
||
if (!st.drag) {
|
||
const [mx, my] = _poseEvtXY(e);
|
||
st.canvas.style.cursor = _poseHitTest(mx, my) ? 'grab' : 'default';
|
||
return;
|
||
}
|
||
const [mx, my] = _poseEvtXY(e);
|
||
const p = st.people[st.drag.pi][st.drag.ki];
|
||
p[0] = Math.max(0, Math.min(st.width, (mx - st.offX) / st.scale));
|
||
p[1] = Math.max(0, Math.min(st.height, (my - st.offY) / st.scale));
|
||
if (p[2] < POSE_MIN_SCORE) p[2] = 1.0; // dragging a hidden joint makes it visible
|
||
_drawPose();
|
||
}
|
||
function _posePointerUp() {
|
||
const st = window._poseState; if (!st || !st.drag) return;
|
||
st.drag = null; st.canvas.style.cursor = 'grab'; _drawPose();
|
||
}
|
||
|
||
async function lbPoseReset() {
|
||
const st = window._poseState; if (!st) return;
|
||
const fname = st.fname;
|
||
try {
|
||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/pose`, { method: 'POST' });
|
||
if (r.ok) { const d = await r.json(); if (d.people?.length) _initPoseOverlay(d, fname); }
|
||
} catch (e) { showToast('Reset failed: ' + e, 'error'); }
|
||
}
|
||
|
||
async function lbFindSimilarPose() {
|
||
const st = window._poseState; if (!st) return;
|
||
// Use the (possibly edited) primary skeleton.
|
||
const kpts = st.people[0];
|
||
const btn = event?.target;
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
const r = await fetch(`${API}/pose/similar`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ keypoints: kpts, width: st.width, height: st.height, limit: 12 }),
|
||
});
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
_renderPoseResults(d.similar || []);
|
||
} else if (r.status === 400) {
|
||
showToast('Pose too sparse — drag more joints into place', 'info');
|
||
} else {
|
||
showToast('Similar failed: ' + await r.text(), 'error');
|
||
}
|
||
} catch (e) { showToast('Similar failed: ' + e, 'error'); }
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
async function lbGenerateWithPose() {
|
||
const st = window._poseState;
|
||
if (!st || !st.people.length) { showToast('No pose overlay active — click Pose first', 'info'); return; }
|
||
const fname = st.fname;
|
||
if (!fname) { showToast('No image active', 'info'); return; }
|
||
const btn = document.getElementById('poseGenBtn');
|
||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||
try {
|
||
const payload = {
|
||
model_filename: fname,
|
||
keypoints: st.people[0],
|
||
width: st.width,
|
||
height: st.height,
|
||
gesture_name: st._gestureName || null,
|
||
seed: -1,
|
||
};
|
||
const r = await fetch(`${API}/generate-with-pose`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!r.ok) { showToast('Generate failed: ' + await r.text(), 'error'); return; }
|
||
const { job_id } = await r.json();
|
||
showToast('Pose generation started…', 'info');
|
||
if (btn) btn.textContent = 'Generating…';
|
||
const poll = () => setTimeout(async () => {
|
||
try {
|
||
const jr = await fetch(`${API}/batch/${job_id}`);
|
||
if (!jr.ok) { poll(); return; }
|
||
const job = await jr.json();
|
||
if (job.status === 'running') { poll(); return; }
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Generate ▸'; }
|
||
if (job.status === 'done') {
|
||
showToast('Pose generation done!', 'success');
|
||
_followLatestGid = lbCurrentGid;
|
||
refreshNow();
|
||
} else {
|
||
showToast('Pose generation failed: ' + (job.error || 'unknown'), 'error');
|
||
}
|
||
} catch (e) { poll(); }
|
||
}, 2500);
|
||
poll();
|
||
} catch (e) {
|
||
showToast('Generate error: ' + e, 'error');
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Generate ▸'; }
|
||
}
|
||
}
|
||
|
||
// ─── Pose from wireframe video ───────────────────────────────────────────
|
||
// Opens a modal that lists wireframe videos for the current group,
|
||
// lets the user scrub to a timestamp, then extracts keypoints from that
|
||
// frame and loads them into the draggable pose overlay.
|
||
async function lbPoseFromVideo() {
|
||
const st = window._poseState;
|
||
if (!st) { showToast('Open Pose first', 'info'); return; }
|
||
|
||
// Gather wireframe videos from the current group's images.
|
||
const videos = (lbNames || []).filter(n => /\.(mp4|webm|mov)$/i.test(n));
|
||
if (!videos.length) {
|
||
showToast('No videos in this group — generate a wireframe video first', 'info');
|
||
return;
|
||
}
|
||
|
||
// Build modal
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'poseVideoModal';
|
||
overlay.style.cssText = 'position:fixed;inset:0;z-index:900;background:rgba(0,0,0,.8);display:flex;align-items:center;justify-content:center;';
|
||
overlay.innerHTML = `
|
||
<div style="background:#18181b;border:1px solid #2a2a2a;border-radius:10px;padding:20px;width:480px;max-width:95vw;box-shadow:0 8px 40px rgba(0,0,0,.8)">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||
<span style="font-size:14px;font-weight:600">Pick pose from wireframe video</span>
|
||
<button class="sb-btn" onclick="document.getElementById('poseVideoModal').remove()">✕</button>
|
||
</div>
|
||
<div style="margin-bottom:10px">
|
||
<label style="font-size:11px;color:#aaa;display:block;margin-bottom:4px">Video</label>
|
||
<select id="pvmVideoSelect" style="width:100%;background:#0f0f0f;color:#e0e0e0;border:1px solid #2a2a2a;border-radius:5px;padding:5px 8px;font-size:12px">
|
||
${videos.map(v => `<option value="${escHtml(v)}">${escHtml(v)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div style="margin-bottom:10px">
|
||
<video id="pvmVideo" style="width:100%;border-radius:6px;background:#000;max-height:260px" preload="metadata"></video>
|
||
</div>
|
||
<div style="margin-bottom:14px">
|
||
<label style="font-size:11px;color:#aaa;display:block;margin-bottom:4px">Scrub to pose frame</label>
|
||
<input type="range" id="pvmScrubber" min="0" max="100" step="0.1" value="0"
|
||
style="width:100%;accent-color:#a78bfa">
|
||
<span id="pvmTimeLabel" style="font-size:11px;color:#888">0.0 s</span>
|
||
</div>
|
||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||
<button class="sb-btn" onclick="document.getElementById('poseVideoModal').remove()">Cancel</button>
|
||
<button class="sb-btn primary" id="pvmLoadBtn" onclick="pvmLoadPose()">Load pose</button>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(overlay);
|
||
|
||
const vidEl = document.getElementById('pvmVideo');
|
||
const select = document.getElementById('pvmVideoSelect');
|
||
const scrubber = document.getElementById('pvmScrubber');
|
||
const timeLabel = document.getElementById('pvmTimeLabel');
|
||
|
||
function pvmSetVideo(filename) {
|
||
vidEl.src = `${API}/output/${encodeURIComponent(filename)}`;
|
||
vidEl.load();
|
||
vidEl.addEventListener('loadedmetadata', () => {
|
||
const dur = vidEl.duration || 10;
|
||
scrubber.max = dur;
|
||
scrubber.step = Math.max(0.033, dur / 500);
|
||
scrubber.value = 0;
|
||
timeLabel.textContent = '0.0 s';
|
||
}, { once: true });
|
||
}
|
||
|
||
scrubber.addEventListener('input', () => {
|
||
const t = parseFloat(scrubber.value);
|
||
vidEl.currentTime = t;
|
||
timeLabel.textContent = t.toFixed(2) + ' s';
|
||
});
|
||
|
||
select.addEventListener('change', () => pvmSetVideo(select.value));
|
||
pvmSetVideo(videos[0]);
|
||
|
||
window.pvmLoadPose = async function() {
|
||
const video = select.value;
|
||
const time_s = parseFloat(scrubber.value);
|
||
const btn = document.getElementById('pvmLoadBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Extracting…';
|
||
try {
|
||
const r = await fetch(`${API}/pose/from-wireframe`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ video, time_s }),
|
||
});
|
||
if (!r.ok) { showToast('Pose extract failed: ' + await r.text(), 'error'); btn.disabled = false; btn.textContent = 'Load pose'; return; }
|
||
const d = await r.json();
|
||
// Rescale keypoints from video dimensions to current image dimensions
|
||
const scaleX = st.width / d.width;
|
||
const scaleY = st.height / d.height;
|
||
st.people[0] = d.keypoints.map(([x, y, c]) => [x * scaleX, y * scaleY, c]);
|
||
st._gestureName = null; // came from real video, not a named preset
|
||
_drawPose();
|
||
document.getElementById('poseVideoModal').remove();
|
||
showToast(`Pose loaded from ${video} @ ${time_s.toFixed(2)}s`, 'success');
|
||
} catch (e) {
|
||
showToast('Pose extract error: ' + e, 'error');
|
||
btn.disabled = false; btn.textContent = 'Load pose';
|
||
}
|
||
};
|
||
}
|
||
|
||
async function lbFindSimilarFaces() {
|
||
if (!lbCurrentGid) return;
|
||
try {
|
||
const r = await fetch(`${API}/faces/similar`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ group_id: lbCurrentGid, limit: 12 }),
|
||
});
|
||
if (r.status === 404) { _renderFaceResults(null); return; }
|
||
if (!r.ok) { showToast('Similar faces failed: ' + await r.text(), 'error'); return; }
|
||
const d = await r.json();
|
||
_renderFaceResults(d.similar || []);
|
||
} catch (e) { showToast('Similar faces failed: ' + e, 'error'); }
|
||
}
|
||
|
||
async function lbBuildFaceIndex() {
|
||
try {
|
||
const r = await fetch(`${API}/faces/index`, { method: 'POST' });
|
||
if (!r.ok) { showToast('Face index failed: ' + await r.text(), 'error'); return; }
|
||
showToast('Building face index…', 'info');
|
||
_pollFaceIndex();
|
||
} catch (e) { showToast('Face index failed: ' + e, 'error'); }
|
||
}
|
||
|
||
async function _pollFaceIndex() {
|
||
try {
|
||
const r = await fetch(`${API}/faces/index/status`);
|
||
if (r.ok) {
|
||
const s = await r.json();
|
||
if (s.running) {
|
||
showToast(`Face index: ${s.done}/${s.total}…`, 'info', 2500);
|
||
setTimeout(_pollFaceIndex, 2000);
|
||
} else {
|
||
showToast(`Face index ready (${s.indexed} embeddings)`, 'success');
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
function _renderFaceResults(items) {
|
||
document.getElementById('faceResults')?.remove();
|
||
const viewer = document.getElementById('studioViewer');
|
||
if (!viewer) return;
|
||
const panel = document.createElement('div');
|
||
panel.id = 'faceResults';
|
||
panel.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:103;background:rgba(0,0,0,0.85);'
|
||
+ 'padding:8px;display:flex;gap:8px;overflow-x:auto;align-items:center;';
|
||
if (!items || !items.length) {
|
||
const msg = items === null
|
||
? 'No face embedding for this group — build the index first'
|
||
: 'No similar faces found';
|
||
panel.innerHTML = `<span style="color:#aaa;font-size:12px;flex:1">${msg}</span>`
|
||
+ '<button class="sb-btn" onclick="lbBuildFaceIndex();document.getElementById(\'faceResults\')?.remove()">Build index</button>'
|
||
+ '<button class="sb-btn" onclick="document.getElementById(\'faceResults\')?.remove()">Close</button>';
|
||
} else {
|
||
panel.innerHTML = '<span style="color:#aaa;font-size:11px;flex-shrink:0">Similar faces:</span>'
|
||
+ items.map(it => {
|
||
const u = IMAGE_FOLDER + it.filename + '?t=' + Date.now();
|
||
const gid = (it.group_id || '').replace(/'/g, "\\'");
|
||
const fn = it.filename.replace(/'/g, "\\'");
|
||
return `<div title="dist ${it.distance}" style="flex-shrink:0;cursor:pointer;text-align:center"
|
||
onclick="openFaceResult('${gid}','${fn}')">
|
||
<img src="${u}" loading="lazy" style="width:64px;height:64px;object-fit:cover;border-radius:5px;border:1px solid #444" onerror="this.style.opacity='0.3'">
|
||
<div style="font-size:9px;color:#777">${it.distance}</div></div>`;
|
||
}).join('')
|
||
+ '<button class="sb-btn" style="flex-shrink:0" onclick="document.getElementById(\'faceResults\')?.remove()">Close</button>';
|
||
}
|
||
viewer.appendChild(panel);
|
||
}
|
||
|
||
function openFaceResult(gid, fname) {
|
||
document.getElementById('faceResults')?.remove();
|
||
if (gid && groupData.has(gid)) {
|
||
openStudio(gid, 0);
|
||
} else {
|
||
const i = lbNames.indexOf(fname);
|
||
if (i >= 0) { lbIdx = i; updateStudio(); }
|
||
else showToast('Group not found in gallery: ' + fname, 'info', 5000);
|
||
}
|
||
}
|
||
|
||
function _renderPoseResults(items) {
|
||
document.getElementById('poseResults')?.remove();
|
||
const viewer = document.getElementById('studioViewer');
|
||
if (!viewer) return;
|
||
const panel = document.createElement('div');
|
||
panel.id = 'poseResults';
|
||
panel.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:103;background:rgba(0,0,0,0.85);'
|
||
+ 'padding:8px;display:flex;gap:8px;overflow-x:auto;align-items:center;';
|
||
if (!items.length) {
|
||
panel.innerHTML = '<span style="color:#aaa;font-size:12px;flex:1">No similar poses found — try building the pose index.</span>'
|
||
+ '<button class="sb-btn" onclick="lbBuildPoseIndex()">Build index</button>'
|
||
+ '<button class="sb-btn" onclick="document.getElementById(\'poseResults\')?.remove()">Close</button>';
|
||
} else {
|
||
panel.innerHTML = '<span style="color:#aaa;font-size:11px;flex-shrink:0">Similar poses:</span>'
|
||
+ items.map(it => {
|
||
const u = IMAGE_FOLDER + it.filename + '?t=' + Date.now();
|
||
return `<div title="dist ${it.distance}" style="flex-shrink:0;cursor:pointer;text-align:center"
|
||
onclick="openPoseResult('${(it.group_id||'').replace(/'/g,"\\'")}','${it.filename.replace(/'/g,"\\'")}')">
|
||
<img src="${u}" loading="lazy" style="width:64px;height:64px;object-fit:cover;border-radius:5px;border:1px solid #444" onerror="this.style.opacity='0.3'">
|
||
<div style="font-size:9px;color:#777">${it.distance}</div></div>`;
|
||
}).join('')
|
||
+ '<button class="sb-btn" style="flex-shrink:0" onclick="document.getElementById(\'poseResults\')?.remove()">Close</button>';
|
||
}
|
||
viewer.appendChild(panel);
|
||
}
|
||
|
||
function openPoseResult(gid, fname) {
|
||
_removePoseOverlay();
|
||
document.getElementById('poseResults')?.remove();
|
||
if (gid && groupData.has(gid)) {
|
||
const i = groupData.get(gid).names.indexOf(fname);
|
||
openStudio(gid, i >= 0 ? i : 0); // openStudio sets lbUrls/lbNames/lbIdx + updateStudio
|
||
} else {
|
||
const i = lbNames.indexOf(fname);
|
||
if (i >= 0) { lbIdx = i; updateStudio(); }
|
||
else showToast('Open its group from the gallery: ' + fname, 'info', 5000);
|
||
}
|
||
}
|
||
|
||
async function lbBuildPoseIndex() {
|
||
try {
|
||
const r = await fetch(`${API}/pose/index`, { method: 'POST' });
|
||
if (r.status === 501) { showToast('Pose estimator not installed', 'info'); return; }
|
||
if (!r.ok) { showToast('Index failed: ' + await r.text(), 'error'); return; }
|
||
showToast('Building pose index…', 'info');
|
||
_pollPoseIndex();
|
||
} catch (e) { showToast('Index failed: ' + e, 'error'); }
|
||
}
|
||
|
||
async function _pollPoseIndex() {
|
||
try {
|
||
const r = await fetch(`${API}/pose/index/status`);
|
||
if (r.ok) {
|
||
const s = await r.json();
|
||
if (s.running) {
|
||
showToast(`Pose index: ${s.done}/${s.total}…`, 'info', 2500);
|
||
setTimeout(_pollPoseIndex, 2000);
|
||
} else {
|
||
showToast(`Pose index ready (${s.indexed} images)`, 'success');
|
||
}
|
||
}
|
||
} catch (e) { /* stop polling */ }
|
||
}
|
||
|
||
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';
|
||
// pointer-events:none on the container so the canvas underneath captures drags
|
||
// from the very top of the image. Individual interactive controls opt back in.
|
||
bar.style.cssText = 'position:absolute;top: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;pointer-events:none;';
|
||
bar.innerHTML = '<span style="flex:1;font-size:11px;color:#aaa">Drag to select crop area</span>'
|
||
+ '<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#ccc;cursor:pointer;pointer-events:auto" title="Keep the original and crop a copy instead">'
|
||
+ '<input type="checkbox" id="cropAsCopy" checked style="cursor:pointer">Save as copy</label>'
|
||
+ '<button class="sb-btn" style="pointer-events:auto" onclick="cancelManualCrop()">Cancel</button>'
|
||
+ '<button class="sb-btn primary" style="pointer-events:auto" 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 asCopy = document.getElementById('cropAsCopy')?.checked ?? false;
|
||
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, as_copy: asCopy }),
|
||
});
|
||
if (r.ok) {
|
||
const d = await r.json().catch(() => ({}));
|
||
cancelManualCrop();
|
||
if (asCopy && d.new_filename) {
|
||
showToast('Cropped to copy', 'success');
|
||
// Insert the cropped copy right after the original 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('Cropped', 'success');
|
||
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];
|
||
const btn = document.getElementById('lbNoBgBtn');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Removing…'; }
|
||
showToast('Removing background…');
|
||
try {
|
||
const r = await fetch(`${API}/remove-background/${fname}`, { method: 'POST' });
|
||
if (r.ok) {
|
||
showToast('Background removed', 'success');
|
||
fileHasBg[fname] = false;
|
||
_bustStudioImage(fname); // cache-busts viewer + calls updateStudio()
|
||
} else {
|
||
showToast(`Failed to remove background for ${fname}`, 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast(`Failed to remove background: ${e}`, 'error');
|
||
} finally {
|
||
if (btn) { btn.disabled = false; btn.textContent = 'No BG'; }
|
||
}
|
||
}
|
||
|
||
async function lbInvertAlpha() {
|
||
const fname = lbNames[lbIdx];
|
||
if (!fname || !/\.(png|webp)$/i.test(fname)) { showToast('Invert needs a transparent PNG', 'info'); return; }
|
||
const btn = document.getElementById('lbInvertAlphaBtn');
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/invert-alpha`, { method: 'POST' });
|
||
if (r.ok) {
|
||
showToast('Transparency inverted', 'success');
|
||
fileHasBg[fname] = false;
|
||
_bustStudioImage(fname);
|
||
refreshNow();
|
||
} else {
|
||
showToast('Invert failed: ' + await r.text(), 'error');
|
||
}
|
||
} catch (e) { showToast('Invert failed: ' + e, 'error'); }
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
async function lbMakeVideo() {
|
||
// Use Shift+Click selected refs if any, otherwise all images in the current group.
|
||
let filenames;
|
||
if (_sbRefIndices.length >= 2) {
|
||
filenames = _sbRefIndices.map(i => lbNames[i]).filter(n => n && !isVideo(n));
|
||
if (filenames.length < 2) { showToast('Need at least 2 non-video images selected (Shift+Click filmstrip)', 'info'); return; }
|
||
} else {
|
||
filenames = lbNames.filter(n => n && !isVideo(n));
|
||
if (filenames.length < 2) { showToast('Need at least 2 images in this group', 'info'); return; }
|
||
}
|
||
const btn = document.getElementById('lbMakeVideoBtn');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Creating…'; }
|
||
try {
|
||
const r = await fetch(`${API}/generate-video`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filenames, fps: 8, loop: true }),
|
||
});
|
||
if (!r.ok) { showToast('Video failed: ' + await r.text(), 'error'); return; }
|
||
const d = await r.json();
|
||
showToast(`Video created: ${d.output}`, 'success');
|
||
_followLatestGid = lbCurrentGid;
|
||
refreshNow();
|
||
} catch (e) { showToast('Video error: ' + e, 'error'); }
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Make video'; }
|
||
}
|
||
|
||
// Force the studio image (filmstrip + main viewer) to reload from disk.
|
||
function _bustStudioImage(fname) {
|
||
const t = Date.now();
|
||
const i = lbNames.indexOf(fname);
|
||
if (i >= 0) lbUrls[i] = IMAGE_FOLDER + fname + '?t=' + t;
|
||
const img = document.getElementById('lbImg');
|
||
if (img && lbNames[lbIdx] === fname) img.src = IMAGE_FOLDER + fname + '?t=' + t;
|
||
updateStudio();
|
||
}
|
||
|
||
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');
|
||
// Add a small delay to allow static regeneration to complete before refresh
|
||
setTimeout(refreshNow, 500);
|
||
} 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');
|
||
}
|
||
}
|
||
// The source image is registered by a background task slightly after /upload
|
||
// returns; one debounced refresh picks it up without piling up concurrent loads.
|
||
setTimeout(refreshNow, 800);
|
||
}
|
||
|
||
// --- 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;
|
||
}
|
||
|
||
function _hydrateImageMeta(imgs) {
|
||
imgs.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) {}
|
||
}
|
||
if (img.group_id) customGroups[img.filename] = img.group_id;
|
||
});
|
||
}
|
||
|
||
async function loadImages() {
|
||
if (loadImages._inFlight) return;
|
||
loadImages._inFlight = true;
|
||
const gallery = document.getElementById('gallery');
|
||
const statusDot = document.getElementById('statusDot');
|
||
statusDot.classList.add('updating');
|
||
|
||
let files = null;
|
||
let _staticOk = false;
|
||
|
||
// Primary: pre-generated static JSON (fast, no DB round-trip per request)
|
||
try {
|
||
const r = await fetch(`${API}/output/_data/images.json?t=${Date.now()}`, { signal: AbortSignal.timeout(4000) });
|
||
if (r.ok) {
|
||
const data = await r.json();
|
||
let imgs = data.images || [];
|
||
imgs = _archiveViewActive ? imgs.filter(i => i.archived) : imgs.filter(i => !i.archived);
|
||
files = imgs.map(img => img.filename);
|
||
_hydrateImageMeta(imgs);
|
||
_staticOk = true;
|
||
}
|
||
} catch (e) { /* fall through */ }
|
||
|
||
// Fallback: live API (handles first startup before static file is written)
|
||
if (!files) {
|
||
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);
|
||
_hydrateImageMeta(data.images);
|
||
}
|
||
} 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');
|
||
loadImages._inFlight = false;
|
||
return;
|
||
}
|
||
|
||
// Refresh names/groups/group-names — skip if already hydrated from static JSON
|
||
if (!_staticOk) {
|
||
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);
|
||
// Snapshot ref filenames before overwriting lbNames
|
||
const oldRefNames = _sbRefIndices.map(i => lbNames[i]).filter(Boolean);
|
||
lbUrls = fresh.urls;
|
||
lbNames = fresh.names;
|
||
// Keep lbIdx stable when current image not yet in fresh data
|
||
// (e.g. duplicate just created — server JSON may lag by the debounce window).
|
||
if (newIdx >= 0) {
|
||
lbIdx = newIdx;
|
||
} else if (lbIdx >= fresh.names.length) {
|
||
lbIdx = fresh.names.length - 1;
|
||
}
|
||
// Re-map multi-ref selections to new positions by filename
|
||
_sbRefIndices = oldRefNames
|
||
.map(n => fresh.names.indexOf(n))
|
||
.filter(i => i >= 0);
|
||
updateStudio();
|
||
}
|
||
}
|
||
|
||
statusDot.classList.remove('updating');
|
||
loadImages._inFlight = false;
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
// Debounced refresh — coalesces multiple rapid calls into one loadImages() run.
|
||
// Direct callers that need a response (pollJob completion, etc.) still use
|
||
// loadImages() directly; everything else should go through refreshNow().
|
||
let _refreshTimer = null;
|
||
function refreshNow() {
|
||
clearTimeout(_refreshTimer);
|
||
_refreshTimer = setTimeout(loadImages, 80);
|
||
}
|
||
|
||
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('button[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 = 'Re-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;
|
||
// Throttle filmstrip navigation — ignore if last nav was < 80 ms ago.
|
||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||
const now = Date.now();
|
||
if (now - (document._lbNavLast || 0) >= 80) {
|
||
document._lbNavLast = now;
|
||
lbNav(e.key === 'ArrowLeft' ? -1 : 1);
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
if (e.key === 'Home') {
|
||
lbIdx = 0;
|
||
updateStudio();
|
||
e.preventDefault();
|
||
}
|
||
if (e.key === 'End') {
|
||
lbIdx = lbUrls.length - 1;
|
||
updateStudio();
|
||
e.preventDefault();
|
||
}
|
||
if (e.key === ' ') {
|
||
const vid = document.getElementById('lbVideo');
|
||
if (vid && vid.style.display !== 'none') {
|
||
vid.paused ? vid.play() : vid.pause();
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
// Guard async actions against concurrent executions via a simple inflight flag.
|
||
if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT') {
|
||
if (!document._lbHideInFlight) { document._lbHideInFlight = true; lbToggleHidden().finally(() => { document._lbHideInFlight = false; }); }
|
||
}
|
||
if (e.key === 'F' || e.key === 'f') {
|
||
lbSetPreferred();
|
||
e.preventDefault();
|
||
}
|
||
if (e.key === 'Delete') {
|
||
lbArchive();
|
||
e.preventDefault();
|
||
}
|
||
// ArrowUp/Down move images within the group — debounce rapid keypresses.
|
||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||
clearTimeout(document._lbMoveTimer);
|
||
const d = e.key === 'ArrowUp' ? -1 : 1;
|
||
document._lbMoveTimer = setTimeout(() => lbMoveInGroup(d), 120);
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
});
|
||
|
||
// 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';
|
||
}
|
||
|
||
// Load the face-book thumbnail for a group. Face extraction runs
|
||
// asynchronously after "set preferred", so we ask the API whether the
|
||
// crop exists (works over file://, unlike probing with an <img> that
|
||
// logs a 404 during the race) and poll a few times while it's pending.
|
||
async function loadFaceThumb(gid, retries = 0) {
|
||
const faceBook = document.getElementById('lbFaceBook');
|
||
const faceThumb = document.getElementById('lbFaceThumb');
|
||
if (!faceBook || !faceThumb || !gid) return;
|
||
try {
|
||
const r = await fetch(`${API}/faces/${encodeURIComponent(gid)}`);
|
||
const d = await r.json();
|
||
// Ignore stale responses if the user navigated away.
|
||
if (lbCurrentGid !== gid || lbIdx !== 0) return;
|
||
if (d.exists) {
|
||
faceThumb.onerror = () => { faceBook.style.display = 'none'; };
|
||
faceThumb.src = IMAGE_FOLDER + d.filename + '?t=' + Date.now();
|
||
faceBook.style.display = '';
|
||
} else if (retries > 0) {
|
||
setTimeout(() => loadFaceThumb(gid, retries - 1), 1500);
|
||
} else {
|
||
faceBook.style.display = 'none';
|
||
}
|
||
} catch (e) {
|
||
faceBook.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function setAsPreferred(filename, btn) {
|
||
try {
|
||
const r = await fetch(`${API}/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);
|
||
// Poll for the freshly-extracted face crop (async on the server).
|
||
loadFaceThumb(gid, 8);
|
||
} 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) {
|
||
_tplStopAll(); // release any hovered template video before re-rendering panels
|
||
_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);
|
||
const ORBIT_NAMES = ['Front', '¾ Left', '¾ Right', 'Side L', 'Side R', 'Back'];
|
||
const allOrbitSel = ORBIT_NAMES.every(n => _sbSelectedAngles.has(n));
|
||
let html = `<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">
|
||
<span class="sb-label" style="margin:0;flex:1">Camera — absolute</span>
|
||
<button class="sb-btn${allOrbitSel?' primary':''}" style="padding:2px 8px;font-size:10px" onclick="sbToggleOrbitAll()" title="Select all 6 orbit angles (front · ¾ · side · back)">${allOrbitSel?'✓ Orbit all':'Orbit all'}</button>
|
||
</div><div class="sb-camera-grid" id="sbCameraGrid">`;
|
||
html += absAngles.map(a => {
|
||
const sel = _sbSelectedAngles.has(a.name) ? ' selected' : '';
|
||
const done = donePoses.has(a.name) ? ' done' : '';
|
||
const nSafe = a.name.replace(/'/g, "\\'");
|
||
return `<button class="sb-angle-btn${done}${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 done = donePoses.has(a.name) ? ' done' : '';
|
||
const nSafe = a.name.replace(/'/g, "\\'");
|
||
return `<button class="sb-angle-btn${done}${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>';
|
||
const poseCount = availablePoses ? Object.keys(availablePoses).length : 0;
|
||
if (poseCount > 8) {
|
||
html += `<input type="text" id="sbPoseFilter" class="sb-input" placeholder="🔍 Filter poses…"
|
||
value="${escHtml(_sbPoseFilter)}" oninput="sbFilterPoses(this.value)"
|
||
style="margin-bottom:6px;font-size:11px;padding:5px 8px">`;
|
||
}
|
||
html += '<div class="sb-poses-grid" id="sbPosesGrid">';
|
||
if (!poseCount) {
|
||
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}" data-pose-name="${escHtml(name.toLowerCase())}" onclick="toggleSbPose('${nSafe}')" title="${tip}">${escHtml(name)}</button>`;
|
||
}).join('');
|
||
}
|
||
html += `</div>`;
|
||
|
||
// Fine-tune section: one editable textarea per selected pose.
|
||
// "Save" persists the edit to poses.md; "Delete" removes the pose entirely.
|
||
if (_fsSelectedPoses.size > 0) {
|
||
html += `<div class="sb-sep"></div><div class="sb-label">Fine-tune poses
|
||
<span style="font-size:9px;color:#555;font-weight:400">· Save persists to poses.md</span></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, '"');
|
||
const isBeta = !!(availablePoses[name]?.beta);
|
||
html += `<div class="sb-pose-edit" data-pose="${escHtml(name)}">
|
||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">
|
||
<span style="font-size:11px;color:#888;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(name)}</span>
|
||
<label style="font-size:10px;color:#777;display:flex;align-items:center;gap:3px;cursor:pointer"><input type="checkbox" class="sb-pose-beta" ${isBeta?'checked':''}>beta</label>
|
||
<button class="sb-btn" style="padding:2px 8px;font-size:10px" onclick="savePoseToServer(this)">Save</button>
|
||
<button class="sb-btn danger" style="padding:2px 8px;font-size:10px" onclick="deletePoseFromServer(this)">Delete</button>
|
||
</div>
|
||
<textarea id="sbPoseEdit_${idSafe}" class="sb-pose-text"
|
||
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>
|
||
</div>`;
|
||
});
|
||
}
|
||
|
||
// Add a brand-new pose (persisted to poses.md).
|
||
html += `<div class="sb-sep"></div>
|
||
<details style="margin-bottom:6px">
|
||
<summary style="font-size:11px;color:#888;cursor:pointer">+ New pose</summary>
|
||
<input type="text" id="sbNewPoseName" placeholder="Pose name"
|
||
style="width:100%;box-sizing:border-box;background:#111;border:1px solid #2a2a2a;border-radius:5px;color:#ccc;font-size:11px;padding:6px;margin:6px 0 4px">
|
||
<textarea id="sbNewPoseText" placeholder="Prompt text"
|
||
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:48px"></textarea>
|
||
<label style="font-size:10px;color:#777;display:flex;align-items:center;gap:3px;cursor:pointer;margin-top:4px"><input type="checkbox" id="sbNewPoseBeta">beta</label>
|
||
<button class="sb-btn primary" style="margin-top:4px;font-size:10px;padding:3px 10px" onclick="addNewPose()">Add pose</button>
|
||
</details>`;
|
||
|
||
// Sticky footer — always visible without scrolling: refs + custom prompt + Generate
|
||
html += `<div class="sb-gen-footer">`;
|
||
if (_sbRefIndices.length > 0) {
|
||
const refThumbs = _sbRefIndices.map((idx, pos) => {
|
||
const fn = lbNames[idx] || '';
|
||
const u = lbUrls[idx] || '';
|
||
const thumb = isVideo(fn) ? posterFor(u) : u;
|
||
return `<div class="sb-gen-ref" title="Click to remove · ${escHtml(fn)}" style="cursor:pointer" onclick="sbRemoveRef(${idx})"><img src="${thumb}" loading="lazy" onerror="this.style.opacity='0.3'"><div class="sb-gen-ref-badge">${pos+1}</div><div class="sb-gen-ref-x">×</div></div>`;
|
||
}).join('');
|
||
html += `<div class="sb-label" style="margin-bottom:4px">References <span style="color:#555;font-weight:400;font-size:9px">· Shift+Click filmstrip or click a thumb to remove</span></div>
|
||
<div class="sb-gen-refs">${refThumbs}</div>`;
|
||
// With 2–3 refs, let the user choose how the prompt is applied.
|
||
if (_sbRefIndices.length >= 2) {
|
||
const n = _sbRefIndices.length;
|
||
const modeBtn = (mode, label, tip) =>
|
||
`<button onclick="setSbRefMode('${mode}')" title="${tip}"
|
||
style="flex:1;padding:5px 6px;font-size:10px;border-radius:5px;cursor:pointer;
|
||
border:1px solid ${_sbRefMode===mode?'#2563eb':'#2a2a2a'};
|
||
background:${_sbRefMode===mode?'#0c1f44':'#111'};
|
||
color:${_sbRefMode===mode?'#7dabff':'#888'}">${label}</button>`;
|
||
html += `<div style="display:flex;gap:4px;margin:6px 0 4px">
|
||
${modeBtn('combine', 'Combine → 1', 'Blend all selected refs into ONE image (#1 = image1, #2 = image2, #3 = image3)')}
|
||
${modeBtn('each', `Each → ${n}`, 'Apply the prompt to each ref separately → one output per ref')}
|
||
</div>
|
||
<div style="font-size:9px;color:#555;margin-bottom:2px">${_sbRefMode==='combine'
|
||
? 'One combined image; reference slots as #1/#2/#3 in the prompt.'
|
||
: 'One edit per selected reference.'}</div>`;
|
||
}
|
||
}
|
||
html += `<div class="sb-label">Custom prompt</div>
|
||
<div class="sb-prompt-wrap" style="position:relative;margin-bottom:10px">
|
||
<textarea class="sb-input" id="sbGenPromptInput" rows="2" autocomplete="off"
|
||
placeholder="Prompt — overrides pose"
|
||
oninput="updateSbGenBtn();autoGrowPrompt(this);showPromptSuggest(this)"
|
||
onfocus="autoGrowPrompt(this);showPromptSuggest(this)"
|
||
onblur="setTimeout(()=>hidePromptSuggest(),150)"
|
||
onkeydown="if(event.key==='Escape')hidePromptSuggest()"
|
||
style="resize:none;max-height:320px;overflow-y:auto;line-height:1.4;font-family:inherit">${escHtml(prevPromptVal)}</textarea>
|
||
<div id="sbPromptSuggest" style="display:none;position:absolute;left:0;right:0;z-index:50;max-height:180px;overflow-y:auto;background:#18181b;border:1px solid #2a2a2a;border-radius:6px;margin-top:2px;box-shadow:0 6px 20px rgba(0,0,0,0.6)"></div>
|
||
</div>
|
||
<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>
|
||
</div>`;
|
||
panel.innerHTML = html;
|
||
updateSbGenBtn();
|
||
const promptEl = document.getElementById('sbGenPromptInput');
|
||
if (promptEl) autoGrowPrompt(promptEl);
|
||
if (_sbPoseFilter) sbFilterPoses(_sbPoseFilter); // re-apply after rebuild
|
||
}
|
||
|
||
// Grow the custom-prompt textarea to fit its content (capped by max-height in CSS).
|
||
function autoGrowPrompt(el) {
|
||
if (!el) return;
|
||
el.style.height = 'auto';
|
||
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
|
||
}
|
||
|
||
// History autocomplete for the custom-prompt textarea. <datalist> can't bind to a
|
||
// <textarea>, so this reuses the same `batchPromptHistory` store that feeds the
|
||
// #promptHistory datalist and renders a small suggestion dropdown.
|
||
// Prompt history dropdown — shared by the Generate and Scenery prompt textareas.
|
||
// Pass the suggestion-box id so each textarea drives its own dropdown.
|
||
function showPromptSuggest(el, boxId = 'sbPromptSuggest') {
|
||
const box = document.getElementById(boxId);
|
||
if (!box) return;
|
||
const hist = JSON.parse(localStorage.getItem('batchPromptHistory') || '[]');
|
||
const q = (el.value || '').trim().toLowerCase();
|
||
const matches = (q ? hist.filter(p => p.toLowerCase().includes(q) && p.toLowerCase() !== q) : hist).slice(0, 40);
|
||
if (!matches.length) { box.style.display = 'none'; return; }
|
||
box.style.top = (el.offsetTop + el.offsetHeight) + 'px';
|
||
const inputId = el.id;
|
||
box.innerHTML = matches.map(p =>
|
||
`<div class="prompt-suggest-item" onmousedown="event.preventDefault();pickPromptSuggest(this,'${inputId}','${boxId}')"
|
||
style="padding:6px 9px;font-size:11px;color:#ccc;cursor:pointer;border-bottom:1px solid #222;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
|
||
onmouseover="this.style.background='#1f2937'" onmouseout="this.style.background=''"
|
||
data-val="${escHtml(p)}" title="${escHtml(p)}">${escHtml(p)}</div>`).join('');
|
||
box.style.display = 'block';
|
||
}
|
||
|
||
function hidePromptSuggest(boxId = 'sbPromptSuggest') {
|
||
const box = document.getElementById(boxId);
|
||
if (box) box.style.display = 'none';
|
||
}
|
||
|
||
function pickPromptSuggest(item, inputId = 'sbGenPromptInput', boxId = 'sbPromptSuggest') {
|
||
const el = document.getElementById(inputId);
|
||
if (!el) return;
|
||
el.value = item.dataset.val;
|
||
hidePromptSuggest(boxId);
|
||
if (inputId === 'sbGenPromptInput') updateSbGenBtn();
|
||
autoGrowPrompt(el);
|
||
el.focus();
|
||
}
|
||
|
||
function sbSavePoseEdit(name) {
|
||
const el = document.getElementById('sbPoseEdit_' + CSS.escape(name));
|
||
if (el) _sbPoseEdits[name] = el.value;
|
||
}
|
||
|
||
// Refetch poses from the backend, then re-render both the Generate tab and batch menu.
|
||
async function _reloadPosesUI() {
|
||
try {
|
||
const r = await fetch(`${API}/poses`);
|
||
if (r.ok) availablePoses = await r.json();
|
||
} catch (e) { /* non-fatal */ }
|
||
renderSidebarGenerate();
|
||
if (document.getElementById('poseMenu')) renderPoseMenu();
|
||
}
|
||
|
||
async function savePoseToServer(btn) {
|
||
const box = btn.closest('.sb-pose-edit');
|
||
if (!box) return;
|
||
const name = box.dataset.pose;
|
||
const text = box.querySelector('.sb-pose-text')?.value ?? '';
|
||
const beta = box.querySelector('.sb-pose-beta')?.checked ?? false;
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await fetch(`${API}/poses`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, text, beta }),
|
||
});
|
||
if (r.ok) {
|
||
delete _sbPoseEdits[name]; // edit is now the saved baseline
|
||
showToast('Pose saved', 'success');
|
||
await _reloadPosesUI();
|
||
} else {
|
||
showToast('Save failed: ' + await r.text(), 'error');
|
||
btn.disabled = false;
|
||
}
|
||
} catch (e) { showToast('Save error: ' + e, 'error'); btn.disabled = false; }
|
||
}
|
||
|
||
async function deletePoseFromServer(btn) {
|
||
const box = btn.closest('.sb-pose-edit');
|
||
if (!box) return;
|
||
const name = box.dataset.pose;
|
||
if (!confirm(`Delete pose "${name}"?`)) return;
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await fetch(`${API}/poses/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||
if (r.ok) {
|
||
_fsSelectedPoses.delete(name);
|
||
selectedPoses.delete(name);
|
||
delete _sbPoseEdits[name];
|
||
showToast('Pose deleted', 'success');
|
||
await _reloadPosesUI();
|
||
} else {
|
||
showToast('Delete failed: ' + await r.text(), 'error');
|
||
btn.disabled = false;
|
||
}
|
||
} catch (e) { showToast('Delete error: ' + e, 'error'); btn.disabled = false; }
|
||
}
|
||
|
||
async function addNewPose() {
|
||
const name = (document.getElementById('sbNewPoseName')?.value || '').trim();
|
||
const text = (document.getElementById('sbNewPoseText')?.value || '').trim();
|
||
const beta = document.getElementById('sbNewPoseBeta')?.checked ?? false;
|
||
if (!name) { showToast('Pose name is required', 'info'); return; }
|
||
try {
|
||
const r = await fetch(`${API}/poses`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, text, beta }),
|
||
});
|
||
if (r.ok) {
|
||
showToast('Pose added', 'success');
|
||
await _reloadPosesUI();
|
||
} else {
|
||
showToast('Add failed: ' + await r.text(), 'error');
|
||
}
|
||
} catch (e) { showToast('Add error: ' + e, 'error'); }
|
||
}
|
||
|
||
function toggleSbPose(name) {
|
||
if (_fsSelectedPoses.has(name)) _fsSelectedPoses.delete(name);
|
||
else _fsSelectedPoses.add(name);
|
||
renderSidebarGenerate();
|
||
}
|
||
|
||
// Live client-side filter of the pose grid by name substring (no re-render).
|
||
function sbFilterPoses(q) {
|
||
_sbPoseFilter = q || '';
|
||
const needle = _sbPoseFilter.trim().toLowerCase();
|
||
document.querySelectorAll('#sbPosesGrid .sb-pose-btn').forEach(btn => {
|
||
const name = btn.dataset.poseName || '';
|
||
btn.style.display = (!needle || name.includes(needle)) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function toggleSbAngle(name) {
|
||
if (_sbSelectedAngles.has(name)) _sbSelectedAngles.delete(name);
|
||
else _sbSelectedAngles.add(name);
|
||
renderSidebarGenerate();
|
||
}
|
||
|
||
function sbToggleOrbitAll() {
|
||
const ORBIT_NAMES = ['Front', '¾ Left', '¾ Right', 'Side L', 'Side R', 'Back'];
|
||
const allSel = ORBIT_NAMES.every(n => _sbSelectedAngles.has(n));
|
||
if (allSel) { ORBIT_NAMES.forEach(n => _sbSelectedAngles.delete(n)); }
|
||
else { ORBIT_NAMES.forEach(n => _sbSelectedAngles.add(n)); }
|
||
renderSidebarGenerate();
|
||
}
|
||
|
||
function setSbRefMode(mode) {
|
||
_sbRefMode = mode;
|
||
renderSidebarGenerate();
|
||
}
|
||
|
||
// Remove a reference by clicking its thumbnail above the custom prompt — no page
|
||
// refresh needed (previously refs could only be cleared via Shift+Click filmstrip).
|
||
function sbRemoveRef(idx) {
|
||
const pos = _sbRefIndices.indexOf(idx);
|
||
if (pos >= 0) _sbRefIndices.splice(pos, 1);
|
||
updateStudio();
|
||
if (_activeSidebarTab === 'generate') 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);
|
||
// Tag the output with the angle name (as a pose) so the angle button
|
||
// shows a green ✓ once generated, mirroring pose tracking.
|
||
if (ang) { prompts.push(ang.prompt); poses.push(name); }
|
||
});
|
||
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)…`;
|
||
// Build filenames array: use multi-ref selection if set, else just primary
|
||
const refFilenames = _sbRefIndices.length > 0
|
||
? _sbRefIndices.map(idx => lbNames[idx]).filter(Boolean)
|
||
: [_fsModelFilename];
|
||
// Routing depends on the References mode toggle:
|
||
// 'combine' (≥2 refs) → /multi-ref: filenames[0] is primary (image1),
|
||
// the rest are extra refs (image2/image3) → ONE output per prompt.
|
||
// 'each' or a single ref → /batch: cross-products filenames × prompts
|
||
// → one edit per ref. (This was the only path before, which is why
|
||
// selecting 3 refs always produced 3 images.)
|
||
const useMultiRef = refFilenames.length >= 2 && _sbRefMode === 'combine';
|
||
const endpoint = useMultiRef ? '/multi-ref' : '/batch';
|
||
const payload = useMultiRef
|
||
? { filenames: refFilenames, prompt: prompts, poses, seed: -1, max_area: 0 }
|
||
: { filenames: refFilenames, prompt: prompts, poses, seed: -1, max_area: 0,
|
||
group_id: lbCurrentGid,
|
||
wireframe_ref: _sbWireframeRef || null, wireframe_time: _sbWireframeTime };
|
||
if (useMultiRef) {
|
||
const shortName = f => f.split('/').pop().replace(/\?.*$/, '');
|
||
let refLabel = `Combining → image1: ${shortName(refFilenames[0])}, image2: ${shortName(refFilenames[1])}`;
|
||
if (refFilenames[2]) refLabel += `, image3: ${shortName(refFilenames[2])}`;
|
||
showToast(refLabel, 'info', 5000);
|
||
}
|
||
try {
|
||
const r = await fetch(`${API}${endpoint}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
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();
|
||
_sbSelectedAngles.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;
|
||
}
|
||
}
|
||
|
||
// ---- template video cards (faceswap / scenery) ----
|
||
// The wireframe library is large (dozens of files, >1 GB total) and lives on a
|
||
// network mount. Rendering every card as an autoplaying <video> spun up that many
|
||
// simultaneous decoders + range-streams, which OOM-crashed the renderer. Instead
|
||
// each card shows a static poster frame (a small server-rendered PNG) and only
|
||
// mounts a single real <video> while hovered — at most one decoder alive at a time.
|
||
|
||
// HTML for one template card. `extraHTML` lets faceswap add its trim button.
|
||
function _tplCardHTML(name, selected, onclickExpr, extraHTML = '') {
|
||
const stem = name.replace(/\.[^.]+$/, '');
|
||
const sel = selected ? ' selected' : '';
|
||
const nSafe = name.replace(/'/g, "\\'");
|
||
return `<div class="sb-template-card${sel}" onclick="${onclickExpr}"
|
||
onmouseenter="_tplPlay(this,'${nSafe}')" onmouseleave="_tplStop(this)">
|
||
<img src="${API}/wireframe/frame/${encodeURIComponent(name)}?t=0" loading="lazy" alt="">
|
||
<div class="sb-template-label">${escHtml(stem)}</div>${extraHTML}
|
||
</div>`;
|
||
}
|
||
|
||
// Mount + play a single <video> over the poster while hovered.
|
||
function _tplPlay(card, name) {
|
||
if (card._vid) return;
|
||
const v = document.createElement('video');
|
||
v.src = `${API}/wireframe/${encodeURIComponent(name)}`;
|
||
v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto';
|
||
card.appendChild(v);
|
||
card._vid = v;
|
||
v.play().catch(() => {});
|
||
}
|
||
|
||
// Tear the video down completely so the decoder + network stream are released.
|
||
function _tplStop(card) {
|
||
const v = card && card._vid;
|
||
if (!v) return;
|
||
v.pause();
|
||
v.removeAttribute('src');
|
||
v.load(); // aborts the in-flight stream and frees the decoder
|
||
v.remove();
|
||
card._vid = null;
|
||
}
|
||
|
||
// Defensive: stop every active template video in the sidebar (called on tab switch
|
||
// so a hovered clip can't keep streaming under a freshly-rendered panel).
|
||
function _tplStopAll() {
|
||
document.querySelectorAll('#studioSidebar .sb-template-card').forEach(_tplStop);
|
||
}
|
||
|
||
// ---- faceswap sidebar tab ----
|
||
|
||
async function loadTemplateVideos() {
|
||
try {
|
||
const r = await fetch(`${API}/output/_data/videos.json`, { signal: AbortSignal.timeout(3000) });
|
||
if (r.ok) { availableVideos = (await r.json()).videos || []; return; }
|
||
} catch (_) {}
|
||
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 vSafe = v.replace(/'/g, "\\'");
|
||
const trimBtn = `<button class="sb-template-trim-btn" title="Trim video"
|
||
onclick="event.stopPropagation();openTrimPanel('${vSafe}')">✂</button>`;
|
||
html += _tplCardHTML(v, _fsSelectedVideo === v, `sbSelectTemplate('${vSafe}')`, trimBtn);
|
||
});
|
||
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;
|
||
// Cards show static poster frames; videos mount only on hover (_tplPlay).
|
||
// 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');
|
||
_followLatestGid = lbCurrentGid;
|
||
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;
|
||
|
||
let _sceneGridOpen = false; // whether the video picker grid is expanded
|
||
let _sceneLibOpen = false; // whether "previous scenery" library panel is expanded
|
||
let _sceneLib = null; // cached { groups, ungrouped } from /scenery/library
|
||
// 3 positional reference slots: 0=Background(image1), 1=Person(image2), 2=Extra(image3).
|
||
// Each entry is null | {kind:'bytes', data:<base64>} | {kind:'file', name:<filename>}.
|
||
// Backend contract: slot0→scene_bytes (bytes; a file here is fetched→bytes on submit),
|
||
// slot1→model_filename (must be a file), slot2→extra_filename (file, optional).
|
||
let _sceneRefs = [null, null, null];
|
||
let _scenePersonInit = false; // person auto-filled into slot1 once per group
|
||
let _sceneDrag = null; // active drag payload: {type:'slot',idx} | {type:'file',name}
|
||
|
||
function renderSidebarScenery() {
|
||
const panel = document.getElementById('sbPanelScenery');
|
||
if (!panel) return;
|
||
// Preserve a typed prompt across the frequent re-renders (slot changes).
|
||
const prevScenePrompt = document.getElementById('scenePromptInput')?.value || '';
|
||
// Auto-fill the Person slot (image2) once per group with the current image.
|
||
const personName = _fsModelFilename || lbNames[lbIdx] || '';
|
||
if (!_scenePersonInit && !_sceneRefs[1] && personName) {
|
||
_sceneRefs[1] = { kind: 'file', name: personName };
|
||
_scenePersonInit = true;
|
||
}
|
||
|
||
const bgRef = _sceneRefs[0];
|
||
const bgBytes = (bgRef && bgRef.kind === 'bytes') ? bgRef.data : null;
|
||
const haveRef = _sceneVideo || bgBytes;
|
||
// Auto-open the grid when no background source is chosen yet.
|
||
const gridOpen = _sceneGridOpen || !haveRef;
|
||
|
||
// --- Interactive 3-slot reference row (drag to reorder, ✕ to clear) ---
|
||
const SLOT_META = [
|
||
{ n: 1, label: 'Background' },
|
||
{ n: 2, label: 'Person' },
|
||
{ n: 3, label: 'Extra' },
|
||
];
|
||
const slotHtml = SLOT_META.map((m, i) => {
|
||
const ref = _sceneRefs[i];
|
||
const thumb = _sceneThumb(ref);
|
||
const kind = ref ? (ref.kind === 'bytes' ? '📷' : '🖼') : '';
|
||
return `<div class="scene-slot ${ref ? 'filled' : 'empty'}" data-slot="${i}"
|
||
draggable="${ref ? 'true' : 'false'}"
|
||
ondragstart="sceneDragSlotStart(event,${i})"
|
||
ondragover="sceneDragOver(event)" ondragleave="sceneDragLeave(event)" ondrop="sceneDrop(event,${i})">
|
||
<div class="scene-slot-thumb">${thumb
|
||
? `<img src="${thumb}" onerror="this.style.opacity=.3">`
|
||
: `<span class="ph">${m.n}</span>`}
|
||
${ref ? `<button class="scene-slot-x" title="Remove" onclick="sceneClearSlot(${i})">✕</button>` : ''}
|
||
${kind ? `<span class="scene-slot-kind">${kind}</span>` : ''}</div>
|
||
<div class="scene-slot-cap"><b>Image ${m.n}</b>${m.label}</div>
|
||
</div>`;
|
||
}).join('');
|
||
let html = `<div class="sb-label">References <span style="color:#555;font-weight:400;font-size:9px">· Shift+Click filmstrip (fills →) · drag to reorder</span></div>
|
||
<div class="scene-slots">${slotHtml}</div>`;
|
||
|
||
// --- Image 1 source: background scene (video scrub + capture, or upload) ---
|
||
html += `<div class="sb-label">Image 1 source · background</div>`;
|
||
|
||
// --- Selected video: player + slider + capture (always at top) ---
|
||
if (_sceneVideo) {
|
||
html += `
|
||
<div class="scene-frame-preview" id="sceneFramePreview" style="display:block">
|
||
<video id="sceneVideoEl" muted preload="auto" playsinline
|
||
style="${_sceneFrameBytes?'display:none':'display:block'};width:100%;height:100%;object-fit:contain"></video>
|
||
<img id="sceneFrameImg" style="${_sceneFrameBytes?'display:block':'display:none'};width:100%;height:100%;object-fit:contain"
|
||
${_sceneFrameBytes?`src="data:image/png;base64,${_sceneFrameBytes}"`:''}/>
|
||
<div class="scene-captured-badge" id="sceneCapturedBadge" style="${_sceneFrameBytes?'display:block':'display:none'}">✓ frame captured</div>
|
||
</div>
|
||
<input type="range" class="scene-scrubber" id="sceneScrubber" min="0" max="1000" value="0"
|
||
oninput="sceneScrub(this.value)" ${_sceneFrameBytes?'disabled':''}>
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||
<span id="sceneTimeLabel" style="font-size:11px;color:#888">0:00.0</span>
|
||
<span style="font-size:10px;color:#555">${escHtml(_sceneVideo.replace(/\.[^.]+$/,''))}</span>
|
||
</div>
|
||
<button class="sb-btn primary" id="sceneCaptureBtn" onclick="sceneExtractFrame()"
|
||
style="${_sceneFrameBytes?'display:none':'display:block'};width:100%;margin-bottom:8px">📷 Capture this frame</button>
|
||
<button class="sb-btn" id="sceneReleaseBtn" onclick="sceneReleaseFrame()"
|
||
style="${_sceneFrameBytes?'display:block':'display:none'};width:100%;margin-bottom:8px">✖ Pick a different frame</button>
|
||
<button class="sb-btn" onclick="sceneToggleGrid()" style="width:100%;margin-bottom:10px">
|
||
${gridOpen?'▲ Hide video list':'▼ Change background video'}</button>`;
|
||
}
|
||
|
||
// --- Video picker grid (collapsible) ---
|
||
if (!_sceneVideo || gridOpen) {
|
||
html += '<div class="sb-label">Background video</div>';
|
||
if (availableVideos.length) {
|
||
html += '<div class="sb-template-grid">';
|
||
availableVideos.forEach(v => {
|
||
const vSafe = v.replace(/'/g, "\\'");
|
||
html += _tplCardHTML(v, _sceneVideo === v, `sceneSelectVideo('${vSafe}')`);
|
||
});
|
||
html += '</div>';
|
||
} else {
|
||
html += '<div style="font-size:11px;color:#555;padding:6px 0">No wireframe videos found</div>';
|
||
}
|
||
}
|
||
|
||
// Upload alternative for the background (still image 1)
|
||
html += `<input type="file" id="sceneUploadInput" accept="image/*" style="display:none" onchange="sceneHandleUpload(event)">
|
||
<button class="sb-btn" onclick="document.getElementById('sceneUploadInput').click()" style="width:100%;margin-bottom:10px">⬆ Upload image as background</button>`;
|
||
|
||
// --- Person / Extra: filled via filmstrip Shift+Click (see slots above) ---
|
||
const person = _sceneRefs[1];
|
||
html += `<div class="sb-sep"></div>
|
||
<div style="font-size:10px;color:#666;line-height:1.5;margin-bottom:8px">
|
||
<b style="color:#999">Image 2 (Person)</b> & <b style="color:#999">Image 3 (Extra)</b>:
|
||
Shift+Click a thumbnail in the bottom filmstrip to add it (fills the first open
|
||
slot right→left). Drag the slots above to rearrange, or ✕ to clear.
|
||
</div>`;
|
||
if (!person || person.kind !== 'file') {
|
||
html += `<div style="font-size:11px;color:#a55;padding:0 0 8px">Image 2 needs a gallery image (Shift+Click the filmstrip).</div>`;
|
||
}
|
||
|
||
// --- Prompt + generate ---
|
||
const canGen = !!bgRef && !!person && person.kind === 'file'
|
||
&& (!_sceneRefs[2] || _sceneRefs[2].kind === 'file');
|
||
html += `<div class="sb-sep"></div>
|
||
<div class="sb-label">Prompt (optional) <span style="color:#555;font-weight:400;font-size:9px">· history while typing</span></div>
|
||
<div class="sb-prompt-wrap" style="position:relative;margin-bottom:10px">
|
||
<textarea class="sb-input" id="scenePromptInput" rows="2" autocomplete="off"
|
||
placeholder="Auto: place person in scene… (type to search history)"
|
||
oninput="autoGrowPrompt(this);showPromptSuggest(this,'scenePromptSuggest')"
|
||
onfocus="autoGrowPrompt(this);showPromptSuggest(this,'scenePromptSuggest')"
|
||
onblur="setTimeout(()=>hidePromptSuggest('scenePromptSuggest'),150)"
|
||
onkeydown="if(event.key==='Escape')hidePromptSuggest('scenePromptSuggest')"
|
||
style="resize:none;max-height:300px;overflow-y:auto;line-height:1.4;font-family:inherit"></textarea>
|
||
<div id="scenePromptSuggest" style="display:none;position:absolute;left:0;right:0;z-index:50;max-height:240px;overflow-y:auto;background:#18181b;border:1px solid #2a2a2a;border-radius:6px;margin-top:2px;box-shadow:0 6px 20px rgba(0,0,0,0.6)"></div>
|
||
</div>
|
||
<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()"
|
||
${canGen?'':'disabled'}>Create Scenery</button>
|
||
</div>`;
|
||
|
||
// --- Previous scenery library (grouped by source video) ---
|
||
html += `<div class="sb-sep"></div>
|
||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
|
||
<span class="sb-label" style="margin:0;flex:1">Previous scenery</span>
|
||
<button class="sb-btn" style="padding:2px 8px;font-size:10px" onclick="sceneLibToggle()">${_sceneLibOpen ? '▲ hide' : '▼ browse'}</button>
|
||
</div>`;
|
||
if (_sceneLibOpen) {
|
||
if (!_sceneLib) {
|
||
html += `<div id="sceneLibLoader" style="font-size:11px;color:#666;padding:4px 0">Loading…</div>`;
|
||
} else if (!_sceneLib.groups.length && !_sceneLib.ungrouped.length) {
|
||
html += `<div style="font-size:11px;color:#555;padding:4px 0">No previous scenery found.</div>`;
|
||
} else {
|
||
const renderLibItems = (items) => items.map(it => {
|
||
const url = IMAGE_FOLDER + encodeURIComponent(it.filename);
|
||
const gLabel = it.group_id ? ` <span style="color:#555">${escHtml(it.group_id)}</span>` : '';
|
||
return `<div style="display:inline-block;margin:2px;cursor:pointer;position:relative;vertical-align:top"
|
||
title="Use as background · ${escHtml(it.filename)}${it.group_id?' ('+escHtml(it.group_id)+')':''}"
|
||
onclick="sceneLibUseAsBackground(${JSON.stringify(it.filename)})">
|
||
<img src="${url}" style="width:52px;height:52px;object-fit:cover;border-radius:4px;border:1px solid #333">
|
||
</div>`;
|
||
}).join('');
|
||
_sceneLib.groups.forEach(g => {
|
||
const vName = g.video.replace(/\.[^.]+$/, '');
|
||
html += `<div style="margin-bottom:8px">
|
||
<div style="font-size:10px;color:#888;margin-bottom:3px">📹 ${escHtml(vName)}</div>
|
||
<div>${renderLibItems(g.items)}</div>
|
||
</div>`;
|
||
});
|
||
if (_sceneLib.ungrouped.length) {
|
||
html += `<div style="margin-bottom:8px">
|
||
<div style="font-size:10px;color:#888;margin-bottom:3px">📷 uploaded / other</div>
|
||
<div>${renderLibItems(_sceneLib.ungrouped)}</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
panel.innerHTML = html;
|
||
|
||
// Lazy-load library data when panel is opened
|
||
if (_sceneLibOpen && !_sceneLib) {
|
||
fetch(`${API}/scenery/library`)
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(d => { if (d) { _sceneLib = d; renderSidebarScenery(); } })
|
||
.catch(() => {});
|
||
}
|
||
|
||
// Restore the preserved prompt text and size the textarea.
|
||
const spEl = document.getElementById('scenePromptInput');
|
||
if (spEl) { spEl.value = prevScenePrompt; autoGrowPrompt(spEl); }
|
||
|
||
if (_sceneVideo) {
|
||
const vid = document.getElementById('sceneVideoEl');
|
||
vid.src = `${API}/wireframe/${encodeURIComponent(_sceneVideo)}`;
|
||
// Once metadata is known, size the slider so 1000 steps span the whole clip.
|
||
vid.addEventListener('loadedmetadata', () => {
|
||
_sceneDuration = vid.duration || 0;
|
||
const lbl = document.getElementById('sceneTimeLabel');
|
||
if (lbl) lbl.textContent = formatSecs(vid.currentTime || 0);
|
||
}, { once: true });
|
||
// Keep the time label in sync if the frame is seeked any other way.
|
||
vid.addEventListener('seeked', () => {
|
||
const lbl = document.getElementById('sceneTimeLabel');
|
||
if (lbl) lbl.textContent = formatSecs(vid.currentTime || 0);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Scrub the preview video live as the slider moves (no server round-trip).
|
||
function sceneScrub(val) {
|
||
const vid = document.getElementById('sceneVideoEl');
|
||
if (!vid || !vid.duration) return;
|
||
const t = (parseFloat(val) / 1000) * vid.duration;
|
||
vid.pause();
|
||
vid.currentTime = t;
|
||
const lbl = document.getElementById('sceneTimeLabel');
|
||
if (lbl) lbl.textContent = formatSecs(t);
|
||
}
|
||
|
||
function sceneToggleGrid() {
|
||
_sceneGridOpen = !_sceneGridOpen;
|
||
renderSidebarScenery();
|
||
}
|
||
|
||
function sceneLibToggle() {
|
||
_sceneLibOpen = !_sceneLibOpen;
|
||
if (_sceneLibOpen && !_sceneLib) _sceneLib = null; // force re-fetch on open
|
||
renderSidebarScenery();
|
||
}
|
||
|
||
// Use a library scenery image as slot 0 background (fetched to bytes).
|
||
async function sceneLibUseAsBackground(filename) {
|
||
try {
|
||
const url = IMAGE_FOLDER + encodeURIComponent(filename);
|
||
const b64 = await _fetchAsBase64(url);
|
||
_sceneRefs[0] = { kind: 'bytes', data: b64 };
|
||
_sceneVideo = null;
|
||
_sceneFrameBytes = null;
|
||
_sceneGridOpen = false;
|
||
renderSidebarScenery();
|
||
showToast('Background set from previous scenery', 'success');
|
||
} catch (e) {
|
||
showToast('Could not load image: ' + e, 'error');
|
||
}
|
||
}
|
||
|
||
// ---- scenery reference-slot helpers ----
|
||
|
||
// Thumbnail (URL or data-URL) for a slot ref.
|
||
function _sceneThumb(ref) {
|
||
if (!ref) return '';
|
||
if (ref.kind === 'bytes') return 'data:image/png;base64,' + ref.data;
|
||
const idx = lbNames.indexOf(ref.name);
|
||
const u = idx >= 0 ? lbUrls[idx] : (IMAGE_FOLDER + ref.name);
|
||
return isVideo(ref.name) ? posterFor(u) : u;
|
||
}
|
||
function _sceneUrlForName(name) {
|
||
const idx = lbNames.indexOf(name);
|
||
return idx >= 0 ? lbUrls[idx] : (IMAGE_FOLDER + name);
|
||
}
|
||
async function _fetchAsBase64(url) {
|
||
const r = await fetch(url);
|
||
const blob = await r.blob();
|
||
return await new Promise((res, rej) => {
|
||
const fr = new FileReader();
|
||
fr.onload = () => res(String(fr.result).split(',')[1]);
|
||
fr.onerror = rej;
|
||
fr.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
// Refresh both the scenery panel and the filmstrip badges.
|
||
function _sceneRefresh() { renderSidebarScenery(); updateStudio(); }
|
||
|
||
// Assign a filmstrip file to the first empty slot scanning right→left ([2,1,0]).
|
||
// Shift+Click an already-assigned file removes it (toggle).
|
||
function sceneAssignRef(name) {
|
||
const at = _sceneRefs.findIndex(r => r && r.kind === 'file' && r.name === name);
|
||
if (at >= 0) { _sceneRefs[at] = null; _sceneRefresh(); return; }
|
||
for (const i of [2, 1, 0]) {
|
||
if (!_sceneRefs[i]) {
|
||
_sceneRefs[i] = { kind: 'file', name };
|
||
if (i === 0) _sceneFrameBytes = null; // a file now backs slot 0
|
||
_sceneRefresh();
|
||
return;
|
||
}
|
||
}
|
||
showToast('All 3 reference slots are full — ✕ one first', 'info');
|
||
}
|
||
|
||
function sceneClearSlot(i) {
|
||
const ref = _sceneRefs[i];
|
||
_sceneRefs[i] = null;
|
||
if (i === 0 && ref && ref.kind === 'bytes') _sceneFrameBytes = null;
|
||
if (i === 1) _scenePersonInit = true; // don't auto-refill after explicit clear
|
||
_sceneRefresh();
|
||
}
|
||
|
||
// ---- drag & drop between slots / from filmstrip ----
|
||
function sceneDragSlotStart(ev, i) {
|
||
if (!_sceneRefs[i]) { ev.preventDefault(); return; }
|
||
_sceneDrag = { type: 'slot', idx: i };
|
||
ev.dataTransfer.effectAllowed = 'move';
|
||
}
|
||
function sceneFilmDragStart(ev, i) {
|
||
_sceneDrag = { type: 'file', name: lbNames[i] };
|
||
ev.dataTransfer.effectAllowed = 'copy';
|
||
}
|
||
function sceneDragOver(ev) { ev.preventDefault(); ev.currentTarget.classList.add('drop-hover'); }
|
||
function sceneDragLeave(ev) { ev.currentTarget.classList.remove('drop-hover'); }
|
||
function sceneDrop(ev, dst) {
|
||
ev.preventDefault();
|
||
ev.currentTarget.classList.remove('drop-hover');
|
||
const drag = _sceneDrag; _sceneDrag = null;
|
||
if (!drag) return;
|
||
if (drag.type === 'file') {
|
||
// Dropping a filmstrip image onto a slot — remove any duplicate first.
|
||
const dup = _sceneRefs.findIndex(r => r && r.kind === 'file' && r.name === drag.name);
|
||
if (dup >= 0 && dup !== dst) _sceneRefs[dup] = null;
|
||
if (dst === 0 && _sceneRefs[0] && _sceneRefs[0].kind === 'bytes') _sceneFrameBytes = null;
|
||
_sceneRefs[dst] = { kind: 'file', name: drag.name };
|
||
} else {
|
||
const src = drag.idx;
|
||
if (src === dst) return;
|
||
const a = _sceneRefs[src], b = _sceneRefs[dst];
|
||
// Captured/uploaded bytes can only live in slot 0 (the background).
|
||
if ((a && a.kind === 'bytes' && dst !== 0) || (b && b.kind === 'bytes' && src !== 0)) {
|
||
showToast('The captured background can only sit in the Image 1 slot', 'info');
|
||
return;
|
||
}
|
||
_sceneRefs[src] = b; _sceneRefs[dst] = a;
|
||
_sceneFrameBytes = (_sceneRefs[0] && _sceneRefs[0].kind === 'bytes') ? _sceneRefs[0].data : null;
|
||
}
|
||
_sceneRefresh();
|
||
}
|
||
|
||
async function sceneSelectVideo(v) {
|
||
_sceneVideo = v; _sceneFrameBytes = null;
|
||
if (_sceneRefs[0] && _sceneRefs[0].kind === 'bytes') _sceneRefs[0] = null;
|
||
_sceneGridOpen = false; // collapse the picker so the player is front-and-centre
|
||
renderSidebarScenery();
|
||
}
|
||
|
||
function sceneReleaseFrame() {
|
||
_sceneFrameBytes = null;
|
||
if (_sceneRefs[0] && _sceneRefs[0].kind === 'bytes') _sceneRefs[0] = null;
|
||
renderSidebarScenery(); // re-render restores the live video + slider + capture button
|
||
}
|
||
|
||
async function sceneExtractFrame() {
|
||
const vid = document.getElementById('sceneVideoEl');
|
||
if (vid) vid.pause();
|
||
const t = vid ? vid.currentTime : 0;
|
||
const captureBtn = document.getElementById('sceneCaptureBtn');
|
||
if (captureBtn) { captureBtn.disabled = true; captureBtn.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');
|
||
if (captureBtn) { captureBtn.disabled = false; captureBtn.textContent = '📷 Capture this frame'; }
|
||
} else {
|
||
const d = await r.json();
|
||
_sceneFrameBytes = d.frame_b64;
|
||
_sceneRefs[0] = { kind: 'bytes', data: d.frame_b64 }; // background → slot 0
|
||
_sceneRefresh(); // show the frozen frame + update slot badge
|
||
}
|
||
} catch (e) {
|
||
showToast('Frame extract error: ' + e, 'error');
|
||
if (captureBtn) { captureBtn.disabled = false; captureBtn.textContent = '📷 Capture this frame'; }
|
||
}
|
||
}
|
||
|
||
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];
|
||
_sceneRefs[0] = { kind: 'bytes', data: _sceneFrameBytes }; // background → slot 0
|
||
_sceneVideo = null;
|
||
renderSidebarScenery();
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
async function submitGenerateScenery() {
|
||
// Map positional slots → backend contract.
|
||
const bg = _sceneRefs[0], person = _sceneRefs[1], extra = _sceneRefs[2];
|
||
if (!bg) { showToast('Set a background (Image 1) — capture, upload, or Shift+Click', 'error'); return; }
|
||
if (!person || person.kind !== 'file') { showToast('Set a person image (Image 2) from the filmstrip', 'error'); return; }
|
||
if (extra && extra.kind !== 'file') { showToast('Image 3 must be a gallery image', 'error'); return; }
|
||
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…';
|
||
if (prompt) savePromptHistory(prompt);
|
||
// slot0 → scene_bytes: bytes directly, or fetch a file-as-background to bytes.
|
||
let sceneBytes;
|
||
try {
|
||
sceneBytes = (bg.kind === 'bytes') ? bg.data : await _fetchAsBase64(_sceneUrlForName(bg.name));
|
||
} catch (e) {
|
||
showToast('Could not read background image: ' + e, 'error');
|
||
if (btn) btn.disabled = false; if (statusEl) statusEl.style.display = 'none'; return;
|
||
}
|
||
const payload = {
|
||
model_filename: person.name, // image2 (Picture 2) — person
|
||
scene_bytes: sceneBytes, // image1 (Picture 1) — background
|
||
scene_video: null,
|
||
scene_time: 0,
|
||
extra_filename: (extra && extra.kind === 'file') ? extra.name : null, // image3 (Picture 3)
|
||
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;
|
||
// Auto-follow so the open studio filmstrip jumps to the
|
||
// new image (matches the Generate tab); refreshNow() alone
|
||
// doesn't update an already-open studio's filmstrip.
|
||
_followLatestGid = lbCurrentGid;
|
||
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');
|
||
loadFaceThumb(lbCurrentGid, 8); // poll until the crop lands
|
||
}
|
||
})
|
||
.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…`);
|
||
const added = [];
|
||
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) {
|
||
const d = await r.json().catch(() => ({}));
|
||
if (d.filename) added.push(d.filename);
|
||
} else {
|
||
showToast('Upload failed: ' + await r.text(), 'error');
|
||
}
|
||
} catch (e) { showToast('Upload error: ' + e, 'error'); }
|
||
}
|
||
|
||
// Optimistically show the new images immediately — the static images.json the
|
||
// gallery reads from is rebuilt on a background thread, so refreshNow() alone
|
||
// would race ahead of it and the images wouldn't appear until the next refresh.
|
||
if (added.length) {
|
||
const fresh = groupData.get(groupId);
|
||
added.forEach(fn => {
|
||
const url = IMAGE_FOLDER + fn + '?t=' + Date.now();
|
||
if (fresh && !fresh.names.includes(fn)) {
|
||
fresh.urls.push(url);
|
||
fresh.names.push(fn);
|
||
if (fresh.visibleIdx) fresh.visibleIdx.push(fresh.names.length - 1);
|
||
}
|
||
if (_isStudioOpen() && lbCurrentGid === groupId && !lbNames.includes(fn)) {
|
||
lbUrls.push(url);
|
||
lbNames.push(fn);
|
||
}
|
||
});
|
||
if (_isStudioOpen() && lbCurrentGid === groupId) updateStudio();
|
||
}
|
||
|
||
showToast('Added to group', 'success');
|
||
// Reconcile with the backend once the static data file has been regenerated.
|
||
setTimeout(refreshNow, 600);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |