H-Liu1997's picture
feat: spectators see 'Take Over' button to seamlessly take control
4da9546
/**
* 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();
});