dippoo's picture
Fix: add required character_orientation param to Kling Motion API call
36371b1
raw
history blame
159 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Content Engine</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a28;
--bg-hover: #22222f;
--border: #2a2a3a;
--text-primary: #eee;
--text-secondary: #888;
--accent: #7c3aed;
--accent-hover: #6d28d9;
--accent-glow: rgba(124, 58, 237, 0.3);
--green: #22c55e;
--red: #ef4444;
--orange: #f59e0b;
--blue: #3b82f6;
--radius: 12px;
}
body {
font-family: 'Segoe UI', -apple-system, system-ui, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
/* --- Layout --- */
.app { display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: 260px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border);
}
.sidebar-header h1 {
font-size: 18px;
font-weight: 700;
background: linear-gradient(135deg, #7c3aed, #ec4899);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.sidebar-header .subtitle {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
}
.nav { flex: 1; padding: 12px; overflow-y: auto; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary);
font-size: 14px;
transition: all 0.15s;
margin-bottom: 2px;
}
.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.nav-item.active { background: var(--accent); color: white; }
.nav-item svg { width: 18px; height: 18px; flex-shrink: 0; }
.nav-separator {
border-top: 1px solid var(--border);
margin: 8px 14px;
}
.status-bar {
padding: 14px 16px;
border-top: 1px solid var(--border);
font-size: 12px;
}
.status-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status-dot.offline { background: var(--red); }
.main-content {
flex: 1;
overflow-y: auto;
padding: 28px;
}
/* --- Page: Generate --- */
.generate-layout { display: grid; grid-template-columns: 340px 1fr; gap: 20px; height: calc(100vh - 56px); }
.controls-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
overflow-y: auto;
}
.preview-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
overflow: hidden;
}
.section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: 8px;
margin-top: 20px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.section-title:first-child { margin-top: 0; padding-top: 0; border-top: none; }
label {
display: block;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 4px;
margin-top: 10px;
}
label:first-of-type { margin-top: 0; }
select, input[type="text"], input[type="number"], textarea {
width: 100%;
padding: 6px 10px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
select:focus, input:focus, textarea:focus { border-color: var(--accent); }
textarea { resize: vertical; min-height: 60px; }
select { cursor: pointer; }
.slider-row {
display: flex;
align-items: center;
gap: 10px;
}
.slider-row input[type="range"] {
flex: 1;
accent-color: var(--accent);
}
.slider-row .value {
font-size: 12px;
color: var(--accent);
min-width: 36px;
text-align: right;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.btn-primary {
background: var(--accent);
color: white;
width: 100%;
margin-top: 16px;
}
.btn-primary:hover { background: var(--accent-hover); box-shadow: 0 0 20px var(--accent-glow); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
background: var(--bg-hover);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--border); }
.btn-small { padding: 6px 14px; font-size: 12px; }
.chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip {
padding: 4px 10px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
color: var(--text-secondary);
}
.chip:hover { border-color: var(--accent); color: var(--text-primary); }
.chip.selected { background: var(--accent); border-color: var(--accent); color: white; }
/* Preview area */
.preview-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 600;
}
.preview-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
overflow: auto;
position: relative;
}
.preview-body img {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
object-fit: contain;
}
.preview-placeholder {
text-align: center;
color: var(--text-secondary);
}
.preview-placeholder svg { width: 64px; height: 64px; opacity: 0.3; margin-bottom: 12px; }
.api-log-panel {
border-top: 1px solid var(--border);
background: var(--bg-primary);
}
.api-log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
user-select: none;
}
.api-log-header:hover { background: var(--bg-hover); }
.api-log-content {
max-height: 200px;
overflow-y: auto;
padding: 8px 12px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
}
.api-log-entry {
padding: 4px 0;
border-bottom: 1px solid var(--border);
line-height: 1.4;
}
.api-log-entry:last-child { border-bottom: none; }
.api-log-time { color: var(--text-secondary); margin-right: 8px; }
.api-log-method { font-weight: 600; margin-right: 4px; }
.api-log-method.POST { color: var(--green); }
.api-log-method.GET { color: var(--blue); }
.api-log-url { color: var(--text-primary); }
.api-log-status { margin-left: 8px; font-weight: 600; }
.api-log-status.ok { color: var(--green); }
.api-log-status.error { color: var(--red); }
.api-log-detail { color: var(--text-secondary); margin-top: 2px; padding-left: 60px; word-break: break-all; }
.generating-overlay {
position: absolute;
inset: 0;
background: rgba(10, 10, 15, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
z-index: 10;
}
.spinner {
width: 48px; height: 48px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* File upload drop zone */
.drop-zone {
border: 1px dashed var(--border);
border-radius: 8px;
padding: 16px 12px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
color: var(--text-secondary);
font-size: 11px;
}
.drop-zone:hover, .drop-zone.dragover { border-color: var(--accent); background: rgba(124,58,237,0.05); }
.drop-zone.has-file { border-color: var(--green); background: rgba(34,197,94,0.05); }
.drop-zone img { max-width: 100%; max-height: 80px; border-radius: 6px; margin-top: 6px; }
.drop-zone svg { width: 24px; height: 24px; opacity: 0.4; margin-bottom: 4px; }
/* --- Page: Gallery --- */
.gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.gallery-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.gallery-filters select {
width: auto;
min-width: 130px;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.gallery-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.gallery-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
.gallery-card-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.2s;
}
.gallery-card:hover .gallery-card-actions {
opacity: 1;
}
.gallery-card-actions button {
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 6px;
background: rgba(0,0,0,0.7);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.gallery-card-actions button:hover {
background: var(--accent);
}
.gallery-card-actions button.delete-btn:hover {
background: #e53935;
}
.gallery-card img {
width: 100%;
aspect-ratio: 3/4;
object-fit: cover;
display: block;
}
.gallery-card-info {
padding: 10px 12px;
}
.gallery-card-info .tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 6px;
}
.tag {
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.tag-sfw { background: rgba(34, 197, 94, 0.15); color: var(--green); }
.tag-nsfw { background: rgba(239, 68, 68, 0.15); color: var(--red); }
.tag-approved { background: rgba(59, 130, 246, 0.15); color: var(--blue); }
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state svg { width: 80px; height: 80px; opacity: 0.2; margin-bottom: 16px; }
/* --- Page: Batch --- */
.batch-form {
max-width: 600px;
}
.batch-form .row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.batch-progress {
margin-top: 24px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.progress-bar-container {
width: 100%;
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
margin-top: 10px;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #ec4899);
border-radius: 4px;
transition: width 0.3s;
}
.batch-stats {
display: flex;
gap: 20px;
margin-top: 12px;
font-size: 13px;
}
.batch-stats span { color: var(--text-secondary); }
.batch-stats strong { color: var(--text-primary); }
/* --- Page: Status --- */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
}
.stat-card .stat-label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .stat-value {
font-size: 28px;
font-weight: 700;
margin-top: 4px;
}
.stat-card .stat-sub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
}
.vram-bar {
width: 100%;
height: 10px;
background: var(--bg-primary);
border-radius: 5px;
overflow: hidden;
margin-top: 8px;
}
.vram-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--green), var(--orange));
border-radius: 5px;
transition: width 0.3s;
}
/* --- Page: Training --- */
.training-layout {
display: grid;
grid-template-columns: 400px 1fr;
gap: 24px;
height: calc(100vh - 56px);
}
.training-form {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
overflow-y: auto;
}
.training-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
overflow-y: auto;
}
.training-log {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 12px;
line-height: 1.5;
max-height: 300px;
overflow-y: auto;
color: var(--text-secondary);
margin-top: 12px;
}
.job-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
}
.job-card .job-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.job-card .job-name { font-weight: 600; }
.job-status {
padding: 2px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.job-status-preparing { background: rgba(245,158,11,0.15); color: var(--orange); }
.job-status-training { background: rgba(59,130,246,0.15); color: var(--blue); }
.job-status-completed { background: rgba(34,197,94,0.15); color: var(--green); }
.job-status-failed { background: rgba(239,68,68,0.15); color: var(--red); }
.job-status-pending { background: rgba(136,136,136,0.15); color: var(--text-secondary); }
.job-logs-panel {
margin-top: 8px;
border-top: 1px solid var(--border);
padding-top: 8px;
}
.job-logs-content {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-family: monospace;
font-size: 11px;
line-height: 1.5;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
color: var(--text-secondary);
}
/* --- Lightbox --- */
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.9);
z-index: 100;
align-items: center;
justify-content: center;
padding: 40px;
}
.lightbox.open { display: flex; }
.lightbox img {
max-width: 90%;
max-height: 90vh;
border-radius: 8px;
object-fit: contain;
}
.lightbox-close {
position: absolute;
top: 16px;
right: 24px;
font-size: 32px;
color: white;
cursor: pointer;
background: none;
border: none;
opacity: 0.7;
}
.lightbox-close:hover { opacity: 1; }
.lightbox-meta {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 20px;
font-size: 12px;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--text-secondary);
min-width: 300px;
max-width: 90vw;
}
.lightbox-meta-info {
display: flex;
gap: 20px;
flex-wrap: wrap;
justify-content: center;
}
.lightbox-meta-actions {
display: flex;
gap: 8px;
justify-content: center;
border-top: 1px solid var(--border);
padding-top: 10px;
}
.lightbox-meta strong { color: var(--text-primary); }
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(26,26,40,0.8);
border: 1px solid var(--border);
color: white;
font-size: 24px;
width: 44px;
height: 44px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
transition: opacity 0.15s;
}
.lightbox-nav:hover { opacity: 1; }
.lightbox-nav.prev { left: 16px; }
.lightbox-nav.next { right: 16px; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #444; }
/* Toast notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 18px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
animation: slideIn 0.3s ease;
max-width: 350px;
}
.toast-success { border-left: 3px solid var(--green); }
.toast-error { border-left: 3px solid var(--red); }
.toast-info { border-left: 3px solid var(--blue); }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
/* Confirm dialog */
.confirm-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
z-index: 300;
align-items: center;
justify-content: center;
}
.confirm-overlay.open { display: flex; }
.confirm-dialog {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
max-width: 400px;
text-align: center;
}
.confirm-dialog p { margin-bottom: 16px; font-size: 14px; }
.confirm-dialog .confirm-actions { display: flex; gap: 10px; justify-content: center; }
.btn-danger { background: var(--red); color: white; }
.btn-danger:hover { background: #dc2626; }
/* Caption editor for training images */
.caption-editor { margin-top: 12px; max-height: 400px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
.caption-item {
display: flex; gap: 10px; align-items: flex-start;
padding: 8px; background: var(--bg-tertiary); border-radius: 8px; border: 1px solid var(--border);
}
.caption-item img { width: 64px; height: 64px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.caption-item .caption-fields { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.caption-item .caption-filename { font-size: 11px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.caption-item textarea {
width: 100%; min-height: 42px; resize: vertical; font-size: 12px;
background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px;
color: var(--text-primary); padding: 6px 8px; font-family: inherit;
}
.caption-item textarea:focus { border-color: var(--accent); outline: none; }
.caption-item .btn-remove {
flex-shrink: 0; width: 24px; height: 24px; border-radius: 50%; border: none;
background: rgba(239,68,68,0.15); color: var(--red); cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 14px; line-height: 1;
}
.caption-item .btn-remove:hover { background: rgba(239,68,68,0.3); }
.caption-toolbar { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
.caption-toolbar .btn-small { font-size: 11px; padding: 4px 10px; }
</style>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<h1>Content Engine</h1>
<div class="subtitle">v0.1.0</div>
</div>
<nav class="nav">
<div class="nav-item active" data-page="generate" onclick="showPage('generate')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
Generate
</div>
<div class="nav-item" data-page="batch" onclick="showPage('batch')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Batch Generate
</div>
<div class="nav-item" data-page="gallery" onclick="showPage('gallery')">
<svg 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 15-5-5L5 21"/></svg>
Gallery
</div>
<div class="nav-separator"></div>
<div class="nav-item" data-page="training" onclick="showPage('training')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Train LoRA
</div>
<div class="nav-separator"></div>
<div class="nav-item" data-page="status" onclick="showPage('status')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
System Status
</div>
<div class="nav-item" data-page="settings" onclick="showPage('settings')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</div>
</nav>
<div class="status-bar">
<div><span class="status-dot" id="comfyui-dot"></span>ComfyUI: <span id="comfyui-status-text">checking...</span></div>
<div style="margin-top:6px"><span class="status-dot" id="engine-dot"></span>Engine: <span id="engine-status-text">running</span></div>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- PAGE: Generate -->
<div id="page-generate" class="page">
<div class="generate-layout">
<div class="controls-panel">
<div class="section-title">Mode</div>
<div class="chips" id="mode-chips">
<div class="chip selected" onclick="selectMode(this, 'txt2img')">Text to Image</div>
<div class="chip" onclick="selectMode(this, 'img2img')">Image to Image</div>
<div class="chip" onclick="selectMode(this, 'img2video')">Image to Video</div>
</div>
<div id="backend-section">
<div class="section-title">Backend</div>
<div class="chips" id="backend-chips">
<div class="chip" onclick="selectBackend(this, 'local')">Local GPU</div>
<div class="chip selected" onclick="selectBackend(this, 'pod')">RunPod GPU</div>
<div class="chip" onclick="selectBackend(this, 'cloud')">Cloud API</div>
</div>
</div>
<div id="cloud-model-select" style="display:none">
<label>Model</label>
<select id="gen-cloud-model" onchange="updateCloudLoraVisibility()">
<optgroup label="Recommended">
<option value="seedream-4.5" selected>SeeDream v4.5 (Best)</option>
<option value="gpt-image-1.5">GPT Image 1.5</option>
<option value="nano-banana-pro">NanoBanana Pro</option>
</optgroup>
<optgroup label="NSFW Friendly">
<option value="seedream-4">SeeDream v4</option>
<option value="seedream-3.1">SeeDream v3.1</option>
</optgroup>
<optgroup label="Fast">
<option value="z-image-turbo">Z-Image Turbo (Fastest)</option>
<option value="z-image-turbo-lora">Z-Image Turbo + LoRA</option>
<option value="gpt-image-1-mini">GPT Image Mini</option>
<option value="nano-banana">NanoBanana</option>
</optgroup>
<optgroup label="LoRA Support">
<option value="z-image-base-lora">Z-Image Base + LoRA ($0.012)</option>
</optgroup>
<optgroup label="Other">
<option value="kling-image-o3">Kling Image O3</option>
<option value="wan-2.6">WAN 2.6</option>
<option value="wan-2.5">WAN 2.5</option>
<option value="qwen-image">Qwen Image</option>
<option value="dreamina-3.1">Dreamina v3.1</option>
</optgroup>
</select>
</div>
<div id="cloud-lora-input" style="display:none">
<label>LoRA Path <span style="color:var(--text-secondary);font-weight:400">(HuggingFace repo or URL)</span></label>
<input type="text" id="cloud-lora-path" placeholder="e.g. username/my-character-lora"
style="width:100%;padding:8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);font-size:13px;box-sizing:border-box">
<div style="display:flex;align-items:center;gap:8px;margin-top:6px">
<label style="margin:0;flex-shrink:0">Strength</label>
<input type="range" id="cloud-lora-strength" min="0" max="2" step="0.05" value="1"
oninput="this.nextElementSibling.textContent=this.value"
style="flex:1">
<span style="font-size:12px;min-width:28px">1</span>
</div>
</div>
<div id="cloud-edit-model-select" style="display:none">
<label>Model</label>
<select id="gen-cloud-edit-model">
<optgroup label="Recommended">
<option value="seedream-4.5-edit" selected>SeeDream v4.5 Edit (Best)</option>
<option value="higgsfield-soul">Higgsfield Soul (Faces)</option>
<option value="gpt-image-1.5-edit">GPT Image 1.5 Edit</option>
</optgroup>
<optgroup label="Multi-Reference (2+ images)">
<option value="seedream-4.5-multi">SeeDream v4.5 Sequential (up to 3)</option>
<option value="seedream-4-multi">SeeDream v4 Sequential (up to 3)</option>
<option value="nano-banana-pro-multi">NanoBanana Pro (2 refs)</option>
<option value="kling-o1-multi">Kling O1 (up to 10 refs)</option>
<option value="qwen-multi-angle">Qwen Multi-Angle</option>
</optgroup>
<optgroup label="NSFW Friendly">
<option value="seedream-4-edit">SeeDream v4 Edit</option>
<option value="wan-2.6-edit">WAN 2.6 Edit</option>
</optgroup>
<optgroup label="Fast">
<option value="gpt-image-1-mini-edit">GPT Image Mini Edit</option>
<option value="nano-banana-edit">NanoBanana Edit</option>
</optgroup>
<optgroup label="Other">
<option value="wan-2.5-edit">WAN 2.5 Edit</option>
<option value="wan-2.2-edit">WAN 2.2 Edit</option>
<option value="qwen-edit-lora">Qwen Edit + LoRA</option>
<option value="kling-o3-edit">Kling O3 Edit</option>
<option value="dreamina-3-edit">Dreamina v3 Edit</option>
</optgroup>
</select>
<div style="font-size:11px;color:var(--text-secondary);margin-top:4px">
Single-ref models use character image. Multi-ref models combine both images for consistency.
</div>
</div>
<!-- RunPod Pod settings -->
<div id="pod-settings-section" style="display:none">
<div id="pod-status-inline" style="padding:8px 12px; background:var(--bg-primary); border-radius:6px; margin-bottom:12px; font-size:13px">
<span id="pod-status-indicator">Checking pod status...</span>
</div>
<label>Base Model</label>
<select id="pod-model-select" onchange="updateVisibility()">
<option value="z_image">Z-Image Turbo (+ LoRA)</option>
<option value="flux">FLUX.2 Dev (Realistic)</option>
<option value="wan22">WAN 2.2 T2V (txt2img + LoRA)</option>
</select>
<label style="margin-top:8px">LoRA 1 <span style="color:var(--text-secondary);font-weight:400">(body)</span></label>
<select id="pod-lora-select">
<option value="">None (Base model only)</option>
</select>
<label style="margin-top:6px">Strength</label>
<input type="number" id="pod-lora-strength" value="0.85" min="0" max="1.5" step="0.05" style="width:80px">
<label style="margin-top:8px">LoRA 2 <span style="color:var(--text-secondary);font-weight:400">(face)</span></label>
<select id="pod-lora-select-2">
<option value="">None</option>
</select>
<label style="margin-top:6px">Strength</label>
<input type="number" id="pod-lora-strength-2" value="0.85" min="0" max="1.5" step="0.05" style="width:80px">
<div style="font-size:11px;color:var(--text-secondary);margin-top:4px">
Start the pod in Status page first.
</div>
</div>
<!-- Image to Video settings -->
<div id="img2video-section" style="display:none">
<!-- Sub-mode: Image to Video vs Animate -->
<div class="chips" id="video-submode-chips" style="margin-bottom:10px">
<div class="chip selected" onclick="selectVideoSubMode(this,'i2v')">Image to Video</div>
<div class="chip" onclick="selectVideoSubMode(this,'animate')">Animate (Dance)</div>
<div class="chip" onclick="selectVideoSubMode(this,'kling-motion')">Kling Motion</div>
</div>
<!-- Standard Image-to-Video -->
<div id="i2v-sub-section">
<div class="section-title">Source Image</div>
<div class="drop-zone" id="video-drop-zone" onclick="document.getElementById('video-file-input').click()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div>Drop or click to upload</div>
</div>
<input type="file" id="video-file-input" accept="image/*" style="display:none" onchange="handleVideoImage(this)">
<div id="video-preview" style="display:none; margin-top:6px">
<img id="video-preview-img" style="max-width:100%; max-height:100px; border-radius:6px">
</div>
<label style="margin-top:12px">Video Model</label>
<select id="video-cloud-model">
<optgroup label="Recommended">
<option value="wan-2.6-i2v-pro" selected>WAN 2.6 Pro ($0.05/s)</option>
<option value="wan-2.6-i2v-flash">WAN 2.6 Flash (Fast)</option>
<option value="kling-o3-pro">Kling O3 Pro</option>
</optgroup>
<optgroup label="Premium (Higgsfield - requires API key)">
<option value="kling-3.0-pro">Kling 3.0 Pro (15s + Audio)</option>
<option value="kling-3.0">Kling 3.0</option>
<option value="sora-2-hf">Sora 2</option>
<option value="veo-3.1-hf">Veo 3.1</option>
</optgroup>
<optgroup label="Budget Friendly">
<option value="wan-2.2-i2v-720p">WAN 2.2 720p ($0.01/s)</option>
<option value="wan-2.2-i2v-1080p">WAN 2.2 1080p</option>
<option value="wan-2.5-i2v">WAN 2.5</option>
</optgroup>
<optgroup label="Cinematic">
<option value="higgsfield-dop">Higgsfield DoP (5s)</option>
<option value="seedance-1.5-pro">Seedance Pro</option>
<option value="dreamina-i2v-1080p">Dreamina 1080p</option>
</optgroup>
<optgroup label="Other">
<option value="kling-o3">Kling O3</option>
<option value="grok-imagine-i2v">Grok Imagine Video (xAI)</option>
<option value="veo-3.1">Veo 3.1 (WaveSpeed)</option>
<option value="sora-2">Sora 2 (WaveSpeed)</option>
<option value="vidu-q3">Vidu Q3</option>
</optgroup>
</select>
<label>Duration</label>
<select id="video-duration">
<option value="41">2s</option>
<option value="81" selected>3s</option>
<option value="121">5s</option>
<option value="241">10s</option>
<option value="361">15s</option>
</select>
</div>
<!-- Animate (Dance) sub-section -->
<div id="animate-sub-section" style="display:none">
<div class="section-title">Character Image</div>
<div class="drop-zone" id="animate-char-zone" onclick="document.getElementById('animate-char-input').click()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div>Character photo</div>
</div>
<input type="file" id="animate-char-input" accept="image/*" style="display:none" onchange="handleAnimateChar(this)">
<div class="section-title" style="margin-top:10px">Driving Video</div>
<div class="drop-zone" id="animate-video-zone" onclick="document.getElementById('animate-video-input').click()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2"/><polygon points="10,8 16,12 10,16"/></svg>
<div>Dance video (mp4)</div>
</div>
<input type="file" id="animate-video-input" accept="video/*" style="display:none" onchange="handleAnimateVideo(this)">
<label style="margin-top:10px">Resolution</label>
<select id="animate-resolution">
<option value="480x832">480×832 (portrait)</option>
<option value="720x1280" selected>720×1280 (HD portrait)</option>
<option value="1080x1920">1080×1920 (TikTok full HD ⚡ high VRAM)</option>
<option value="832x480">832×480 (landscape)</option>
<option value="1280x720">1280×720 (HD landscape)</option>
<option value="512x512">512×512 (square)</option>
</select>
<label>Background</label>
<select id="animate-bg-mode">
<option value="auto" selected>Auto (model decides)</option>
<option value="driving_video">From driving video</option>
<option value="keep">Keep (character image bg)</option>
</select>
<label>Frames</label>
<select id="animate-frames">
<option value="0">Match video (auto)</option>
<option value="25">25 (~1.5s)</option>
<option value="49">49 (~3s)</option>
<option value="81" selected>81 (~5s)</option>
<option value="121">121 (~7.5s)</option>
<option value="161">161 (~10s)</option>
<option value="201">201 (~12.5s)</option>
<option value="241">241 (~15s)</option>
<option value="289">289 (~18s)</option>
<option value="321">321 (~20s)</option>
<option value="385">385 (~24s)</option>
<option value="481">481 (~30s)</option>
</select>
<div style="font-size:11px;color:var(--text-secondary);margin-top:6px">
Runs on RunPod pod via WAN 2.2 Animate. Pod must be running with models installed.
</div>
</div>
<!-- Kling Motion Control sub-section -->
<div id="kling-motion-sub-section" style="display:none">
<div class="section-title">Character Image</div>
<div class="drop-zone" id="kling-motion-char-zone" onclick="document.getElementById('kling-motion-char-input').click()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div>Character photo</div>
</div>
<input type="file" id="kling-motion-char-input" accept="image/*" style="display:none" onchange="handleKlingMotionChar(this)">
<div class="section-title" style="margin-top:10px">Driving Video</div>
<div class="drop-zone" id="kling-motion-video-zone" onclick="document.getElementById('kling-motion-video-input').click()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2"/><polygon points="10,8 16,12 10,16"/></svg>
<div>Motion reference video (mp4)</div>
</div>
<input type="file" id="kling-motion-video-input" accept="video/*" style="display:none" onchange="handleKlingMotionVideo(this)">
<label style="margin-top:10px">Orientation</label>
<select id="kling-motion-orientation">
<option value="image" selected>Match image framing</option>
<option value="video">Match video framing (up to 30s)</option>
</select>
<label style="margin-top:10px">Duration</label>
<select id="kling-motion-duration">
<option value="5" selected>5s (~$0.56)</option>
<option value="10">10s (~$1.12)</option>
</select>
<div style="font-size:11px;color:var(--text-secondary);margin-top:6px">
Kling Motion Control via WaveSpeed. ~1 min generation. Requires WAVESPEED_API_KEY.
</div>
</div>
</div>
<!-- Reference image upload for img2img -->
<div id="img2img-section" style="display:none">
<div class="section-title">Reference Images</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<div class="drop-zone" id="ref-drop-zone" onclick="document.getElementById('ref-file-input').click()" style="min-height:100px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div>Character</div>
</div>
<input type="file" id="ref-file-input" accept="image/*" style="display:none" onchange="handleRefImage(this)">
</div>
<div>
<div class="drop-zone" id="pose-drop-zone" onclick="document.getElementById('pose-file-input').click()" style="min-height:100px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div>Pose <span style="color:var(--text-secondary)">(opt)</span></div>
</div>
<input type="file" id="pose-file-input" accept="image/*" style="display:none" onchange="handlePoseImage(this)">
</div>
</div>
<label style="margin-top:10px">Denoise (0=keep, 1=ignore ref)</label>
<div class="slider-row">
<input type="range" id="gen-denoise" min="0" max="1" step="0.05" value="0.65" oninput="this.nextElementSibling.textContent=this.value">
<span class="value">0.65</span>
</div>
</div>
<div id="local-template-select">
<div class="section-title">Character & Template</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<label style="margin-top:0">Character</label>
<select id="gen-character">
<option value="">None</option>
</select>
</div>
<div>
<label style="margin-top:0">Template</label>
<select id="gen-template" onchange="loadTemplateVariables()">
<option value="">None</option>
</select>
</div>
</div>
<label>Rating</label>
<div class="chips" id="content-rating-chips">
<div class="chip selected" onclick="selectRating(this, 'sfw')">SFW</div>
<div class="chip" onclick="selectRating(this, 'nsfw')">NSFW</div>
</div>
<div id="template-variables"></div>
</div>
<div class="section-title">Prompt</div>
<label style="margin-top:0">Positive</label>
<textarea id="gen-positive" placeholder="masterpiece, best quality, photorealistic..." rows="3"></textarea>
<label>Negative</label>
<textarea id="gen-negative" placeholder="worst quality, blurry, deformed..." rows="2"></textarea>
<div id="local-settings-section">
<div class="section-title">Output Settings</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
<div>
<label style="margin-top:0">Aspect</label>
<select id="gen-aspect" onchange="updateDimensions()">
<option value="9:16" selected>9:16</option>
<option value="2:3">2:3</option>
<option value="1:1">1:1</option>
<option value="3:2">3:2</option>
<option value="16:9">16:9</option>
</select>
</div>
<div>
<label style="margin-top:0">Seed</label>
<input type="number" id="gen-seed" value="-1" placeholder="-1 = random">
</div>
</div>
<input type="hidden" id="gen-width" value="832">
<input type="hidden" id="gen-height" value="1216">
<details style="margin-top:10px">
<summary style="cursor:pointer;color:var(--text-secondary);font-size:11px">Advanced</summary>
<div style="padding-top:6px">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
<div>
<label style="margin-top:0">Steps</label>
<input type="number" id="gen-steps" value="28">
</div>
<div>
<label>CFG Scale</label>
<input type="number" id="gen-cfg" value="7" min="1" max="20" step="0.5">
</div>
</div>
</div>
</details>
</div>
<button class="btn btn-primary" id="generate-btn" onclick="doGenerate()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px"><path d="M12 5v14M5 12h14"/></svg>
Generate Image
</button>
</div>
<div class="preview-panel">
<div class="preview-header">
<span>Preview</span>
<span id="gen-time" style="font-size:12px; color:var(--text-secondary)"></span>
</div>
<div class="preview-body" id="preview-body">
<div class="preview-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
<p>Generated images will appear here</p>
<p style="font-size:12px; margin-top:4px">Write a prompt and click Generate</p>
</div>
</div>
<!-- API Log Panel -->
<div class="api-log-panel">
<div class="api-log-header" onclick="toggleApiLog()">
<span>API Log</span>
<svg id="api-log-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;transition:transform 0.2s"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div id="api-log-content" class="api-log-content" style="display:none">
<div id="api-log-entries"></div>
<button onclick="clearApiLog()" style="margin-top:8px;padding:4px 8px;font-size:11px;background:var(--bg-hover);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer">Clear Log</button>
</div>
</div>
</div>
</div>
</div>
<!-- PAGE: Batch -->
<div id="page-batch" class="page" style="display:none">
<h2 style="margin-bottom:20px">Batch Generate</h2>
<div class="batch-form">
<label>Character</label>
<select id="batch-character"></select>
<label>Template</label>
<select id="batch-template"></select>
<div class="section-title">Batch Settings</div>
<div class="row">
<div>
<label>Number of Images</label>
<input type="number" id="batch-count" value="10" min="1" max="100">
</div>
<div>
<label>Variation Mode</label>
<select id="batch-mode">
<option value="random">Random</option>
<option value="exhaustive">Exhaustive</option>
</select>
</div>
</div>
<div class="row">
<div>
<label>Content Rating</label>
<select id="batch-rating">
<option value="sfw">SFW</option>
<option value="nsfw">NSFW</option>
</select>
</div>
<div>
<label>Seed Strategy</label>
<select id="batch-seed-strategy">
<option value="random">Random</option>
<option value="sequential">Sequential</option>
<option value="fixed">Fixed</option>
</select>
</div>
</div>
<button class="btn btn-primary" id="batch-btn" onclick="doBatch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Start Batch
</button>
<div class="batch-progress" id="batch-progress" style="display:none">
<div style="font-weight:600">Batch Progress</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="batch-bar" style="width:0%"></div>
</div>
<div class="batch-stats">
<span>Completed: <strong id="batch-completed">0</strong></span>
<span>Failed: <strong id="batch-failed">0</strong></span>
<span>Pending: <strong id="batch-pending">0</strong></span>
<span>Total: <strong id="batch-total">0</strong></span>
</div>
</div>
</div>
</div>
<!-- PAGE: Gallery -->
<div id="page-gallery" class="page" style="display:none">
<div class="gallery-header">
<h2>Gallery <span id="gallery-count" style="font-size:14px;font-weight:400;color:var(--text-secondary)"></span></h2>
<div class="gallery-filters">
<select id="gal-character" onchange="loadGallery()">
<option value="">All Characters</option>
</select>
<select id="gal-rating" onchange="loadGallery()">
<option value="">All Ratings</option>
<option value="sfw">SFW</option>
<option value="nsfw">NSFW</option>
</select>
<select id="gal-approved" onchange="loadGallery()">
<option value="">All Status</option>
<option value="true">Approved</option>
<option value="false">Pending Review</option>
</select>
<button class="btn btn-secondary btn-small" onclick="loadGallery()">Refresh</button>
</div>
</div>
<div class="gallery-grid" id="gallery-grid"></div>
<div id="gallery-load-more" style="display:none;text-align:center;margin-top:20px">
<button class="btn btn-secondary" onclick="loadMoreGallery()">Load More</button>
</div>
</div>
<!-- PAGE: Training -->
<div id="page-training" class="page" style="display:none">
<h2 style="margin-bottom:20px">Train LoRA Model</h2>
<div class="training-layout">
<div class="training-form">
<div class="section-title" style="margin-top:0">Training Backend</div>
<div class="chips" id="train-backend-chips">
<div class="chip selected" onclick="selectTrainBackend(this, 'local')">Local GPU</div>
<div class="chip" onclick="selectTrainBackend(this, 'runpod')">Cloud (RunPod)</div>
</div>
<div id="runpod-info" style="display:none;margin-top:8px;padding:12px;background:rgba(59,130,246,0.08);border:1px solid var(--blue);border-radius:8px;font-size:12px;color:var(--text-secondary)">
<div style="font-weight:600;color:var(--blue);margin-bottom:4px">Cloud Training</div>
Trains on a rented RunPod GPU. No local GPU needed. Costs ~$0.30-0.50/hr. Pod is auto-terminated when done.
<div style="margin-top:8px">
<label style="margin:0">GPU Type</label>
<select id="train-gpu-type" style="margin-top:4px">
<optgroup label="48GB+ (Required for FLUX.2 Dev)">
<option value="NVIDIA A40">A40 48GB (~$0.64/hr) - Cheapest for FLUX.2</option>
<option value="NVIDIA RTX A6000" selected>RTX A6000 48GB (~$0.76/hr) - Recommended</option>
<option value="NVIDIA L40">L40 48GB (~$0.89/hr)</option>
<option value="NVIDIA L40S">L40S 48GB (~$1.09/hr)</option>
<option value="NVIDIA A100-SXM4-80GB">A100 SXM 80GB (~$1.64/hr)</option>
<option value="NVIDIA A100 80GB PCIe">A100 PCIe 80GB (~$1.89/hr)</option>
<option value="NVIDIA H100 80GB HBM3">H100 80GB (~$3.89/hr) - Fastest</option>
</optgroup>
<optgroup label="24-32GB (SD 1.5, SDXL, FLUX.1 only)">
<option value="NVIDIA GeForce RTX 5090">RTX 5090 32GB (~$0.69/hr)</option>
<option value="NVIDIA GeForce RTX 4090">RTX 4090 24GB (~$0.44/hr)</option>
<option value="NVIDIA GeForce RTX 3090">RTX 3090 24GB (~$0.22/hr)</option>
<option value="NVIDIA RTX A5000">RTX A5000 24GB (~$0.28/hr)</option>
<option value="NVIDIA RTX A4000">RTX A4000 16GB (~$0.20/hr)</option>
</optgroup>
</select>
</div>
<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(59,130,246,0.2)">
<button class="btn btn-secondary btn-small" onclick="preDownloadModels()" id="btn-predownload">Pre-download models to volume</button>
<span id="predownload-status" style="font-size:11px;margin-left:8px;color:var(--text-secondary)"></span>
</div>
</div>
<div id="runpod-not-configured" style="display:none;margin-top:8px;padding:12px;background:rgba(239,68,68,0.08);border:1px solid var(--red);border-radius:8px;font-size:12px;color:var(--text-secondary)">
<div style="font-weight:600;color:var(--red);margin-bottom:4px">RunPod Not Configured</div>
Add your RunPod API key to <code>.env</code> file: <code>RUNPOD_API_KEY=your_key_here</code><br>
Get your key at <strong>runpod.io/console/user/settings</strong>
</div>
<div id="training-install-banner" style="display:none; padding:16px; background:rgba(245,158,11,0.1); border:1px solid var(--orange); border-radius:8px; margin-bottom:16px;">
<div style="font-weight:600; color:var(--orange); margin-bottom:6px">Setup Required</div>
<div style="font-size:13px; color:var(--text-secondary); margin-bottom:10px">Kohya sd-scripts needs to be installed for LoRA training.</div>
<button class="btn btn-secondary btn-small" onclick="installSdScripts()">Install sd-scripts</button>
</div>
<div class="section-title">Training Images</div>
<div class="drop-zone" id="train-drop-zone" onclick="document.getElementById('train-file-input').click()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:32px;height:32px;opacity:0.5;margin-bottom:8px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div>Drop images here or click to browse</div>
<div style="font-size:11px;margin-top:4px">Upload 20-50 images of the subject (min 5)</div>
</div>
<input type="file" id="train-file-input" accept="image/*,.txt" multiple style="display:none" onchange="handleTrainImages(this)">
<div id="train-image-count" style="font-size:12px;color:var(--text-secondary);margin-top:6px"></div>
<!-- Caption editor: shown after images are uploaded -->
<div id="caption-editor-section" style="display:none">
<div class="caption-toolbar">
<span style="font-size:12px;color:var(--text-secondary)">Captions per image (improves LoRA quality)</span>
<button class="btn btn-secondary btn-small" onclick="autoCaptionAll()">Auto-fill from trigger word</button>
<button class="btn btn-secondary btn-small" onclick="toggleBulkCaptions()">Bulk Paste</button>
<button class="btn btn-secondary btn-small" onclick="clearTrainImages()">Clear all</button>
</div>
<div id="bulk-caption-area" style="display:none;margin:8px 0">
<textarea id="bulk-caption-text" style="width:100%;height:200px;font-size:12px;padding:8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);resize:vertical;font-family:inherit" placeholder="Paste numbered captions, one per line. They map to images in order.&#10;&#10;1. ohwx woman, portrait, natural lighting, smiling&#10;2. ohwx woman, full body, outdoor, park&#10;3. ohwx woman, close-up, studio lighting&#10;..."></textarea>
<div style="display:flex;gap:8px;margin-top:6px">
<button class="btn btn-primary btn-small" onclick="applyBulkCaptions()">Apply to images</button>
<button class="btn btn-secondary btn-small" onclick="toggleBulkCaptions()">Cancel</button>
<span style="font-size:11px;color:var(--text-secondary);align-self:center">Accepts: "1. text", "1) text", or plain lines</span>
</div>
</div>
<div id="caption-editor" class="caption-editor"></div>
</div>
<div class="section-title">Model Config</div>
<label>Model Name</label>
<input type="text" id="train-name" placeholder="my_character_v1">
<label>Trigger Word</label>
<input type="text" id="train-trigger" placeholder="ohwx, sks, etc.">
<label>Base Model</label>
<select id="train-base-model" onchange="updateModelDefaults()">
<option value="flux_dev">Loading models...</option>
</select>
<div id="model-info" style="font-size:11px;color:var(--text-secondary);margin-top:4px;padding:6px;background:var(--bg-primary);border-radius:4px">
<span id="model-description">Select a model to see recommended settings</span>
</div>
<div class="section-title">Training Settings</div>
<div class="row" style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<label>Max Steps</label>
<input type="number" id="train-max-steps" value="1500" min="50" max="10000" step="100">
<div style="font-size:10px;color:var(--text-secondary);margin-top:2px">1500-2000 recommended</div>
</div>
<div>
<label>Network Rank (dim)</label>
<select id="train-rank">
<option value="8">8 (Small, fast)</option>
<option value="16">16</option>
<option value="32" selected>32 (Recommended)</option>
<option value="64">64 (High quality)</option>
<option value="128">128 (Max detail)</option>
</select>
</div>
</div>
<div class="row" style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<label>Learning Rate <span id="lr-default" style="font-size:10px;color:var(--accent)"></span></label>
<input type="text" id="train-lr" placeholder="Use model default">
</div>
<div>
<label>Optimizer</label>
<select id="train-optimizer">
<option value="AdamW8bit">AdamW 8bit</option>
<option value="Lion">Lion</option>
<option value="Prodigy">Prodigy</option>
</select>
</div>
</div>
<div class="row" style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<label>Resolution</label>
<select id="train-resolution">
<option value="512" selected>512 (SD 1.5)</option>
<option value="768">768</option>
<option value="1024">1024 (SDXL)</option>
</select>
</div>
<div>
<label>Save Every N Steps</label>
<input type="number" id="train-save-every" value="500" min="50" step="50">
</div>
</div>
<button class="btn btn-primary" id="train-btn" onclick="startTraining()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Start Training
</button>
</div>
<div class="training-panel">
<div class="section-title" style="margin-top:0">Training Jobs</div>
<div id="training-jobs">
<div class="empty-state" style="padding:30px">
<p>No training jobs yet</p>
<p style="font-size:12px;margin-top:4px">Upload images and configure settings to start training</p>
</div>
</div>
</div>
</div>
</div>
<!-- PAGE: Status -->
<div id="page-status" class="page" style="display:none">
<h2 style="margin-bottom:20px">System Status</h2>
<div class="status-grid" id="status-grid"></div>
<!-- RunPod GPU Pod Controls -->
<h3 style="margin:20px 0 12px">RunPod GPU Pod</h3>
<div class="stat-card" style="margin-bottom:16px">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px">
<div>
<div class="stat-label">GPU Pod Status</div>
<div id="pod-status-text" style="font-size:14px; margin-top:4px">
<span style="color:var(--text-secondary)">Checking...</span>
</div>
</div>
<div id="pod-controls" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap">
<select id="pod-model-type" style="padding:8px 12px; border-radius:6px; background:var(--bg-primary); border:1px solid var(--border); color:var(--text-primary)">
<option value="z_image">Z-Image Turbo (txt2img + LoRA)</option>
<option value="flux2">FLUX.2 Dev (Realistic txt2img)</option>
<option value="flux1">FLUX.1 Dev (txt2img)</option>
<option value="wan22">WAN 2.2 T2V (txt2img + LoRA)</option>
<option value="wan22_i2v">WAN 2.2 I2V (img2video)</option>
<option value="wan22_animate">WAN 2.2 Animate (Dance/Motion transfer)</option>
</select>
<select id="pod-gpu-select" style="padding:8px 12px; border-radius:6px; background:var(--bg-primary); border:1px solid var(--border); color:var(--text-primary)">
<optgroup label="48GB+ (FLUX.2 / Large models)">
<option value="NVIDIA A40">A40 48GB - $0.64/hr</option>
<option value="NVIDIA RTX A6000" selected>A6000 48GB - $0.76/hr</option>
<option value="NVIDIA L40">L40 48GB - $0.89/hr</option>
<option value="NVIDIA L40S">L40S 48GB - $1.09/hr</option>
<option value="NVIDIA A100-SXM4-80GB">A100 SXM 80GB - $1.64/hr</option>
<option value="NVIDIA A100 80GB PCIe">A100 PCIe 80GB - $1.89/hr</option>
<option value="NVIDIA H100 80GB HBM3">H100 80GB - $3.89/hr</option>
</optgroup>
<optgroup label="24-32GB (SD 1.5 / SDXL / FLUX.1)">
<option value="NVIDIA GeForce RTX 5090">RTX 5090 32GB - $0.69/hr</option>
<option value="NVIDIA GeForce RTX 4090">RTX 4090 24GB - $0.44/hr</option>
<option value="NVIDIA GeForce RTX 3090">RTX 3090 24GB - $0.22/hr</option>
<option value="NVIDIA RTX A5000">A5000 24GB - $0.28/hr</option>
<option value="NVIDIA RTX A4000">A4000 16GB - $0.20/hr</option>
</optgroup>
</select>
<button id="pod-start-btn" class="btn" onclick="startPod()">Start Pod</button>
<button id="pod-stop-btn" class="btn btn-danger" onclick="stopPod()" style="display:none">Stop Pod</button>
</div>
</div>
<div id="pod-info" style="display:none; padding:12px; background:var(--bg-primary); border-radius:6px; font-size:13px">
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:12px">
<div>
<span style="color:var(--text-secondary)">ComfyUI:</span>
<a id="pod-comfyui-link" href="#" target="_blank" style="color:var(--accent); margin-left:4px">Open</a>
</div>
<div>
<span style="color:var(--text-secondary)">Uptime:</span>
<span id="pod-uptime" style="margin-left:4px">0 min</span>
</div>
<div>
<span style="color:var(--text-secondary)">Cost:</span>
<span id="pod-cost" style="margin-left:4px">$0.00</span>
</div>
</div>
</div>
<div style="margin-top:8px; font-size:12px; color:var(--text-secondary)">
Start a GPU pod for image generation and LoRA training. Stop when done to save costs.
</div>
</div>
<h3 style="margin:20px 0 12px">Available Models</h3>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<div class="stat-card">
<div class="stat-label">Checkpoints</div>
<div id="checkpoint-list" style="margin-top:8px; font-size:13px; color:var(--text-secondary)">Loading...</div>
</div>
<div class="stat-card">
<div class="stat-label">LoRA Models</div>
<div id="lora-list" style="margin-top:8px; font-size:13px; color:var(--text-secondary)">Loading...</div>
</div>
</div>
<h3 style="margin:20px 0 12px">Templates</h3>
<div id="template-list-status"></div>
</div>
<!-- PAGE: Settings -->
<div id="page-settings" class="page" style="display:none">
<h2 style="margin-bottom:20px">Settings</h2>
<!-- API Keys Section -->
<div class="stat-card" style="margin-bottom:20px">
<h3 style="margin-bottom:16px">API Keys</h3>
<div id="api-settings-status" style="font-size:13px; color:var(--text-secondary); margin-bottom:16px">
Loading API settings...
</div>
<div id="api-keys-form">
<div style="margin-bottom:16px">
<label style="font-weight:600; margin-bottom:6px; display:block">RunPod API Key</label>
<div style="display:flex; gap:8px; align-items:center">
<input type="password" id="runpod-key-input" placeholder="Enter RunPod API key" style="flex:1; padding:10px 12px; border-radius:6px; background:var(--bg-primary); border:1px solid var(--border); color:var(--text-primary)">
<button type="button" class="btn btn-secondary" onclick="toggleKeyVisibility('runpod-key-input', this)">Show</button>
</div>
<div style="font-size:11px; color:var(--text-secondary); margin-top:4px">
Get your key from <a href="https://www.runpod.io/console/user/settings" target="_blank" style="color:var(--accent)">RunPod Console</a>
</div>
<div id="runpod-key-status" style="font-size:12px; margin-top:4px"></div>
</div>
<div style="margin-bottom:16px">
<label style="font-weight:600; margin-bottom:6px; display:block">WaveSpeed API Key (Optional)</label>
<div style="display:flex; gap:8px; align-items:center">
<input type="password" id="wavespeed-key-input" placeholder="Enter WaveSpeed API key" style="flex:1; padding:10px 12px; border-radius:6px; background:var(--bg-primary); border:1px solid var(--border); color:var(--text-primary)">
<button type="button" class="btn btn-secondary" onclick="toggleKeyVisibility('wavespeed-key-input', this)">Show</button>
</div>
<div style="font-size:11px; color:var(--text-secondary); margin-top:4px">
For cloud-based image generation (NanoBanana, SeeDream models)
</div>
<div id="wavespeed-key-status" style="font-size:12px; margin-top:4px"></div>
</div>
<div id="api-keys-actions" style="display:flex; gap:8px; margin-top:20px">
<button class="btn" onclick="saveAPIKeys()">Save API Keys</button>
</div>
<div id="cloud-mode-warning" style="display:none; margin-top:12px; padding:12px; background:rgba(245,158,11,0.1); border:1px solid var(--orange); border-radius:8px; font-size:13px">
<strong style="color:var(--orange)">Running on Hugging Face Spaces</strong><br>
API keys must be set via your Space's Settings > Variables and secrets panel.
<a href="#" style="color:var(--accent); margin-left:4px" onclick="window.open('https://huggingface.co/settings/spaces', '_blank')">Open HF Settings</a>
</div>
</div>
</div>
<!-- Info Section -->
<div class="stat-card">
<h3 style="margin-bottom:12px">About</h3>
<div style="font-size:13px; color:var(--text-secondary); line-height:1.6">
<p><strong>Content Engine</strong> - AI Image & Video Generation</p>
<p style="margin-top:8px">
Powered by RunPod GPU pods with FLUX.2 and WAN 2.2 models.
</p>
<div style="margin-top:12px; display:grid; grid-template-columns:1fr 1fr; gap:8px; font-size:12px">
<div><span style="color:var(--text-secondary)">Version:</span> 1.0.0</div>
<div><span style="color:var(--text-secondary)">Backend:</span> FastAPI</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Lightbox -->
<div class="lightbox" id="lightbox" onclick="if(event.target===this)closeLightbox()">
<button class="lightbox-close" onclick="closeLightbox()">&times;</button>
<button class="lightbox-nav prev" id="lightbox-prev" onclick="event.stopPropagation();navigateLightbox(-1)">&#8249;</button>
<img id="lightbox-img" src="">
<button class="lightbox-nav next" id="lightbox-next" onclick="event.stopPropagation();navigateLightbox(1)">&#8250;</button>
<div class="lightbox-meta" id="lightbox-meta"></div>
</div>
<!-- Confirm dialog -->
<div class="confirm-overlay" id="confirm-overlay" onclick="if(event.target===this)closeConfirm()">
<div class="confirm-dialog">
<p id="confirm-message">Are you sure?</p>
<div class="confirm-actions">
<button class="btn btn-secondary btn-small" onclick="closeConfirm()">Cancel</button>
<button class="btn btn-danger btn-small" id="confirm-action-btn">Confirm</button>
</div>
</div>
</div>
<!-- Toasts -->
<div class="toast-container" id="toast-container"></div>
<script>
const API = ''; // same origin
// --- State ---
let currentPage = 'generate';
let selectedRating = 'sfw';
let selectedBackend = 'pod';
let selectedVideoBackend = 'cloud';
let videoSubMode = 'i2v';
let animateCharFile = null;
let animateDrivingVideoFile = null;
let klingMotionCharFile = null;
let klingMotionVideoFile = null;
let selectedMode = 'txt2img';
let templatesData = [];
let charactersData = [];
let currentBatchId = null;
let batchPollInterval = null;
let trainingPollInterval = null;
let refImageFile = null;
let poseImageFile = null;
let videoImageFile = null;
let trainImageFiles = [];
let trainCaptions = {}; // filename -> caption text
let selectedTrainBackend = 'local';
let runpodAvailable = false;
let apiLogs = [];
const MAX_API_LOGS = 50;
let currentJobId = null;
// --- API Logging ---
function logApi(method, url, status, detail = null) {
const now = new Date();
const time = now.toLocaleTimeString('en-US', { hour12: false });
apiLogs.unshift({ time, method, url, status, detail });
if (apiLogs.length > MAX_API_LOGS) apiLogs.pop();
renderApiLog();
}
function renderApiLog() {
const container = document.getElementById('api-log-entries');
if (!container) return;
container.innerHTML = apiLogs.map(log => `
<div class="api-log-entry">
<span class="api-log-time">${log.time}</span>
<span class="api-log-method ${log.method}">${log.method}</span>
<span class="api-log-url">${log.url}</span>
<span class="api-log-status ${log.status >= 200 && log.status < 300 ? 'ok' : 'error'}">${log.status}</span>
${log.detail ? `<div class="api-log-detail">${log.detail}</div>` : ''}
</div>
`).join('');
}
function toggleApiLog() {
const content = document.getElementById('api-log-content');
const chevron = document.getElementById('api-log-chevron');
if (content.style.display === 'none') {
content.style.display = '';
chevron.style.transform = 'rotate(180deg)';
} else {
content.style.display = 'none';
chevron.style.transform = '';
}
}
function clearApiLog() {
apiLogs = [];
renderApiLog();
}
async function cancelGeneration() {
if (!currentJobId) return;
try {
const res = await fetch(API + `/api/generate/jobs/${currentJobId}/cancel`, { method: 'POST' });
const data = await res.json();
toast('Generation cancelled', 'info');
document.getElementById('preview-body').innerHTML = `
<div class="preview-placeholder">
<p style="color:var(--orange)">Generation cancelled</p>
</div>
`;
const btn = document.getElementById('generate-btn');
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px"><path d="M12 5v14M5 12h14"/></svg> Generate Image';
} catch(e) {
toast('Failed to cancel: ' + e.message, 'error');
}
}
async function pollJobStatus(jobId) {
// Poll job status to show progress
const statusEl = document.getElementById('job-status-msg');
const cancelBtn = document.getElementById('cancel-btn');
if (cancelBtn) cancelBtn.style.display = '';
for (let i = 0; i < 60; i++) { // Poll for up to 2 minutes
await new Promise(r => setTimeout(r, 2000));
try {
const res = await fetch(API + `/api/generate/jobs/${jobId}`);
const job = await res.json();
if (statusEl && job.message) {
statusEl.textContent = job.message;
}
if (job.status === 'completed') {
return true;
} else if (job.status === 'failed') {
throw new Error(job.message || 'Generation failed');
} else if (job.status === 'cancelled') {
return false;
}
} catch(e) {
if (e.message.includes('failed') || e.message.includes('cancelled')) throw e;
}
}
return false;
}
// Wrap fetch to log API calls
const originalFetch = window.fetch;
window.fetch = async function(url, options = {}) {
const method = options.method || 'GET';
const urlStr = typeof url === 'string' ? url : url.toString();
// Only log API calls, not static resources
if (!urlStr.includes('/api/')) {
return originalFetch.apply(this, arguments);
}
const shortUrl = urlStr.replace(API, '').split('?')[0];
try {
const response = await originalFetch.apply(this, arguments);
const status = response.status;
// Clone response to read body for error details
if (!response.ok) {
try {
const clone = response.clone();
const data = await clone.json();
const detail = data.detail || data.error || JSON.stringify(data).substring(0, 100);
logApi(method, shortUrl, status, detail);
} catch {
logApi(method, shortUrl, status, 'Failed to parse error');
}
} else {
logApi(method, shortUrl, status);
}
return response;
} catch (error) {
logApi(method, shortUrl, 'ERR', error.message);
throw error;
}
};
let galleryImages = [];
let currentLightboxIndex = -1;
let galleryOffset = 0;
const GALLERY_PAGE_SIZE = 50;
// --- Init ---
document.addEventListener('DOMContentLoaded', async () => {
await Promise.all([loadCharacters(), loadTemplates(), checkStatus()]);
loadGallery();
setInterval(checkStatus, 10000);
setupDropZones();
checkTrainingStatus();
updateCloudModelVisibility(); // Show pod settings by default
});
// --- Drop zone setup ---
function setupDropZones() {
['ref-drop-zone', 'pose-drop-zone', 'train-drop-zone', 'video-drop-zone'].forEach(id => {
const zone = document.getElementById(id);
if (!zone) return;
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
if (id === 'ref-drop-zone') {
refImageFile = file;
showRefPreview(file);
} else if (id === 'pose-drop-zone') {
poseImageFile = file;
showPosePreview(file);
} else if (id === 'video-drop-zone') {
videoImageFile = file;
showVideoPreview(file);
} else {
handleTrainDrop(e.dataTransfer.files);
}
}
});
});
}
function handleRefImage(input) {
if (input.files[0]) {
refImageFile = input.files[0];
showRefPreview(refImageFile);
}
}
function showRefPreview(file) {
const zone = document.getElementById('ref-drop-zone');
zone.classList.add('has-file');
const reader = new FileReader();
reader.onload = e => {
zone.innerHTML = `
<img src="${e.target.result}" style="max-height:70px;max-width:100%;border-radius:4px">
<button class="btn btn-secondary" onclick="event.stopPropagation();clearRefImage()" style="margin-top:4px;padding:2px 6px;font-size:9px">Remove</button>
`;
};
reader.readAsDataURL(file);
}
function clearRefImage() {
refImageFile = null;
const zone = document.getElementById('ref-drop-zone');
zone.classList.remove('has-file');
zone.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div>Character</div>
`;
document.getElementById('ref-file-input').value = '';
}
function handlePoseImage(input) {
if (input.files[0]) {
poseImageFile = input.files[0];
showPosePreview(poseImageFile);
}
}
function showPosePreview(file) {
const zone = document.getElementById('pose-drop-zone');
zone.classList.add('has-file');
const reader = new FileReader();
reader.onload = e => {
zone.innerHTML = `
<img src="${e.target.result}" style="max-height:70px;max-width:100%;border-radius:4px">
<button class="btn btn-secondary" onclick="event.stopPropagation();clearPoseImage()" style="margin-top:4px;padding:2px 6px;font-size:9px">Remove</button>
`;
};
reader.readAsDataURL(file);
}
function clearPoseImage() {
poseImageFile = null;
const zone = document.getElementById('pose-drop-zone');
zone.classList.remove('has-file');
zone.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div>Pose <span style="color:var(--text-secondary)">(opt)</span></div>
`;
document.getElementById('pose-file-input').value = '';
}
function selectVideoSubMode(chip, mode) {
chip.parentElement.querySelectorAll('.chip').forEach(c => c.classList.remove('selected'));
chip.classList.add('selected');
videoSubMode = mode;
document.getElementById('i2v-sub-section').style.display = mode === 'i2v' ? '' : 'none';
document.getElementById('animate-sub-section').style.display = mode === 'animate' ? '' : 'none';
document.getElementById('kling-motion-sub-section').style.display = mode === 'kling-motion' ? '' : 'none';
}
function handleAnimateChar(input) {
if (!input.files[0]) return;
animateCharFile = input.files[0];
const zone = document.getElementById('animate-char-zone');
zone.classList.add('has-file');
const reader = new FileReader();
reader.onload = e => {
zone.innerHTML = `
<img src="${e.target.result}" style="max-height:120px;border-radius:6px">
<div style="margin-top:4px;font-size:11px">${input.files[0].name}</div>
<button class="btn btn-secondary btn-small" onclick="event.stopPropagation();animateCharFile=null;this.closest('.drop-zone').classList.remove('has-file');this.closest('.drop-zone').innerHTML='<svg viewBox=\\'0 0 24 24\\' fill=\\'none\\' stroke=\\'currentColor\\' stroke-width=\\'1.5\\'><path d=\\'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\\'/><polyline points=\\'17 8 12 3 7 8\\'/><line x1=\\'12\\' y1=\\'3\\' x2=\\'12\\' y2=\\'15\\'/></svg><div>Character photo</div>'" style="margin-top:6px">Remove</button>
`;
};
reader.readAsDataURL(input.files[0]);
}
function handleAnimateVideo(input) {
if (!input.files[0]) return;
animateDrivingVideoFile = input.files[0];
const zone = document.getElementById('animate-video-zone');
zone.classList.add('has-file');
zone.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2"/><polygon points="10,8 16,12 10,16"/></svg>
<div style="font-size:12px;margin-top:4px">${input.files[0].name}</div>
<div style="font-size:11px;color:var(--text-secondary)">${(input.files[0].size/1024/1024).toFixed(1)} MB</div>
<button class="btn btn-secondary btn-small" onclick="event.stopPropagation();animateDrivingVideoFile=null;this.closest('.drop-zone').classList.remove('has-file');this.closest('.drop-zone').innerHTML='<svg viewBox=\\'0 0 24 24\\' fill=\\'none\\' stroke=\\'currentColor\\' stroke-width=\\'1.5\\'><rect x=\\'2\\' y=\\'2\\' width=\\'20\\' height=\\'20\\' rx=\\'2\\'/><polygon points=\\'10,8 16,12 10,16\\'/></svg><div>Dance video (mp4)</div>'" style="margin-top:6px">Remove</button>
`;
}
function handleKlingMotionChar(input) {
if (!input.files[0]) return;
klingMotionCharFile = input.files[0];
const zone = document.getElementById('kling-motion-char-zone');
zone.classList.add('has-file');
const reader = new FileReader();
reader.onload = e => {
zone.innerHTML = `
<img src="${e.target.result}" style="max-height:120px;border-radius:6px">
<div style="margin-top:4px;font-size:11px">${input.files[0].name}</div>
<button class="btn btn-secondary btn-small" onclick="event.stopPropagation();klingMotionCharFile=null;this.closest('.drop-zone').classList.remove('has-file');this.closest('.drop-zone').innerHTML='<svg viewBox=\\'0 0 24 24\\' fill=\\'none\\' stroke=\\'currentColor\\' stroke-width=\\'1.5\\'><path d=\\'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\\'/><polyline points=\\'17 8 12 3 7 8\\'/><line x1=\\'12\\' y1=\\'3\\' x2=\\'12\\' y2=\\'15\\'/></svg><div>Character photo</div>'" style="margin-top:6px">Remove</button>
`;
};
reader.readAsDataURL(input.files[0]);
}
function handleKlingMotionVideo(input) {
if (!input.files[0]) return;
klingMotionVideoFile = input.files[0];
const zone = document.getElementById('kling-motion-video-zone');
zone.classList.add('has-file');
zone.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2"/><polygon points="10,8 16,12 10,16"/></svg>
<div style="font-size:12px;margin-top:4px">${input.files[0].name}</div>
<div style="font-size:11px;color:var(--text-secondary)">${(input.files[0].size/1024/1024).toFixed(1)} MB</div>
<button class="btn btn-secondary btn-small" onclick="event.stopPropagation();klingMotionVideoFile=null;this.closest('.drop-zone').classList.remove('has-file');this.closest('.drop-zone').innerHTML='<svg viewBox=\\'0 0 24 24\\' fill=\\'none\\' stroke=\\'currentColor\\' stroke-width=\\'1.5\\'><rect x=\\'2\\' y=\\'2\\' width=\\'20\\' height=\\'20\\' rx=\\'2\\'/><polygon points=\\'10,8 16,12 10,16\\'/></svg><div>Motion reference video (mp4)</div>'" style="margin-top:6px">Remove</button>
`;
}
function handleVideoImage(input) {
if (input.files[0]) {
videoImageFile = input.files[0];
showVideoPreview(videoImageFile);
}
}
function showVideoPreview(file) {
const zone = document.getElementById('video-drop-zone');
zone.classList.add('has-file');
const reader = new FileReader();
reader.onload = e => {
zone.innerHTML = `
<img src="${e.target.result}" style="max-height:150px;border-radius:6px">
<div style="margin-top:6px;font-size:12px">${file.name}</div>
<button class="btn btn-secondary btn-small" onclick="event.stopPropagation();clearVideoImage()" style="margin-top:8px">Remove</button>
`;
};
reader.readAsDataURL(file);
}
function clearVideoImage() {
videoImageFile = null;
const zone = document.getElementById('video-drop-zone');
zone.classList.remove('has-file');
zone.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:32px;height:32px;opacity:0.5;margin-bottom:8px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div>Drop image here or click to browse</div>
<div style="font-size:11px;margin-top:4px">This image will be animated into a video</div>
`;
document.getElementById('video-file-input').value = '';
}
function handleTrainImages(input) {
const allFiles = Array.from(input.files);
const imageFiles = allFiles.filter(f => f.type.startsWith('image/'));
const txtFiles = allFiles.filter(f => f.name.endsWith('.txt'));
trainImageFiles = imageFiles;
// Auto-load captions from .txt files matching image filenames (e.g. 1.txt -> 1.png)
if (txtFiles.length > 0) {
const pending = txtFiles.map(tf => tf.text().then(text => {
const baseName = tf.name.replace(/\.txt$/, '');
const matchImg = imageFiles.find(img => img.name.replace(/\.[^.]+$/, '') === baseName);
if (matchImg) trainCaptions[matchImg.name] = text.trim();
}));
Promise.all(pending).then(() => {
updateTrainCount();
buildCaptionEditor();
const loaded = Object.keys(trainCaptions).length;
if (loaded > 0) toast(`Loaded ${loaded} captions from .txt files`, 'success');
});
} else {
updateTrainCount();
buildCaptionEditor();
}
}
function handleTrainDrop(files) {
const allFiles = Array.from(files);
const imageFiles = allFiles.filter(f => f.type.startsWith('image/'));
const txtFiles = allFiles.filter(f => f.name.endsWith('.txt'));
trainImageFiles = imageFiles;
if (txtFiles.length > 0) {
const pending = txtFiles.map(tf => tf.text().then(text => {
const baseName = tf.name.replace(/\.txt$/, '');
const matchImg = imageFiles.find(img => img.name.replace(/\.[^.]+$/, '') === baseName);
if (matchImg) trainCaptions[matchImg.name] = text.trim();
}));
Promise.all(pending).then(() => {
updateTrainCount();
buildCaptionEditor();
const loaded = Object.keys(trainCaptions).length;
if (loaded > 0) toast(`Loaded ${loaded} captions from .txt files`, 'success');
});
} else {
updateTrainCount();
buildCaptionEditor();
}
}
function updateTrainCount() {
const el = document.getElementById('train-image-count');
const zone = document.getElementById('train-drop-zone');
if (trainImageFiles.length > 0) {
el.textContent = `${trainImageFiles.length} images selected`;
zone.classList.add('has-file');
zone.innerHTML = `<div style="font-size:24px;font-weight:700;color:var(--green)">${trainImageFiles.length}</div><div>images selected</div><div style="font-size:11px;margin-top:4px;color:var(--text-secondary)">Click to add more</div>`;
} else {
el.textContent = '';
}
}
function buildCaptionEditor() {
const section = document.getElementById('caption-editor-section');
const container = document.getElementById('caption-editor');
if (trainImageFiles.length === 0) {
section.style.display = 'none';
container.innerHTML = '';
return;
}
section.style.display = '';
container.innerHTML = '';
trainImageFiles.forEach((file, idx) => {
const item = document.createElement('div');
item.className = 'caption-item';
item.dataset.idx = idx;
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
const fields = document.createElement('div');
fields.className = 'caption-fields';
const fname = document.createElement('div');
fname.className = 'caption-filename';
fname.textContent = file.name;
const textarea = document.createElement('textarea');
textarea.placeholder = 'Describe this image... e.g. "ohwx woman, portrait, natural lighting, smiling"';
textarea.value = trainCaptions[file.name] || '';
textarea.oninput = () => { trainCaptions[file.name] = textarea.value; };
fields.appendChild(fname);
fields.appendChild(textarea);
const removeBtn = document.createElement('button');
removeBtn.className = 'btn-remove';
removeBtn.title = 'Remove this image';
removeBtn.innerHTML = '×';
removeBtn.onclick = () => removeTrainImage(idx);
item.appendChild(img);
item.appendChild(fields);
item.appendChild(removeBtn);
container.appendChild(item);
});
}
function removeTrainImage(idx) {
const removed = trainImageFiles.splice(idx, 1);
if (removed[0]) delete trainCaptions[removed[0].name];
updateTrainCount();
buildCaptionEditor();
if (trainImageFiles.length === 0) clearTrainImages();
}
function autoCaptionAll() {
const trigger = document.getElementById('train-trigger').value.trim();
if (!trigger) { toast('Set a trigger word first', 'error'); return; }
const textareas = document.querySelectorAll('#caption-editor textarea');
trainImageFiles.forEach((file, idx) => {
const caption = trigger;
trainCaptions[file.name] = caption;
if (textareas[idx]) textareas[idx].value = caption;
});
toast(`Applied "${trigger}" to ${trainImageFiles.length} images`, 'success');
}
function toggleBulkCaptions() {
const area = document.getElementById('bulk-caption-area');
area.style.display = area.style.display === 'none' ? 'block' : 'none';
}
function applyBulkCaptions() {
const raw = document.getElementById('bulk-caption-text').value.trim();
if (!raw) { toast('Paste captions first', 'error'); return; }
if (trainImageFiles.length === 0) { toast('Upload images first', 'error'); return; }
// Parse lines: strip numbering like "1. ", "1) ", "1: ", or plain lines
const lines = raw.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0)
.map(l => l.replace(/^\d+[\.\)\:\-]\s*/, ''));
const count = Math.min(lines.length, trainImageFiles.length);
const textareas = document.querySelectorAll('#caption-editor textarea');
for (let i = 0; i < count; i++) {
trainCaptions[trainImageFiles[i].name] = lines[i];
if (textareas[i]) textareas[i].value = lines[i];
}
document.getElementById('bulk-caption-area').style.display = 'none';
document.getElementById('bulk-caption-text').value = '';
toast(`Applied captions to ${count} of ${trainImageFiles.length} images`, 'success');
if (lines.length > trainImageFiles.length) {
toast(`${lines.length - trainImageFiles.length} extra captions ignored (more captions than images)`, 'warning');
} else if (lines.length < trainImageFiles.length) {
toast(`${trainImageFiles.length - lines.length} images have no caption yet`, 'warning');
}
}
function clearTrainImages() {
trainImageFiles = [];
trainCaptions = {};
const zone = document.getElementById('train-drop-zone');
zone.classList.remove('has-file');
zone.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:32px;height:32px;opacity:0.5;margin-bottom:8px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div>Drop images here or click to browse</div>
<div style="font-size:11px;margin-top:4px">Upload 20-50 images of the subject (min 5)</div>
`;
document.getElementById('train-file-input').value = '';
document.getElementById('train-image-count').textContent = '';
document.getElementById('caption-editor-section').style.display = 'none';
document.getElementById('caption-editor').innerHTML = '';
}
// --- Navigation ---
function showPage(page) {
document.querySelectorAll('.page').forEach(p => p.style.display = 'none');
document.getElementById('page-' + page).style.display = '';
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-page="${page}"]`).classList.add('active');
currentPage = page;
if (page === 'gallery') loadGallery();
if (page === 'status') loadStatusPage();
if (page === 'training') { checkTrainingStatus(); loadTrainingModels(); pollTrainingJobs(); }
if (page === 'settings') loadAPISettings();
}
// --- Mode selection ---
function selectMode(chip, mode) {
chip.parentElement.querySelectorAll('.chip').forEach(c => c.classList.remove('selected'));
chip.classList.add('selected');
selectedMode = mode;
document.getElementById('img2img-section').style.display = mode === 'img2img' ? '' : 'none';
document.getElementById('img2video-section').style.display = mode === 'img2video' ? '' : 'none';
// Hide regular backend section for video mode (video has its own backend selector)
const backendSection = document.getElementById('backend-section');
if (backendSection) {
backendSection.style.display = mode === 'img2video' ? 'none' : '';
}
// Update generate button text
const btn = document.getElementById('generate-btn');
if (btn) {
if (mode === 'img2video') {
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px"><polygon points="5 3 19 12 5 21 5 3"/></svg> Generate Video';
} else {
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px"><path d="M12 5v14M5 12h14"/></svg> Generate Image';
}
}
// Update cloud model selectors based on mode + backend
updateCloudModelVisibility();
}
// --- Data Loading ---
async function loadCharacters() {
try {
const res = await fetch(API + '/api/characters');
charactersData = await res.json();
} catch(e) {
charactersData = [];
}
populateCharacterDropdowns();
}
async function loadTemplates() {
try {
const res = await fetch(API + '/api/templates');
templatesData = await res.json();
populateTemplateDropdowns();
} catch(e) {
toast('Failed to load templates', 'error');
}
}
function populateCharacterDropdowns() {
const selects = ['gen-character', 'batch-character', 'gal-character'];
selects.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
const isFilter = id === 'gal-character';
const isBatch = id === 'batch-character';
if (isFilter) {
el.innerHTML = '<option value="">All Characters</option>';
} else if (isBatch) {
el.innerHTML = '<option value="">-- Select Character --</option>';
} else {
el.innerHTML = '<option value="">None (free prompt)</option>';
}
charactersData.forEach(c => {
el.innerHTML += `<option value="${c.id}">${c.name}</option>`;
});
});
}
function populateTemplateDropdowns() {
const selects = ['gen-template', 'batch-template'];
selects.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.innerHTML = id === 'gen-template'
? '<option value="">None (use prompt below)</option>'
: '<option value="">-- Select Template --</option>';
templatesData.forEach(t => {
el.innerHTML += `<option value="${t.id}">${t.name} (${t.rating.toUpperCase()})</option>`;
});
});
}
function loadTemplateVariables() {
const templateId = document.getElementById('gen-template').value;
const container = document.getElementById('template-variables');
container.innerHTML = '';
const template = templatesData.find(t => t.id === templateId);
if (!template) return;
// Auto-select content rating to match template
if (template.rating) {
const rating = template.rating.toLowerCase();
const chips = document.querySelectorAll('#content-rating-chips .chip');
chips.forEach(c => {
c.classList.toggle('selected', c.textContent.trim().toLowerCase() === rating);
});
selectedRating = rating;
}
container.innerHTML = '<div class="section-title">Variations</div>';
for (const [name, def] of Object.entries(template.variables)) {
if (name === 'character_trigger' || name === 'character_lora') continue;
if (def.type === 'choice' && def.options.length > 0) {
container.innerHTML += `
<label>${name.replace(/_/g, ' ')}</label>
<select id="var-${name}">
<option value="">Random</option>
${def.options.map(o => `<option value="${o}">${o}</option>`).join('')}
</select>
`;
}
}
}
function selectRating(chip, rating) {
chip.parentElement.querySelectorAll('.chip').forEach(c => c.classList.remove('selected'));
chip.classList.add('selected');
selectedRating = rating;
}
function selectBackend(chip, backend) {
chip.parentElement.querySelectorAll('.chip').forEach(c => c.classList.remove('selected'));
chip.classList.add('selected');
selectedBackend = backend;
updateCloudModelVisibility();
}
function updateCloudLoraVisibility() {
const model = document.getElementById('gen-cloud-model')?.value || '';
const loraInput = document.getElementById('cloud-lora-input');
if (loraInput) loraInput.style.display = model.includes('-lora') ? '' : 'none';
}
function updateDimensions() {
const aspect = document.getElementById('gen-aspect').value;
const dimensions = {
'9:16': [832, 1216],
'2:3': [832, 1248],
'1:1': [1024, 1024],
'3:2': [1248, 832],
'16:9': [1216, 832],
};
const [w, h] = dimensions[aspect] || [832, 1216];
document.getElementById('gen-width').value = w;
document.getElementById('gen-height').value = h;
}
function selectVideoBackend(chip, backend) {
chip.parentElement.querySelectorAll('.chip').forEach(c => c.classList.remove('selected'));
chip.classList.add('selected');
selectedVideoBackend = backend;
// Show/hide video model dropdown based on backend
const videoModelSelect = document.getElementById('video-cloud-model-select');
if (videoModelSelect) {
videoModelSelect.style.display = backend === 'cloud' ? '' : 'none';
}
// Update note
const videoNote = document.getElementById('video-note');
if (videoNote) {
videoNote.textContent = backend === 'cloud'
? 'Cloud API: Fast generation via WaveSpeed. Pay per video.'
: 'RunPod: Uses WAN 2.2 I2V on your pod (~2 sec per frame).';
}
}
function updateCloudModelVisibility() {
const isCloud = selectedBackend === 'cloud';
const isPod = selectedBackend === 'pod';
const isImg2img = selectedMode === 'img2img';
const isVideo = selectedMode === 'img2video';
// Hide all model selectors for video mode (video has its own selector)
if (isVideo) {
document.getElementById('cloud-model-select').style.display = 'none';
document.getElementById('cloud-edit-model-select').style.display = 'none';
document.getElementById('pod-settings-section').style.display = 'none';
return;
}
// Show txt2img cloud models when cloud + txt2img
document.getElementById('cloud-model-select').style.display = (isCloud && !isImg2img) ? '' : 'none';
// Show edit cloud models when cloud + img2img
document.getElementById('cloud-edit-model-select').style.display = (isCloud && isImg2img) ? '' : 'none';
// Show LoRA input for z-image lora models
updateCloudLoraVisibility();
// Show pod settings when pod backend selected (not in video mode)
document.getElementById('pod-settings-section').style.display = isPod ? '' : 'none';
if (isPod) {
loadPodLorasForGeneration();
// Set defaults based on pod model type
const podModel = document.getElementById('pod-model-select')?.value || '';
if (podModel.startsWith('wan22')) {
document.getElementById('gen-cfg').value = '1';
document.getElementById('gen-steps').value = '8';
} else {
document.getElementById('gen-cfg').value = '2';
document.getElementById('gen-steps').value = '28';
}
// Auto-open Advanced section so CFG/steps are visible
const adv = document.querySelector('#local-settings-section details');
if (adv) adv.open = true;
} else if (!isCloud) {
// Reset to local defaults
document.getElementById('gen-cfg').value = '7';
}
// Show denoise slider only for local img2img (cloud edit doesn't use denoise)
const denoiseRow = document.querySelector('#img2img-section .slider-row');
const denoiseLabel = document.querySelector('#img2img-section label');
if (denoiseRow) denoiseRow.style.display = (isImg2img && !isCloud && !isPod) ? '' : 'none';
if (denoiseLabel) denoiseLabel.style.display = (isImg2img && !isCloud && !isPod) ? '' : 'none';
// Hide local-only settings for cloud img2img, but show for pod
const localSettings = document.getElementById('local-settings-section');
if (localSettings) localSettings.style.display = (isCloud && isImg2img) ? 'none' : '';
}
async function loadPodLorasForGeneration() {
const statusEl = document.getElementById('pod-status-indicator');
const loraSelect = document.getElementById('pod-lora-select');
try {
// Check pod status
const statusRes = await fetch(API + '/api/pod/status');
const podStatus = await statusRes.json();
if (podStatus.status !== 'running') {
statusEl.innerHTML = '<span style="color:var(--yellow)">Pod not running</span> - <a href="#" onclick="showPage(\'status\');return false;" style="color:var(--accent)">Start it in Status page</a>';
loraSelect.innerHTML = '<option value="">Start pod first</option>';
loraSelect.disabled = true;
return;
}
statusEl.innerHTML = '<span style="color:var(--green)">Pod running</span> - Ready to generate';
loraSelect.disabled = false;
// Load available LoRAs from pod
const loraRes = await fetch(API + '/api/pod/loras');
const loraData = await loraRes.json();
const loraSelect2 = document.getElementById('pod-lora-select-2');
loraSelect.innerHTML = '<option value="">None - Base model</option>';
if (loraSelect2) loraSelect2.innerHTML = '<option value="">None</option>';
if (loraData.loras && loraData.loras.length > 0) {
loraData.loras.forEach(lora => {
const label = lora.replace('.safetensors', '');
loraSelect.appendChild(Object.assign(document.createElement('option'), { value: lora, text: label }));
if (loraSelect2) loraSelect2.appendChild(Object.assign(document.createElement('option'), { value: lora, text: label }));
});
}
} catch(e) {
statusEl.innerHTML = '<span style="color:var(--red)">Error checking pod</span>';
loraSelect.disabled = true;
}
}
// --- Generation ---
async function doGenerate() {
const btn = document.getElementById('generate-btn');
btn.disabled = true;
btn.innerHTML = '<div class="spinner" style="width:18px;height:18px;border-width:2px"></div> Generating...';
const preview = document.getElementById('preview-body');
const isVideo = selectedMode === 'img2video';
currentJobId = null;
preview.innerHTML = `
<div class="generating-overlay">
<div class="spinner"></div>
<div>Generating ${isVideo ? 'video' : 'image'}...</div>
<div id="job-status-msg" style="font-size:12px; color:var(--text-secondary)">${isVideo ? 'This may take 1-3 minutes' : 'This may take 10-30 seconds'}</div>
<button onclick="cancelGeneration()" id="cancel-btn" style="margin-top:12px;padding:8px 16px;background:var(--red);border:none;border-radius:6px;color:white;cursor:pointer;font-size:13px;display:none">Cancel</button>
</div>
`;
const startTime = Date.now();
try {
// img2video mode — video generation
if (selectedMode === 'img2video') {
// Kling Motion Control
if (videoSubMode === 'kling-motion') {
if (!klingMotionCharFile) throw new Error('Please upload a character image');
if (!klingMotionVideoFile) throw new Error('Please upload a driving video');
const formData = new FormData();
formData.append('image', klingMotionCharFile);
formData.append('driving_video', klingMotionVideoFile);
formData.append('prompt', document.getElementById('gen-positive').value || 'smooth motion, high quality video');
formData.append('duration', document.getElementById('kling-motion-duration').value || '5');
formData.append('character_orientation', document.getElementById('kling-motion-orientation').value || 'image');
formData.append('seed', document.getElementById('gen-seed').value || '-1');
const res = await fetch(API + '/api/video/generate/kling-motion', { method: 'POST', body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kling Motion generation failed');
toast('Kling Motion generating via WaveSpeed (~1 min)...', 'info');
await pollForVideo(data.job_id);
return;
}
// Animate (Dance) sub-mode — WAN 2.2 Animate on RunPod
if (videoSubMode === 'animate') {
if (!animateCharFile) throw new Error('Please upload a character image');
if (!animateDrivingVideoFile) throw new Error('Please upload a driving dance video');
const resParts = document.getElementById('animate-resolution').value.split('x');
const formData = new FormData();
formData.append('image', animateCharFile);
formData.append('driving_video', animateDrivingVideoFile);
formData.append('prompt', document.getElementById('gen-positive').value || 'a person dancing, smooth motion, high quality');
formData.append('negative_prompt', document.getElementById('gen-negative').value || '');
formData.append('width', resParts[0] || '832');
formData.append('height', resParts[1] || '480');
formData.append('num_frames', document.getElementById('animate-frames').value || '81');
formData.append('bg_mode', document.getElementById('animate-bg-mode').value || 'keep');
formData.append('seed', document.getElementById('gen-seed').value || '-1');
const res = await fetch(API + '/api/video/animate', { method: 'POST', body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Animate generation failed');
toast('Animation generating on RunPod (WAN 2.2 Animate)...', 'info');
await pollForVideo(data.job_id);
return;
}
// Standard Image-to-Video
if (!videoImageFile) {
throw new Error('Please upload an image to animate');
}
const formData = new FormData();
formData.append('image', videoImageFile);
formData.append('prompt', document.getElementById('gen-positive').value || 'smooth motion, high quality video');
formData.append('negative_prompt', document.getElementById('gen-negative').value || 'blurry, low quality, static');
formData.append('num_frames', document.getElementById('video-duration').value || '81');
formData.append('fps', document.getElementById('video-fps')?.value || '24');
formData.append('seed', document.getElementById('gen-seed').value || '-1');
formData.append('backend', selectedVideoBackend);
// Add video model for cloud backend
if (selectedVideoBackend === 'cloud') {
formData.append('model', document.getElementById('video-cloud-model').value);
}
const endpoint = selectedVideoBackend === 'cloud' ? '/api/video/generate/cloud' : '/api/video/generate';
const res = await fetch(API + endpoint, { method: 'POST', body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Video generation failed');
const backendLabel = selectedVideoBackend === 'cloud' ? 'Cloud API' : 'RunPod';
toast(`Video generating via ${backendLabel}...`, 'info');
await pollForVideo(data.job_id);
return;
}
// img2img mode — use FormData
if (selectedMode === 'img2img') {
if (!refImageFile) {
throw new Error('Please upload a reference image');
}
const formData = new FormData();
formData.append('image', refImageFile);
// Add pose/style reference image if provided (for multi-ref models)
if (poseImageFile) {
formData.append('image2', poseImageFile);
}
formData.append('positive_prompt', document.getElementById('gen-positive').value || '');
formData.append('negative_prompt', document.getElementById('gen-negative').value || '');
formData.append('content_rating', selectedRating);
formData.append('backend', selectedBackend);
// Only send local-specific settings for local backend
if (selectedBackend !== 'cloud') {
formData.append('denoise', document.getElementById('gen-denoise').value);
formData.append('seed', document.getElementById('gen-seed').value || '-1');
formData.append('steps', document.getElementById('gen-steps').value || '28');
formData.append('cfg', document.getElementById('gen-cfg').value || '7');
formData.append('width', document.getElementById('gen-width').value || '832');
formData.append('height', document.getElementById('gen-height').value || '1216');
}
const charId = document.getElementById('gen-character').value;
if (charId) formData.append('character_id', charId);
const templateId = document.getElementById('gen-template').value;
if (templateId) formData.append('template_id', templateId);
// Collect template variables
const variables = {};
document.querySelectorAll('[id^="var-"]').forEach(el => {
const name = el.id.replace('var-', '');
if (el.value) variables[name] = el.value;
});
formData.append('variables_json', JSON.stringify(variables));
// Cloud edit model selection
if (selectedBackend === 'cloud') {
formData.append('checkpoint', document.getElementById('gen-cloud-edit-model').value);
}
const res = await fetch(API + '/api/generate/img2img', { method: 'POST', body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Generation failed');
// Track job ID for cloud img2img
if (selectedBackend === 'cloud' && data.job_id) {
currentJobId = data.job_id;
toast(`Cloud edit started (${data.job_id.substring(0,8)})`, 'info');
pollJobStatus(data.job_id).catch(e => {
if (e.message) toast('Job failed: ' + e.message, 'error');
});
} else {
const backendLabel = selectedBackend === 'cloud' ? 'Cloud edit' : 'Local img2img';
toast(`${backendLabel} generation started!`, 'info');
}
await pollForNewImage(startTime);
return;
}
// txt2img mode — JSON
const variables = {};
document.querySelectorAll('[id^="var-"]').forEach(el => {
const name = el.id.replace('var-', '');
if (el.value) variables[name] = el.value;
});
// RunPod Pod backend - use dedicated endpoint
if (selectedBackend === 'pod') {
const podBody = {
prompt: document.getElementById('gen-positive').value || '',
negative_prompt: document.getElementById('gen-negative').value || '',
content_rating: selectedRating,
seed: parseInt(document.getElementById('gen-seed').value) || -1,
steps: parseInt(document.getElementById('gen-steps').value) || 28,
cfg: parseFloat(document.getElementById('gen-cfg').value) || 3.5,
width: parseInt(document.getElementById('gen-width').value) || 1024,
height: parseInt(document.getElementById('gen-height').value) || 1024,
lora_name: document.getElementById('pod-lora-select')?.value || null,
lora_strength: parseFloat(document.getElementById('pod-lora-strength')?.value) || 0.85,
lora_name_2: document.getElementById('pod-lora-select-2')?.value || null,
lora_strength_2: parseFloat(document.getElementById('pod-lora-strength-2')?.value) || 0.85,
character_id: document.getElementById('gen-character').value || null,
template_id: document.getElementById('gen-template').value || null,
};
const res = await fetch(API + '/api/pod/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(podBody),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Pod not running - start it first');
const podJobId = data.job_id;
toast('Generating on RunPod GPU...', 'info');
await pollPodJob(podJobId, startTime);
return;
}
const cloudLoraPath = document.getElementById('cloud-lora-path')?.value?.trim();
const cloudLoraStrength = parseFloat(document.getElementById('cloud-lora-strength')?.value) || 1.0;
const body = {
character_id: document.getElementById('gen-character').value || null,
template_id: document.getElementById('gen-template').value || null,
content_rating: selectedRating,
positive_prompt: document.getElementById('gen-positive').value || null,
negative_prompt: document.getElementById('gen-negative').value || null,
checkpoint: selectedBackend === 'cloud' ? document.getElementById('gen-cloud-model').value : null,
seed: parseInt(document.getElementById('gen-seed').value) || -1,
steps: parseInt(document.getElementById('gen-steps').value) || 28,
cfg: parseFloat(document.getElementById('gen-cfg').value) || 7.0,
width: parseInt(document.getElementById('gen-width').value) || 832,
height: parseInt(document.getElementById('gen-height').value) || 1216,
variables: variables,
loras: cloudLoraPath ? [{ name: cloudLoraPath, strength_model: cloudLoraStrength, strength_clip: cloudLoraStrength }] : [],
};
const endpoint = selectedBackend === 'cloud' ? '/api/generate/cloud' : '/api/generate';
const res = await fetch(API + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Generation failed');
// Track job ID for cloud generation
if (selectedBackend === 'cloud' && data.job_id) {
currentJobId = data.job_id;
toast(`Generation started (${data.job_id.substring(0,8)})`, 'info');
// Start job status polling in background
pollJobStatus(data.job_id).catch(e => {
if (e.message) toast('Job failed: ' + e.message, 'error');
});
} else {
toast('Generation started! Waiting for result...', 'info');
}
await pollForNewImage(startTime);
} catch(e) {
preview.innerHTML = `<div class="preview-placeholder"><p style="color:var(--red)">Error: ${e.message}</p></div>`;
toast('Generation failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px"><path d="M12 5v14M5 12h14"/></svg> Generate Image`;
}
}
async function pollPodJob(jobId, startTime) {
const preview = document.getElementById('preview-body');
for (let i = 0; i < 180; i++) { // 180 × 5s = 15 min max
await new Promise(r => setTimeout(r, 5000));
try {
const res = await fetch(API + `/api/pod/jobs/${jobId}`);
if (!res.ok) continue;
const job = await res.json();
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
const progressMsg = job.progress_msg || `${elapsed}s elapsed...`;
preview.innerHTML = `<div class="preview-placeholder"><p>Generating on RunPod GPU...</p><p style="font-size:12px;color:var(--text-secondary)">${progressMsg}</p></div>`;
if (job.status === 'completed' && job.output_path) {
document.getElementById('gen-time').textContent = `${elapsed}s`;
// Show image directly from pod job endpoint
preview.innerHTML = `
<div style="text-align:center;width:100%">
<img src="${API}/api/pod/jobs/${jobId}/image" alt="Generated image" style="max-width:100%;max-height:70vh;border-radius:8px;margin-bottom:12px">
<p style="color:var(--text-secondary);font-size:12px">${elapsed}s on RunPod GPU</p>
</div>`;
toast('Image generated!', 'success');
return;
}
if (job.status === 'failed') {
throw new Error(job.error || 'Generation failed');
}
} catch(e) {
if (e.message && e.message !== 'Failed to fetch') {
preview.innerHTML = `<div class="preview-placeholder"><p style="color:var(--error)">Error: ${e.message}</p></div>`;
toast(e.message, 'error');
return;
}
}
}
preview.innerHTML = `<div class="preview-placeholder"><p>Timed out waiting for image (15 min)</p></div>`;
}
async function pollForNewImage(startTime) {
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 3000));
try {
const res = await fetch(API + '/api/images?limit=1');
const images = await res.json();
if (images.length > 0) {
const img = images[0];
// Server stores UTC without 'Z' suffix — append it so JS parses correctly
const isoStr = img.created_at.endsWith('Z') ? img.created_at : img.created_at + 'Z';
const imgTime = new Date(isoStr).getTime();
if (imgTime > startTime - 5000) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
document.getElementById('gen-time').textContent = `${elapsed}s`;
showPreviewImage(img);
toast('Image generated successfully!', 'success');
return;
}
}
} catch(e) {}
}
document.getElementById('preview-body').innerHTML = `<div class="preview-placeholder"><p>Timed out waiting for image</p></div>`;
}
function showPreviewImage(img) {
const preview = document.getElementById('preview-body');
preview.innerHTML = `
<div style="text-align:center;width:100%">
<img src="/api/images/${img.id}/file" alt="Generated image" style="max-width:100%;max-height:70vh;border-radius:8px;margin-bottom:12px"
onerror="this.style.display='none'">
<div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
<span class="tag tag-${img.content_rating}">${img.content_rating}</span>
${img.pose ? `<span class="tag" style="background:var(--bg-hover)">${img.pose}</span>` : ''}
${img.emotion ? `<span class="tag" style="background:var(--bg-hover)">${img.emotion}</span>` : ''}
${img.scene ? `<span class="tag" style="background:var(--bg-hover)">${img.scene}</span>` : ''}
</div>
<p style="color:var(--text-secondary);margin-top:8px;font-size:12px">Seed: ${img.seed || 'N/A'}</p>
</div>
`;
}
async function pollForVideo(jobId) {
const preview = document.getElementById('preview-body');
const startTime = Date.now();
for (let i = 0; i < 600; i++) { // Up to 30 minutes
await new Promise(r => setTimeout(r, 3000));
try {
const res = await fetch(API + `/api/video/jobs/${jobId}`);
const job = await res.json();
if (job.status === 'completed') {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
document.getElementById('gen-time').textContent = `${elapsed}s`;
showPreviewVideo(job);
toast('Video generated successfully!', 'success');
return;
} else if (job.status === 'failed') {
throw new Error(job.error || 'Video generation failed');
}
// Update progress
preview.innerHTML = `
<div class="generating-overlay">
<div class="spinner"></div>
<div>Generating video...</div>
<div style="font-size:12px; color:var(--text-secondary)">Elapsed: ${((Date.now() - startTime) / 1000).toFixed(0)}s</div>
</div>
`;
} catch(e) {
if (e.message.includes('failed')) throw e;
}
}
preview.innerHTML = `<div class="preview-placeholder"><p>Timed out waiting for video</p></div>`;
}
function showPreviewVideo(job) {
const preview = document.getElementById('preview-body');
preview.innerHTML = `
<div style="text-align:center;width:100%">
<video id="preview-video" src="/api/video/${job.filename}" autoplay loop controls playsinline
style="max-width:100%;max-height:70vh;border-radius:8px;margin-bottom:12px"></video>
<div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-bottom:8px">
<span class="tag" style="background:var(--accent);color:white">Video</span>
<span class="tag" style="background:var(--bg-hover)">${job.num_frames} frames</span>
<span class="tag" style="background:var(--bg-hover)">${job.fps} fps</span>
<button id="audio-toggle-btn" onclick="toggleVideoAudio()" style="padding:4px 12px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);cursor:pointer;font-size:12px">🔇 Unmute</button>
</div>
<p style="color:var(--text-secondary);margin-top:4px;font-size:12px">Seed: ${job.seed || 'N/A'}</p>
<a href="/api/video/${job.filename}" download class="btn btn-secondary" style="margin-top:12px">Download Video</a>
</div>
`;
}
function toggleVideoAudio() {
const video = document.getElementById('preview-video');
const btn = document.getElementById('audio-toggle-btn');
if (!video) return;
video.muted = !video.muted;
btn.textContent = video.muted ? '🔇 Unmute' : '🔊 Mute';
}
// --- Batch ---
async function doBatch() {
const btn = document.getElementById('batch-btn');
btn.disabled = true;
const body = {
character_id: document.getElementById('batch-character').value,
template_id: document.getElementById('batch-template').value,
content_rating: document.getElementById('batch-rating').value,
count: parseInt(document.getElementById('batch-count').value) || 10,
variation_mode: document.getElementById('batch-mode').value,
seed_strategy: document.getElementById('batch-seed-strategy').value,
};
if (!body.character_id || !body.template_id) {
toast('Please select a character and template', 'error');
btn.disabled = false;
return;
}
try {
const res = await fetch(API + '/api/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Batch failed');
currentBatchId = data.batch_id;
document.getElementById('batch-progress').style.display = '';
toast(`Batch started: ${body.count} images`, 'success');
if (batchPollInterval) clearInterval(batchPollInterval);
batchPollInterval = setInterval(pollBatch, 3000);
pollBatch();
} catch(e) {
toast('Batch failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
}
async function pollBatch() {
if (!currentBatchId) return;
try {
const res = await fetch(API + `/api/batch/${currentBatchId}/status`);
const data = await res.json();
document.getElementById('batch-completed').textContent = data.completed;
document.getElementById('batch-failed').textContent = data.failed;
document.getElementById('batch-pending').textContent = data.pending;
document.getElementById('batch-total').textContent = data.total_jobs;
const pct = data.total_jobs > 0 ? ((data.completed + data.failed) / data.total_jobs * 100) : 0;
document.getElementById('batch-bar').style.width = pct + '%';
if (data.completed + data.failed >= data.total_jobs) {
clearInterval(batchPollInterval);
batchPollInterval = null;
toast(`Batch complete: ${data.completed} succeeded, ${data.failed} failed`, data.failed > 0 ? 'error' : 'success');
}
} catch(e) {}
}
// --- Gallery ---
async function loadGallery() {
galleryOffset = 0;
galleryImages = [];
const grid = document.getElementById('gallery-grid');
grid.innerHTML = '';
await fetchGalleryPage();
}
async function fetchGalleryPage() {
const grid = document.getElementById('gallery-grid');
const params = new URLSearchParams();
const char = document.getElementById('gal-character')?.value;
const rating = document.getElementById('gal-rating')?.value;
const approved = document.getElementById('gal-approved')?.value;
if (char) params.set('character_id', char);
if (rating) params.set('content_rating', rating);
if (approved) params.set('is_approved', approved);
params.set('limit', String(GALLERY_PAGE_SIZE));
params.set('offset', String(galleryOffset));
try {
const res = await fetch(API + '/api/images?' + params);
const images = await res.json();
galleryImages = galleryImages.concat(images);
document.getElementById('gallery-count').textContent = galleryImages.length > 0 ? `(${galleryImages.length})` : '';
if (galleryImages.length === 0) {
grid.innerHTML = `
<div class="empty-state" style="grid-column:1/-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
<p style="font-size:16px">No images yet</p>
<p style="font-size:13px;margin-top:4px">Generate some images to see them here</p>
</div>
`;
document.getElementById('gallery-load-more').style.display = 'none';
return;
}
grid.innerHTML += images.map(img => `
<div class="gallery-card" onclick="openLightbox('${img.id}')">
<div class="gallery-card-actions">
<button onclick="event.stopPropagation(); downloadImage('${img.id}')" title="Download">
<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-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button class="delete-btn" onclick="event.stopPropagation(); deleteImageFromCard('${img.id}', this)" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 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>
</button>
</div>
<img src="/api/images/${img.id}/file" alt="${img.pose || ''} ${img.emotion || ''}" loading="lazy"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<div style="display:none;width:100%;aspect-ratio:3/4;background:var(--bg-hover);align-items:center;justify-content:center;color:var(--text-secondary);font-size:12px;padding:10px;text-align:center">
${img.pose || ''} ${img.emotion || ''}<br>${img.scene || ''}
</div>
<div class="gallery-card-info">
<div style="font-size:12px;color:var(--text-secondary)">Seed: ${img.seed || 'N/A'}</div>
<div class="tags">
<span class="tag tag-${img.content_rating}">${img.content_rating}</span>
${img.is_approved ? '<span class="tag tag-approved">Approved</span>' : ''}
${img.pose ? `<span class="tag" style="background:var(--bg-hover);color:var(--text-secondary)">${img.pose}</span>` : ''}
</div>
</div>
</div>
`).join('');
// Show/hide Load More button
document.getElementById('gallery-load-more').style.display = images.length >= GALLERY_PAGE_SIZE ? '' : 'none';
galleryOffset += images.length;
} catch(e) {
if (galleryImages.length === 0) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><p>Failed to load gallery</p></div>';
}
}
}
async function loadMoreGallery() {
await fetchGalleryPage();
}
async function openLightbox(imageId) {
try {
const res = await fetch(API + `/api/images/${imageId}`);
const img = await res.json();
currentLightboxIndex = galleryImages.findIndex(i => i.id === imageId);
document.getElementById('lightbox-prev').style.display = currentLightboxIndex > 0 ? '' : 'none';
document.getElementById('lightbox-next').style.display = currentLightboxIndex < galleryImages.length - 1 ? '' : 'none';
document.getElementById('lightbox-img').src = `/api/images/${imageId}/file`;
const meta = document.getElementById('lightbox-meta');
meta.innerHTML = `
<div class="lightbox-meta-info">
<div>Seed: <strong>${img.seed || 'N/A'}</strong></div>
<div>Rating: <strong>${img.content_rating}</strong></div>
${img.pose ? `<div>Pose: <strong>${img.pose}</strong></div>` : ''}
${img.emotion ? `<div>Emotion: <strong>${img.emotion}</strong></div>` : ''}
${img.scene ? `<div>Scene: <strong>${img.scene}</strong></div>` : ''}
${img.lighting ? `<div>Lighting: <strong>${img.lighting}</strong></div>` : ''}
${img.camera_angle ? `<div>Camera: <strong>${img.camera_angle}</strong></div>` : ''}
</div>
<div class="lightbox-meta-actions">
${!img.is_approved ? `<button class="btn btn-secondary btn-small" onclick="approveImage('${img.id}')">Approve</button>` : '<button class="btn btn-secondary btn-small" disabled style="color:var(--green);opacity:0.7">Approved</button>'}
<button class="btn btn-secondary btn-small" onclick="downloadImage('${img.id}')">Download</button>
<button class="btn btn-secondary btn-small" style="color:var(--red)" onclick="deleteImage('${img.id}')">Delete</button>
</div>
`;
document.getElementById('lightbox').classList.add('open');
} catch(e) {}
}
function navigateLightbox(direction) {
const newIndex = currentLightboxIndex + direction;
if (newIndex >= 0 && newIndex < galleryImages.length) {
openLightbox(galleryImages[newIndex].id);
}
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
}
async function approveImage(imageId) {
try {
await fetch(API + `/api/images/${imageId}/approve`, { method: 'POST' });
toast('Image approved!', 'success');
loadGallery();
closeLightbox();
} catch(e) {
toast('Failed to approve', 'error');
}
}
function downloadImage(imageId) {
const a = document.createElement('a');
a.href = API + `/api/images/${imageId}/download`;
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
}
async function deleteImage(imageId) {
showConfirm('Delete this image? This cannot be undone.', async () => {
try {
const res = await fetch(API + `/api/images/${imageId}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Delete failed');
}
toast('Image deleted', 'success');
closeLightbox();
loadGallery();
} catch(e) {
toast('Failed to delete: ' + e.message, 'error');
}
});
}
async function deleteImageFromCard(imageId, btn) {
showConfirm('Delete this image? This cannot be undone.', async () => {
try {
const res = await fetch(API + `/api/images/${imageId}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Delete failed');
}
// Remove the card from the grid
const card = btn.closest('.gallery-card');
if (card) {
card.style.transition = 'opacity 0.3s, transform 0.3s';
card.style.opacity = '0';
card.style.transform = 'scale(0.8)';
setTimeout(() => card.remove(), 300);
}
// Update gallery images array
galleryImages = galleryImages.filter(img => img.id !== imageId);
document.getElementById('gallery-count').textContent = galleryImages.length > 0 ? `(${galleryImages.length})` : '';
toast('Image deleted', 'success');
} catch(e) {
toast('Failed to delete: ' + e.message, 'error');
}
});
}
// --- Confirm dialog ---
function showConfirm(message, onConfirm) {
document.getElementById('confirm-message').textContent = message;
const btn = document.getElementById('confirm-action-btn');
btn.onclick = () => { closeConfirm(); onConfirm(); };
document.getElementById('confirm-overlay').classList.add('open');
}
function closeConfirm() {
document.getElementById('confirm-overlay').classList.remove('open');
}
// --- Training ---
async function checkTrainingStatus() {
try {
const res = await fetch(API + '/api/training/status');
const data = await res.json();
const banner = document.getElementById('training-install-banner');
if (banner && selectedTrainBackend === 'local') {
banner.style.display = data.sd_scripts_installed ? 'none' : '';
}
runpodAvailable = data.runpod_available || false;
} catch(e) {}
}
// Model registry data from API
let trainingModels = {};
async function loadTrainingModels() {
try {
const res = await fetch(API + '/api/training/models');
const data = await res.json();
trainingModels = data.models || {};
const defaultModel = data.default || 'flux2_dev';
const select = document.getElementById('train-base-model');
select.innerHTML = '';
for (const [key, info] of Object.entries(trainingModels)) {
const opt = document.createElement('option');
opt.value = key;
opt.text = `${info.name} (${info.model_type.toUpperCase()})`;
if (key === defaultModel) opt.selected = true;
select.appendChild(opt);
}
updateModelDefaults();
} catch(e) {
console.error('Failed to load training models:', e);
}
}
function updateModelDefaults() {
const modelKey = document.getElementById('train-base-model').value;
const model = trainingModels[modelKey];
if (!model) return;
// Update info display
const infoDiv = document.getElementById('model-info');
infoDiv.innerHTML = `
<span id="model-description">${model.description || ''}</span><br>
<span style="color:var(--accent)">Resolution: ${model.resolution}px | LR: ${model.learning_rate} | Rank: ${model.network_rank} | VRAM: ${model.vram_required_gb}GB</span>
`;
// Update placeholder hints and auto-fill LR
document.getElementById('train-lr').placeholder = `Default: ${model.learning_rate}`;
document.getElementById('lr-default').textContent = `(default: ${model.learning_rate})`;
document.getElementById('train-lr').value = model.learning_rate;
// Update resolution default
const resSelect = document.getElementById('train-resolution');
for (let opt of resSelect.options) {
if (parseInt(opt.value) === model.resolution) {
opt.selected = true;
break;
}
}
// Update rank default
const rankSelect = document.getElementById('train-rank');
for (let opt of rankSelect.options) {
if (parseInt(opt.value) === model.network_rank) {
opt.selected = true;
break;
}
}
// Update optimizer default
const optSelect = document.getElementById('train-optimizer');
const optName = (model.optimizer || 'AdamW8bit').toLowerCase();
for (let opt of optSelect.options) {
if (opt.value.toLowerCase() === optName) {
opt.selected = true;
break;
}
}
// Auto-select GPU for models that need specific hardware
const gpuSelect = document.getElementById('train-gpu-type');
if (gpuSelect) {
const modelType = model.model_type || '';
if (modelType === 'wan22') {
// WAN 2.2 needs A100 80GB
for (let opt of gpuSelect.options) {
if (opt.value.includes('A100-SXM4')) { opt.selected = true; break; }
}
} else if (modelType === 'flux2') {
// FLUX.2 needs 48GB+ — default to A6000
for (let opt of gpuSelect.options) {
if (opt.value.includes('A6000')) { opt.selected = true; break; }
}
}
}
}
async function preDownloadModels() {
const btn = document.getElementById('btn-predownload');
const status = document.getElementById('predownload-status');
const modelKey = document.getElementById('train-base-model').value;
const model = trainingModels[modelKey];
const modelType = model?.model_type || 'wan22';
btn.disabled = true;
status.textContent = 'Starting download pod...';
status.style.color = 'var(--blue)';
try {
const res = await fetch(API + '/api/pod/download-models', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model_type: modelType, gpu_type: 'NVIDIA GeForce RTX 3090'})
});
const data = await res.json();
if (!res.ok) { throw new Error(data.detail || 'Failed'); }
// Poll for progress
const poll = setInterval(async () => {
try {
const r = await fetch(API + '/api/pod/download-models/status');
const d = await r.json();
status.textContent = d.progress || d.status;
if (d.status === 'completed') {
clearInterval(poll);
status.style.color = 'var(--green)';
btn.disabled = false;
btn.textContent = 'Models downloaded!';
} else if (d.status === 'failed') {
clearInterval(poll);
status.style.color = 'var(--red)';
status.textContent = 'Failed: ' + (d.error || 'unknown');
btn.disabled = false;
}
} catch(e) { /* ignore poll errors */ }
}, 5000);
} catch(e) {
status.textContent = 'Error: ' + e.message;
status.style.color = 'var(--red)';
btn.disabled = false;
}
}
function selectTrainBackend(chip, backend) {
chip.parentElement.querySelectorAll('.chip').forEach(c => c.classList.remove('selected'));
chip.classList.add('selected');
selectedTrainBackend = backend;
const runpodInfo = document.getElementById('runpod-info');
const runpodNotConfigured = document.getElementById('runpod-not-configured');
const installBanner = document.getElementById('training-install-banner');
const baseModelSelect = document.querySelector('#train-base-model')?.closest('div')?.parentElement;
if (backend === 'runpod') {
if (runpodAvailable) {
runpodInfo.style.display = '';
runpodNotConfigured.style.display = 'none';
} else {
runpodInfo.style.display = 'none';
runpodNotConfigured.style.display = '';
}
if (installBanner) installBanner.style.display = 'none';
} else {
runpodInfo.style.display = 'none';
runpodNotConfigured.style.display = 'none';
checkTrainingStatus();
}
}
async function installSdScripts() {
toast('Installing sd-scripts... this may take a few minutes', 'info');
try {
const res = await fetch(API + '/api/training/install', { method: 'POST' });
const data = await res.json();
if (res.ok) {
toast('sd-scripts installed!', 'success');
checkTrainingStatus();
} else {
toast('Install failed: ' + (data.detail || ''), 'error');
}
} catch(e) {
toast('Install failed: ' + e.message, 'error');
}
}
async function startTraining() {
if (trainImageFiles.length < 5) {
toast('Please upload at least 5 training images', 'error');
return;
}
const name = document.getElementById('train-name').value.trim();
if (!name) {
toast('Please enter a model name', 'error');
return;
}
const btn = document.getElementById('train-btn');
btn.disabled = true;
btn.textContent = 'Starting...';
const formData = new FormData();
trainImageFiles.forEach(f => formData.append('images', f));
// Send captions as JSON (filename -> caption)
const captions = {};
trainImageFiles.forEach(f => {
if (trainCaptions[f.name]) captions[f.name] = trainCaptions[f.name];
});
formData.append('captions_json', JSON.stringify(captions));
formData.append('name', name);
formData.append('trigger_word', document.getElementById('train-trigger').value);
formData.append('base_model', document.getElementById('train-base-model').value);
formData.append('max_steps', document.getElementById('train-max-steps').value);
formData.append('save_every_n_steps', document.getElementById('train-save-every').value);
formData.append('backend', selectedTrainBackend);
// Optional params - only send if user explicitly set them (otherwise use model defaults)
const lr = document.getElementById('train-lr').value.trim();
if (lr) formData.append('learning_rate', lr);
const rank = document.getElementById('train-rank').value;
if (rank) formData.append('network_rank', rank);
const optimizer = document.getElementById('train-optimizer').value;
if (optimizer) formData.append('optimizer', optimizer);
const resolution = document.getElementById('train-resolution').value;
if (resolution) formData.append('resolution', resolution);
if (selectedTrainBackend === 'runpod') {
formData.append('gpu_type', document.getElementById('train-gpu-type').value);
}
try {
const res = await fetch(API + '/api/training/start', { method: 'POST', body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Training failed to start');
const backendLabel = selectedTrainBackend === 'runpod' ? 'Cloud (RunPod)' : 'Local';
toast(`${backendLabel} training started: ${name}`, 'success');
// Start polling
if (!trainingPollInterval) {
trainingPollInterval = setInterval(pollTrainingJobs, 5000);
}
pollTrainingJobs();
} catch(e) {
toast('Failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg> Start Training`;
}
}
async function pollTrainingJobs() {
try {
const res = await fetch(API + '/api/training/jobs');
const jobs = await res.json();
renderTrainingJobs(jobs);
// Stop polling if no active jobs
const active = jobs.filter(j => ['training','preparing','creating_pod','uploading','installing','downloading','pending'].includes(j.status));
if (active.length === 0 && trainingPollInterval) {
clearInterval(trainingPollInterval);
trainingPollInterval = null;
}
} catch(e) {}
}
function renderTrainingJobs(jobs) {
const container = document.getElementById('training-jobs');
if (jobs.length === 0) {
container.innerHTML = `<div class="empty-state" style="padding:30px"><p>No training jobs yet</p><p style="font-size:12px;margin-top:4px">Upload images and configure settings to start training</p></div>`;
return;
}
// Store latest jobs for log viewer
window._trainingJobs = jobs;
// Show "Clear Failed" button if there are any failed jobs
const failedCount = jobs.filter(j => j.status === 'failed' || j.status === 'error').length;
let html = failedCount > 0 ? `<div style="text-align:right;margin-bottom:8px"><button class="btn btn-secondary btn-small" style="color:var(--red)" onclick="clearFailedJobs()">Clear ${failedCount} Failed</button></div>` : '';
html += jobs.map(j => {
const pct = (j.progress * 100).toFixed(1);
const elapsed = j.started_at ? ((Date.now()/1000 - j.started_at) / 60).toFixed(0) : '?';
const isActive = ['training','preparing','creating_pod','uploading','installing','downloading'].includes(j.status);
const hasLogs = j.log_lines && j.log_lines.length > 0;
return `
<div class="job-card" id="job-card-${j.id}">
<div class="job-header">
<span class="job-name">${j.name} ${j.backend === 'runpod' ? '<span style="font-size:10px;color:var(--blue);font-weight:400">☁ RunPod</span>' : ''}</span>
<span class="job-status job-status-${j.status}">${j.status}</span>
</div>
${isActive ? `
<div class="progress-bar-container" style="margin-top:0">
<div class="progress-bar-fill" style="width:${pct}%"></div>
</div>
<div style="display:flex;gap:16px;margin-top:8px;font-size:12px;color:var(--text-secondary)">
<span>Progress: <strong style="color:var(--text-primary)">${pct}%</strong></span>
${j.current_step ? `<span>Step: <strong style="color:var(--text-primary)">${j.current_step}/${j.total_steps}</strong></span>` : ''}
${j.loss !== null && j.loss !== undefined ? `<span>Loss: <strong style="color:var(--text-primary)">${j.loss.toFixed(4)}</strong></span>` : ''}
<span>Time: <strong style="color:var(--text-primary)">${elapsed}m</strong></span>
</div>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="btn btn-secondary btn-small" onclick="toggleJobLogs('${j.id}')">View Logs</button>
<button class="btn btn-secondary btn-small" style="color:var(--red)" onclick="cancelTraining('${j.id}')">Cancel</button>
</div>
` : ''}
${j.status === 'completed' ? `
<div style="font-size:12px;color:var(--green);margin-top:4px">LoRA saved to ComfyUI models folder</div>
${j.output_path ? `<div style="font-size:11px;color:var(--text-secondary);margin-top:2px;word-break:break-all">${j.output_path}</div>` : ''}
<button class="btn btn-secondary btn-small" style="margin-top:6px" onclick="toggleJobLogs('${j.id}')">View Logs</button>
` : ''}
${j.status === 'failed' ? `
${j.error ? `<div style="font-size:12px;color:var(--red);margin-top:4px">${j.error}</div>` : ''}
<div style="display:flex;gap:6px;margin-top:6px">
<button class="btn btn-secondary btn-small" onclick="toggleJobLogs('${j.id}')">View Logs</button>
<button class="btn btn-secondary btn-small" style="color:var(--red)" onclick="deleteJob('${j.id}')">Delete</button>
</div>
` : ''}
<div id="job-logs-${j.id}" class="job-logs-panel" style="display:none">
<div class="job-logs-content"></div>
</div>
</div>
`;
}).join('');
container.innerHTML = html;
// Auto-show logs for active jobs
const activeJob = jobs.find(j => ['training','preparing','creating_pod','uploading','installing','downloading'].includes(j.status));
if (activeJob && activeJob.log_lines && activeJob.log_lines.length > 0) {
showJobLogs(activeJob.id);
}
}
function toggleJobLogs(jobId) {
const panel = document.getElementById('job-logs-' + jobId);
if (!panel) return;
if (panel.style.display === 'none') {
showJobLogs(jobId);
} else {
panel.style.display = 'none';
}
}
function showJobLogs(jobId) {
const panel = document.getElementById('job-logs-' + jobId);
if (!panel) return;
panel.style.display = 'block';
// Find job data
const job = (window._trainingJobs || []).find(j => j.id === jobId);
if (!job || !job.log_lines) {
panel.querySelector('.job-logs-content').textContent = 'No logs available';
return;
}
const content = panel.querySelector('.job-logs-content');
content.textContent = job.log_lines.join('\n');
content.scrollTop = content.scrollHeight;
}
async function cancelTraining(jobId) {
try {
await fetch(API + `/api/training/jobs/${jobId}/cancel`, { method: 'POST' });
toast('Training cancelled', 'info');
pollTrainingJobs();
} catch(e) {
toast('Failed to cancel', 'error');
}
}
async function deleteJob(jobId) {
try {
await fetch(API + `/api/training/jobs/${jobId}`, { method: 'DELETE' });
toast('Job deleted', 'info');
pollTrainingJobs();
} catch(e) {
toast('Failed to delete job', 'error');
}
}
async function clearFailedJobs() {
try {
const res = await fetch(API + '/api/training/jobs', { method: 'DELETE' });
const data = await res.json();
toast(`Cleared ${data.deleted} failed jobs`, 'info');
pollTrainingJobs();
} catch(e) {
toast('Failed to clear jobs', 'error');
}
}
// --- Status ---
async function checkStatus() {
try {
const res = await fetch(API + '/api/status');
const data = await res.json();
const comfyDot = document.getElementById('comfyui-dot');
const comfyText = document.getElementById('comfyui-status-text');
comfyDot.className = 'status-dot ' + (data.comfyui_connected ? 'online' : 'offline');
comfyText.textContent = data.comfyui_connected ? 'connected' : 'offline';
const engineDot = document.getElementById('engine-dot');
engineDot.className = 'status-dot online';
document.getElementById('engine-status-text').textContent = 'running';
if (currentPage === 'status') renderStatusPage(data);
} catch(e) {
document.getElementById('comfyui-dot').className = 'status-dot offline';
document.getElementById('engine-dot').className = 'status-dot offline';
document.getElementById('engine-status-text').textContent = 'offline';
}
}
async function loadStatusPage() {
try {
const [statusRes, checkpointsRes, lorasRes, templatesRes] = await Promise.all([
fetch(API + '/api/status'),
fetch(API + '/api/models/checkpoints'),
fetch(API + '/api/models/loras'),
fetch(API + '/api/templates'),
]);
const status = await statusRes.json();
const checkpoints = await checkpointsRes.json();
const loras = await lorasRes.json();
const templates = await templatesRes.json();
renderStatusPage(status);
document.getElementById('checkpoint-list').innerHTML = checkpoints.length > 0
? checkpoints.map(c => `<div style="padding:3px 0">${c}</div>`).join('')
: '<div>No checkpoints found</div>';
document.getElementById('lora-list').innerHTML = loras.length > 0
? loras.map(l => `<div style="padding:3px 0">${l}</div>`).join('')
: '<div>No LoRAs found - train one to get started!</div>';
document.getElementById('template-list-status').innerHTML = `
<div class="status-grid">
${templates.map(t => `
<div class="stat-card">
<div class="stat-label">${t.rating.toUpperCase()}</div>
<div style="font-size:16px;font-weight:600;margin-top:4px">${t.name}</div>
<div style="font-size:12px;color:var(--text-secondary);margin-top:4px">${Object.keys(t.variables).length} variables</div>
</div>
`).join('')}
</div>
`;
} catch(e) {}
}
function renderStatusPage(data) {
const grid = document.getElementById('status-grid');
const vramUsedPct = data.vram_total_gb ? ((data.vram_total_gb - (data.vram_free_gb || 0)) / data.vram_total_gb * 100).toFixed(0) : 0;
grid.innerHTML = `
<div class="stat-card">
<div class="stat-label">GPU</div>
<div class="stat-value" style="font-size:16px;margin-top:8px">${data.gpu_name || 'N/A'}</div>
</div>
<div class="stat-card">
<div class="stat-label">VRAM Usage</div>
<div class="stat-value">${data.vram_free_gb ? (data.vram_total_gb - data.vram_free_gb).toFixed(1) : '?'} <span style="font-size:14px;color:var(--text-secondary)">/ ${data.vram_total_gb?.toFixed(1) || '?'} GB</span></div>
<div class="vram-bar"><div class="vram-bar-fill" style="width:${vramUsedPct}%"></div></div>
</div>
<div class="stat-card">
<div class="stat-label">ComfyUI Status</div>
<div class="stat-value" style="color:${data.comfyui_connected ? 'var(--green)' : 'var(--red)'}">${data.comfyui_connected ? 'Online' : 'Offline'}</div>
<div class="stat-sub">Queue depth: ${data.local_queue_depth}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Images</div>
<div class="stat-value">${data.total_images}</div>
<div class="stat-sub">In catalog</div>
</div>
`;
}
// --- RunPod Pod Management ---
let podPollInterval = null;
async function loadPodStatus() {
try {
const res = await fetch(API + '/api/pod/status');
if (!res.ok) {
document.getElementById('pod-status-text').innerHTML = '<span style="color:var(--text-secondary)">RunPod not configured</span>';
return;
}
const pod = await res.json();
updatePodUI(pod);
} catch(e) {
document.getElementById('pod-status-text').innerHTML = '<span style="color:var(--text-secondary)">Error checking pod status</span>';
}
}
function updatePodUI(pod) {
const statusText = document.getElementById('pod-status-text');
const startBtn = document.getElementById('pod-start-btn');
const stopBtn = document.getElementById('pod-stop-btn');
const gpuSelect = document.getElementById('pod-gpu-select');
const podInfo = document.getElementById('pod-info');
if (pod.status === 'running') {
statusText.innerHTML = `<span style="color:var(--green)">● Running</span> <span style="color:var(--text-secondary)">(${pod.gpu_type?.split(' ').pop() || 'GPU'})</span>`;
startBtn.style.display = 'none';
stopBtn.style.display = '';
gpuSelect.style.display = 'none';
podInfo.style.display = '';
if (pod.comfyui_url) {
document.getElementById('pod-comfyui-link').href = pod.comfyui_url;
}
if (pod.uptime_minutes != null) {
document.getElementById('pod-uptime').textContent = pod.uptime_minutes.toFixed(0) + ' min';
const cost = (pod.uptime_minutes / 60) * (pod.cost_per_hour || 0.44);
document.getElementById('pod-cost').textContent = '$' + cost.toFixed(2);
}
// Start polling if not already
if (!podPollInterval) {
podPollInterval = setInterval(loadPodStatus, 30000);
}
} else if (pod.status === 'starting' || pod.status === 'setting_up') {
const setupMsg = pod.setup_status || 'Starting pod...';
statusText.innerHTML = `<span style="color:var(--yellow)">● ${setupMsg}</span>`;
startBtn.style.display = 'none';
stopBtn.style.display = ''; // Allow stopping during setup
gpuSelect.style.display = 'none';
podInfo.style.display = 'none';
// Poll more frequently while starting
if (!podPollInterval) {
podPollInterval = setInterval(loadPodStatus, 5000);
}
} else {
statusText.innerHTML = '<span style="color:var(--text-secondary)">● Stopped</span>';
startBtn.style.display = '';
stopBtn.style.display = 'none';
gpuSelect.style.display = '';
podInfo.style.display = 'none';
// Stop polling when stopped
if (podPollInterval) {
clearInterval(podPollInterval);
podPollInterval = null;
}
}
}
async function startPod() {
const gpuType = document.getElementById('pod-gpu-select').value;
const modelType = document.getElementById('pod-model-type').value;
const btn = document.getElementById('pod-start-btn');
btn.disabled = true;
btn.textContent = 'Starting...';
try {
const res = await fetch(API + '/api/pod/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({gpu_type: gpuType, model_type: modelType})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Failed to start pod');
}
const modelName = modelType === 'wan' ? 'WAN 2.2' : 'FLUX.2';
toast(`Starting ${modelName} pod... This takes 3-5 minutes`, 'info');
loadPodStatus();
} catch(e) {
toast('Failed to start pod: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Start Pod';
}
}
async function stopPod() {
showConfirm('Stop the GPU pod? You will stop being charged.', async () => {
const btn = document.getElementById('pod-stop-btn');
btn.disabled = true;
btn.textContent = 'Stopping...';
try {
const res = await fetch(API + '/api/pod/stop', {method: 'POST'});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Failed to stop pod');
}
toast('GPU pod stopped', 'success');
loadPodStatus();
} catch(e) {
toast('Failed to stop pod: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Stop Pod';
}
});
}
// Load pod status when status page loads
const originalLoadStatusPage = loadStatusPage;
loadStatusPage = async function() {
await originalLoadStatusPage();
await loadPodStatus();
};
// --- Toast ---
function toast(message, type = 'info') {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.textContent = message;
container.appendChild(el);
setTimeout(() => el.remove(), 5000);
}
// --- Settings ---
let apiSettingsData = null;
async function loadAPISettings() {
try {
const res = await fetch(API + '/api/settings/api');
if (!res.ok) throw new Error('Failed to load settings');
apiSettingsData = await res.json();
const statusEl = document.getElementById('api-settings-status');
const cloudWarning = document.getElementById('cloud-mode-warning');
const actionsEl = document.getElementById('api-keys-actions');
// Show cloud mode warning if on HF Spaces
if (apiSettingsData.is_cloud) {
cloudWarning.style.display = 'block';
actionsEl.style.display = 'none';
document.getElementById('runpod-key-input').disabled = true;
document.getElementById('wavespeed-key-input').disabled = true;
} else {
cloudWarning.style.display = 'none';
actionsEl.style.display = 'flex';
document.getElementById('runpod-key-input').disabled = false;
document.getElementById('wavespeed-key-input').disabled = false;
}
// Update status indicators
const runpodStatus = document.getElementById('runpod-key-status');
if (apiSettingsData.runpod_configured) {
runpodStatus.innerHTML = `<span style="color:var(--green)">✓ Configured</span> <span style="color:var(--text-secondary)">(${apiSettingsData.runpod_key_preview})</span>`;
} else {
runpodStatus.innerHTML = `<span style="color:var(--orange)">⚠ Not configured</span>`;
}
const wavespeedStatus = document.getElementById('wavespeed-key-status');
if (apiSettingsData.wavespeed_configured) {
wavespeedStatus.innerHTML = `<span style="color:var(--green)">✓ Configured</span> <span style="color:var(--text-secondary)">(${apiSettingsData.wavespeed_key_preview})</span>`;
} else {
wavespeedStatus.innerHTML = `<span style="color:var(--text-secondary)">Not configured (optional)</span>`;
}
statusEl.innerHTML = apiSettingsData.is_cloud
? '<span style="color:var(--blue)">Running on Hugging Face Spaces</span>'
: `<span style="color:var(--green)">Running locally</span> <span style="color:var(--text-secondary)">(${apiSettingsData.env_file_path})</span>`;
} catch (err) {
document.getElementById('api-settings-status').innerHTML = `<span style="color:var(--red)">Error loading settings: ${err.message}</span>`;
}
}
function toggleKeyVisibility(inputId, btn) {
const input = document.getElementById(inputId);
if (input.type === 'password') {
input.type = 'text';
btn.textContent = 'Hide';
} else {
input.type = 'password';
btn.textContent = 'Show';
}
}
async function saveAPIKeys() {
const runpodKey = document.getElementById('runpod-key-input').value.trim();
const wavespeedKey = document.getElementById('wavespeed-key-input').value.trim();
if (!runpodKey && !wavespeedKey) {
toast('Enter at least one API key', 'error');
return;
}
try {
const body = {};
if (runpodKey) body.runpod_api_key = runpodKey;
if (wavespeedKey) body.wavespeed_api_key = wavespeedKey;
const res = await fetch(API + '/api/settings/api', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Failed to save');
}
toast('API keys saved! Restart server to apply changes.', 'success');
document.getElementById('runpod-key-input').value = '';
document.getElementById('wavespeed-key-input').value = '';
loadAPISettings();
} catch (err) {
toast('Error saving keys: ' + err.message, 'error');
}
}
// --- Keyboard shortcuts ---
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (document.getElementById('confirm-overlay').classList.contains('open')) {
closeConfirm();
} else if (document.getElementById('lightbox').classList.contains('open')) {
closeLightbox();
}
}
// Arrow key navigation in lightbox
if (document.getElementById('lightbox').classList.contains('open')) {
if (e.key === 'ArrowLeft') navigateLightbox(-1);
if (e.key === 'ArrowRight') navigateLightbox(1);
}
});
</script>
</body>
</html>