| |
| |
| |
| |
|
|
| class MotionApp { |
| constructor() { |
| this.isRunning = false; |
| this.targetFps = 20; |
| this.frameInterval = 1000 / this.targetFps; |
| this.nextFetchTime = 0; |
| this.frameCount = 0; |
|
|
| |
| this.motionFpsCounter = 0; |
| this.motionFpsUpdateTime = 0; |
| |
| |
| this.isFetchingFrame = false; |
| this.consecutiveWaiting = 0; |
|
|
| |
| this.localFrameQueue = []; |
| this.batchSize = 8; |
| this.broadcastLastId = 0; |
| |
| |
| this.sessionId = this.generateSessionId(); |
| |
| |
| this.lastUserInteraction = 0; |
| this.autoFollowDelay = 2000; |
| this.currentRootPos = new THREE.Vector3(0, 1, 0); |
| |
| this.initThreeJS(); |
| this.initUI(); |
| this.updateStatus(); |
| this.setupBeforeUnload(); |
| |
| console.log('Session ID:', this.sessionId); |
| } |
| |
| generateSessionId() { |
| |
| return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); |
| } |
| |
| setupBeforeUnload() { |
| |
| window.addEventListener('beforeunload', () => { |
| |
| if (!this.isIdle) { |
| |
| 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'); |
| } |
| }); |
| |
| |
| document.addEventListener('visibilitychange', () => { |
| if (document.hidden && !this.isIdle && this.isRunning) { |
| |
| |
| console.log('Tab hidden while generating - consumption monitor will auto-reset if needed'); |
| } |
| }); |
| } |
| |
| initThreeJS() { |
| |
| const canvas = document.getElementById('renderCanvas'); |
| const container = document.getElementById('canvas-container'); |
| |
| |
| this.scene = new THREE.Scene(); |
| this.scene.background = new THREE.Color(0xffffff); |
| |
| |
| 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); |
| |
| |
| 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; |
| |
| |
| 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); |
| |
| |
| const fillLight = new THREE.DirectionalLight(0xffffff, 0.4); |
| fillLight.position.set(-3, 5, -3); |
| this.scene.add(fillLight); |
| |
| |
| 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); |
| |
| |
| const gridHelper = new THREE.GridHelper(1000, 1000, 0xdddddd, 0xeeeeee); |
| gridHelper.position.y = 0.01; |
| this.scene.add(gridHelper); |
| |
| |
| 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(); |
| |
| |
| const updateInteractionTime = () => { |
| this.lastUserInteraction = Date.now(); |
| }; |
| canvas.addEventListener('mousedown', updateInteractionTime); |
| canvas.addEventListener('wheel', updateInteractionTime); |
| canvas.addEventListener('touchstart', updateInteractionTime); |
| |
| |
| this.skeleton = new Skeleton3D(this.scene); |
| |
| |
| window.addEventListener('resize', () => this.onWindowResize()); |
| |
| |
| this.animate(); |
| } |
| |
| initUI() { |
| |
| 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'); |
|
|
| |
| this.historyLengthValue = null; |
| this.smoothingAlphaValue = 0.5; |
|
|
| |
| this.isPaused = false; |
| this.isIdle = true; |
| this.isWatching = false; |
| this.isProcessing = false; |
| this.pendingStartRequest = null; |
|
|
| |
| 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()); |
|
|
| |
| 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('/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; |
|
|
| if (this.isIdle || this.isWatching) { |
| |
| await this.startGeneration(this.isWatching); |
| } else { |
| |
| await this.reset(); |
| } |
| } |
| |
| async startGeneration(force = false) { |
| if (this.isProcessing) return; |
|
|
| 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) { |
| |
| this.statusEl.textContent = 'Conflict - Another user is generating'; |
| this.conflictWarning.style.display = 'block'; |
| |
| |
| this.pendingStartRequest = { |
| text: text, |
| history_length: historyLength |
| }; |
| |
| return; |
| } else { |
| |
| 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'; |
| |
| this.isIdle = true; |
| this.isRunning = false; |
| this.isPaused = false; |
| } finally { |
| this.isProcessing = false; |
| } |
| } |
| |
| async updateText() { |
| if (this.isProcessing) return; |
| |
| 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; |
| if (this.isPaused) { |
| |
| await this.resumeGeneration(); |
| } else { |
| |
| 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; |
|
|
| 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() { |
| |
| this.conflictWarning.style.display = 'none'; |
| |
| if (!this.pendingStartRequest) return; |
| |
| |
| this.isProcessing = false; |
| await this.startGeneration(true); |
| |
| this.pendingStartRequest = null; |
| } |
| |
| handleCancelTakeover() { |
| |
| 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(); |
|
|
| |
| 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(); |
| } |
|
|
| |
| 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; |
| }); |
| } |
|
|
| |
| requestAnimationFrame(() => this.fetchFrame()); |
| } |
| |
| updateAutoFollow() { |
| const timeSinceInteraction = Date.now() - this.lastUserInteraction; |
| |
| |
| if (timeSinceInteraction > this.autoFollowDelay) { |
| |
| const currentOffset = new THREE.Vector3().subVectors( |
| this.camera.position, |
| this.controls.target |
| ); |
| |
| |
| const newTarget = this.currentRootPos.clone(); |
| newTarget.y = 1.0; |
| |
| |
| const newCameraPos = newTarget.clone().add(currentOffset); |
| |
| |
| |
| this.controls.target.lerp(newTarget, 0.2); |
| this.camera.position.lerp(newCameraPos, 0.2); |
| |
| |
| |
| } |
| } |
| |
| _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(); |
| } |
|
|
| |
|
|
| 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); |
|
|
| |
| this.renderConfigSection('schedule_config', data.schedule_config, |
| document.getElementById('scheduleConfigFields')); |
|
|
| |
| this.renderConfigSection('cfg_config', data.cfg_config, |
| document.getElementById('cfgConfigFields')); |
|
|
| |
| 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); |
|
|
| |
| 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 { |
| |
| 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}`; |
|
|
| |
| if (data.smoothing_alpha !== undefined) { |
| this.currentSmoothing.textContent = data.smoothing_alpha.toFixed(2); |
| } |
|
|
| |
| if (data.history_length !== undefined) { |
| this.currentHistory.textContent = data.history_length; |
| } |
|
|
| |
| 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(); |
| } |
| |
| 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; |
| } |
| } |
| |
| |
| const now = performance.now(); |
| if (now - this.motionFpsUpdateTime > 1000) { |
| this.fpsEl.textContent = this.motionFpsCounter; |
| this.motionFpsCounter = 0; |
| this.motionFpsUpdateTime = now; |
| } |
| } catch (error) { |
| |
| } |
| |
| |
| setTimeout(() => this.updateStatus(), 500); |
| } |
| |
| animate() { |
| requestAnimationFrame(() => this.animate()); |
| |
| |
| this.controls.update(); |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| window.addEventListener('DOMContentLoaded', () => { |
| window.app = new MotionApp(); |
| }); |
|
|
|
|