Spaces:
Running on T4
Running on T4
| /** | |
| * Main application logic | |
| * Handles UI interactions, API calls, and 3D rendering loop | |
| */ | |
| class MotionApp { | |
| constructor() { | |
| this.isRunning = false; | |
| this.targetFps = 20; // Model generates data at 20fps | |
| this.frameInterval = 1000 / this.targetFps; // 50ms | |
| this.nextFetchTime = 0; // Scheduled time for next fetch | |
| this.frameCount = 0; | |
| // Motion FPS tracking (frame consumption rate) | |
| this.motionFpsCounter = 0; | |
| this.motionFpsUpdateTime = 0; | |
| // Request throttling | |
| this.isFetchingFrame = false; // Prevent concurrent requests | |
| this.consecutiveWaiting = 0; // Count consecutive 'waiting' responses | |
| // Local frame queue for batch fetching (reduces HTTP round-trip overhead) | |
| this.localFrameQueue = []; | |
| this.batchSize = 8; // Fetch up to 8 frames per request | |
| this.broadcastLastId = 0; // For spectator mode (broadcast buffer cursor) | |
| // Session management | |
| this.sessionId = this.generateSessionId(); | |
| // Camera follow settings | |
| this.lastUserInteraction = 0; | |
| this.autoFollowDelay = 2000; // Auto-follow after 2 seconds of inactivity (reduced from 3s) | |
| this.currentRootPos = new THREE.Vector3(0, 1, 0); | |
| this.initThreeJS(); | |
| this.initUI(); | |
| this.updateStatus(); | |
| this.setupBeforeUnload(); | |
| console.log('Session ID:', this.sessionId); | |
| } | |
| generateSessionId() { | |
| // Generate a simple unique session ID | |
| return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); | |
| } | |
| setupBeforeUnload() { | |
| // Handle page close/refresh - send reset request | |
| window.addEventListener('beforeunload', () => { | |
| // Send synchronous reset if we're generating | |
| if (!this.isIdle) { | |
| // Use Blob to set correct Content-Type for JSON | |
| const blob = new Blob( | |
| [JSON.stringify({session_id: this.sessionId})], | |
| {type: 'application/json'} | |
| ); | |
| navigator.sendBeacon('/api/reset', blob); | |
| console.log('Sent reset beacon on page unload'); | |
| } | |
| }); | |
| // Also handle visibility change (tab hidden, mobile app switch) | |
| document.addEventListener('visibilitychange', () => { | |
| if (document.hidden && !this.isIdle && this.isRunning) { | |
| // User switched away while generating - they might not come back | |
| // Note: Don't reset immediately, let the frame consumption monitor handle it | |
| console.log('Tab hidden while generating - consumption monitor will auto-reset if needed'); | |
| } | |
| }); | |
| } | |
| initThreeJS() { | |
| // Get canvas | |
| const canvas = document.getElementById('renderCanvas'); | |
| const container = document.getElementById('canvas-container'); | |
| // Create scene | |
| this.scene = new THREE.Scene(); | |
| this.scene.background = new THREE.Color(0xffffff); // White background | |
| // Create camera | |
| this.camera = new THREE.PerspectiveCamera( | |
| 60, | |
| container.clientWidth / container.clientHeight, | |
| 0.1, | |
| 1000 | |
| ); | |
| this.camera.position.set(3, 1.5, 3); | |
| this.camera.lookAt(0, 1, 0); | |
| // Create renderer | |
| this.renderer = new THREE.WebGLRenderer({ | |
| canvas: canvas, | |
| antialias: true | |
| }); | |
| this.renderer.setSize(container.clientWidth, container.clientHeight); | |
| this.renderer.shadowMap.enabled = true; | |
| this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| this.renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| this.renderer.toneMappingExposure = 1.0; | |
| // Add lights - bright and soft | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.7); | |
| this.scene.add(ambientLight); | |
| const keyLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| keyLight.position.set(5, 8, 3); | |
| keyLight.castShadow = true; | |
| keyLight.shadow.mapSize.width = 2048; | |
| keyLight.shadow.mapSize.height = 2048; | |
| keyLight.shadow.camera.near = 0.5; | |
| keyLight.shadow.camera.far = 50; | |
| keyLight.shadow.camera.left = -5; | |
| keyLight.shadow.camera.right = 5; | |
| keyLight.shadow.camera.top = 5; | |
| keyLight.shadow.camera.bottom = -5; | |
| keyLight.shadow.bias = -0.0001; | |
| this.scene.add(keyLight); | |
| // Fill light | |
| const fillLight = new THREE.DirectionalLight(0xffffff, 0.4); | |
| fillLight.position.set(-3, 5, -3); | |
| this.scene.add(fillLight); | |
| // Add ground plane - light gray, very large | |
| const groundGeometry = new THREE.PlaneGeometry(1000, 1000); | |
| const groundMaterial = new THREE.ShadowMaterial({ | |
| opacity: 0.15 | |
| }); | |
| const ground = new THREE.Mesh(groundGeometry, groundMaterial); | |
| ground.rotation.x = -Math.PI / 2; | |
| ground.position.y = 0; | |
| ground.receiveShadow = true; | |
| this.scene.add(ground); | |
| // Add infinite-looking grid - very large grid | |
| const gridHelper = new THREE.GridHelper(1000, 1000, 0xdddddd, 0xeeeeee); | |
| gridHelper.position.y = 0.01; | |
| this.scene.add(gridHelper); | |
| // Add orbit controls | |
| this.controls = new THREE.OrbitControls(this.camera, canvas); | |
| this.controls.target.set(0, 1, 0); | |
| this.controls.enableDamping = true; | |
| this.controls.dampingFactor = 0.05; | |
| this.controls.update(); | |
| // Listen for user interaction - record time | |
| const updateInteractionTime = () => { | |
| this.lastUserInteraction = Date.now(); | |
| }; | |
| canvas.addEventListener('mousedown', updateInteractionTime); | |
| canvas.addEventListener('wheel', updateInteractionTime); | |
| canvas.addEventListener('touchstart', updateInteractionTime); | |
| // Create skeleton | |
| this.skeleton = new Skeleton3D(this.scene); | |
| // Handle window resize | |
| window.addEventListener('resize', () => this.onWindowResize()); | |
| // Start render loop | |
| this.animate(); | |
| } | |
| initUI() { | |
| // Get UI elements | |
| this.motionText = document.getElementById('motionText'); | |
| this.currentSmoothing = document.getElementById('currentSmoothing'); | |
| this.currentHistory = document.getElementById('currentHistory'); | |
| this.startResetBtn = document.getElementById('startResetBtn'); | |
| this.updateBtn = document.getElementById('updateBtn'); | |
| this.pauseResumeBtn = document.getElementById('pauseResumeBtn'); | |
| this.configBtn = document.getElementById('configBtn'); | |
| this.statusEl = document.getElementById('status'); | |
| this.bufferSizeEl = document.getElementById('bufferSize'); | |
| this.fpsEl = document.getElementById('fps'); | |
| this.frameCountEl = document.getElementById('frameCount'); | |
| this.conflictWarning = document.getElementById('conflictWarning'); | |
| this.forceTakeoverBtn = document.getElementById('forceTakeoverBtn'); | |
| this.cancelTakeoverBtn = document.getElementById('cancelTakeoverBtn'); | |
| // Stored runtime parameter values (updated by Config modal) | |
| this.historyLengthValue = null; | |
| this.smoothingAlphaValue = 0.5; // Default | |
| // Track state | |
| this.isPaused = false; | |
| this.isIdle = true; | |
| this.isWatching = false; // Spectator mode | |
| this.isProcessing = false; // Prevent concurrent API calls | |
| this.pendingStartRequest = null; // Store pending start request data | |
| // Attach event listeners | |
| this.startResetBtn.addEventListener('click', () => this.toggleStartReset()); | |
| this.updateBtn.addEventListener('click', () => this.updateText()); | |
| this.pauseResumeBtn.addEventListener('click', () => this.togglePauseResume()); | |
| this.configBtn.addEventListener('click', () => this.openConfigEditor()); | |
| this.forceTakeoverBtn.addEventListener('click', () => this.handleForceTakeover()); | |
| this.cancelTakeoverBtn.addEventListener('click', () => this.handleCancelTakeover()); | |
| // Modal event listeners | |
| document.getElementById('configDiscardBtn').addEventListener('click', () => this.closeConfigEditor()); | |
| document.getElementById('configSaveBtn').addEventListener('click', () => this.saveConfigAndReset()); | |
| document.getElementById('modalSmoothingAlpha').addEventListener('input', (e) => { | |
| document.getElementById('modalSmoothingValue').textContent = parseFloat(e.target.value).toFixed(2); | |
| }); | |
| // Fetch config from server on page load | |
| fetch('/api/config') | |
| .then(r => { | |
| if (!r.ok) throw new Error(`HTTP ${r.status}`); | |
| return r.json(); | |
| }) | |
| .then(data => { | |
| if (data.status === 'error') throw new Error(data.message); | |
| this.historyLengthValue = data.history_length; | |
| this.smoothingAlphaValue = data.smoothing_alpha; | |
| }) | |
| .catch(e => { | |
| this.statusEl.textContent = 'Error: failed to load config'; | |
| this.startResetBtn.disabled = true; | |
| console.error('Failed to fetch config:', e); | |
| }); | |
| } | |
| async toggleStartReset() { | |
| if (this.isProcessing) return; // Prevent concurrent operations | |
| if (this.isIdle || this.isWatching) { | |
| // Idle or spectator watching, so start (will force takeover if needed) | |
| await this.startGeneration(this.isWatching); | |
| } else { | |
| // Currently running/paused, so reset | |
| await this.reset(); | |
| } | |
| } | |
| async startGeneration(force = false) { | |
| if (this.isProcessing) return; // Prevent concurrent operations | |
| const text = this.motionText.value.trim(); | |
| if (!text) { | |
| alert('Please enter a motion description'); | |
| return; | |
| } | |
| const historyLength = this.historyLengthValue || 30; | |
| const smoothingAlpha = this.smoothingAlphaValue; | |
| this.isProcessing = true; | |
| this.statusEl.textContent = 'Initializing...'; | |
| try { | |
| const response = await fetch('/api/start', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| session_id: this.sessionId, | |
| text: text, | |
| history_length: historyLength, | |
| smoothing_alpha: smoothingAlpha, | |
| force: force | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| this.isRunning = true; | |
| this.isPaused = false; | |
| this.isIdle = false; | |
| this.frameCount = 0; | |
| this.motionFpsCounter = 0; | |
| this.motionFpsUpdateTime = performance.now(); | |
| this.isFetchingFrame = false; | |
| this.consecutiveWaiting = 0; | |
| this.startResetBtn.textContent = 'Reset'; | |
| this.startResetBtn.classList.remove('btn-primary'); | |
| this.startResetBtn.classList.add('btn-danger'); | |
| this.updateBtn.disabled = false; | |
| this.pauseResumeBtn.disabled = false; | |
| this.pauseResumeBtn.textContent = 'Pause'; | |
| this.statusEl.textContent = 'Running'; | |
| this.startFrameLoop(); | |
| } else if (response.status === 409 && data.conflict) { | |
| // Another session is running, show warning UI | |
| this.statusEl.textContent = 'Conflict - Another user is generating'; | |
| this.conflictWarning.style.display = 'block'; | |
| // Store request data for later | |
| this.pendingStartRequest = { | |
| text: text, | |
| history_length: historyLength | |
| }; | |
| return; | |
| } else { | |
| // Other errors | |
| alert('Error: ' + data.message); | |
| this.statusEl.textContent = 'Idle'; | |
| this.isIdle = true; | |
| this.isRunning = false; | |
| this.isPaused = false; | |
| } | |
| } catch (error) { | |
| console.error('Error starting generation:', error); | |
| alert('Failed to start generation: ' + error.message); | |
| this.statusEl.textContent = 'Idle'; | |
| // Keep idle state on error | |
| this.isIdle = true; | |
| this.isRunning = false; | |
| this.isPaused = false; | |
| } finally { | |
| this.isProcessing = false; | |
| } | |
| } | |
| async updateText() { | |
| if (this.isProcessing) return; // Prevent concurrent operations | |
| const text = this.motionText.value.trim(); | |
| if (!text) { | |
| alert('Please enter a motion description'); | |
| return; | |
| } | |
| this.isProcessing = true; | |
| try { | |
| const response = await fetch('/api/update_text', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| session_id: this.sessionId, | |
| text: text | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| console.log('Text updated:', text); | |
| } else { | |
| alert('Error: ' + data.message); | |
| } | |
| } catch (error) { | |
| console.error('Error updating text:', error); | |
| } finally { | |
| this.isProcessing = false; | |
| } | |
| } | |
| async togglePauseResume() { | |
| if (this.isProcessing) return; // Prevent concurrent operations | |
| if (this.isPaused) { | |
| // Currently paused, so resume | |
| await this.resumeGeneration(); | |
| } else { | |
| // Currently running, so pause | |
| await this.pauseGeneration(); | |
| } | |
| } | |
| async pauseGeneration() { | |
| this.isProcessing = true; | |
| try { | |
| const response = await fetch('/api/pause', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({session_id: this.sessionId}) | |
| }); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| this.isRunning = false; | |
| this.isPaused = true; | |
| this.pauseResumeBtn.textContent = 'Resume'; | |
| this.pauseResumeBtn.classList.remove('btn-warning'); | |
| this.pauseResumeBtn.classList.add('btn-success'); | |
| this.updateBtn.disabled = true; | |
| this.statusEl.textContent = 'Paused'; | |
| console.log('Generation paused (state preserved)'); | |
| } | |
| } catch (error) { | |
| console.error('Error pausing generation:', error); | |
| } finally { | |
| this.isProcessing = false; | |
| } | |
| } | |
| async resumeGeneration() { | |
| this.isProcessing = true; | |
| try { | |
| const response = await fetch('/api/resume', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({session_id: this.sessionId}) | |
| }); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| this.isRunning = true; | |
| this.isPaused = false; | |
| this.pauseResumeBtn.textContent = 'Pause'; | |
| this.pauseResumeBtn.classList.remove('btn-success'); | |
| this.pauseResumeBtn.classList.add('btn-warning'); | |
| this.updateBtn.disabled = false; | |
| this.statusEl.textContent = 'Running'; | |
| this.startFrameLoop(); | |
| console.log('Generation resumed'); | |
| } | |
| } catch (error) { | |
| console.error('Error resuming generation:', error); | |
| } finally { | |
| this.isProcessing = false; | |
| } | |
| } | |
| async reset() { | |
| if (this.isProcessing) return; // Prevent concurrent operations | |
| const historyLength = this.historyLengthValue || 30; | |
| const smoothingAlpha = this.smoothingAlphaValue; | |
| this.isProcessing = true; | |
| try { | |
| const response = await fetch('/api/reset', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| session_id: this.sessionId, | |
| history_length: historyLength, | |
| smoothing_alpha: smoothingAlpha, | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| this._resetUIToIdle(); | |
| console.log('Reset complete - all state cleared'); | |
| } | |
| } catch (error) { | |
| console.error('Error resetting:', error); | |
| } finally { | |
| this.isProcessing = false; | |
| } | |
| } | |
| async handleForceTakeover() { | |
| // Hide warning | |
| this.conflictWarning.style.display = 'none'; | |
| if (!this.pendingStartRequest) return; | |
| // Retry with force=true | |
| this.isProcessing = false; | |
| await this.startGeneration(true); | |
| this.pendingStartRequest = null; | |
| } | |
| handleCancelTakeover() { | |
| // Hide warning | |
| this.conflictWarning.style.display = 'none'; | |
| this.statusEl.textContent = 'Idle'; | |
| this.isProcessing = false; | |
| this.pendingStartRequest = null; | |
| } | |
| startFrameLoop() { | |
| const now = performance.now(); | |
| this.nextFetchTime = now + this.frameInterval; | |
| this.fetchFrame(); | |
| } | |
| fetchFrame() { | |
| if (!this.isRunning) return; | |
| const now = performance.now(); | |
| // Play back from local queue at target FPS | |
| if (now >= this.nextFetchTime && this.localFrameQueue.length > 0) { | |
| this.nextFetchTime += this.frameInterval; | |
| if (this.nextFetchTime < now) { | |
| this.nextFetchTime = now + this.frameInterval; | |
| } | |
| const joints = this.localFrameQueue.shift(); | |
| this.skeleton.updatePose(joints); | |
| this.frameCount++; | |
| this.frameCountEl.textContent = this.frameCount; | |
| this.motionFpsCounter++; | |
| this.currentRootPos.set(joints[0][0], joints[0][1], joints[0][2]); | |
| this.updateAutoFollow(); | |
| } | |
| // Fetch a batch from server when local queue is running low | |
| if (this.localFrameQueue.length < this.batchSize && !this.isFetchingFrame) { | |
| this.isFetchingFrame = true; | |
| let url = `/api/get_frame?session_id=${this.sessionId}&count=${this.batchSize}`; | |
| if (this.broadcastLastId > 0) { | |
| url += `&after_id=${this.broadcastLastId}`; | |
| } | |
| fetch(url) | |
| .then(response => response.json()) | |
| .then(data => { | |
| if (data.status === 'success') { | |
| for (const frame of data.frames) { | |
| this.localFrameQueue.push(frame); | |
| } | |
| if (data.last_id !== undefined) { | |
| this.broadcastLastId = data.last_id; | |
| } | |
| this.consecutiveWaiting = 0; | |
| } else if (data.status === 'waiting') { | |
| this.consecutiveWaiting++; | |
| } | |
| }) | |
| .catch(error => { | |
| console.error('Error fetching frames:', error); | |
| }) | |
| .finally(() => { | |
| this.isFetchingFrame = false; | |
| }); | |
| } | |
| // Use requestAnimationFrame for continuous checking | |
| requestAnimationFrame(() => this.fetchFrame()); | |
| } | |
| updateAutoFollow() { | |
| const timeSinceInteraction = Date.now() - this.lastUserInteraction; | |
| // Auto-follow if user hasn't interacted for more than 3 seconds | |
| if (timeSinceInteraction > this.autoFollowDelay) { | |
| // Calculate camera offset relative to current target | |
| const currentOffset = new THREE.Vector3().subVectors( | |
| this.camera.position, | |
| this.controls.target | |
| ); | |
| // New target position (character position, waist height) | |
| const newTarget = this.currentRootPos.clone(); | |
| newTarget.y = 1.0; | |
| // Calculate new camera position (maintain relative offset) | |
| const newCameraPos = newTarget.clone().add(currentOffset); | |
| // Smooth interpolation follow (increased lerp factor for more obvious following) | |
| // 0.2 = more aggressive following, 0.05 = gentle following | |
| this.controls.target.lerp(newTarget, 0.2); | |
| this.camera.position.lerp(newCameraPos, 0.2); | |
| // Debug log (comment out in production) | |
| // console.log('Auto-follow active, tracking:', newTarget); | |
| } | |
| } | |
| _resetUIToIdle() { | |
| this.isRunning = false; | |
| this.isPaused = false; | |
| this.isIdle = true; | |
| this.isWatching = false; | |
| this.frameCount = 0; | |
| this.motionFpsCounter = 0; | |
| this.isFetchingFrame = false; | |
| this.consecutiveWaiting = 0; | |
| this.localFrameQueue = []; | |
| this.broadcastLastId = 0; | |
| this.startResetBtn.textContent = 'Start'; | |
| this.startResetBtn.classList.remove('btn-danger'); | |
| this.startResetBtn.classList.add('btn-primary'); | |
| this.updateBtn.disabled = true; | |
| this.pauseResumeBtn.disabled = true; | |
| this.pauseResumeBtn.textContent = 'Pause'; | |
| this.pauseResumeBtn.classList.remove('btn-success'); | |
| this.pauseResumeBtn.classList.add('btn-warning'); | |
| this.statusEl.textContent = 'Idle'; | |
| this.bufferSizeEl.textContent = '0 / 4'; | |
| this.frameCountEl.textContent = '0'; | |
| this.fpsEl.textContent = '0'; | |
| if (this.skeleton) this.skeleton.clearTrail(); | |
| } | |
| // --- Config Editor --- | |
| async openConfigEditor() { | |
| try { | |
| const response = await fetch('/api/config'); | |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); | |
| const data = await response.json(); | |
| if (data.status === 'error') throw new Error(data.message); | |
| // Render schedule_config fields | |
| this.renderConfigSection('schedule_config', data.schedule_config, | |
| document.getElementById('scheduleConfigFields')); | |
| // Render cfg_config fields | |
| this.renderConfigSection('cfg_config', data.cfg_config, | |
| document.getElementById('cfgConfigFields')); | |
| // Populate runtime params | |
| document.getElementById('modalHistoryLength').value = data.history_length; | |
| const slider = document.getElementById('modalSmoothingAlpha'); | |
| slider.value = data.smoothing_alpha; | |
| document.getElementById('modalSmoothingValue').textContent = | |
| parseFloat(data.smoothing_alpha).toFixed(2); | |
| // Show modal | |
| document.getElementById('configModal').style.display = 'flex'; | |
| } catch (error) { | |
| console.error('Error opening config editor:', error); | |
| alert('Failed to load config: ' + error.message); | |
| } | |
| } | |
| renderConfigSection(sectionName, obj, container) { | |
| container.innerHTML = ''; | |
| for (const [key, value] of Object.entries(obj)) { | |
| const field = document.createElement('div'); | |
| field.className = 'config-field'; | |
| const label = document.createElement('label'); | |
| label.textContent = key; | |
| field.appendChild(label); | |
| let input; | |
| if (typeof value === 'boolean') { | |
| input = document.createElement('select'); | |
| input.innerHTML = | |
| `<option value="true" ${value ? 'selected' : ''}>true</option>` + | |
| `<option value="false" ${!value ? 'selected' : ''}>false</option>`; | |
| } else { | |
| input = document.createElement('input'); | |
| input.type = typeof value === 'number' ? 'number' : 'text'; | |
| if (typeof value === 'number' && !Number.isInteger(value)) { | |
| input.step = 'any'; | |
| } | |
| input.value = value; | |
| } | |
| input.dataset.section = sectionName; | |
| input.dataset.key = key; | |
| input.dataset.type = typeof value; | |
| input.className = 'config-input'; | |
| field.appendChild(input); | |
| container.appendChild(field); | |
| } | |
| } | |
| async saveConfigAndReset() { | |
| try { | |
| // Collect config values from dynamically rendered fields | |
| const scheduleConfig = {}; | |
| const cfgConfig = {}; | |
| document.querySelectorAll('.config-input').forEach(input => { | |
| const section = input.dataset.section; | |
| const key = input.dataset.key; | |
| const type = input.dataset.type; | |
| let value; | |
| if (type === 'boolean') { | |
| value = input.value === 'true'; | |
| } else if (type === 'number') { | |
| value = Number(input.value); | |
| } else { | |
| value = input.value; | |
| } | |
| if (section === 'schedule_config') { | |
| scheduleConfig[key] = value; | |
| } else if (section === 'cfg_config') { | |
| cfgConfig[key] = value; | |
| } | |
| }); | |
| const historyLength = parseInt(document.getElementById('modalHistoryLength').value); | |
| const smoothingAlpha = parseFloat(document.getElementById('modalSmoothingAlpha').value); | |
| const response = await fetch('/api/config', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| schedule_config: scheduleConfig, | |
| cfg_config: cfgConfig, | |
| history_length: historyLength, | |
| smoothing_alpha: smoothingAlpha, | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| this.historyLengthValue = historyLength; | |
| this.smoothingAlphaValue = smoothingAlpha; | |
| this._resetUIToIdle(); | |
| this.closeConfigEditor(); | |
| console.log('Config updated and reset complete'); | |
| } else { | |
| alert('Error: ' + data.message); | |
| } | |
| } catch (error) { | |
| console.error('Error saving config:', error); | |
| alert('Failed to save config: ' + error.message); | |
| } | |
| } | |
| closeConfigEditor() { | |
| document.getElementById('configModal').style.display = 'none'; | |
| } | |
| async updateStatus() { | |
| try { | |
| const response = await fetch(`/api/status?session_id=${this.sessionId}`); | |
| const data = await response.json(); | |
| if (data.initialized) { | |
| this.bufferSizeEl.textContent = `${data.buffer_size} / ${data.target_size}`; | |
| // Update current smoothing display | |
| if (data.smoothing_alpha !== undefined) { | |
| this.currentSmoothing.textContent = data.smoothing_alpha.toFixed(2); | |
| } | |
| // Update current history length display | |
| if (data.history_length !== undefined) { | |
| this.currentHistory.textContent = data.history_length; | |
| } | |
| // Auto-start spectator mode if someone else is generating | |
| if (data.is_generating && !data.is_active_session && this.isIdle && !this.isWatching) { | |
| this.isWatching = true; | |
| this.isRunning = true; | |
| this.statusEl.textContent = 'Watching'; | |
| this.startResetBtn.textContent = 'Take Over'; | |
| this.startResetBtn.classList.remove('btn-danger'); | |
| this.startResetBtn.classList.add('btn-primary'); | |
| this.startFrameLoop(); | |
| } | |
| // Stop spectator mode when generation stops | |
| if (!data.is_generating && !data.is_active_session && this.isWatching) { | |
| this.isWatching = false; | |
| this.isRunning = false; | |
| this.isIdle = true; | |
| this.statusEl.textContent = 'Idle'; | |
| this.startResetBtn.textContent = 'Start'; | |
| this.localFrameQueue = []; | |
| this.broadcastLastId = 0; | |
| } | |
| } | |
| // Update motion FPS (frame consumption rate) | |
| const now = performance.now(); | |
| if (now - this.motionFpsUpdateTime > 1000) { | |
| this.fpsEl.textContent = this.motionFpsCounter; | |
| this.motionFpsCounter = 0; | |
| this.motionFpsUpdateTime = now; | |
| } | |
| } catch (error) { | |
| // Silently fail for status updates | |
| } | |
| // Update status every 500ms | |
| setTimeout(() => this.updateStatus(), 500); | |
| } | |
| animate() { | |
| requestAnimationFrame(() => this.animate()); | |
| // Update controls | |
| this.controls.update(); | |
| // Render scene | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| onWindowResize() { | |
| const container = document.getElementById('canvas-container'); | |
| this.camera.aspect = container.clientWidth / container.clientHeight; | |
| this.camera.updateProjectionMatrix(); | |
| this.renderer.setSize(container.clientWidth, container.clientHeight); | |
| } | |
| } | |
| // Initialize app when page loads | |
| window.addEventListener('DOMContentLoaded', () => { | |
| window.app = new MotionApp(); | |
| }); | |