Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
| <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> | |