krea-realtime-video / index.html
multimodalart's picture
Update UX
fd346ae verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KREA Realtime Video</title>
<link href="https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;500;700&family=Roboto+Mono&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Ubuntu', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #fafaf9;
color: #1c1917;
min-height: 100vh;
}
.container {
max-width: 1270px;
margin: 0 auto;
padding: 20px;
}
/* Login Strip */
.login-strip {
background: #fef3c7;
border-bottom: 2px solid #f59e0b;
padding: 15px 20px;
box-shadow: 0 3px 0 0 #d97706;
position: sticky;
top: 0;
z-index: 100;
}
.login-strip-content {
max-width: 1600px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.login-message {
color: #78350f;
font-weight: 500;
}
.app-grayed {
opacity: 0.4;
pointer-events: none;
filter: grayscale(50%);
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: all 0.1s;
text-decoration: none;
display: inline-block;
font-family: 'Ubuntu', sans-serif;
border-width: 0px;
}
.btn-primary {
background: #f59e0b;
color: #78350f;
box-shadow: 0px 3px 0px 0px #d97706;
}
.btn-primary:hover {
box-shadow: 0px 5px 0px 0px #d97706;
transform: translateY(-2px);
}
.btn-primary:active {
box-shadow: 0px 2px 0px 0px #d97706;
transform: translateY(1px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #fbbf24;
color: #78350f;
box-shadow: 0px 3px 0px 0px #f59e0b;
}
.btn-secondary:hover {
box-shadow: 0px 5px 0px 0px #f59e0b;
transform: translateY(-2px);
}
.btn-secondary:active {
box-shadow: 0px 2px 0px 0px #f59e0b;
transform: translateY(1px);
}
.btn-danger {
background: #ef4444;
color: white;
box-shadow: 0px 3px 0px 0px #dc2626;
}
.btn-danger:hover {
box-shadow: 0px 5px 0px 0px #dc2626;
transform: translateY(-2px);
}
.btn-danger:active {
box-shadow: 0px 2px 0px 0px #dc2626;
transform: translateY(1px);
}
.info-box, .error-box, .warning-box {
border-radius: 6px;
padding: 12px;
margin-top: 15px;
font-size: 0.85rem;
border-width: 1px;
border-style: solid;
}
.info-box {
background: #fef3c7;
border-color: #fbbf24;
color: #78350f;
}
.error-box {
background: #fee2e2;
border-color: #fca5a5;
color: #7f1d1d;
}
.warning-box {
background: #fef3c7;
border-color: #fbbf24;
color: #78350f;
}
/* User Bar */
.user-bar {
background: #fafaf9;
border-radius: 8px;
border: 1px solid #d6d3d1;
padding: 15px 20px;
margin-top: 20px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0px 3px 0px 0px #d6d3d1;
}
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid #f59e0b;
}
.user-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-pro {
background: #f59e0b;
color: white;
}
.badge-free {
background: #78716c;
color: #e7e5e4;
}
.btn-logout {
padding: 8px 16px;
background: #78716c;
color: #fafaf9;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-logout:hover {
background: #57534e;
}
#spinner, #placeholder {
position: absolute;
}
/* Main App Layout */
header {
text-align: center;
margin-bottom: 20px;
}
h1 {
font-size: 2.5rem;
margin-bottom: 10px;
color: #1c1917;
font-weight: 700;
}
.header-links {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 10px;
}
.header-link {
color: #f59e0b;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
}
.header-link:hover {
text-decoration: underline;
}
.subtitle {
color: #78716c;
font-size: 0.9rem;
margin-top: 5px;
}
/* Video Grid */
.video-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.video-grid.text-mode {
grid-template-columns: 1fr;
}
.video-grid.text-mode .output-panel {
max-width: 65%;
margin: 0 auto;
}
@media (max-width: 900px) {
.video-grid.text-mode .output-panel {
max-width: 100%;
}
}
.panel {
background: #fafaf9;
border-radius: 8px;
border: 1px solid #d6d3d1;
padding: 20px;
box-shadow: 0px 3px 0px 0px #d6d3d1;
}
.panel h2 {
font-size: 1.1rem;
margin-bottom: 15px;
color: #1c1917;
}
.video-container {
background: #000;
border-radius: 8px;
aspect-ratio: 832/480;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
box-shadow: 0px -1px 0px 0px #d6d3d1;
}
#outputCanvas, #webcamVideo {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 6px;
}
.placeholder {
text-align: center;
color: #78716c;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #e7e5e4;
border-top: 4px solid #f59e0b;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #d6d3d1;
font-size: 0.85rem;
}
.status-pill {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
display: none;
}
.status-connected {
background: #dcfce7;
color: #15803d;
}
.status-disconnected {
background: #fee2e2;
color: #991b1b;
}
.status-finished {
background: #dbeafe;
color: #1e40af;
}
.mode-toggle {
display: flex;
gap: 10px;
margin-bottom: 20px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.mode-btn {
flex: 1;
padding: 5px;
border: 1px solid #d6d3d1;
border-radius: 6px;
background: #fafaf9;
color: #57534e;
cursor: pointer;
transition: all 0.2s;
font-size: 0.85rem;
font-weight: 500;
}
.mode-btn:hover:not(:disabled) {
background: #fef3c7;
}
.mode-btn.active {
background: #fbbf24;
color: #78350f;
box-shadow: 0px 3px 0px 0px #f59e0b;
border-color: #f59e0b;
}
.mode-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
font-size: 0.85rem;
color: #57534e;
margin-bottom: 5px;
font-weight: 500;
}
input[type="text"],
input[type="number"],
input[type="password"],
textarea,
select {
width: 100%;
padding: 8px 12px;
background: #fafaf9;
border: 1px solid #d6d3d1;
border-radius: 6px;
color: #1c1917;
font-size: 0.9rem;
box-shadow: 0px -1px 0px 0px #d6d3d1;
font-family: 'Ubuntu', sans-serif;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: #f59e0b;
box-shadow: 0px -1px 0px 0px #f59e0b;
background: #fef3c7;
}
textarea {
min-height: 80px;
resize: vertical;
font-family: 'Ubuntu', sans-serif;
}
input[type="range"] {
width: 100%;
height: 4px;
background: #d6d3d1;
border-radius: 2px;
outline: none;
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #f59e0b;
cursor: pointer;
border-radius: 50%;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: #f59e0b;
cursor: pointer;
border-radius: 50%;
border: none;
}
.range-value {
display: inline-block;
margin-left: 10px;
font-weight: 500;
color: #f59e0b;
}
.hint {
font-size: 0.75rem;
color: #78716c;
margin-top: 4px;
}
.progress-bar {
width: 100%;
height: 6px;
background: #e7e5e4;
border-radius: 3px;
margin-top: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #f59e0b;
transition: width 0.3s ease;
width: 0%;
}
.action-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.hidden {
display: none;
}
.limit-warning {
background: #fee2e2;
border: 1px solid #fca5a5;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
text-align: center;
}
.upgrade-link {
color: #f59e0b;
text-decoration: none;
font-weight: 500;
}
.upgrade-link:hover {
text-decoration: underline;
}
/* Accordion */
.accordion {
margin-top: 15px;
}
.accordion-header {
background: #e7e5e4;
padding: 12px 15px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
color: #1c1917;
border: 1px solid #d6d3d1;
}
.accordion-header:hover {
background: #d6d3d1;
}
.accordion-icon {
transition: transform 0.2s;
}
.accordion-icon.open {
transform: rotate(180deg);
}
.accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.accordion-content.open {
max-height: 1000px;
}
.accordion-body {
padding: 15px 0;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-overlay:not(.hidden) {
display: flex;
}
.modal {
background: #fafaf9;
border-radius: 12px;
border: 1px solid #d6d3d1;
padding: 30px;
max-width: 500px;
width: 90%;
box-shadow: 0px 10px 40px rgba(0, 0, 0, 0.3);
}
.modal h2 {
color: #1c1917;
margin-bottom: 15px;
font-size: 1.5rem;
}
.modal p {
color: #57534e;
margin-bottom: 20px;
line-height: 1.5;
}
.modal-options {
display: flex;
flex-direction: column;
gap: 15px;
}
.modal-option {
padding: 15px;
border: 2px solid #d6d3d1;
border-radius: 8px;
text-align: left;
background: #fafaf9;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.modal-option:hover {
border-color: #f59e0b;
background: #fef3c7;
}
.modal-option h3 {
color: #1c1917;
font-size: 1rem;
margin-bottom: 5px;
}
.modal-option p {
color: #78716c;
font-size: 0.85rem;
margin: 0;
}
.modal-fal-key {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #d6d3d1;
}
.modal-fal-key h3 {
color: #1c1917;
font-size: 1rem;
margin-bottom: 10px;
}
.modal-fal-key .form-group {
margin-bottom: 10px;
}
@media (max-width: 1024px) {
.video-grid {
grid-template-columns: 1fr;
}
.user-bar {
flex-direction: column;
gap: 15px;
}
.mode-toggle {
max-width: 100%;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Login Strip (Hidden when authenticated) -->
<div id="loginStrip" class="login-strip hidden">
<div class="login-strip-content">
<div class="login-message">
Sign in with Hugging Face to start generating videos
</div>
<button class="btn btn-primary" onclick="auth.loginWithOAuth()">
Sign in with Hugging Face
</button>
</div>
</div>
<!-- App Container (Grayed out when not authenticated) -->
<div id="appContainer">
<header>
<h1>KREA Realtime Video</h1>
<div class="header-links">
<a href="https://huggingface.co/krea/krea-realtime-video" target="_blank" class="header-link">Model on Hugging Face</a>
<span style="color: #d6d3d1;"></span>
<a href="https://github.com/krea-ai/realtime-video" target="_blank" class="header-link">GitHub Repository</a>
<span style="color: #d6d3d1;"></span>
<a href="https://fal.ai/models/fal-ai/krea/realtime-video" target="_blank" class="header-link">FAL API</a>
</div>
</header>
<!-- Mode Selection -->
<div class="mode-toggle">
<button id="webcamModeBtn" class="mode-btn active">Webcam-to-Video</button>
<button id="videoModeBtn" class="mode-btn">Video-to-Video</button>
<button id="textModeBtn" class="mode-btn">Text-to-Video</button>
</div>
<!-- Video Grid: Webcam Left, Output Right -->
<div id="videoGrid" class="video-grid">
<!-- Input Panel (Webcam or Video) -->
<div id="inputPanel" class="panel">
<h2 id="inputPanelTitle">Webcam Input</h2>
<!-- Webcam Video -->
<div id="webcamContainer" class="video-container">
<video id="webcamVideo" autoplay playsinline muted></video>
<div id="webcamPlaceholder" class="placeholder">
<p>Webcam will appear here</p>
</div>
</div>
<!-- Uploaded Video Preview -->
<div id="uploadedVideoContainer" class="video-container hidden">
<video id="uploadedVideoPreview" loop muted playsinline></video>
<div id="uploadedVideoPlaceholder" class="placeholder">
<p>Upload a video to see preview</p>
</div>
</div>
<!-- Video Upload UI (for video mode) -->
<div id="videoUploadUI" class="hidden" style="margin-top: 15px;">
<input type="file" id="videoFileInput" accept="video/*" class="hidden">
<button class="btn btn-secondary" style="width: 100%;" onclick="document.getElementById('videoFileInput').click()">
Upload Video
</button>
<div id="videoInfoDisplay" style="margin-top: 10px; font-size: 0.85rem; color: #78716c;"></div>
</div>
</div>
<!-- Video Output Panel -->
<div class="panel output-panel">
<h2>Generated Video</h2>
<div class="video-container">
<canvas id="outputCanvas"></canvas>
<div id="spinner" class="spinner hidden"></div>
<div id="placeholder" class="placeholder">
<p>Configure settings and click Start</p>
</div>
</div>
<div class="status-bar">
<div>
<span id="statusPill" class="status-pill status-disconnected">Disconnected</span>
<span style="margin-left: 15px;">Frames: <strong id="frameCount">0</strong> / <strong id="maxFrames">474</strong></span>
</div>
<div>
<label style="display: inline; margin: 0;">Playback:</label>
<input type="range" id="playbackFps" min="8" max="20" value="8" style="width: 100px;">
<span class="range-value" id="playbackFpsValue">8 fps</span>
</div>
</div>
<!-- Video Controls (Replay & Download) -->
<div id="videoControls" class="hidden" style="display: flex; gap: 10px; margin-top: 10px;">
<button id="replayBtn" class="btn btn-secondary" onclick="app.replayVideo()" style="flex: 1;">
Replay
</button>
<button id="downloadBtn" class="btn btn-secondary" onclick="app.downloadVideo()" style="flex: 1;">
Download
</button>
</div>
<div class="progress-bar">
<div id="progressFill" class="progress-fill"></div>
</div>
</div>
</div>
<!-- Controls Panel -->
<div class="panel">
<!-- Prompt (Main Setting) -->
<div class="form-group">
<label>Prompt <span style="color: #15803d; font-size: 0.75rem;">(updates sent every 2 seconds)</span></label>
<textarea id="prompt">A small, fluffy cat. Soft morning light filters through sheer curtains, casting warm, golden patterns across its fur. The cat’s coat is silky and well-groomed, a mix of cream and light orange tones that shimmer gently as it breathes. Dust motes drift lazily in the air, glowing in the sunlight. The room is peaceful — a few plants rest by the window, and a faint breeze causes the curtains to sway. The cat’s eyes are half-closed, reflecting a soft green hue, and its tiny ears twitch at distant sounds outside. Everything feels calm, intimate, and comforting — a portrait of stillness and warmth.</textarea>
<div class="hint">Detailed prompts work better</div>
</div>
<!-- Action Buttons (Outside Accordion) -->
<div class="action-buttons">
<button id="startStopBtn" class="btn btn-primary" onclick="app.toggleGeneration()" style="flex: 1;">
Start Generation
</button>
</div>
<!-- Advanced Settings Accordion -->
<div class="accordion">
<div class="accordion-header" onclick="app.toggleAccordion()">
<span>Advanced Settings</span>
<span class="accordion-icon" id="accordionIcon"></span>
</div>
<div class="accordion-content" id="accordionContent">
<div class="accordion-body">
<!-- Input Controls (V2V and Webcam modes) -->
<div id="inputControls">
<div class="form-group">
<label>Input Frame Rate: <span class="range-value" id="inputFpsValue">10</span> fps</label>
<input type="range" id="inputFps" min="8" max="14" value="10">
<div class="hint">Recommended: 10-12 fps</div>
</div>
<div class="form-group">
<label>Transformation Strength: <span class="range-value" id="strengthValue">0.45</span></label>
<input type="range" id="strength" min="0.05" max="1" step="0.05" value="0.45">
<div class="hint">Lower values preserve input better, higher values allow more creative freedom</div>
</div>
</div>
<div class="form-group">
<label>Max Frames (Blocks): <span class="range-value" id="numBlocksValue">25</span> → ~<span id="estimatedFrames">294</span> frames</label>
<input type="range" id="numBlocks" min="10" max="50" step="5" value="25">
<div class="hint">Each block generates ~12 frames</div>
</div>
<div class="form-group">
<label>Seed (optional)</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<input type="number" id="seed" placeholder="random">
<button class="btn btn-secondary" onclick="app.randomizeSeed()">Random</button>
</div>
</div>
</div>
</div>
</div>
<div id="errorBox" class="error-box hidden"></div>
<div id="infoBox" class="info-box hidden"></div>
</div>
<!-- Limit Reached Modal -->
<div id="limitModal" class="modal-overlay hidden">
<div class="modal">
<h2>Daily Limit Reached</h2>
<p>You've used all your generations for today. Here are your options:</p>
<div class="modal-options">
<!-- Subscribe to PRO (Free users only) -->
<a href="https://huggingface.co/subscribe/pro" target="_blank" class="modal-option" id="proOption">
<h3>Subscribe to PRO</h3>
<p>Get more generations per day with Hugging Face PRO</p>
</a>
<!-- Run Locally -->
<a href="https://huggingface.co/krea/krea-realtime-video" target="_blank" class="modal-option">
<h3>Run Locally</h3>
<p>Download and run the model on your own hardware</p>
</a>
<!-- Use API -->
<a href="https://fal.ai" target="_blank" class="modal-option">
<h3>Use with API</h3>
<p>Integrate into your own applications with FAL API</p>
</a>
</div>
<!-- FAL Key Input -->
<div class="modal-fal-key">
<h3>Or use your own FAL API Key</h3>
<div class="form-group">
<label>FAL API Key <a href="https://fal.ai/dashboard/keys" target="_blank" style="color: #f59e0b; font-size: 0.75rem;">(Get your key)</a></label>
<input type="password" id="falKeyInput" placeholder="Enter your FAL API key">
</div>
<button class="btn btn-primary" style="width: 100%;" onclick="limitModal.saveFalKey()">
Save & Continue
</button>
<p style="font-size: 0.75rem; color: #78716c; margin-top: 10px;">
Your key is stored locally and never sent to our servers
</p>
</div>
<button onclick="limitModal.close()" style="margin-top: 20px; background: transparent; border: none; color: #78716c; cursor: pointer; width: 100%;">
Close
</button>
</div>
</div>
<!-- User Bar (Hidden when not authenticated) -->
<div id="userBar" class="user-bar hidden">
<div class="user-info">
<img id="userAvatar" src="" alt="Avatar" class="user-avatar">
<div class="user-details">
<h3 id="userFullname" style="font-size: 1rem; margin-bottom: 3px;"></h3>
<span id="userBadge" class="user-badge"></span>
</div>
</div>
<div style="text-align: right;">
<button class="btn-logout" onclick="auth.logout()">Logout</button>
</div>
</div>
</div>
</div>
<!-- Hidden elements for video processing -->
<video id="hiddenVideo" style="display: none;" muted playsinline></video>
<canvas id="extractionCanvas" style="display: none;"></canvas>
<script>
const OAUTH_CLIENT_ID = "{{ oauth_client_id }}";
const AUTH_STORAGE_KEY = 'HF_AUTH_STATE';
const FAL_KEY_STORAGE = 'FAL_API_KEY';
// Limit Modal Manager
const limitModal = {
show(isPro) {
// Show/hide PRO option based on user tier
const proOption = document.getElementById('proOption');
if (isPro) {
proOption.style.display = 'none';
} else {
proOption.style.display = 'block';
}
// Check if FAL key already saved
const savedKey = localStorage.getItem(FAL_KEY_STORAGE);
if (savedKey) {
document.getElementById('falKeyInput').value = '••••••••••••';
}
document.getElementById('limitModal').classList.remove('hidden');
},
close() {
const modal = document.getElementById('limitModal');
if (modal) {
modal.classList.add('hidden');
}
},
saveFalKey() {
const key = document.getElementById('falKeyInput').value.trim();
if (!key || key === '••••••••••••') {
app.showError('Please enter a valid FAL API key');
return;
}
localStorage.setItem(FAL_KEY_STORAGE, key);
app.showInfo('FAL API key saved! You can now use unlimited generations.');
auth.hasFalKey = true;
this.close();
},
hasFalKey() {
return !!localStorage.getItem(FAL_KEY_STORAGE);
},
getFalKey() {
return localStorage.getItem(FAL_KEY_STORAGE);
}
};
// Authentication Manager
const auth = {
token: null,
user: null,
canStart: false,
sessionsUsed: 0,
sessionsLimit: 2,
hasFalKey: false,
async init() {
// Check if user has FAL key
this.hasFalKey = limitModal.hasFalKey();
// Try to restore saved auth from localStorage
const saved = localStorage.getItem(AUTH_STORAGE_KEY);
if (saved) {
try {
const authState = JSON.parse(saved);
await this.validateAndSetAuth(authState.token);
} catch (e) {
console.error('Failed to restore auth:', e);
this.clearAuth();
}
} else {
this.showLoginStrip();
}
},
async validateAndSetAuth(token) {
try {
const response = await fetch('/api/whoami', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Token validation failed with status: ' + response.status);
}
const data = await response.json();
this.token = token;
this.user = data;
this.canStart = data.can_start;
this.sessionsUsed = data.sessions_used;
this.sessionsLimit = data.sessions_limit;
// Save to localStorage
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ token, user: data }));
this.updateUI();
app.init();
} catch (error) {
console.error('Auth validation failed:', error);
this.clearAuth();
throw error;
}
},
loginWithOAuth() {
window.location.href = '/api/auth/login';
},
logout() {
this.clearAuth();
window.location.reload();
},
clearAuth() {
this.token = null;
this.user = null;
this.canStart = false;
localStorage.removeItem(AUTH_STORAGE_KEY);
this.showLoginStrip();
},
showLoginStrip() {
document.getElementById('loginStrip').classList.remove('hidden');
document.getElementById('appContainer').classList.add('app-grayed');
document.getElementById('userBar').classList.add('hidden');
},
updateUI() {
// Hide login strip, show app
document.getElementById('loginStrip').classList.add('hidden');
document.getElementById('appContainer').classList.remove('app-grayed');
// Show user bar
const userBar = document.getElementById('userBar');
userBar.classList.remove('hidden');
// Update user info
if (this.user.avatar) {
document.getElementById('userAvatar').src = this.user.avatar;
}
document.getElementById('userFullname').textContent = this.user.fullname;
const badge = document.getElementById('userBadge');
badge.textContent = this.user.is_pro ? 'PRO' : 'FREE';
badge.className = 'user-badge ' + (this.user.is_pro ? 'badge-pro' : 'badge-free');
// No longer show banner - modal will appear when user tries to generate
},
getAuthHeaders() {
return this.token ? { 'Authorization': `Bearer ${this.token}` } : {};
}
};
// MsgPack Encoder
const createMsgpackEncoder = (() => {
const textEncoder = new TextEncoder();
const ensureUint8Array = (value) => (value instanceof Uint8Array ? value : new Uint8Array(value));
const writeUInt8 = (buffer, value) => buffer.push(value & 0xff);
const writeUInt16 = (buffer, value) => {
buffer.push((value >>> 8) & 0xff, value & 0xff);
};
const writeUInt32 = (buffer, value) => {
buffer.push((value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff);
};
const writeFloat64 = (buffer, value) => {
const array = new ArrayBuffer(8);
new DataView(array).setFloat64(0, value);
buffer.push(...new Uint8Array(array));
};
const encodeString = (buffer, value) => {
const utf8 = ensureUint8Array(textEncoder.encode(value));
const length = utf8.length;
if (length <= 31) {
writeUInt8(buffer, 0xa0 | length);
} else if (length <= 0xff) {
writeUInt8(buffer, 0xd9);
writeUInt8(buffer, length);
} else if (length <= 0xffff) {
writeUInt8(buffer, 0xda);
writeUInt16(buffer, length);
} else {
writeUInt8(buffer, 0xdb);
writeUInt32(buffer, length);
}
buffer.push(...utf8);
};
const encodeNumber = (buffer, value) => {
if (Number.isInteger(value)) {
if (value >= 0 && value <= 0x7f) {
writeUInt8(buffer, value);
} else if (value < 0 && value >= -32) {
buffer.push(value & 0xff);
} else if (value >= 0 && value <= 0xff) {
writeUInt8(buffer, 0xcc);
writeUInt8(buffer, value);
} else if (value >= 0 && value <= 0xffff) {
writeUInt8(buffer, 0xcd);
writeUInt16(buffer, value);
} else if (value >= 0 && value <= 0xffffffff) {
writeUInt8(buffer, 0xce);
writeUInt32(buffer, value);
} else {
writeUInt8(buffer, 0xcb);
writeFloat64(buffer, value);
}
} else {
writeUInt8(buffer, 0xcb);
writeFloat64(buffer, value);
}
};
const encodeArray = (buffer, value) => {
const length = value.length;
if (length < 16) {
writeUInt8(buffer, 0x90 | length);
} else if (length <= 0xffff) {
writeUInt8(buffer, 0xdc);
writeUInt16(buffer, length);
} else {
writeUInt8(buffer, 0xdd);
writeUInt32(buffer, length);
}
value.forEach((item) => encodeValue(buffer, item));
};
const encodeObject = (buffer, value) => {
const entries = Object.entries(value).filter(([, v]) => v !== undefined);
const length = entries.length;
if (length < 16) {
writeUInt8(buffer, 0x80 | length);
} else if (length <= 0xffff) {
writeUInt8(buffer, 0xde);
writeUInt16(buffer, length);
} else {
writeUInt8(buffer, 0xdf);
writeUInt32(buffer, length);
}
for (const [key, entryValue] of entries) {
encodeString(buffer, key);
encodeValue(buffer, entryValue);
}
};
function encodeValue(buffer, value) {
if (value === null) {
writeUInt8(buffer, 0xc0);
} else if (value === false) {
writeUInt8(buffer, 0xc2);
} else if (value === true) {
writeUInt8(buffer, 0xc3);
} else if (typeof value === "number") {
encodeNumber(buffer, value);
} else if (typeof value === "string") {
encodeString(buffer, value);
} else if (Array.isArray(value)) {
encodeArray(buffer, value);
} else if (value instanceof Uint8Array) {
const length = value.length;
if (length <= 0xff) {
writeUInt8(buffer, 0xc4);
writeUInt8(buffer, length);
} else if (length <= 0xffff) {
writeUInt8(buffer, 0xc5);
writeUInt16(buffer, length);
} else {
writeUInt8(buffer, 0xc6);
writeUInt32(buffer, length);
}
buffer.push(...value);
} else if (value && typeof value === "object") {
encodeObject(buffer, value);
} else {
throw new Error("Unsupported type for MsgPack encoding");
}
}
return (input) => {
const buffer = [];
encodeValue(buffer, input);
return new Uint8Array(buffer);
};
})();
// Main App
const app = {
mode: 'webcam',
isGenerating: false,
frameCount: 0,
maxFrames: 474,
ws: null,
frameBuffer: [],
allFrames: [], // Store all frames for replay
playbackInterval: null,
frameExtractionInterval: null,
webcamStream: null,
currentVideoFile: null,
videoMetadata: null,
mediaRecorder: null,
recordedChunks: [],
promptUpdateTimer: null,
pendingPromptUpdate: null,
generationFinished: false,
idleTimeout: null,
lastFrameTime: null,
init() {
this.setupEventListeners();
this.updateEstimatedFrames();
// Auto-start webcam since it's the default mode
this.setMode('webcam');
},
toggleAccordion() {
const content = document.getElementById('accordionContent');
const icon = document.getElementById('accordionIcon');
content.classList.toggle('open');
icon.classList.toggle('open');
},
setupEventListeners() {
document.getElementById('textModeBtn').addEventListener('click', () => this.setMode('text'));
document.getElementById('videoModeBtn').addEventListener('click', () => this.setMode('video'));
document.getElementById('webcamModeBtn').addEventListener('click', () => this.setMode('webcam'));
// Video file upload (new location in left panel)
document.getElementById('videoFileInput').addEventListener('change', (e) => this.handleVideoUpload(e));
document.getElementById('playbackFps').addEventListener('input', (e) => {
document.getElementById('playbackFpsValue').textContent = e.target.value + ' fps';
if (this.playbackInterval) {
this.startPlaybackLoop();
}
});
document.getElementById('inputFps').addEventListener('input', (e) => {
document.getElementById('inputFpsValue').textContent = e.target.value;
if (this.isGenerating && (this.mode === 'video' || this.mode === 'webcam')) {
this.stopFrameExtraction();
this.startFrameExtraction();
}
});
document.getElementById('strength').addEventListener('input', (e) => {
document.getElementById('strengthValue').textContent = parseFloat(e.target.value).toFixed(2);
});
document.getElementById('numBlocks').addEventListener('input', (e) => {
document.getElementById('numBlocksValue').textContent = e.target.value;
this.updateEstimatedFrames();
if (this.isGenerating) {
this.queuePromptUpdate();
}
});
// Prompt changes for live updates
document.getElementById('prompt').addEventListener('input', () => {
if (this.isGenerating && (this.mode === 'text' || this.mode === 'webcam')) {
this.queuePromptUpdate();
}
});
},
setMode(mode) {
if (this.isGenerating) return;
this.mode = mode;
// Update mode buttons
document.getElementById('textModeBtn').classList.toggle('active', mode === 'text');
document.getElementById('videoModeBtn').classList.toggle('active', mode === 'video');
document.getElementById('webcamModeBtn').classList.toggle('active', mode === 'webcam');
// Update grid layout for text mode (center output, hide input)
const videoGrid = document.getElementById('videoGrid');
const inputPanel = document.getElementById('inputPanel');
if (mode === 'text') {
videoGrid.classList.add('text-mode');
inputPanel.classList.add('hidden');
} else {
videoGrid.classList.remove('text-mode');
inputPanel.classList.remove('hidden');
}
// Update input panel title and content
const inputPanelTitle = document.getElementById('inputPanelTitle');
const webcamContainer = document.getElementById('webcamContainer');
const uploadedVideoContainer = document.getElementById('uploadedVideoContainer');
const videoUploadUI = document.getElementById('videoUploadUI');
if (mode === 'webcam') {
inputPanelTitle.textContent = 'Webcam Input';
webcamContainer.classList.remove('hidden');
uploadedVideoContainer.classList.add('hidden');
videoUploadUI.classList.add('hidden');
this.startWebcam();
} else if (mode === 'video') {
inputPanelTitle.textContent = 'Video Input';
webcamContainer.classList.add('hidden');
uploadedVideoContainer.classList.remove('hidden');
videoUploadUI.classList.remove('hidden');
this.stopWebcam();
} else {
this.stopWebcam();
}
// Input controls visible in accordion for webcam/video modes
const inputControls = document.getElementById('inputControls');
if (mode === 'text') {
inputControls.style.display = 'none';
} else {
inputControls.style.display = 'block';
}
},
async startWebcam() {
try {
this.webcamStream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: false
});
const video = document.getElementById('webcamVideo');
video.srcObject = this.webcamStream;
document.getElementById('webcamPlaceholder').style.display = 'none';
this.showInfo('Webcam started successfully');
} catch (error) {
this.showError(`Failed to access webcam: ${error.message}`);
// Switch back to text mode if webcam fails
this.setMode('text');
}
},
stopWebcam() {
if (this.webcamStream) {
this.webcamStream.getTracks().forEach(track => track.stop());
this.webcamStream = null;
const video = document.getElementById('webcamVideo');
video.srcObject = null;
document.getElementById('webcamPlaceholder').style.display = 'flex';
}
},
updateEstimatedFrames() {
const numBlocks = parseInt(document.getElementById('numBlocks').value);
const estimatedFrames = (12 * numBlocks) - 6;
this.maxFrames = estimatedFrames;
document.getElementById('estimatedFrames').textContent = estimatedFrames;
document.getElementById('maxFrames').textContent = estimatedFrames;
},
randomizeSeed() {
document.getElementById('seed').value = Math.floor(Math.random() * (1 << 24));
},
async toggleGeneration() {
if (this.isGenerating) {
this.disconnect();
} else {
if (!auth.token) {
this.showError('Please sign in first');
return;
}
// Check if user has FAL key - if so, skip HF generation limits
if (!auth.hasFalKey) {
// Record session and check limits
try {
const response = await fetch('/api/start-session', {
method: 'POST',
headers: auth.getAuthHeaders()
});
if (!response.ok) {
const data = await response.json();
// If limit reached, show modal
if (response.status === 429) {
limitModal.show(auth.user.is_pro);
return;
}
this.showError(data.detail || 'Failed to start session');
return;
}
// Update session count
const sessionData = await response.json();
auth.sessionsUsed = sessionData.sessions_used;
auth.sessionsLimit = sessionData.sessions_limit;
auth.canStart = sessionData.sessions_used < sessionData.sessions_limit;
} catch (error) {
this.showError('Failed to start session');
return;
}
}
const prompt = document.getElementById('prompt').value.trim();
if (!prompt) {
this.showError('Please enter a prompt');
return;
}
if (this.mode === 'video' && !this.currentVideoFile) {
this.showError('Please upload a video file');
return;
}
if (this.mode === 'webcam' && !this.webcamStream) {
this.showError('Webcam not started');
return;
}
this.isGenerating = true;
this.frameCount = 0;
this.generationFinished = false;
document.getElementById('frameCount').textContent = '0';
this.frameBuffer = [];
this.allFrames = [];
this.updateUI();
// Show spinner, hide placeholder and video controls
document.getElementById('spinner').classList.remove('hidden');
document.getElementById('placeholder').style.display = 'none';
document.getElementById('videoControls').classList.add('hidden');
// Start recording
this.startRecording();
// Connect to backend WebSocket proxy
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let wsUrl = `${protocol}//${window.location.host}/ws/video-gen`;
// If user has their own FAL key, pass it as query param
const userFalKey = limitModal.getFalKey();
if (userFalKey) {
wsUrl += `?user_fal_key=${encodeURIComponent(userFalKey)}`;
}
try {
this.ws = new WebSocket(wsUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.showInfo('Connected! Waiting for ready signal...');
this.startIdleTimeout();
};
this.ws.onmessage = async (event) => {
if (typeof event.data === 'string') {
try {
const data = JSON.parse(event.data);
if (data.status === 'ready') {
this.showInfo('Ready - sending parameters...');
await this.sendInitialParams();
} else if (data.error) {
this.showError(`Server error: ${JSON.stringify(data.error)}`);
this.disconnect();
}
} catch (e) {
console.error('WebSocket message parse error:', e);
}
} else if (event.data instanceof ArrayBuffer) {
await this.displayFrame(event.data);
}
};
this.ws.onerror = (error) => {
this.showError('WebSocket connection error');
console.error('WebSocket error:', error);
};
this.ws.onclose = (event) => {
// Force state reset
this.isGenerating = false;
this.ws = null;
// Stop all processes
this.stopFrameExtraction();
this.stopRecording();
this.stopPlaybackLoop();
// Update UI immediately
const btn = document.getElementById('startStopBtn');
if (btn) {
btn.textContent = 'Start Generation';
btn.className = 'btn btn-primary';
btn.disabled = !auth.canStart;
}
// Update status - only if not already finished
if (!this.generationFinished) {
const statusPill = document.getElementById('statusPill');
if (statusPill) {
statusPill.className = 'status-pill status-disconnected';
statusPill.textContent = 'Disconnected';
}
this.showInfo(`Disconnected: ${event.reason || 'Connection closed'}`);
}
// Re-enable mode buttons
['textModeBtn', 'videoModeBtn', 'webcamModeBtn'].forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.disabled = false;
});
};
} catch (error) {
this.showError('Failed to connect: ' + error.message);
this.isGenerating = false;
this.updateUI();
}
}
},
async sendInitialParams() {
const payload = {
prompt: document.getElementById('prompt').value,
num_blocks: parseInt(document.getElementById('numBlocks').value),
num_denoising_steps: 4,
strength: parseFloat(document.getElementById('strength').value) || 0.45,
width: 832,
height: 480
};
// Add start_frame for video and webcam modes
if (this.mode === 'video' && this.currentVideoFile) {
try {
const video = document.getElementById('hiddenVideo');
video.currentTime = 0;
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Video seek timeout')), 5000);
video.onseeked = () => {
clearTimeout(timeout);
resolve();
};
});
const startFrame = await this.extractVideoFrameBytes();
if (!startFrame) throw new Error('Failed to extract start frame');
payload.start_frame = startFrame;
} catch (error) {
this.showError(`Failed to extract start frame: ${error.message}`);
this.disconnect();
return;
}
} else if (this.mode === 'webcam') {
try {
const startFrame = await this.extractWebcamBytes();
if (!startFrame) throw new Error('Failed to extract webcam frame');
payload.start_frame = startFrame;
} catch (error) {
this.showError(`Failed to extract webcam frame: ${error.message}`);
this.disconnect();
return;
}
}
const seedValue = document.getElementById('seed').value;
if (seedValue && seedValue.trim() !== '') {
payload.seed = parseInt(seedValue);
} else {
payload.seed = Math.floor(Math.random() * (1 << 24));
}
try {
const encoded = createMsgpackEncoder(payload);
this.ws.send(encoded);
this.showInfo('Generation started!');
// Start frame extraction for v2v and webcam modes
if (this.mode === 'video' || this.mode === 'webcam') {
setTimeout(() => this.startFrameExtraction(), 500);
}
} catch (error) {
this.showError('Failed to send parameters: ' + error.message);
}
},
async displayFrame(imageData) {
const blob = new Blob([imageData], { type: 'image/jpeg' });
const bitmap = await createImageBitmap(blob);
this.frameBuffer.push(bitmap);
this.allFrames.push(imageData); // Store raw data for replay
this.frameCount++;
document.getElementById('frameCount').textContent = this.frameCount;
// Reset idle timeout on every frame
this.startIdleTimeout();
if (this.frameCount === 1) {
document.getElementById('spinner').classList.add('hidden');
this.startPlaybackLoop();
}
// Update progress
const progress = Math.min(100, (this.frameCount / this.maxFrames) * 100);
document.getElementById('progressFill').style.width = progress + '%';
// Check if generation is complete
if (this.frameCount >= this.maxFrames) {
this.generationFinished = true;
// Update status to Finished
const statusPill = document.getElementById('statusPill');
statusPill.className = 'status-pill status-finished';
statusPill.textContent = 'Finished';
// Show video controls (replay and download)
document.getElementById('videoControls').classList.remove('hidden');
// Disconnect to clean up (will trigger onclose which resets UI)
setTimeout(() => {
this.disconnect();
this.showInfo('Generation complete!');
}, 500);
}
},
drawNextFrame() {
if (this.frameBuffer.length === 0) return;
const canvas = document.getElementById('outputCanvas');
const bitmap = this.frameBuffer.shift();
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
if (typeof bitmap.close === 'function') {
bitmap.close();
}
},
startPlaybackLoop() {
if (this.playbackInterval) {
clearInterval(this.playbackInterval);
}
const fps = parseInt(document.getElementById('playbackFps').value);
const intervalMs = Math.max(10, Math.floor(1000 / fps));
this.playbackInterval = setInterval(() => this.drawNextFrame(), intervalMs);
},
stopPlaybackLoop() {
if (this.playbackInterval) {
clearInterval(this.playbackInterval);
this.playbackInterval = null;
}
},
async handleVideoUpload(event) {
const file = event.target.files?.[0];
if (!file) return;
this.currentVideoFile = file;
const hiddenVideo = document.getElementById('hiddenVideo');
const previewVideo = document.getElementById('uploadedVideoPreview');
const url = URL.createObjectURL(file);
return new Promise((resolve) => {
hiddenVideo.onloadedmetadata = () => {
this.videoMetadata = {
duration: hiddenVideo.duration,
width: hiddenVideo.videoWidth,
height: hiddenVideo.videoHeight
};
// Update info display
const infoHTML = `
${file.name}<br>
${this.videoMetadata.width}×${this.videoMetadata.height}${this.videoMetadata.duration.toFixed(1)}s
`;
document.getElementById('videoInfoDisplay').innerHTML = infoHTML;
// Show preview video
previewVideo.src = url;
previewVideo.play();
document.getElementById('uploadedVideoPlaceholder').style.display = 'none';
this.showInfo('Video loaded successfully');
resolve();
};
hiddenVideo.onerror = () => {
this.showError('Failed to load video');
this.currentVideoFile = null;
this.videoMetadata = null;
};
hiddenVideo.src = url;
});
},
async extractVideoFrameBytes() {
const video = document.getElementById('hiddenVideo');
const canvas = document.getElementById('extractionCanvas');
if (!video || !canvas) return null;
const ctx = canvas.getContext('2d');
canvas.width = 832;
canvas.height = 480;
// Scale and crop to 832x480
const scale = Math.max(832 / video.videoWidth, 480 / video.videoHeight);
const scaledWidth = video.videoWidth * scale;
const scaledHeight = video.videoHeight * scale;
const offsetX = (832 - scaledWidth) / 2;
const offsetY = (480 - scaledHeight) / 2;
ctx.clearRect(0, 0, 832, 480);
ctx.drawImage(video, offsetX, offsetY, scaledWidth, scaledHeight);
return new Promise((resolve) => {
canvas.toBlob(async (blob) => {
if (!blob) {
resolve(null);
return;
}
try {
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
resolve(uint8Array);
} catch (error) {
console.error('Failed to convert blob:', error);
resolve(null);
}
}, 'image/jpeg', 0.9);
});
},
async extractWebcamBytes() {
const video = document.getElementById('webcamVideo');
const canvas = document.getElementById('extractionCanvas');
if (!video || !canvas || !video.videoWidth) return null;
const ctx = canvas.getContext('2d');
const targetWidth = 832;
const targetHeight = 480;
canvas.width = targetWidth;
canvas.height = targetHeight;
const videoWidth = video.videoWidth;
const videoHeight = video.videoHeight;
const sourceAspect = videoWidth / videoHeight;
const targetAspect = targetWidth / targetHeight;
let sx, sy, sWidth, sHeight;
if (sourceAspect > targetAspect) {
sHeight = videoHeight;
sWidth = videoHeight * targetAspect;
sx = (videoWidth - sWidth) / 2;
sy = 0;
} else {
sWidth = videoWidth;
sHeight = videoWidth / targetAspect;
sx = 0;
sy = (videoHeight - sHeight) / 2;
}
ctx.drawImage(video, sx, sy, sWidth, sHeight, 0, 0, targetWidth, targetHeight);
return new Promise((resolve) => {
canvas.toBlob(async (blob) => {
if (!blob) {
resolve(null);
return;
}
try {
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
resolve(uint8Array);
} catch (error) {
console.error('Failed to convert blob:', error);
resolve(null);
}
}, 'image/jpeg', 0.9);
});
},
startFrameExtraction() {
const inputFps = parseInt(document.getElementById('inputFps').value);
const intervalMs = Math.floor(1000 / inputFps);
const video = document.getElementById('hiddenVideo');
if (this.mode === 'video') {
video.play();
}
this.frameExtractionInterval = setInterval(async () => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.stopFrameExtraction();
return;
}
let frameBytes = null;
if (this.mode === 'video') {
if (video.ended) {
video.currentTime = 0;
video.play();
}
frameBytes = await this.extractVideoFrameBytes();
} else if (this.mode === 'webcam') {
frameBytes = await this.extractWebcamBytes();
}
if (!frameBytes || frameBytes.length === 0) {
console.warn('Empty frame, skipping');
return;
}
const strengthValue = parseFloat(document.getElementById('strength').value);
const message = {
image: frameBytes,
strength: strengthValue,
timestamp: Date.now()
};
// For webcam mode, include prompt and num_blocks
if (this.mode === 'webcam') {
message.prompt = document.getElementById('prompt').value;
message.num_blocks = parseInt(document.getElementById('numBlocks').value);
}
const encoded = createMsgpackEncoder(message);
this.ws.send(encoded);
}, intervalMs);
},
stopFrameExtraction() {
if (this.frameExtractionInterval) {
clearInterval(this.frameExtractionInterval);
this.frameExtractionInterval = null;
}
const video = document.getElementById('hiddenVideo');
video.pause();
video.currentTime = 0;
},
queuePromptUpdate() {
this.pendingPromptUpdate = {
prompt: document.getElementById('prompt').value,
num_blocks: parseInt(document.getElementById('numBlocks').value)
};
if (this.promptUpdateTimer) {
clearTimeout(this.promptUpdateTimer);
}
this.promptUpdateTimer = setTimeout(() => {
if (this.pendingPromptUpdate && this.ws?.readyState === WebSocket.OPEN) {
const encoded = createMsgpackEncoder(this.pendingPromptUpdate);
this.ws.send(encoded);
this.maxFrames = (12 * this.pendingPromptUpdate.num_blocks) - 6;
document.getElementById('maxFrames').textContent = this.maxFrames;
this.pendingPromptUpdate = null;
}
}, 2000);
},
startRecording() {
const canvas = document.getElementById('outputCanvas');
const fps = parseInt(document.getElementById('playbackFps').value);
const stream = canvas.captureStream(fps);
this.recordedChunks = [];
this.mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
? 'video/webm;codecs=vp9'
: 'video/webm'
});
this.mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.recordedChunks.push(event.data);
}
};
this.mediaRecorder.start();
},
stopRecording() {
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop();
}
},
startIdleTimeout() {
// Clear existing timeout
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
}
// Set new timeout - disconnect after 15 seconds of no frames
this.idleTimeout = setTimeout(() => {
if (this.isGenerating) {
this.showError('The FAL API server is too busy, try again soon!');
this.disconnect();
}
}, 15000);
},
clearIdleTimeout() {
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
this.idleTimeout = null;
}
},
disconnect() {
this.clearIdleTimeout();
if (this.ws) {
this.ws.close();
this.ws = null;
}
if (this.playbackInterval) {
clearInterval(this.playbackInterval);
this.playbackInterval = null;
}
if (this.promptUpdateTimer) {
clearTimeout(this.promptUpdateTimer);
this.promptUpdateTimer = null;
}
this.pendingPromptUpdate = null;
this.stopFrameExtraction();
this.stopRecording();
this.isGenerating = false;
// Hide spinner, show placeholder if no frames
document.getElementById('spinner').classList.add('hidden');
if (this.frameCount === 0) {
document.getElementById('placeholder').style.display = 'flex';
}
// Update status pill - only show "Disconnected" if manually stopped or error
if (!this.generationFinished) {
const statusPill = document.getElementById('statusPill');
if (statusPill) {
statusPill.className = 'status-pill status-disconnected';
statusPill.textContent = 'Disconnected';
}
}
this.updateUI();
},
async replayVideo() {
if (this.allFrames.length === 0) {
this.showError('No frames to replay');
return;
}
// Stop current playback
this.stopPlaybackLoop();
// Recreate frameBuffer from stored frames
this.frameBuffer = [];
for (const imageData of this.allFrames) {
const blob = new Blob([imageData], { type: 'image/jpeg' });
const bitmap = await createImageBitmap(blob);
this.frameBuffer.push(bitmap);
}
// Restart playback
this.startPlaybackLoop();
this.showInfo('Replaying video');
},
downloadVideo() {
if (this.recordedChunks.length === 0) {
this.showError('No video data to download');
return;
}
const blob = new Blob(this.recordedChunks, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `generated-video-${Date.now()}.webm`;
a.click();
URL.revokeObjectURL(url);
this.showInfo('Video downloaded successfully');
},
updateUI() {
const btn = document.getElementById('startStopBtn');
btn.textContent = this.isGenerating ? 'Stop Generation' : 'Start Generation';
btn.className = this.isGenerating ? 'btn btn-danger' : 'btn btn-primary';
// Update mode buttons
const textBtn = document.getElementById('textModeBtn');
const videoBtn = document.getElementById('videoModeBtn');
const webcamBtn = document.getElementById('webcamModeBtn');
if (textBtn && videoBtn && webcamBtn) {
textBtn.disabled = this.isGenerating;
videoBtn.disabled = this.isGenerating;
webcamBtn.disabled = this.isGenerating;
}
},
showError(message) {
const box = document.getElementById('errorBox');
box.textContent = message;
box.classList.remove('hidden');
setTimeout(() => box.classList.add('hidden'), 5000);
},
showInfo(message) {
const box = document.getElementById('infoBox');
box.textContent = message;
box.classList.remove('hidden');
setTimeout(() => box.classList.add('hidden'), 3000);
}
};
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
auth.init();
});
</script>
</body>
</html>