H-Liu1997 Claude Opus 4.6 (1M context) commited on
Commit
a4f8eb3
·
1 Parent(s): 384dead

feat: initial FloodDiffusion streaming demo for HF Space

Browse files

Docker-based Space with T4 GPU for real-time streaming motion generation.
Model loaded from ShandaAI/FloodDiffusionTiny via HF Hub.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (9) hide show
  1. Dockerfile +26 -0
  2. README.md +7 -6
  3. app.py +571 -0
  4. model_manager.py +318 -0
  5. motion_process.py +143 -0
  6. static/css/style.css +451 -0
  7. static/js/main.js +777 -0
  8. static/js/skeleton.js +289 -0
  9. templates/index.html +122 -0
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM nvidia/cuda:12.1.0-cudnn8-runtime-ubuntu22.04
2
+
3
+ RUN apt-get update && apt-get install -y \
4
+ python3 python3-pip git \
5
+ && rm -rf /var/lib/apt/lists/*
6
+
7
+ RUN pip3 install --no-cache-dir \
8
+ flask flask-cors \
9
+ torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 \
10
+ numpy transformers accelerate sentencepiece protobuf
11
+
12
+ # Create non-root user (HF Spaces runs as uid 1000)
13
+ RUN useradd -m -u 1000 user
14
+ USER user
15
+ ENV HOME=/home/user \
16
+ PATH=/home/user/.local/bin:$PATH \
17
+ HF_HOME=/home/user/.cache/huggingface
18
+
19
+ WORKDIR /home/user/app
20
+ COPY --chown=user:user . .
21
+
22
+ # Pre-download model during build (avoids slow cold start)
23
+ RUN python3 -c "from transformers import AutoModel; AutoModel.from_pretrained('ShandaAI/FloodDiffusionTiny', trust_remote_code=True)"
24
+
25
+ EXPOSE 7860
26
+ CMD ["python3", "app.py", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,11 @@
1
  ---
2
- title: FloodDiffusion Streaming
3
- emoji: 😻
4
- colorFrom: red
5
  colorTo: purple
6
  sdk: docker
7
- pinned: false
 
 
 
8
  ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: FloodDiffusion Streaming Demo
3
+ emoji: "\U0001F30A"
4
+ colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
+ hardware: t4-small
9
+ pinned: true
10
+ license: apache-2.0
11
  ---
 
 
app.py ADDED
@@ -0,0 +1,571 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Flask server for real-time 3D motion generation demo (HF Space version)
3
+ """
4
+
5
+ import argparse
6
+ import threading
7
+ import time
8
+
9
+ from flask import Flask, jsonify, render_template, request
10
+ from flask_cors import CORS
11
+ from model_manager import get_model_manager
12
+
13
+
14
+ def _coerce_value(value, reference):
15
+ """Coerce a value to match the type of a reference value"""
16
+ if isinstance(reference, bool):
17
+ return value if isinstance(value, bool) else str(value).lower() in ("true", "1")
18
+ elif isinstance(reference, int):
19
+ return int(value)
20
+ elif isinstance(reference, float):
21
+ return float(value)
22
+ return str(value)
23
+
24
+
25
+ app = Flask(__name__)
26
+ CORS(app)
27
+
28
+ # Global model manager (loaded eagerly on startup)
29
+ model_manager = None
30
+ model_name_global = None # Will be set once at startup
31
+
32
+ # Session tracking - only one active session can generate at a time
33
+ active_session_id = None # The session ID currently generating
34
+ session_lock = threading.Lock()
35
+
36
+ # Frame consumption monitoring - detect if client disconnected by tracking frame consumption
37
+ last_frame_consumed_time = None
38
+ consumption_timeout = (
39
+ 5.0 # If no frame consumed for 5 seconds, assume client disconnected
40
+ )
41
+ consumption_monitor_thread = None
42
+ consumption_monitor_lock = threading.Lock()
43
+
44
+
45
+ def init_model():
46
+ """Initialize model manager"""
47
+ global model_manager
48
+ if model_manager is None:
49
+ if model_name_global is None:
50
+ raise RuntimeError(
51
+ "model_name_global not set. Server not properly initialized."
52
+ )
53
+ print(f"Initializing model manager with model: {model_name_global}")
54
+ model_manager = get_model_manager(model_name=model_name_global)
55
+ print("Model manager ready!")
56
+ return model_manager
57
+
58
+
59
+ def consumption_monitor():
60
+ """Monitor frame consumption and auto-reset if client stops consuming"""
61
+ global last_frame_consumed_time, active_session_id, model_manager
62
+
63
+ while True:
64
+ time.sleep(2.0) # Check every 2 seconds
65
+
66
+ # Read state with proper locking - no nested locks!
67
+ should_reset = False
68
+ current_session = None
69
+ time_since_last_consumption = 0
70
+
71
+ # First, check consumption time
72
+ with consumption_monitor_lock:
73
+ if last_frame_consumed_time is not None:
74
+ time_since_last_consumption = time.time() - last_frame_consumed_time
75
+ if time_since_last_consumption > consumption_timeout:
76
+ # Need to check if still generating before reset
77
+ if model_manager and model_manager.is_generating:
78
+ should_reset = True
79
+
80
+ # Then, get current session (separate lock)
81
+ if should_reset:
82
+ with session_lock:
83
+ current_session = active_session_id
84
+
85
+ # Perform reset outside of locks to avoid deadlock
86
+ if should_reset and current_session is not None:
87
+ print(
88
+ f"No frame consumed for {time_since_last_consumption:.1f}s - client disconnected, auto-resetting..."
89
+ )
90
+
91
+ if model_manager:
92
+ model_manager.reset()
93
+ print(
94
+ "Generation reset due to client disconnect (no frame consumption)"
95
+ )
96
+
97
+ # Clear state with proper locking - no nested locks!
98
+ with session_lock:
99
+ if active_session_id == current_session:
100
+ active_session_id = None
101
+
102
+ with consumption_monitor_lock:
103
+ last_frame_consumed_time = None
104
+
105
+
106
+ def start_consumption_monitor():
107
+ """Start the consumption monitoring thread if not already running"""
108
+ global consumption_monitor_thread
109
+
110
+ if consumption_monitor_thread is None or not consumption_monitor_thread.is_alive():
111
+ consumption_monitor_thread = threading.Thread(
112
+ target=consumption_monitor, daemon=True
113
+ )
114
+ consumption_monitor_thread.start()
115
+ print("Consumption monitor started")
116
+
117
+
118
+ @app.route("/")
119
+ def index():
120
+ """Main page"""
121
+ return render_template("index.html")
122
+
123
+
124
+ @app.route("/api/config", methods=["GET"])
125
+ def get_config():
126
+ """Get current config"""
127
+ try:
128
+ if model_manager:
129
+ model = model_manager.model
130
+ base_sched_keys = set(model_manager._base_schedule_config.keys())
131
+ base_cfg_keys = set(model_manager._base_cfg_config.keys())
132
+ return jsonify(
133
+ {
134
+ "schedule_config": {
135
+ k: v
136
+ for k, v in model.schedule_config.items()
137
+ if k in base_sched_keys
138
+ },
139
+ "cfg_config": {
140
+ k: v
141
+ for k, v in model.cfg_config.items()
142
+ if k in base_cfg_keys
143
+ },
144
+ "history_length": model_manager.history_length,
145
+ "smoothing_alpha": float(model_manager.smoothing_alpha),
146
+ }
147
+ )
148
+ else:
149
+ # Model not loaded yet - return defaults
150
+ return jsonify(
151
+ {
152
+ "schedule_config": {},
153
+ "cfg_config": {},
154
+ "history_length": 30,
155
+ "smoothing_alpha": 0.5,
156
+ }
157
+ )
158
+ except Exception as e:
159
+ import traceback
160
+
161
+ traceback.print_exc()
162
+ return jsonify({"status": "error", "message": str(e)}), 500
163
+
164
+
165
+ @app.route("/api/config", methods=["POST"])
166
+ def update_config():
167
+ """Update model config in memory"""
168
+ try:
169
+ global active_session_id, last_frame_consumed_time
170
+
171
+ if not model_manager or not model_manager.model:
172
+ return jsonify({"status": "error", "message": "Model not loaded yet"}), 400
173
+
174
+ data = request.json
175
+ new_schedule_config = data.get("schedule_config")
176
+ new_cfg_config = data.get("cfg_config")
177
+ history_length = data.get("history_length")
178
+ smoothing_alpha = data.get("smoothing_alpha")
179
+
180
+ valid_schedule_keys = set(model_manager._base_schedule_config.keys())
181
+ valid_cfg_keys = set(model_manager._base_cfg_config.keys())
182
+
183
+ # Validate and update schedule_config
184
+ if new_schedule_config:
185
+ for key in new_schedule_config:
186
+ if key not in valid_schedule_keys:
187
+ return jsonify(
188
+ {
189
+ "status": "error",
190
+ "message": f"Unknown schedule_config key: {key}",
191
+ }
192
+ ), 400
193
+ for key, value in new_schedule_config.items():
194
+ model_manager._base_schedule_config[key] = _coerce_value(
195
+ value, model_manager._base_schedule_config[key]
196
+ )
197
+
198
+ # Validate and update cfg_config
199
+ if new_cfg_config:
200
+ for key in new_cfg_config:
201
+ if key not in valid_cfg_keys:
202
+ return jsonify(
203
+ {"status": "error", "message": f"Unknown cfg_config key: {key}"}
204
+ ), 400
205
+ for key, value in new_cfg_config.items():
206
+ model_manager._base_cfg_config[key] = _coerce_value(
207
+ value, model_manager._base_cfg_config[key]
208
+ )
209
+
210
+ # Reset with new parameters
211
+ model_manager.reset(
212
+ history_length=history_length,
213
+ smoothing_alpha=smoothing_alpha,
214
+ )
215
+
216
+ # Clear active session
217
+ with session_lock:
218
+ active_session_id = None
219
+ with consumption_monitor_lock:
220
+ last_frame_consumed_time = None
221
+
222
+ return jsonify({"status": "success"})
223
+ except Exception as e:
224
+ import traceback
225
+
226
+ traceback.print_exc()
227
+ return jsonify({"status": "error", "message": str(e)}), 500
228
+
229
+
230
+ @app.route("/api/start", methods=["POST"])
231
+ def start_generation():
232
+ """Start generation with given text"""
233
+ try:
234
+ global active_session_id, last_frame_consumed_time
235
+
236
+ data = request.json
237
+ session_id = data.get("session_id")
238
+ text = data.get("text", "walk in a circle.")
239
+ history_length = data.get("history_length")
240
+ smoothing_alpha = data.get(
241
+ "smoothing_alpha", None
242
+ ) # Optional smoothing parameter
243
+ force = data.get("force", False) # Allow force takeover
244
+
245
+ if not session_id:
246
+ return jsonify(
247
+ {"status": "error", "message": "session_id is required"}
248
+ ), 400
249
+
250
+ print(
251
+ f"[Session {session_id}] Starting generation with text: {text}, history_length: {history_length}, force: {force}"
252
+ )
253
+
254
+ # Initialize model if needed
255
+ mm = init_model()
256
+
257
+ # Check if another session is already generating
258
+ need_force_takeover = False
259
+
260
+ with session_lock:
261
+ if active_session_id and active_session_id != session_id:
262
+ if not force:
263
+ # Another session is active, return conflict
264
+ return jsonify(
265
+ {
266
+ "status": "error",
267
+ "message": "Another session is already generating.",
268
+ "conflict": True,
269
+ "active_session_id": active_session_id,
270
+ }
271
+ ), 409
272
+ else:
273
+ # Force takeover
274
+ print(
275
+ f"[Session {session_id}] Force takeover from session {active_session_id}"
276
+ )
277
+ need_force_takeover = True
278
+
279
+ if mm.is_generating and active_session_id == session_id:
280
+ return jsonify(
281
+ {
282
+ "status": "error",
283
+ "message": "Generation is already running for this session.",
284
+ }
285
+ ), 400
286
+
287
+ # Set this session as active
288
+ active_session_id = session_id
289
+
290
+ # Clear previous session's consumption tracking if force takeover (no nested locks)
291
+ if need_force_takeover:
292
+ with consumption_monitor_lock:
293
+ last_frame_consumed_time = None
294
+
295
+ # Reset and start generation
296
+ mm.reset(history_length=history_length, smoothing_alpha=smoothing_alpha)
297
+ mm.start_generation(text, history_length=history_length)
298
+
299
+ # Initialize consumption tracking (no nested locks)
300
+ with consumption_monitor_lock:
301
+ last_frame_consumed_time = time.time()
302
+
303
+ # Start consumption monitoring
304
+ start_consumption_monitor()
305
+ print(f"[Session {session_id}] Consumption monitoring activated")
306
+
307
+ return jsonify(
308
+ {
309
+ "status": "success",
310
+ "message": f"Generation started with text: {text}, history_length: {history_length}",
311
+ "session_id": session_id,
312
+ }
313
+ )
314
+ except Exception as e:
315
+ print(f"Error in start_generation: {e}")
316
+ import traceback
317
+
318
+ traceback.print_exc()
319
+ return jsonify({"status": "error", "message": str(e)}), 500
320
+
321
+
322
+ @app.route("/api/update_text", methods=["POST"])
323
+ def update_text():
324
+ """Update the generation text"""
325
+ try:
326
+ data = request.json
327
+ session_id = data.get("session_id")
328
+ text = data.get("text", "")
329
+
330
+ if not session_id:
331
+ return jsonify(
332
+ {"status": "error", "message": "session_id is required"}
333
+ ), 400
334
+
335
+ # Verify this is the active session
336
+ with session_lock:
337
+ if active_session_id != session_id:
338
+ return jsonify(
339
+ {"status": "error", "message": "Not the active session"}
340
+ ), 403
341
+
342
+ if model_manager is None:
343
+ return jsonify({"status": "error", "message": "Model not initialized"}), 400
344
+
345
+ model_manager.update_text(text)
346
+
347
+ return jsonify({"status": "success", "message": f"Text updated to: {text}"})
348
+ except Exception as e:
349
+ return jsonify({"status": "error", "message": str(e)}), 500
350
+
351
+
352
+ @app.route("/api/pause", methods=["POST"])
353
+ def pause_generation():
354
+ """Pause generation (keeps state for resume)"""
355
+ try:
356
+ data = request.json if request.json else {}
357
+ session_id = data.get("session_id")
358
+
359
+ if not session_id:
360
+ return jsonify(
361
+ {"status": "error", "message": "session_id is required"}
362
+ ), 400
363
+
364
+ # Verify this is the active session
365
+ with session_lock:
366
+ if active_session_id != session_id:
367
+ return jsonify(
368
+ {"status": "error", "message": "Not the active session"}
369
+ ), 403
370
+
371
+ if model_manager:
372
+ model_manager.pause_generation()
373
+
374
+ return jsonify({"status": "success", "message": "Generation paused"})
375
+ except Exception as e:
376
+ return jsonify({"status": "error", "message": str(e)}), 500
377
+
378
+
379
+ @app.route("/api/resume", methods=["POST"])
380
+ def resume_generation():
381
+ """Resume generation from paused state"""
382
+ try:
383
+ global last_frame_consumed_time
384
+
385
+ data = request.json if request.json else {}
386
+ session_id = data.get("session_id")
387
+
388
+ if not session_id:
389
+ return jsonify(
390
+ {"status": "error", "message": "session_id is required"}
391
+ ), 400
392
+
393
+ # Verify this is the active session
394
+ with session_lock:
395
+ if active_session_id != session_id:
396
+ return jsonify(
397
+ {"status": "error", "message": "Not the active session"}
398
+ ), 403
399
+
400
+ if model_manager is None:
401
+ return jsonify({"status": "error", "message": "Model not initialized"}), 400
402
+
403
+ model_manager.resume_generation()
404
+
405
+ # Reset consumption tracking when resuming
406
+ with consumption_monitor_lock:
407
+ last_frame_consumed_time = time.time()
408
+
409
+ return jsonify({"status": "success", "message": "Generation resumed"})
410
+ except Exception as e:
411
+ return jsonify({"status": "error", "message": str(e)}), 500
412
+
413
+
414
+ @app.route("/api/reset", methods=["POST"])
415
+ def reset():
416
+ """Reset generation state"""
417
+ try:
418
+ global active_session_id, last_frame_consumed_time
419
+
420
+ data = request.json if request.json else {}
421
+ session_id = data.get("session_id")
422
+ history_length = data.get("history_length")
423
+ smoothing_alpha = data.get("smoothing_alpha")
424
+
425
+ # If session_id provided, verify it's the active session
426
+ if session_id:
427
+ with session_lock:
428
+ if active_session_id and active_session_id != session_id:
429
+ return jsonify(
430
+ {"status": "error", "message": "Not the active session"}
431
+ ), 403
432
+
433
+ if model_manager:
434
+ model_manager.reset(
435
+ history_length=history_length, smoothing_alpha=smoothing_alpha
436
+ )
437
+
438
+ # Clear the active session
439
+ with session_lock:
440
+ if active_session_id == session_id or not session_id:
441
+ active_session_id = None
442
+
443
+ # Clear consumption tracking
444
+ with consumption_monitor_lock:
445
+ last_frame_consumed_time = None
446
+
447
+ print(f"[Session {session_id}] Reset complete, session cleared")
448
+
449
+ return jsonify(
450
+ {
451
+ "status": "success",
452
+ "message": "Reset complete",
453
+ }
454
+ )
455
+ except Exception as e:
456
+ return jsonify({"status": "error", "message": str(e)}), 500
457
+
458
+
459
+ @app.route("/api/get_frame", methods=["GET"])
460
+ def get_frame():
461
+ """Get the next frame"""
462
+ try:
463
+ global last_frame_consumed_time
464
+
465
+ session_id = request.args.get("session_id")
466
+
467
+ if not session_id:
468
+ return jsonify(
469
+ {"status": "error", "message": "session_id is required"}
470
+ ), 400
471
+
472
+ # Verify this is the active session
473
+ with session_lock:
474
+ if active_session_id != session_id:
475
+ return jsonify(
476
+ {"status": "error", "message": "Not the active session"}
477
+ ), 403
478
+
479
+ if model_manager is None:
480
+ return jsonify({"status": "error", "message": "Model not initialized"}), 400
481
+
482
+ # Get next frame from buffer
483
+ joints = model_manager.get_next_frame()
484
+
485
+ if joints is not None:
486
+ # Update last consumption time
487
+ with consumption_monitor_lock:
488
+ last_frame_consumed_time = time.time()
489
+
490
+ # Convert numpy array to list for JSON
491
+ joints_list = joints.tolist()
492
+ return jsonify(
493
+ {
494
+ "status": "success",
495
+ "joints": joints_list,
496
+ "buffer_size": model_manager.frame_buffer.size(),
497
+ }
498
+ )
499
+ else:
500
+ return jsonify(
501
+ {
502
+ "status": "waiting",
503
+ "message": "No frame available yet",
504
+ "buffer_size": model_manager.frame_buffer.size(),
505
+ }
506
+ )
507
+ except Exception as e:
508
+ print(f"Error in get_frame: {e}")
509
+ import traceback
510
+
511
+ traceback.print_exc()
512
+ return jsonify({"status": "error", "message": str(e)}), 500
513
+
514
+
515
+ @app.route("/api/status", methods=["GET"])
516
+ def get_status():
517
+ """Get generation status"""
518
+ try:
519
+ session_id = request.args.get("session_id")
520
+
521
+ with session_lock:
522
+ is_active_session = session_id and active_session_id == session_id
523
+ current_active_session = active_session_id
524
+
525
+ if model_manager is None:
526
+ return jsonify(
527
+ {
528
+ "initialized": False,
529
+ "buffer_size": 0,
530
+ "is_generating": False,
531
+ "is_active_session": is_active_session,
532
+ "active_session_id": current_active_session,
533
+ }
534
+ )
535
+
536
+ status = model_manager.get_buffer_status()
537
+ status["initialized"] = True
538
+ status["is_active_session"] = is_active_session
539
+ status["active_session_id"] = current_active_session
540
+
541
+ return jsonify(status)
542
+ except Exception as e:
543
+ return jsonify({"status": "error", "message": str(e)}), 500
544
+
545
+
546
+ if __name__ == "__main__":
547
+ parser = argparse.ArgumentParser(
548
+ description="Flask server for real-time 3D motion generation"
549
+ )
550
+ parser.add_argument(
551
+ "--model_name",
552
+ type=str,
553
+ default="ShandaAI/FloodDiffusionTiny",
554
+ help="HF Hub model name (default: ShandaAI/FloodDiffusionTiny)",
555
+ )
556
+ parser.add_argument(
557
+ "--port",
558
+ type=int,
559
+ default=7860,
560
+ help="Port to run the server on (default: 7860)",
561
+ )
562
+ args = parser.parse_args()
563
+
564
+ model_name_global = args.model_name
565
+
566
+ # Load model eagerly on startup (pre-downloaded in Docker)
567
+ print(f"Loading model: {model_name_global}")
568
+ init_model()
569
+
570
+ print("Starting Flask server...")
571
+ app.run(host="0.0.0.0", port=args.port, debug=False, threaded=True)
model_manager.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model Manager for real-time motion generation (HF Space version)
3
+ Loads model from Hugging Face Hub instead of local checkpoints.
4
+ """
5
+
6
+ import threading
7
+ import time
8
+ from collections import deque
9
+
10
+ import numpy as np
11
+ import torch
12
+
13
+ from motion_process import StreamJointRecovery263
14
+
15
+
16
+ class FrameBuffer:
17
+ """
18
+ Thread-safe frame buffer that maintains a queue of generated frames
19
+ """
20
+
21
+ def __init__(self, target_buffer_size=4):
22
+ self.buffer = deque(maxlen=100) # Max 100 frames in buffer
23
+ self.target_size = target_buffer_size
24
+ self.lock = threading.Lock()
25
+
26
+ def add_frame(self, joints):
27
+ """Add a frame to the buffer"""
28
+ with self.lock:
29
+ self.buffer.append(joints)
30
+
31
+ def get_frame(self):
32
+ """Get the next frame from buffer"""
33
+ with self.lock:
34
+ if len(self.buffer) > 0:
35
+ return self.buffer.popleft()
36
+ return None
37
+
38
+ def size(self):
39
+ """Get current buffer size"""
40
+ with self.lock:
41
+ return len(self.buffer)
42
+
43
+ def clear(self):
44
+ """Clear the buffer"""
45
+ with self.lock:
46
+ self.buffer.clear()
47
+
48
+ def needs_generation(self):
49
+ """Check if buffer needs more frames"""
50
+ return self.size() < self.target_size
51
+
52
+
53
+ class ModelManager:
54
+ """
55
+ Manages model loading from HF Hub and real-time frame generation
56
+ """
57
+
58
+ def __init__(self, model_name):
59
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
60
+ print(f"Using device: {self.device}")
61
+
62
+ # Load models from HF Hub
63
+ self.vae, self.model = self._load_models(model_name)
64
+
65
+ # Save clean copies of user-facing configs (before any runtime injection)
66
+ self._base_schedule_config = dict(self.model.schedule_config)
67
+ self._base_cfg_config = dict(self.model.cfg_config)
68
+
69
+ # Frame buffer
70
+ self.frame_buffer = FrameBuffer(target_buffer_size=4)
71
+
72
+ # Stream joint recovery with smoothing
73
+ self.smoothing_alpha = 0.5 # Default: medium smoothing
74
+ self.stream_recovery = StreamJointRecovery263(
75
+ joints_num=22, smoothing_alpha=self.smoothing_alpha
76
+ )
77
+
78
+ # Generation state
79
+ self.current_text = ""
80
+ self.is_generating = False
81
+ self.generation_thread = None
82
+ self.should_stop = False
83
+
84
+ # Model generation state
85
+ self.first_chunk = True
86
+ self.history_length = 30
87
+
88
+ print("ModelManager initialized successfully")
89
+
90
+ def _load_models(self, model_name):
91
+ """Load VAE and diffusion models from HF Hub"""
92
+ torch.set_float32_matmul_precision("high")
93
+
94
+ print(f"Loading model from HF Hub: {model_name}")
95
+ from transformers import AutoModel
96
+
97
+ hf_model = AutoModel.from_pretrained(model_name, trust_remote_code=True)
98
+ hf_model.to(self.device)
99
+
100
+ # Trigger lazy loading / warmup
101
+ print("Warming up model...")
102
+ _ = hf_model("test", length=1)
103
+
104
+ # Access underlying streaming components
105
+ model = hf_model.ldf_model
106
+ vae = hf_model.vae
107
+
108
+ model.eval()
109
+ vae.eval()
110
+
111
+ print("Models loaded successfully")
112
+ return vae, model
113
+
114
+ def start_generation(self, text, history_length=None):
115
+ """Start or update generation with new text"""
116
+ self.current_text = text
117
+
118
+ if history_length is not None:
119
+ self.history_length = history_length
120
+
121
+ if not self.is_generating:
122
+ # Reset state before starting (only once at the beginning)
123
+ self.frame_buffer.clear()
124
+ self.stream_recovery.reset()
125
+ self.vae.clear_cache()
126
+ self.first_chunk = True
127
+ # Restore clean config before init (clears runtime-injected keys)
128
+ self.model.schedule_config.clear()
129
+ self.model.schedule_config.update(self._base_schedule_config)
130
+ self.model.init_generated(
131
+ self.history_length,
132
+ batch_size=1,
133
+ schedule_config=self.model.schedule_config,
134
+ )
135
+ print(
136
+ f"Model initialized with history length: {self.history_length}, schedule_config: {self.model.schedule_config}"
137
+ )
138
+
139
+ # Start generation thread
140
+ self.should_stop = False
141
+ self.generation_thread = threading.Thread(target=self._generation_loop)
142
+ self.generation_thread.daemon = True
143
+ self.generation_thread.start()
144
+ self.is_generating = True
145
+
146
+ def update_text(self, text):
147
+ """Update text without resetting state (continuous generation with new text)"""
148
+ if text != self.current_text:
149
+ old_text = self.current_text
150
+ self.current_text = text
151
+ # Don't reset first_chunk, stream_recovery, or vae cache
152
+ # This allows continuous generation with text changes
153
+ print(f"Text updated: '{old_text}' -> '{text}' (continuous generation)")
154
+
155
+ def pause_generation(self):
156
+ """Pause generation (keeps all state)"""
157
+ self.should_stop = True
158
+ if self.generation_thread:
159
+ self.generation_thread.join(timeout=2.0)
160
+ self.is_generating = False
161
+ print("Generation paused (state preserved)")
162
+
163
+ def resume_generation(self):
164
+ """Resume generation from paused state"""
165
+ if self.is_generating:
166
+ print("Already generating, ignoring resume")
167
+ return
168
+
169
+ # Restart generation thread with existing state
170
+ self.should_stop = False
171
+ self.generation_thread = threading.Thread(target=self._generation_loop)
172
+ self.generation_thread.daemon = True
173
+ self.generation_thread.start()
174
+ self.is_generating = True
175
+ print("Generation resumed")
176
+
177
+ def reset(self, history_length=None, smoothing_alpha=None):
178
+ """Reset generation state completely
179
+
180
+ Args:
181
+ history_length: History window length for the model
182
+ smoothing_alpha: EMA smoothing factor (0.0 to 1.0)
183
+ - 1.0 = no smoothing (default)
184
+ - 0.0 = infinite smoothing
185
+ - Recommended: 0.3-0.7 for visible smoothing
186
+ """
187
+ # Stop if running
188
+ if self.is_generating:
189
+ self.pause_generation()
190
+
191
+ # Clear everything
192
+ self.frame_buffer.clear()
193
+ self.vae.clear_cache()
194
+ self.first_chunk = True
195
+
196
+ if history_length is not None:
197
+ self.history_length = history_length
198
+
199
+ # Update smoothing alpha if provided and recreate stream recovery
200
+ if smoothing_alpha is not None:
201
+ self.smoothing_alpha = np.clip(smoothing_alpha, 0.0, 1.0)
202
+ print(f"Smoothing alpha updated to: {self.smoothing_alpha}")
203
+
204
+ # Recreate stream recovery with new smoothing alpha
205
+ self.stream_recovery = StreamJointRecovery263(
206
+ joints_num=22, smoothing_alpha=self.smoothing_alpha
207
+ )
208
+
209
+ # Restore clean configs before init (clears runtime-injected keys)
210
+ self.model.schedule_config.clear()
211
+ self.model.schedule_config.update(self._base_schedule_config)
212
+ self.model.cfg_config.clear()
213
+ self.model.cfg_config.update(self._base_cfg_config)
214
+
215
+ # Initialize model (reads steps/chunk_size from model.schedule_config directly)
216
+ self.model.init_generated(
217
+ self.history_length,
218
+ batch_size=1,
219
+ schedule_config=self.model.schedule_config,
220
+ )
221
+ print(
222
+ f"Model reset - history: {self.history_length}, smoothing: {self.smoothing_alpha}, schedule_config: {self.model.schedule_config}"
223
+ )
224
+
225
+ def _generation_loop(self):
226
+ """Main generation loop that runs in background thread"""
227
+ print("Generation loop started")
228
+
229
+ step_count = 0
230
+ total_gen_time = 0
231
+
232
+ with torch.no_grad():
233
+ while not self.should_stop:
234
+ # Check if buffer needs more frames
235
+ if self.frame_buffer.needs_generation():
236
+ try:
237
+ step_start = time.time()
238
+
239
+ # Generate one token (produces 4 frames from VAE)
240
+ text_key = self.model.input_keys["text"]
241
+ x = {text_key: [self.current_text]}
242
+
243
+ # Generate from model (1 token)
244
+ output = self.model.stream_generate_step(x)
245
+ generated = output["generated"]
246
+
247
+ # Skip if no frames committed yet
248
+ if generated[0].shape[0] == 0:
249
+ continue
250
+
251
+ # Decode with VAE (1 token -> 4 frames)
252
+ decoded = self.vae.stream_decode(
253
+ generated[0][None, :], first_chunk=self.first_chunk
254
+ )[0]
255
+
256
+ self.first_chunk = False
257
+
258
+ # Convert each frame to joints
259
+ for i in range(decoded.shape[0]):
260
+ frame_data = decoded[i].cpu().numpy()
261
+ joints = self.stream_recovery.process_frame(frame_data)
262
+ self.frame_buffer.add_frame(joints)
263
+
264
+ step_time = time.time() - step_start
265
+ total_gen_time += step_time
266
+ step_count += 1
267
+
268
+ # Print performance stats every 10 steps
269
+ if step_count % 10 == 0:
270
+ avg_time = total_gen_time / step_count
271
+ fps = decoded.shape[0] / avg_time
272
+ print(
273
+ f"[Generation] Step {step_count}: {step_time * 1000:.1f}ms, "
274
+ f"Avg: {avg_time * 1000:.1f}ms, "
275
+ f"FPS: {fps:.1f}, "
276
+ f"Buffer: {self.frame_buffer.size()}"
277
+ )
278
+
279
+ except Exception as e:
280
+ print(f"Error in generation: {e}")
281
+ import traceback
282
+
283
+ traceback.print_exc()
284
+ time.sleep(0.1)
285
+ else:
286
+ # Buffer is full, wait a bit
287
+ time.sleep(0.01)
288
+
289
+ print("Generation loop stopped")
290
+
291
+ def get_next_frame(self):
292
+ """Get the next frame from buffer"""
293
+ return self.frame_buffer.get_frame()
294
+
295
+ def get_buffer_status(self):
296
+ """Get buffer status"""
297
+ return {
298
+ "buffer_size": self.frame_buffer.size(),
299
+ "target_size": self.frame_buffer.target_size,
300
+ "is_generating": self.is_generating,
301
+ "current_text": self.current_text,
302
+ "smoothing_alpha": self.smoothing_alpha,
303
+ "history_length": self.history_length,
304
+ "schedule_config": dict(self.model.schedule_config),
305
+ "cfg_config": dict(self.model.cfg_config),
306
+ }
307
+
308
+
309
+ # Global model manager instance
310
+ _model_manager = None
311
+
312
+
313
+ def get_model_manager(model_name=None):
314
+ """Get or create the global model manager instance"""
315
+ global _model_manager
316
+ if _model_manager is None:
317
+ _model_manager = ModelManager(model_name)
318
+ return _model_manager
motion_process.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Streaming joint recovery from 263-dim motion features.
3
+ Extracted from FloodDiffusion utils for standalone use (only numpy + torch).
4
+ """
5
+
6
+ import numpy as np
7
+ import torch
8
+
9
+
10
+ def qinv(q):
11
+ assert q.shape[-1] == 4, "q must be a tensor of shape (*, 4)"
12
+ mask = torch.ones_like(q)
13
+ mask[..., 1:] = -mask[..., 1:]
14
+ return q * mask
15
+
16
+
17
+ def qrot(q, v):
18
+ assert q.shape[-1] == 4
19
+ assert v.shape[-1] == 3
20
+ assert q.shape[:-1] == v.shape[:-1]
21
+
22
+ original_shape = list(v.shape)
23
+ q = q.contiguous().view(-1, 4)
24
+ v = v.contiguous().view(-1, 3)
25
+
26
+ qvec = q[:, 1:]
27
+ uv = torch.cross(qvec, v, dim=1)
28
+ uuv = torch.cross(qvec, uv, dim=1)
29
+ return (v + 2 * (q[:, :1] * uv + uuv)).view(original_shape)
30
+
31
+
32
+ class StreamJointRecovery263:
33
+ """
34
+ Stream version of recover_joint_positions_263 that processes one frame at a time.
35
+ Maintains cumulative state for rotation angles and positions.
36
+
37
+ Key insight: The batch version uses PREVIOUS frame's velocity for the current frame,
38
+ so we need to delay the velocity application by one frame.
39
+
40
+ Args:
41
+ joints_num: Number of joints in the skeleton
42
+ smoothing_alpha: EMA smoothing factor (0.0 to 1.0)
43
+ - 1.0 = no smoothing (default), output follows input exactly
44
+ - 0.0 = infinite smoothing, output never changes
45
+ - Recommended values: 0.3-0.7 for visible smoothing
46
+ - Formula: smoothed = alpha * current + (1 - alpha) * previous
47
+ """
48
+
49
+ def __init__(self, joints_num: int, smoothing_alpha: float = 1.0):
50
+ self.joints_num = joints_num
51
+ self.smoothing_alpha = np.clip(smoothing_alpha, 0.0, 1.0)
52
+ self.reset()
53
+
54
+ def reset(self):
55
+ """Reset the accumulated state"""
56
+ self.r_rot_ang_accum = 0.0
57
+ self.r_pos_accum = np.array([0.0, 0.0, 0.0])
58
+ # Store previous frame's velocities for delayed application
59
+ self.prev_rot_vel = 0.0
60
+ self.prev_linear_vel = np.array([0.0, 0.0])
61
+ # Store previous smoothed joints for EMA
62
+ self.prev_smoothed_joints = None
63
+
64
+ def process_frame(self, frame_data: np.ndarray) -> np.ndarray:
65
+ """
66
+ Process a single frame and return joint positions for that frame.
67
+
68
+ Args:
69
+ frame_data: numpy array of shape (263,) for a single frame
70
+
71
+ Returns:
72
+ joints: numpy array of shape (joints_num, 3) representing joint positions
73
+ """
74
+ # Convert to torch tensor
75
+ feature_vec = torch.from_numpy(frame_data).float()
76
+
77
+ # Extract current frame's velocities (will be used in NEXT frame)
78
+ curr_rot_vel = feature_vec[0].item()
79
+ curr_linear_vel = feature_vec[1:3].numpy()
80
+
81
+ # Update accumulated rotation angle with PREVIOUS frame's velocity FIRST
82
+ # This matches the batch processing: r_rot_ang[i] uses rot_vel[i-1]
83
+ self.r_rot_ang_accum += self.prev_rot_vel
84
+
85
+ # Calculate current rotation quaternion using updated accumulated angle
86
+ r_rot_quat = torch.zeros(4)
87
+ r_rot_quat[0] = np.cos(self.r_rot_ang_accum)
88
+ r_rot_quat[2] = np.sin(self.r_rot_ang_accum)
89
+
90
+ # Create velocity vector with Y=0 using PREVIOUS frame's velocity
91
+ r_vel = np.array([self.prev_linear_vel[0], 0.0, self.prev_linear_vel[1]])
92
+
93
+ # Apply inverse rotation to velocity using CURRENT rotation
94
+ r_vel_torch = torch.from_numpy(r_vel).float()
95
+ r_vel_rotated = qrot(qinv(r_rot_quat).unsqueeze(0), r_vel_torch.unsqueeze(0))
96
+ r_vel_rotated = r_vel_rotated.squeeze(0).numpy()
97
+
98
+ # Update accumulated position with rotated velocity
99
+ self.r_pos_accum += r_vel_rotated
100
+
101
+ # Get Y position from data
102
+ r_pos = self.r_pos_accum.copy()
103
+ r_pos[1] = feature_vec[3].item()
104
+
105
+ # Extract local joint positions
106
+ positions = feature_vec[4 : (self.joints_num - 1) * 3 + 4]
107
+ positions = positions.view(-1, 3)
108
+
109
+ # Apply inverse rotation to local joints
110
+ r_rot_quat_expanded = (
111
+ qinv(r_rot_quat).unsqueeze(0).expand(positions.shape[0], 4)
112
+ )
113
+ positions = qrot(r_rot_quat_expanded, positions)
114
+
115
+ # Add root XZ to joints
116
+ positions[:, 0] += r_pos[0]
117
+ positions[:, 2] += r_pos[2]
118
+
119
+ # Concatenate root and joints
120
+ r_pos_torch = torch.from_numpy(r_pos).float()
121
+ positions = torch.cat([r_pos_torch.unsqueeze(0), positions], dim=0)
122
+
123
+ # Convert to numpy
124
+ joints_np = positions.detach().cpu().numpy()
125
+
126
+ # Apply EMA smoothing if enabled
127
+ if self.smoothing_alpha < 1.0:
128
+ if self.prev_smoothed_joints is None:
129
+ # First frame, no smoothing possible
130
+ self.prev_smoothed_joints = joints_np.copy()
131
+ else:
132
+ # EMA: smoothed = alpha * current + (1 - alpha) * previous
133
+ joints_np = (
134
+ self.smoothing_alpha * joints_np
135
+ + (1.0 - self.smoothing_alpha) * self.prev_smoothed_joints
136
+ )
137
+ self.prev_smoothed_joints = joints_np.copy()
138
+
139
+ # Store current velocities for next frame
140
+ self.prev_rot_vel = curr_rot_vel
141
+ self.prev_linear_vel = curr_linear_vel
142
+
143
+ return joints_np
static/css/style.css ADDED
@@ -0,0 +1,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
9
+ background: #f5f5f5; /* Light gray background */
10
+ min-height: 100vh;
11
+ padding: 20px;
12
+ color: #333;
13
+ }
14
+
15
+ .container {
16
+ max-width: 1400px;
17
+ margin: 0 auto;
18
+ background: #ffffff; /* Pure white container */
19
+ border-radius: 16px;
20
+ border: 1px solid #e0e0e0;
21
+ overflow: hidden;
22
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
23
+ }
24
+
25
+ .header {
26
+ background: #ffffff;
27
+ color: #000000; /* Black text */
28
+ padding: 30px;
29
+ text-align: center;
30
+ border-bottom: 1px solid #e0e0e0;
31
+ }
32
+
33
+ .header h1 {
34
+ font-size: 2.2em;
35
+ margin-bottom: 8px;
36
+ font-weight: 600;
37
+ letter-spacing: -0.5px;
38
+ }
39
+
40
+ .subtitle {
41
+ font-size: 1em;
42
+ opacity: 0.6;
43
+ font-weight: 400;
44
+ }
45
+
46
+ .controls {
47
+ padding: 25px 30px;
48
+ background: #fafafa; /* Very light gray */
49
+ border-bottom: 1px solid #e0e0e0;
50
+ }
51
+
52
+ .input-group {
53
+ margin-bottom: 15px;
54
+ }
55
+
56
+ .input-group label {
57
+ display: block;
58
+ font-weight: 500;
59
+ margin-bottom: 8px;
60
+ color: #666;
61
+ font-size: 0.95em;
62
+ letter-spacing: 0.3px;
63
+ }
64
+
65
+ .input-group input {
66
+ width: 100%;
67
+ padding: 12px 16px;
68
+ border: 1px solid #ddd;
69
+ border-radius: 8px;
70
+ font-size: 1em;
71
+ background: #ffffff;
72
+ color: #333;
73
+ transition: all 0.3s;
74
+ }
75
+
76
+ .input-group input:focus {
77
+ outline: none;
78
+ border-color: #999;
79
+ background: #fff;
80
+ }
81
+
82
+ .param-hint {
83
+ font-size: 0.75em;
84
+ color: #999;
85
+ font-style: italic;
86
+ }
87
+
88
+ /* Description box */
89
+ .description-box {
90
+ background: #f8f8f8;
91
+ border: 1px solid #e0e0e0;
92
+ border-radius: 8px;
93
+ padding: 10px 14px;
94
+ margin-bottom: 12px;
95
+ font-size: 0.85em;
96
+ color: #666;
97
+ line-height: 1.5;
98
+ }
99
+
100
+ .description-box strong {
101
+ color: #333;
102
+ font-weight: 600;
103
+ }
104
+
105
+ .button-group {
106
+ display: flex;
107
+ gap: 10px;
108
+ margin-bottom: 15px;
109
+ flex-wrap: wrap;
110
+ }
111
+
112
+ .btn {
113
+ padding: 12px 24px;
114
+ border: 1px solid #ddd;
115
+ border-radius: 8px;
116
+ font-size: 0.95em;
117
+ font-weight: 500;
118
+ cursor: pointer;
119
+ transition: all 0.2s;
120
+ flex: 1;
121
+ min-width: 120px;
122
+ background: #ffffff;
123
+ color: #333;
124
+ }
125
+
126
+ .btn:hover:not(:disabled) {
127
+ background: #f5f5f5;
128
+ border-color: #999;
129
+ transform: translateY(-1px);
130
+ }
131
+
132
+ .btn:active:not(:disabled) {
133
+ transform: translateY(0);
134
+ }
135
+
136
+ .btn:disabled {
137
+ opacity: 0.3;
138
+ cursor: not-allowed;
139
+ }
140
+
141
+ .btn-primary {
142
+ background: #000; /* Black primary button */
143
+ color: #fff;
144
+ border-color: #000;
145
+ }
146
+
147
+ .btn-primary:hover:not(:disabled) {
148
+ background: #333;
149
+ border-color: #333;
150
+ }
151
+
152
+ .btn-secondary {
153
+ background: #666;
154
+ color: #fff;
155
+ border-color: #666;
156
+ }
157
+
158
+ .btn-danger {
159
+ background: #fff;
160
+ border-color: #ddd;
161
+ color: #d33;
162
+ }
163
+
164
+ .btn-danger:hover:not(:disabled) {
165
+ background: #fee;
166
+ border-color: #d33;
167
+ }
168
+
169
+ .btn-warning {
170
+ background: #fff;
171
+ border-color: #ddd;
172
+ color: #f90;
173
+ }
174
+
175
+ .btn-warning:hover:not(:disabled) {
176
+ background: #fff5e6;
177
+ border-color: #f90;
178
+ }
179
+
180
+ .btn-success {
181
+ background: #fff;
182
+ border-color: #ddd;
183
+ color: #0a0;
184
+ }
185
+
186
+ .btn-success:hover:not(:disabled) {
187
+ background: #e6ffe6;
188
+ border-color: #0a0;
189
+ }
190
+
191
+ .status-group {
192
+ display: grid;
193
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
194
+ gap: 12px;
195
+ background: #ffffff;
196
+ padding: 16px;
197
+ border-radius: 8px;
198
+ border: 1px solid #e0e0e0;
199
+ margin-bottom: 15px;
200
+ }
201
+
202
+ .status-item {
203
+ display: flex;
204
+ flex-direction: column;
205
+ gap: 5px;
206
+ }
207
+
208
+ .status-label {
209
+ font-size: 0.8em;
210
+ color: #999;
211
+ font-weight: 500;
212
+ text-transform: uppercase;
213
+ letter-spacing: 0.8px;
214
+ }
215
+
216
+ .status-value {
217
+ font-size: 1.4em;
218
+ font-weight: 600;
219
+ color: #000;
220
+ font-variant-numeric: tabular-nums;
221
+ }
222
+
223
+ #canvas-container {
224
+ width: 100%;
225
+ height: 600px;
226
+ background: #ffffff; /* White background */
227
+ position: relative;
228
+ overflow: hidden;
229
+ border-top: 1px solid #e0e0e0;
230
+ border-bottom: 1px solid #e0e0e0;
231
+ }
232
+
233
+ #renderCanvas {
234
+ width: 100%;
235
+ height: 100%;
236
+ display: block;
237
+ }
238
+
239
+ .info {
240
+ padding: 20px 30px;
241
+ background: #fafafa;
242
+ border-top: 1px solid #e0e0e0;
243
+ }
244
+
245
+ .note {
246
+ padding: 12px 16px;
247
+ background: #fff;
248
+ border-left: 3px solid #666;
249
+ border-radius: 4px;
250
+ color: #666;
251
+ font-size: 0.9em;
252
+ margin: 0;
253
+ }
254
+
255
+ /* Responsive */
256
+ @media (max-width: 768px) {
257
+ .header h1 {
258
+ font-size: 1.8em;
259
+ }
260
+
261
+ .button-group {
262
+ flex-direction: column;
263
+ }
264
+
265
+ .btn {
266
+ width: 100%;
267
+ }
268
+
269
+ #canvas-container {
270
+ height: 400px;
271
+ }
272
+ }
273
+
274
+ /* Conflict Warning */
275
+ .conflict-warning {
276
+ background: #fff3cd;
277
+ border: 2px solid #ffc107;
278
+ border-radius: 8px;
279
+ padding: 20px;
280
+ margin-top: 20px;
281
+ text-align: center;
282
+ }
283
+
284
+ .conflict-warning p {
285
+ margin: 10px 0;
286
+ color: #856404;
287
+ }
288
+
289
+ .conflict-warning strong {
290
+ font-size: 1.1em;
291
+ }
292
+
293
+ .conflict-warning button {
294
+ margin: 10px 5px 0;
295
+ }
296
+
297
+ input[type="range"]::-webkit-slider-thumb {
298
+ -webkit-appearance: none;
299
+ appearance: none;
300
+ width: 18px;
301
+ height: 18px;
302
+ border-radius: 50%;
303
+ background: #000;
304
+ cursor: pointer;
305
+ transition: all 0.2s;
306
+ }
307
+
308
+ input[type="range"]::-webkit-slider-thumb:hover {
309
+ transform: scale(1.2);
310
+ background: #333;
311
+ }
312
+
313
+ input[type="range"]::-moz-range-thumb {
314
+ width: 18px;
315
+ height: 18px;
316
+ border-radius: 50%;
317
+ background: #000;
318
+ cursor: pointer;
319
+ border: none;
320
+ transition: all 0.2s;
321
+ }
322
+
323
+ input[type="range"]::-moz-range-thumb:hover {
324
+ transform: scale(1.2);
325
+ background: #333;
326
+ }
327
+
328
+ /* Config Modal */
329
+ .modal-overlay {
330
+ position: fixed;
331
+ top: 0;
332
+ left: 0;
333
+ width: 100%;
334
+ height: 100%;
335
+ background: rgba(0, 0, 0, 0.5);
336
+ display: flex;
337
+ align-items: center;
338
+ justify-content: center;
339
+ z-index: 1000;
340
+ }
341
+
342
+ .modal-content {
343
+ background: #fff;
344
+ border-radius: 12px;
345
+ width: 90%;
346
+ max-width: 600px;
347
+ max-height: 80vh;
348
+ overflow-y: auto;
349
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
350
+ }
351
+
352
+ .modal-header {
353
+ padding: 20px 24px;
354
+ border-bottom: 1px solid #e0e0e0;
355
+ }
356
+
357
+ .modal-header h2 {
358
+ font-size: 1.3em;
359
+ font-weight: 600;
360
+ color: #333;
361
+ margin: 0;
362
+ }
363
+
364
+ .modal-body {
365
+ padding: 20px 24px;
366
+ }
367
+
368
+ .config-section {
369
+ margin-bottom: 20px;
370
+ }
371
+
372
+ .config-section:last-child {
373
+ margin-bottom: 0;
374
+ }
375
+
376
+ .config-section h3 {
377
+ font-size: 0.9em;
378
+ font-weight: 600;
379
+ color: #666;
380
+ text-transform: uppercase;
381
+ letter-spacing: 0.5px;
382
+ margin-bottom: 12px;
383
+ padding-bottom: 8px;
384
+ border-bottom: 1px solid #eee;
385
+ }
386
+
387
+ .config-field {
388
+ margin-bottom: 12px;
389
+ }
390
+
391
+ .config-field label {
392
+ display: block;
393
+ font-size: 0.85em;
394
+ font-weight: 500;
395
+ color: #666;
396
+ margin-bottom: 4px;
397
+ }
398
+
399
+ .config-field input[type="number"],
400
+ .config-field input[type="text"],
401
+ .config-field select {
402
+ width: 100%;
403
+ padding: 8px 12px;
404
+ border: 1px solid #ddd;
405
+ border-radius: 6px;
406
+ font-size: 0.95em;
407
+ background: #fff;
408
+ color: #333;
409
+ }
410
+
411
+ .config-field input:focus,
412
+ .config-field select:focus {
413
+ outline: none;
414
+ border-color: #999;
415
+ }
416
+
417
+ .config-field .slider-container {
418
+ display: flex;
419
+ align-items: center;
420
+ gap: 10px;
421
+ }
422
+
423
+ .config-field .slider-container input[type="range"] {
424
+ flex: 1;
425
+ height: 6px;
426
+ border-radius: 3px;
427
+ background: #ddd;
428
+ outline: none;
429
+ -webkit-appearance: none;
430
+ }
431
+
432
+ .config-field .slider-value {
433
+ min-width: 50px;
434
+ font-weight: 600;
435
+ color: #000;
436
+ font-size: 0.95em;
437
+ }
438
+
439
+ .modal-footer {
440
+ padding: 16px 24px;
441
+ border-top: 1px solid #e0e0e0;
442
+ display: flex;
443
+ justify-content: flex-end;
444
+ gap: 10px;
445
+ }
446
+
447
+ .modal-footer .btn {
448
+ flex: 0;
449
+ min-width: 140px;
450
+ }
451
+
static/js/main.js ADDED
@@ -0,0 +1,777 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Main application logic
3
+ * Handles UI interactions, API calls, and 3D rendering loop
4
+ */
5
+
6
+ class MotionApp {
7
+ constructor() {
8
+ this.isRunning = false;
9
+ this.targetFps = 20; // Model generates data at 20fps
10
+ this.frameInterval = 1000 / this.targetFps; // 50ms
11
+ this.nextFetchTime = 0; // Scheduled time for next fetch
12
+ this.frameCount = 0;
13
+
14
+ // Motion FPS tracking (frame consumption rate)
15
+ this.motionFpsCounter = 0;
16
+ this.motionFpsUpdateTime = 0;
17
+
18
+ // Request throttling
19
+ this.isFetchingFrame = false; // Prevent concurrent requests
20
+ this.consecutiveWaiting = 0; // Count consecutive 'waiting' responses
21
+
22
+ // Session management
23
+ this.sessionId = this.generateSessionId();
24
+
25
+ // Camera follow settings
26
+ this.lastUserInteraction = 0;
27
+ this.autoFollowDelay = 2000; // Auto-follow after 2 seconds of inactivity (reduced from 3s)
28
+ this.currentRootPos = new THREE.Vector3(0, 1, 0);
29
+
30
+ this.initThreeJS();
31
+ this.initUI();
32
+ this.updateStatus();
33
+ this.setupBeforeUnload();
34
+
35
+ console.log('Session ID:', this.sessionId);
36
+ }
37
+
38
+ generateSessionId() {
39
+ // Generate a simple unique session ID
40
+ return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
41
+ }
42
+
43
+ setupBeforeUnload() {
44
+ // Handle page close/refresh - send reset request
45
+ window.addEventListener('beforeunload', () => {
46
+ // Send synchronous reset if we're generating
47
+ if (!this.isIdle) {
48
+ // Use Blob to set correct Content-Type for JSON
49
+ const blob = new Blob(
50
+ [JSON.stringify({session_id: this.sessionId})],
51
+ {type: 'application/json'}
52
+ );
53
+ navigator.sendBeacon('/api/reset', blob);
54
+ console.log('Sent reset beacon on page unload');
55
+ }
56
+ });
57
+
58
+ // Also handle visibility change (tab hidden, mobile app switch)
59
+ document.addEventListener('visibilitychange', () => {
60
+ if (document.hidden && !this.isIdle && this.isRunning) {
61
+ // User switched away while generating - they might not come back
62
+ // Note: Don't reset immediately, let the frame consumption monitor handle it
63
+ console.log('Tab hidden while generating - consumption monitor will auto-reset if needed');
64
+ }
65
+ });
66
+ }
67
+
68
+ initThreeJS() {
69
+ // Get canvas
70
+ const canvas = document.getElementById('renderCanvas');
71
+ const container = document.getElementById('canvas-container');
72
+
73
+ // Create scene
74
+ this.scene = new THREE.Scene();
75
+ this.scene.background = new THREE.Color(0xffffff); // White background
76
+
77
+ // Create camera
78
+ this.camera = new THREE.PerspectiveCamera(
79
+ 60,
80
+ container.clientWidth / container.clientHeight,
81
+ 0.1,
82
+ 1000
83
+ );
84
+ this.camera.position.set(3, 1.5, 3);
85
+ this.camera.lookAt(0, 1, 0);
86
+
87
+ // Create renderer
88
+ this.renderer = new THREE.WebGLRenderer({
89
+ canvas: canvas,
90
+ antialias: true
91
+ });
92
+ this.renderer.setSize(container.clientWidth, container.clientHeight);
93
+ this.renderer.shadowMap.enabled = true;
94
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
95
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
96
+ this.renderer.toneMappingExposure = 1.0;
97
+
98
+ // Add lights - bright and soft
99
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
100
+ this.scene.add(ambientLight);
101
+
102
+ const keyLight = new THREE.DirectionalLight(0xffffff, 0.8);
103
+ keyLight.position.set(5, 8, 3);
104
+ keyLight.castShadow = true;
105
+ keyLight.shadow.mapSize.width = 2048;
106
+ keyLight.shadow.mapSize.height = 2048;
107
+ keyLight.shadow.camera.near = 0.5;
108
+ keyLight.shadow.camera.far = 50;
109
+ keyLight.shadow.camera.left = -5;
110
+ keyLight.shadow.camera.right = 5;
111
+ keyLight.shadow.camera.top = 5;
112
+ keyLight.shadow.camera.bottom = -5;
113
+ keyLight.shadow.bias = -0.0001;
114
+ this.scene.add(keyLight);
115
+
116
+ // Fill light
117
+ const fillLight = new THREE.DirectionalLight(0xffffff, 0.4);
118
+ fillLight.position.set(-3, 5, -3);
119
+ this.scene.add(fillLight);
120
+
121
+ // Add ground plane - light gray, very large
122
+ const groundGeometry = new THREE.PlaneGeometry(1000, 1000);
123
+ const groundMaterial = new THREE.ShadowMaterial({
124
+ opacity: 0.15
125
+ });
126
+ const ground = new THREE.Mesh(groundGeometry, groundMaterial);
127
+ ground.rotation.x = -Math.PI / 2;
128
+ ground.position.y = 0;
129
+ ground.receiveShadow = true;
130
+ this.scene.add(ground);
131
+
132
+ // Add infinite-looking grid - very large grid
133
+ const gridHelper = new THREE.GridHelper(1000, 1000, 0xdddddd, 0xeeeeee);
134
+ gridHelper.position.y = 0.01;
135
+ this.scene.add(gridHelper);
136
+
137
+ // Add orbit controls
138
+ this.controls = new THREE.OrbitControls(this.camera, canvas);
139
+ this.controls.target.set(0, 1, 0);
140
+ this.controls.enableDamping = true;
141
+ this.controls.dampingFactor = 0.05;
142
+ this.controls.update();
143
+
144
+ // Listen for user interaction - record time
145
+ const updateInteractionTime = () => {
146
+ this.lastUserInteraction = Date.now();
147
+ };
148
+ canvas.addEventListener('mousedown', updateInteractionTime);
149
+ canvas.addEventListener('wheel', updateInteractionTime);
150
+ canvas.addEventListener('touchstart', updateInteractionTime);
151
+
152
+ // Create skeleton
153
+ this.skeleton = new Skeleton3D(this.scene);
154
+
155
+ // Handle window resize
156
+ window.addEventListener('resize', () => this.onWindowResize());
157
+
158
+ // Start render loop
159
+ this.animate();
160
+ }
161
+
162
+ initUI() {
163
+ // Get UI elements
164
+ this.motionText = document.getElementById('motionText');
165
+ this.currentSmoothing = document.getElementById('currentSmoothing');
166
+ this.currentHistory = document.getElementById('currentHistory');
167
+ this.startResetBtn = document.getElementById('startResetBtn');
168
+ this.updateBtn = document.getElementById('updateBtn');
169
+ this.pauseResumeBtn = document.getElementById('pauseResumeBtn');
170
+ this.configBtn = document.getElementById('configBtn');
171
+ this.statusEl = document.getElementById('status');
172
+ this.bufferSizeEl = document.getElementById('bufferSize');
173
+ this.fpsEl = document.getElementById('fps');
174
+ this.frameCountEl = document.getElementById('frameCount');
175
+ this.conflictWarning = document.getElementById('conflictWarning');
176
+ this.forceTakeoverBtn = document.getElementById('forceTakeoverBtn');
177
+ this.cancelTakeoverBtn = document.getElementById('cancelTakeoverBtn');
178
+
179
+ // Stored runtime parameter values (updated by Config modal)
180
+ this.historyLengthValue = null;
181
+ this.smoothingAlphaValue = 0.5; // Default
182
+
183
+ // Track state
184
+ this.isPaused = false;
185
+ this.isIdle = true;
186
+ this.isProcessing = false; // Prevent concurrent API calls
187
+ this.pendingStartRequest = null; // Store pending start request data
188
+
189
+ // Attach event listeners
190
+ this.startResetBtn.addEventListener('click', () => this.toggleStartReset());
191
+ this.updateBtn.addEventListener('click', () => this.updateText());
192
+ this.pauseResumeBtn.addEventListener('click', () => this.togglePauseResume());
193
+ this.configBtn.addEventListener('click', () => this.openConfigEditor());
194
+ this.forceTakeoverBtn.addEventListener('click', () => this.handleForceTakeover());
195
+ this.cancelTakeoverBtn.addEventListener('click', () => this.handleCancelTakeover());
196
+
197
+ // Modal event listeners
198
+ document.getElementById('configDiscardBtn').addEventListener('click', () => this.closeConfigEditor());
199
+ document.getElementById('configSaveBtn').addEventListener('click', () => this.saveConfigAndReset());
200
+ document.getElementById('modalSmoothingAlpha').addEventListener('input', (e) => {
201
+ document.getElementById('modalSmoothingValue').textContent = parseFloat(e.target.value).toFixed(2);
202
+ });
203
+
204
+ // Fetch config from server on page load
205
+ fetch('/api/config')
206
+ .then(r => {
207
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
208
+ return r.json();
209
+ })
210
+ .then(data => {
211
+ if (data.status === 'error') throw new Error(data.message);
212
+ this.historyLengthValue = data.history_length;
213
+ this.smoothingAlphaValue = data.smoothing_alpha;
214
+ })
215
+ .catch(e => {
216
+ this.statusEl.textContent = 'Error: failed to load config';
217
+ this.startResetBtn.disabled = true;
218
+ console.error('Failed to fetch config:', e);
219
+ });
220
+ }
221
+
222
+ async toggleStartReset() {
223
+ if (this.isProcessing) return; // Prevent concurrent operations
224
+
225
+ if (this.isIdle) {
226
+ // Currently idle, so start
227
+ await this.startGeneration();
228
+ } else {
229
+ // Currently running/paused, so reset
230
+ await this.reset();
231
+ }
232
+ }
233
+
234
+ async startGeneration(force = false) {
235
+ if (this.isProcessing) return; // Prevent concurrent operations
236
+
237
+ const text = this.motionText.value.trim();
238
+ if (!text) {
239
+ alert('Please enter a motion description');
240
+ return;
241
+ }
242
+
243
+ const historyLength = this.historyLengthValue || 30;
244
+ const smoothingAlpha = this.smoothingAlphaValue;
245
+
246
+ this.isProcessing = true;
247
+ this.statusEl.textContent = 'Initializing...';
248
+
249
+ try {
250
+ const response = await fetch('/api/start', {
251
+ method: 'POST',
252
+ headers: {'Content-Type': 'application/json'},
253
+ body: JSON.stringify({
254
+ session_id: this.sessionId,
255
+ text: text,
256
+ history_length: historyLength,
257
+ smoothing_alpha: smoothingAlpha,
258
+ force: force
259
+ })
260
+ });
261
+
262
+ const data = await response.json();
263
+
264
+ if (data.status === 'success') {
265
+ this.isRunning = true;
266
+ this.isPaused = false;
267
+ this.isIdle = false;
268
+ this.frameCount = 0;
269
+ this.motionFpsCounter = 0;
270
+ this.motionFpsUpdateTime = performance.now();
271
+ this.isFetchingFrame = false;
272
+ this.consecutiveWaiting = 0;
273
+ this.startResetBtn.textContent = 'Reset';
274
+ this.startResetBtn.classList.remove('btn-primary');
275
+ this.startResetBtn.classList.add('btn-danger');
276
+ this.updateBtn.disabled = false;
277
+ this.pauseResumeBtn.disabled = false;
278
+ this.pauseResumeBtn.textContent = 'Pause';
279
+ this.statusEl.textContent = 'Running';
280
+ this.startFrameLoop();
281
+ } else if (response.status === 409 && data.conflict) {
282
+ // Another session is running, show warning UI
283
+ this.statusEl.textContent = 'Conflict - Another user is generating';
284
+ this.conflictWarning.style.display = 'block';
285
+
286
+ // Store request data for later
287
+ this.pendingStartRequest = {
288
+ text: text,
289
+ history_length: historyLength
290
+ };
291
+
292
+ return;
293
+ } else {
294
+ // Other errors
295
+ alert('Error: ' + data.message);
296
+ this.statusEl.textContent = 'Idle';
297
+ this.isIdle = true;
298
+ this.isRunning = false;
299
+ this.isPaused = false;
300
+ }
301
+ } catch (error) {
302
+ console.error('Error starting generation:', error);
303
+ alert('Failed to start generation: ' + error.message);
304
+ this.statusEl.textContent = 'Idle';
305
+ // Keep idle state on error
306
+ this.isIdle = true;
307
+ this.isRunning = false;
308
+ this.isPaused = false;
309
+ } finally {
310
+ this.isProcessing = false;
311
+ }
312
+ }
313
+
314
+ async updateText() {
315
+ if (this.isProcessing) return; // Prevent concurrent operations
316
+
317
+ const text = this.motionText.value.trim();
318
+ if (!text) {
319
+ alert('Please enter a motion description');
320
+ return;
321
+ }
322
+
323
+ this.isProcessing = true;
324
+ try {
325
+ const response = await fetch('/api/update_text', {
326
+ method: 'POST',
327
+ headers: {'Content-Type': 'application/json'},
328
+ body: JSON.stringify({
329
+ session_id: this.sessionId,
330
+ text: text
331
+ })
332
+ });
333
+
334
+ const data = await response.json();
335
+
336
+ if (data.status === 'success') {
337
+ console.log('Text updated:', text);
338
+ } else {
339
+ alert('Error: ' + data.message);
340
+ }
341
+ } catch (error) {
342
+ console.error('Error updating text:', error);
343
+ } finally {
344
+ this.isProcessing = false;
345
+ }
346
+ }
347
+
348
+ async togglePauseResume() {
349
+ if (this.isProcessing) return; // Prevent concurrent operations
350
+ if (this.isPaused) {
351
+ // Currently paused, so resume
352
+ await this.resumeGeneration();
353
+ } else {
354
+ // Currently running, so pause
355
+ await this.pauseGeneration();
356
+ }
357
+ }
358
+
359
+ async pauseGeneration() {
360
+ this.isProcessing = true;
361
+ try {
362
+ const response = await fetch('/api/pause', {
363
+ method: 'POST',
364
+ headers: {'Content-Type': 'application/json'},
365
+ body: JSON.stringify({session_id: this.sessionId})
366
+ });
367
+
368
+ const data = await response.json();
369
+
370
+ if (data.status === 'success') {
371
+ this.isRunning = false;
372
+ this.isPaused = true;
373
+ this.pauseResumeBtn.textContent = 'Resume';
374
+ this.pauseResumeBtn.classList.remove('btn-warning');
375
+ this.pauseResumeBtn.classList.add('btn-success');
376
+ this.updateBtn.disabled = true;
377
+ this.statusEl.textContent = 'Paused';
378
+ console.log('Generation paused (state preserved)');
379
+ }
380
+ } catch (error) {
381
+ console.error('Error pausing generation:', error);
382
+ } finally {
383
+ this.isProcessing = false;
384
+ }
385
+ }
386
+
387
+ async resumeGeneration() {
388
+ this.isProcessing = true;
389
+ try {
390
+ const response = await fetch('/api/resume', {
391
+ method: 'POST',
392
+ headers: {'Content-Type': 'application/json'},
393
+ body: JSON.stringify({session_id: this.sessionId})
394
+ });
395
+
396
+ const data = await response.json();
397
+
398
+ if (data.status === 'success') {
399
+ this.isRunning = true;
400
+ this.isPaused = false;
401
+ this.pauseResumeBtn.textContent = 'Pause';
402
+ this.pauseResumeBtn.classList.remove('btn-success');
403
+ this.pauseResumeBtn.classList.add('btn-warning');
404
+ this.updateBtn.disabled = false;
405
+ this.statusEl.textContent = 'Running';
406
+ this.startFrameLoop();
407
+ console.log('Generation resumed');
408
+ }
409
+ } catch (error) {
410
+ console.error('Error resuming generation:', error);
411
+ } finally {
412
+ this.isProcessing = false;
413
+ }
414
+ }
415
+
416
+ async reset() {
417
+ if (this.isProcessing) return; // Prevent concurrent operations
418
+
419
+ const historyLength = this.historyLengthValue || 30;
420
+ const smoothingAlpha = this.smoothingAlphaValue;
421
+
422
+ this.isProcessing = true;
423
+ try {
424
+ const response = await fetch('/api/reset', {
425
+ method: 'POST',
426
+ headers: {'Content-Type': 'application/json'},
427
+ body: JSON.stringify({
428
+ session_id: this.sessionId,
429
+ history_length: historyLength,
430
+ smoothing_alpha: smoothingAlpha,
431
+ })
432
+ });
433
+
434
+ const data = await response.json();
435
+
436
+ if (data.status === 'success') {
437
+ this._resetUIToIdle();
438
+ console.log('Reset complete - all state cleared');
439
+ }
440
+ } catch (error) {
441
+ console.error('Error resetting:', error);
442
+ } finally {
443
+ this.isProcessing = false;
444
+ }
445
+ }
446
+
447
+ async handleForceTakeover() {
448
+ // Hide warning
449
+ this.conflictWarning.style.display = 'none';
450
+
451
+ if (!this.pendingStartRequest) return;
452
+
453
+ // Retry with force=true
454
+ this.isProcessing = false;
455
+ await this.startGeneration(true);
456
+
457
+ this.pendingStartRequest = null;
458
+ }
459
+
460
+ handleCancelTakeover() {
461
+ // Hide warning
462
+ this.conflictWarning.style.display = 'none';
463
+ this.statusEl.textContent = 'Idle';
464
+ this.isProcessing = false;
465
+ this.pendingStartRequest = null;
466
+ }
467
+
468
+ startFrameLoop() {
469
+ const now = performance.now();
470
+ this.nextFetchTime = now + this.frameInterval;
471
+ this.fetchFrame();
472
+ }
473
+
474
+ fetchFrame() {
475
+ if (!this.isRunning) return;
476
+
477
+ const now = performance.now();
478
+
479
+ // Check if it's time to fetch next frame AND we're not already fetching
480
+ if (now >= this.nextFetchTime && !this.isFetchingFrame) {
481
+ // Schedule next fetch (maintain fixed rate regardless of delays)
482
+ this.nextFetchTime += this.frameInterval;
483
+
484
+ // If we've fallen behind, catch up
485
+ if (this.nextFetchTime < now) {
486
+ this.nextFetchTime = now + this.frameInterval;
487
+ }
488
+
489
+ // Mark as fetching to prevent concurrent requests
490
+ this.isFetchingFrame = true;
491
+
492
+ fetch(`/api/get_frame?session_id=${this.sessionId}`)
493
+ .then(response => response.json())
494
+ .then(data => {
495
+ if (data.status === 'success') {
496
+ this.skeleton.updatePose(data.joints);
497
+ this.frameCount++;
498
+ this.frameCountEl.textContent = this.frameCount;
499
+
500
+ // Update motion FPS counter (only when frame consumed)
501
+
502
+
503
+ this.motionFpsCounter++;
504
+
505
+ // Update current root position
506
+ this.currentRootPos.set(
507
+ data.joints[0][0],
508
+ data.joints[0][1],
509
+ data.joints[0][2]
510
+ );
511
+
512
+ // Auto-follow (if user hasn't interacted for a while)
513
+ this.updateAutoFollow();
514
+
515
+ // Reset waiting counter on success
516
+ this.consecutiveWaiting = 0;
517
+ } else if (data.status === 'waiting') {
518
+ // No frame available, slow down requests if this happens repeatedly
519
+ this.consecutiveWaiting++;
520
+
521
+ // If buffer is consistently empty, back off a bit
522
+ if (this.consecutiveWaiting > 5) {
523
+ // Add a small delay to reduce server load
524
+ this.nextFetchTime = now + this.frameInterval * 1.5;
525
+ this.consecutiveWaiting = 0;
526
+ }
527
+ }
528
+ })
529
+ .catch(error => {
530
+ console.error('Error fetching frame:', error);
531
+ })
532
+ .finally(() => {
533
+ // Always mark as done fetching
534
+ this.isFetchingFrame = false;
535
+ });
536
+ }
537
+
538
+ // Use requestAnimationFrame for continuous checking
539
+ requestAnimationFrame(() => this.fetchFrame());
540
+ }
541
+
542
+ updateAutoFollow() {
543
+ const timeSinceInteraction = Date.now() - this.lastUserInteraction;
544
+
545
+ // Auto-follow if user hasn't interacted for more than 3 seconds
546
+ if (timeSinceInteraction > this.autoFollowDelay) {
547
+ // Calculate camera offset relative to current target
548
+ const currentOffset = new THREE.Vector3().subVectors(
549
+ this.camera.position,
550
+ this.controls.target
551
+ );
552
+
553
+ // New target position (character position, waist height)
554
+ const newTarget = this.currentRootPos.clone();
555
+ newTarget.y = 1.0;
556
+
557
+ // Calculate new camera position (maintain relative offset)
558
+ const newCameraPos = newTarget.clone().add(currentOffset);
559
+
560
+ // Smooth interpolation follow (increased lerp factor for more obvious following)
561
+ // 0.2 = more aggressive following, 0.05 = gentle following
562
+ this.controls.target.lerp(newTarget, 0.2);
563
+ this.camera.position.lerp(newCameraPos, 0.2);
564
+
565
+ // Debug log (comment out in production)
566
+ // console.log('Auto-follow active, tracking:', newTarget);
567
+ }
568
+ }
569
+
570
+ _resetUIToIdle() {
571
+ this.isRunning = false;
572
+ this.isPaused = false;
573
+ this.isIdle = true;
574
+ this.frameCount = 0;
575
+ this.motionFpsCounter = 0;
576
+ this.isFetchingFrame = false;
577
+ this.consecutiveWaiting = 0;
578
+ this.startResetBtn.textContent = 'Start';
579
+ this.startResetBtn.classList.remove('btn-danger');
580
+ this.startResetBtn.classList.add('btn-primary');
581
+ this.updateBtn.disabled = true;
582
+ this.pauseResumeBtn.disabled = true;
583
+ this.pauseResumeBtn.textContent = 'Pause';
584
+ this.pauseResumeBtn.classList.remove('btn-success');
585
+ this.pauseResumeBtn.classList.add('btn-warning');
586
+ this.statusEl.textContent = 'Idle';
587
+ this.bufferSizeEl.textContent = '0 / 4';
588
+ this.frameCountEl.textContent = '0';
589
+ this.fpsEl.textContent = '0';
590
+ if (this.skeleton) this.skeleton.clearTrail();
591
+ }
592
+
593
+ // --- Config Editor ---
594
+
595
+ async openConfigEditor() {
596
+ try {
597
+ const response = await fetch('/api/config');
598
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
599
+ const data = await response.json();
600
+ if (data.status === 'error') throw new Error(data.message);
601
+
602
+ // Render schedule_config fields
603
+ this.renderConfigSection('schedule_config', data.schedule_config,
604
+ document.getElementById('scheduleConfigFields'));
605
+
606
+ // Render cfg_config fields
607
+ this.renderConfigSection('cfg_config', data.cfg_config,
608
+ document.getElementById('cfgConfigFields'));
609
+
610
+ // Populate runtime params
611
+ document.getElementById('modalHistoryLength').value = data.history_length;
612
+ const slider = document.getElementById('modalSmoothingAlpha');
613
+ slider.value = data.smoothing_alpha;
614
+ document.getElementById('modalSmoothingValue').textContent =
615
+ parseFloat(data.smoothing_alpha).toFixed(2);
616
+
617
+ // Show modal
618
+ document.getElementById('configModal').style.display = 'flex';
619
+ } catch (error) {
620
+ console.error('Error opening config editor:', error);
621
+ alert('Failed to load config: ' + error.message);
622
+ }
623
+ }
624
+
625
+ renderConfigSection(sectionName, obj, container) {
626
+ container.innerHTML = '';
627
+ for (const [key, value] of Object.entries(obj)) {
628
+ const field = document.createElement('div');
629
+ field.className = 'config-field';
630
+
631
+ const label = document.createElement('label');
632
+ label.textContent = key;
633
+ field.appendChild(label);
634
+
635
+ let input;
636
+ if (typeof value === 'boolean') {
637
+ input = document.createElement('select');
638
+ input.innerHTML =
639
+ `<option value="true" ${value ? 'selected' : ''}>true</option>` +
640
+ `<option value="false" ${!value ? 'selected' : ''}>false</option>`;
641
+ } else {
642
+ input = document.createElement('input');
643
+ input.type = typeof value === 'number' ? 'number' : 'text';
644
+ if (typeof value === 'number' && !Number.isInteger(value)) {
645
+ input.step = 'any';
646
+ }
647
+ input.value = value;
648
+ }
649
+ input.dataset.section = sectionName;
650
+ input.dataset.key = key;
651
+ input.dataset.type = typeof value;
652
+ input.className = 'config-input';
653
+ field.appendChild(input);
654
+
655
+ container.appendChild(field);
656
+ }
657
+ }
658
+
659
+ async saveConfigAndReset() {
660
+ try {
661
+ // Collect config values from dynamically rendered fields
662
+ const scheduleConfig = {};
663
+ const cfgConfig = {};
664
+
665
+ document.querySelectorAll('.config-input').forEach(input => {
666
+ const section = input.dataset.section;
667
+ const key = input.dataset.key;
668
+ const type = input.dataset.type;
669
+
670
+ let value;
671
+ if (type === 'boolean') {
672
+ value = input.value === 'true';
673
+ } else if (type === 'number') {
674
+ value = Number(input.value);
675
+ } else {
676
+ value = input.value;
677
+ }
678
+
679
+ if (section === 'schedule_config') {
680
+ scheduleConfig[key] = value;
681
+ } else if (section === 'cfg_config') {
682
+ cfgConfig[key] = value;
683
+ }
684
+ });
685
+
686
+ const historyLength = parseInt(document.getElementById('modalHistoryLength').value);
687
+ const smoothingAlpha = parseFloat(document.getElementById('modalSmoothingAlpha').value);
688
+
689
+ const response = await fetch('/api/config', {
690
+ method: 'POST',
691
+ headers: {'Content-Type': 'application/json'},
692
+ body: JSON.stringify({
693
+ schedule_config: scheduleConfig,
694
+ cfg_config: cfgConfig,
695
+ history_length: historyLength,
696
+ smoothing_alpha: smoothingAlpha,
697
+ })
698
+ });
699
+
700
+ const data = await response.json();
701
+
702
+ if (data.status === 'success') {
703
+ this.historyLengthValue = historyLength;
704
+ this.smoothingAlphaValue = smoothingAlpha;
705
+ this._resetUIToIdle();
706
+ this.closeConfigEditor();
707
+ console.log('Config updated and reset complete');
708
+ } else {
709
+ alert('Error: ' + data.message);
710
+ }
711
+ } catch (error) {
712
+ console.error('Error saving config:', error);
713
+ alert('Failed to save config: ' + error.message);
714
+ }
715
+ }
716
+
717
+ closeConfigEditor() {
718
+ document.getElementById('configModal').style.display = 'none';
719
+ }
720
+
721
+ async updateStatus() {
722
+ try {
723
+ const response = await fetch(`/api/status?session_id=${this.sessionId}`);
724
+ const data = await response.json();
725
+
726
+ if (data.initialized) {
727
+ this.bufferSizeEl.textContent = `${data.buffer_size} / ${data.target_size}`;
728
+
729
+ // Update current smoothing display
730
+ if (data.smoothing_alpha !== undefined) {
731
+ this.currentSmoothing.textContent = data.smoothing_alpha.toFixed(2);
732
+ }
733
+
734
+ // Update current history length display
735
+ if (data.history_length !== undefined) {
736
+ this.currentHistory.textContent = data.history_length;
737
+ }
738
+ }
739
+
740
+ // Update motion FPS (frame consumption rate)
741
+ const now = performance.now();
742
+ if (now - this.motionFpsUpdateTime > 1000) {
743
+ this.fpsEl.textContent = this.motionFpsCounter;
744
+ this.motionFpsCounter = 0;
745
+ this.motionFpsUpdateTime = now;
746
+ }
747
+ } catch (error) {
748
+ // Silently fail for status updates
749
+ }
750
+
751
+ // Update status every 500ms
752
+ setTimeout(() => this.updateStatus(), 500);
753
+ }
754
+
755
+ animate() {
756
+ requestAnimationFrame(() => this.animate());
757
+
758
+ // Update controls
759
+ this.controls.update();
760
+
761
+ // Render scene
762
+ this.renderer.render(this.scene, this.camera);
763
+ }
764
+
765
+ onWindowResize() {
766
+ const container = document.getElementById('canvas-container');
767
+ this.camera.aspect = container.clientWidth / container.clientHeight;
768
+ this.camera.updateProjectionMatrix();
769
+ this.renderer.setSize(container.clientWidth, container.clientHeight);
770
+ }
771
+ }
772
+
773
+ // Initialize app when page loads
774
+ window.addEventListener('DOMContentLoaded', () => {
775
+ window.app = new MotionApp();
776
+ });
777
+
static/js/skeleton.js ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Skeleton visualization with capsule bones and sphere joints
3
+ * Based on HumanML3D skeleton structure (22 joints)
4
+ */
5
+
6
+ class Skeleton3D {
7
+ constructor(scene) {
8
+ this.scene = scene;
9
+ this.joints = [];
10
+ this.bones = [];
11
+
12
+ // Trail settings
13
+ this.trailPoints = []; // Store root positions
14
+ this.maxTrailPoints = 200; // Maximum trail length
15
+ this.trailLine = null;
16
+ this.trailGeometry = null;
17
+ this.trailMaterial = null;
18
+
19
+ // HumanML3D skeleton chains (from render_skeleton.py)
20
+ this.chains = [
21
+ [0, 2, 5, 8, 11], // Chain 0: spine
22
+ [0, 1, 4, 7, 10], // Chain 1: left leg
23
+ [0, 3, 6, 9, 12, 15], // Chain 2: right leg + torso
24
+ [9, 14, 17, 19, 21], // Chain 3: left arm
25
+ [9, 13, 16, 18, 20], // Chain 4: right arm
26
+ ];
27
+
28
+ // Convert chains to bone connections
29
+ this.boneConnections = [];
30
+ for (const chain of this.chains) {
31
+ for (let i = 0; i < chain.length - 1; i++) {
32
+ this.boneConnections.push([chain[i], chain[i + 1]]);
33
+ }
34
+ }
35
+
36
+ // Bone and joint sizes - stick figure style (thin and small)
37
+ this.boneRadius = 0.015; // Thin cylinder
38
+ this.jointSize = 0.03; // Small sphere
39
+
40
+ // Colors from render_skeleton.py
41
+ this.chainColors = [
42
+ 0xFEB21A, // orange (chain 0 - spine)
43
+ 0x00AAFF, // cyan (chain 1 - left leg)
44
+ 0x134686, // aquamarine (chain 2 - right leg)
45
+ 0xFFB600, // amber (chain 3 - left arm)
46
+ 0x00D47E, // aquamarine (chain 4 - right arm)
47
+ ];
48
+
49
+ // Joint color: teal (0, 128, 157)
50
+ this.jointMaterial = new THREE.MeshStandardMaterial({
51
+ color: 0x00809D, // teal
52
+ metalness: 0.2,
53
+ roughness: 0.5,
54
+ });
55
+
56
+ // Will create multiple bone materials for different chains
57
+ this.boneMaterials = this.chainColors.map(color =>
58
+ new THREE.MeshStandardMaterial({
59
+ color: color,
60
+ metalness: 0.2,
61
+ roughness: 0.5,
62
+ })
63
+ );
64
+
65
+ this.initSkeleton();
66
+ this.initTrail();
67
+ }
68
+
69
+ initTrail() {
70
+ // Create trail line with gradient opacity
71
+ this.trailGeometry = new THREE.BufferGeometry();
72
+
73
+ // Initialize with empty arrays
74
+ const positions = new Float32Array(this.maxTrailPoints * 3);
75
+ const colors = new Float32Array(this.maxTrailPoints * 4); // RGBA
76
+
77
+ this.trailGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
78
+ this.trailGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 4));
79
+
80
+ this.trailMaterial = new THREE.LineBasicMaterial({
81
+ vertexColors: true,
82
+ transparent: true,
83
+ opacity: 1.0,
84
+ linewidth: 2,
85
+ });
86
+
87
+ this.trailLine = new THREE.Line(this.trailGeometry, this.trailMaterial);
88
+ this.trailLine.frustumCulled = false;
89
+ this.scene.add(this.trailLine);
90
+ }
91
+
92
+ initSkeleton() {
93
+ // Create 22 joint spheres - uniform small spheres
94
+ const jointGeometry = new THREE.SphereGeometry(this.jointSize, 16, 16);
95
+
96
+ for (let i = 0; i < 22; i++) {
97
+ const joint = new THREE.Mesh(jointGeometry, this.jointMaterial);
98
+ joint.castShadow = true;
99
+ joint.receiveShadow = true;
100
+ this.joints.push(joint);
101
+ this.scene.add(joint);
102
+ }
103
+
104
+ // Create bone cylinders - different color for each chain
105
+ let boneIndex = 0;
106
+ for (let chainIdx = 0; chainIdx < this.chains.length; chainIdx++) {
107
+ const chain = this.chains[chainIdx];
108
+ const material = this.boneMaterials[chainIdx];
109
+
110
+ for (let i = 0; i < chain.length - 1; i++) {
111
+ const bone = this.createBone(material);
112
+ this.bones.push(bone);
113
+ this.scene.add(bone);
114
+ boneIndex++;
115
+ }
116
+ }
117
+ }
118
+
119
+ createBone(material) {
120
+ // Create a simple thin cylinder with specified material
121
+ const geometry = new THREE.CylinderGeometry(this.boneRadius, this.boneRadius, 1, 8);
122
+ const bone = new THREE.Mesh(geometry, material);
123
+ bone.castShadow = true;
124
+ bone.receiveShadow = true;
125
+ return bone;
126
+ }
127
+
128
+ updatePose(jointPositions) {
129
+ /**
130
+ * Update skeleton with new joint positions
131
+ * jointPositions: array of shape [22, 3]
132
+ */
133
+ if (!jointPositions || jointPositions.length !== 22) {
134
+ console.error('Invalid joint positions:', jointPositions);
135
+ return;
136
+ }
137
+
138
+ // Update joint positions
139
+ for (let i = 0; i < 22; i++) {
140
+ const pos = jointPositions[i];
141
+ this.joints[i].position.set(pos[0], pos[1], pos[2]);
142
+ }
143
+
144
+ // Update bone positions and orientations
145
+ for (let i = 0; i < this.boneConnections.length; i++) {
146
+ const [startIdx, endIdx] = this.boneConnections[i];
147
+ const startPos = new THREE.Vector3(
148
+ jointPositions[startIdx][0],
149
+ jointPositions[startIdx][1],
150
+ jointPositions[startIdx][2]
151
+ );
152
+ const endPos = new THREE.Vector3(
153
+ jointPositions[endIdx][0],
154
+ jointPositions[endIdx][1],
155
+ jointPositions[endIdx][2]
156
+ );
157
+
158
+ this.updateBone(this.bones[i], startPos, endPos);
159
+ }
160
+
161
+ // Update trail with root position (joint 0)
162
+ this.updateTrail(jointPositions[0]);
163
+ }
164
+
165
+ updateTrail(rootPos) {
166
+ // Add new point to trail (project to ground y=0.01)
167
+ const trailPoint = {
168
+ x: rootPos[0],
169
+ y: 0.01, // Slightly above ground to avoid z-fighting
170
+ z: rootPos[2]
171
+ };
172
+
173
+ // Only add if moved significantly (avoid duplicate points)
174
+ if (this.trailPoints.length === 0) {
175
+ this.trailPoints.push(trailPoint);
176
+ } else {
177
+ const lastPoint = this.trailPoints[this.trailPoints.length - 1];
178
+ const dist = Math.sqrt(
179
+ Math.pow(trailPoint.x - lastPoint.x, 2) +
180
+ Math.pow(trailPoint.z - lastPoint.z, 2)
181
+ );
182
+ if (dist > 0.02) { // Minimum distance threshold
183
+ this.trailPoints.push(trailPoint);
184
+ }
185
+ }
186
+
187
+ // Limit trail length
188
+ if (this.trailPoints.length > this.maxTrailPoints) {
189
+ this.trailPoints.shift();
190
+ }
191
+
192
+ // Update geometry
193
+ const positions = this.trailGeometry.attributes.position.array;
194
+ const colors = this.trailGeometry.attributes.color.array;
195
+
196
+ const numPoints = this.trailPoints.length;
197
+
198
+ for (let i = 0; i < this.maxTrailPoints; i++) {
199
+ if (i < numPoints) {
200
+ const point = this.trailPoints[i];
201
+ positions[i * 3] = point.x;
202
+ positions[i * 3 + 1] = point.y;
203
+ positions[i * 3 + 2] = point.z;
204
+
205
+ // Gradient: older points (lower index) are more transparent
206
+ const alpha = i / (numPoints - 1); // 0 (oldest) to 1 (newest)
207
+ const opacity = Math.pow(alpha, 1.5) * 0.8; // Fade out older points
208
+
209
+ // Use cyan color (matching joint color)
210
+ colors[i * 4] = 0.0; // R
211
+ colors[i * 4 + 1] = 0.67; // G (cyan)
212
+ colors[i * 4 + 2] = 0.85; // B
213
+ colors[i * 4 + 3] = opacity; // A
214
+ } else {
215
+ // Hide unused vertices
216
+ positions[i * 3] = 0;
217
+ positions[i * 3 + 1] = 0;
218
+ positions[i * 3 + 2] = 0;
219
+ colors[i * 4 + 3] = 0;
220
+ }
221
+ }
222
+
223
+ this.trailGeometry.attributes.position.needsUpdate = true;
224
+ this.trailGeometry.attributes.color.needsUpdate = true;
225
+ this.trailGeometry.setDrawRange(0, numPoints);
226
+ }
227
+
228
+ clearTrail() {
229
+ this.trailPoints = [];
230
+ this.trailGeometry.setDrawRange(0, 0);
231
+ }
232
+
233
+ updateBone(bone, startPos, endPos) {
234
+ /**
235
+ * Update a bone's position, rotation, and scale to connect two joints
236
+ */
237
+ const direction = new THREE.Vector3().subVectors(endPos, startPos);
238
+ const length = direction.length();
239
+
240
+ if (length < 0.001) return;
241
+
242
+ // Position bone at midpoint
243
+ const midpoint = new THREE.Vector3().addVectors(startPos, endPos).multiplyScalar(0.5);
244
+ bone.position.copy(midpoint);
245
+
246
+ // Scale bone to match distance
247
+ bone.scale.y = length;
248
+
249
+ // Rotate bone to point from start to end
250
+ bone.quaternion.setFromUnitVectors(
251
+ new THREE.Vector3(0, 1, 0),
252
+ direction.normalize()
253
+ );
254
+ }
255
+
256
+ setVisible(visible) {
257
+ this.joints.forEach(joint => joint.visible = visible);
258
+ this.bones.forEach(bone => bone.visible = visible);
259
+ if (this.trailLine) this.trailLine.visible = visible;
260
+ }
261
+
262
+ dispose() {
263
+ // Clean up resources
264
+ this.joints.forEach(joint => {
265
+ this.scene.remove(joint);
266
+ joint.geometry.dispose();
267
+ });
268
+
269
+ this.bones.forEach(bone => {
270
+ bone.children.forEach(child => {
271
+ if (child.geometry) child.geometry.dispose();
272
+ });
273
+ this.scene.remove(bone);
274
+ });
275
+
276
+ if (this.trailLine) {
277
+ this.scene.remove(this.trailLine);
278
+ this.trailGeometry.dispose();
279
+ this.trailMaterial.dispose();
280
+ }
281
+
282
+ this.jointMaterial.dispose();
283
+ this.boneMaterials.forEach(mat => mat.dispose());
284
+ }
285
+ }
286
+
287
+ // Export for use in main.js
288
+ window.Skeleton3D = Skeleton3D;
289
+
templates/index.html ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Real-time 3D Motion Generation</title>
7
+ <link rel="stylesheet" href="/static/css/style.css">
8
+ </head>
9
+ <body>
10
+ <div class="container">
11
+ <div class="header">
12
+ <h1>Real-time 3D Motion Generation</h1>
13
+ <p class="subtitle">AI-powered motion synthesis with streaming rendering</p>
14
+ </div>
15
+
16
+ <div class="controls">
17
+ <div class="status-group">
18
+ <div class="status-item">
19
+ <span class="status-label">Status:</span>
20
+ <span id="status" class="status-value">Idle</span>
21
+ </div>
22
+ <div class="status-item">
23
+ <span class="status-label">Buffer:</span>
24
+ <span id="bufferSize" class="status-value">0 / 4</span>
25
+ </div>
26
+ <div class="status-item">
27
+ <span class="status-label">Motion FPS:</span>
28
+ <span id="fps" class="status-value">0</span>
29
+ </div>
30
+ <div class="status-item">
31
+ <span class="status-label">Frames:</span>
32
+ <span id="frameCount" class="status-value">0</span>
33
+ </div>
34
+ <div class="status-item">
35
+ <span class="status-label">Smoothing α:</span>
36
+ <span id="currentSmoothing" class="status-value">0.50</span>
37
+ </div>
38
+ <div class="status-item">
39
+ <span class="status-label">History:</span>
40
+ <span id="currentHistory" class="status-value">-</span>
41
+ </div>
42
+ </div>
43
+
44
+ <div class="input-group">
45
+ <label for="motionText">Motion Description:</label>
46
+ <input type="text" id="motionText" placeholder="Enter motion description (e.g., walk forward, jump, dance)" value="walk in a circle.">
47
+ </div>
48
+
49
+ <div class="button-group">
50
+ <button id="startResetBtn" class="btn btn-primary">Start</button>
51
+ <button id="updateBtn" class="btn btn-secondary" disabled>Update Text</button>
52
+ <button id="pauseResumeBtn" class="btn btn-warning" disabled>Pause</button>
53
+ <button id="configBtn" class="btn">Config</button>
54
+ </div>
55
+
56
+ <div id="conflictWarning" class="conflict-warning" style="display: none;">
57
+ <p><strong>⚠️ Another user is currently generating!</strong></p>
58
+ <p>Do you want to force stop their session and take over?</p>
59
+ <button id="forceTakeoverBtn" class="btn btn-danger">Force Takeover</button>
60
+ <button id="cancelTakeoverBtn" class="btn btn-secondary">Cancel</button>
61
+ </div>
62
+ </div>
63
+
64
+ <div id="canvas-container">
65
+ <canvas id="renderCanvas"></canvas>
66
+ </div>
67
+
68
+ <div class="info">
69
+ <div class="description-box">
70
+ <strong>Start/Reset:</strong> Start when idle, Reset when running (clears all state and applies new parameters) •
71
+ <strong>Update Text:</strong> Change motion description on-the-fly (no reset needed) •
72
+ <strong>Pause/Resume:</strong> Pause/resume generation (preserves all state)
73
+ </div>
74
+ <p class="note">💡 First generation requires model loading. Target Motion FPS: 20. Ground trail shows movement path. Use mouse to orbit camera.</p>
75
+ </div>
76
+ </div>
77
+
78
+ <!-- Config Modal -->
79
+ <div id="configModal" class="modal-overlay" style="display: none;">
80
+ <div class="modal-content">
81
+ <div class="modal-header">
82
+ <h2>Config Editor</h2>
83
+ </div>
84
+ <div class="modal-body">
85
+ <div class="config-section">
86
+ <h3>Schedule Config</h3>
87
+ <div id="scheduleConfigFields"></div>
88
+ </div>
89
+ <div class="config-section">
90
+ <h3>CFG Config</h3>
91
+ <div id="cfgConfigFields"></div>
92
+ </div>
93
+ <div class="config-section">
94
+ <h3>Runtime Parameters</h3>
95
+ <div class="config-field">
96
+ <label for="modalHistoryLength">History Length</label>
97
+ <input type="number" id="modalHistoryLength" value="">
98
+ </div>
99
+ <div class="config-field">
100
+ <label for="modalSmoothingAlpha">Smoothing α</label>
101
+ <div class="slider-container">
102
+ <input type="range" id="modalSmoothingAlpha" min="0" max="1" step="0.05" value="0.5">
103
+ <span id="modalSmoothingValue" class="slider-value">0.50</span>
104
+ </div>
105
+ <span class="param-hint">0.0 = max smoothing, 1.0 = no smoothing</span>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ <div class="modal-footer">
110
+ <button class="btn btn-secondary" id="configDiscardBtn">Discard</button>
111
+ <button class="btn btn-primary" id="configSaveBtn">Update &amp; Reset</button>
112
+ </div>
113
+ </div>
114
+ </div>
115
+
116
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
117
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
118
+ <script src="/static/js/skeleton.js"></script>
119
+ <script src="/static/js/main.js"></script>
120
+ </body>
121
+ </html>
122
+