Spaces:
Running
Running
| <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. 1. ohwx woman, portrait, natural lighting, smiling 2. ohwx woman, full body, outdoor, park 3. ohwx woman, close-up, studio lighting ..."></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()">×</button> | |
| <button class="lightbox-nav prev" id="lightbox-prev" onclick="event.stopPropagation();navigateLightbox(-1)">‹</button> | |
| <img id="lightbox-img" src=""> | |
| <button class="lightbox-nav next" id="lightbox-next" onclick="event.stopPropagation();navigateLightbox(1)">›</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> | |