Prathamesh Sarjerao Vaidya commited on
Commit
a601b1d
·
1 Parent(s): 1671e4d

modularize both backend and frontend js part

Browse files
Files changed (40) hide show
  1. Dockerfile +1 -0
  2. backend/app/__init__.py +3 -3
  3. backend/app/api/__init__.py +14 -0
  4. backend/app/api/dependencies.py +21 -0
  5. backend/app/api/routes.py +316 -0
  6. backend/app/api/websocket.py +50 -0
  7. backend/app/celery_app.py +32 -0
  8. backend/app/core/__init__.py +16 -0
  9. backend/app/{movement_classifier.py → core/movement_classifier.py} +3 -3
  10. backend/app/{pose_analyzer.py → core/pose_analyzer.py} +2 -2
  11. backend/app/{video_processor.py → core/video_processor.py} +5 -4
  12. backend/app/main.py +28 -559
  13. backend/app/models/__init__.py +31 -0
  14. backend/app/models/requests.py +31 -0
  15. backend/app/models/responses.py +46 -0
  16. backend/app/services/__init__.py +19 -0
  17. backend/app/services/cleanup_service.py +218 -0
  18. backend/app/services/processing_service.py +82 -0
  19. backend/app/services/session_manager.py +73 -0
  20. backend/app/services/video_service.py +75 -0
  21. backend/app/tasks.py +117 -0
  22. backend/app/utils/__init__.py +40 -0
  23. backend/app/{utils.py → utils/file_utils.py} +10 -103
  24. backend/app/utils/helpers.py +49 -0
  25. backend/app/utils/throttle.py +52 -0
  26. backend/app/utils/validation.py +34 -0
  27. backend/requirements.txt +7 -1
  28. docker_compose.yml +56 -18
  29. frontend/index.html +5 -4
  30. frontend/js/app.js +0 -617
  31. frontend/js/core/config.js +25 -0
  32. frontend/js/core/state.js +55 -0
  33. frontend/js/{video-handler.js → handlers/video-handler.js} +2 -7
  34. frontend/js/{old_app.js → main.js} +89 -291
  35. frontend/js/services/api-service.js +73 -0
  36. frontend/js/services/polling-service.js +65 -0
  37. frontend/js/ui/progress.js +104 -0
  38. frontend/js/ui/toast.js +41 -0
  39. frontend/js/{visualization.js → utils/visualization.js} +2 -7
  40. frontend/js/websocket-client.js +0 -194
Dockerfile CHANGED
@@ -9,6 +9,7 @@ ENV PYTHONUNBUFFERED=1 \
9
 
10
  # Install system dependencies with full ffmpeg and x264
11
  RUN apt-get update && apt-get install -y --no-install-recommends \
 
12
  libgl1 \
13
  libglib2.0-0 \
14
  libsm6 \
 
9
 
10
  # Install system dependencies with full ffmpeg and x264
11
  RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ redis-tools \
13
  libgl1 \
14
  libglib2.0-0 \
15
  libsm6 \
backend/app/__init__.py CHANGED
@@ -7,9 +7,9 @@ __version__ = "1.0.0"
7
  __author__ = "Prathamesh Vaidya"
8
 
9
  from .config import Config
10
- from .pose_analyzer import PoseAnalyzer, PoseKeypoints
11
- from .movement_classifier import MovementClassifier, MovementType, MovementMetrics
12
- from .video_processor import VideoProcessor
13
  from .utils import (
14
  generate_session_id,
15
  validate_file_extension,
 
7
  __author__ = "Prathamesh Vaidya"
8
 
9
  from .config import Config
10
+ from .core.pose_analyzer import PoseAnalyzer, PoseKeypoints
11
+ from .core.movement_classifier import MovementClassifier, MovementType, MovementMetrics
12
+ from .core.video_processor import VideoProcessor
13
  from .utils import (
14
  generate_session_id,
15
  validate_file_extension,
backend/app/api/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API layer - routes and websocket handling
3
+ """
4
+
5
+ from .routes import router
6
+ from .websocket import manager, ConnectionManager
7
+ from .dependencies import get_video_processor
8
+
9
+ __all__ = [
10
+ 'router',
11
+ 'manager',
12
+ 'ConnectionManager',
13
+ 'get_video_processor'
14
+ ]
backend/app/api/dependencies.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared API dependencies
3
+ """
4
+
5
+ from typing import Optional
6
+ from app.core.video_processor import VideoProcessor
7
+
8
+ # Global processor instance
9
+ global_processor: Optional[VideoProcessor] = None
10
+
11
+
12
+ def get_video_processor() -> VideoProcessor:
13
+ """Get or create the global VideoProcessor instance"""
14
+ global global_processor
15
+ if global_processor is None:
16
+ import logging
17
+ logger = logging.getLogger(__name__)
18
+ logger.info("Initializing VideoProcessor (first use)...")
19
+ global_processor = VideoProcessor()
20
+ logger.info("✅ VideoProcessor initialized")
21
+ return global_processor
backend/app/api/routes.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API Routes
3
+ """
4
+
5
+ from fastapi import APIRouter, File, UploadFile, HTTPException, WebSocket, WebSocketDisconnect
6
+ from fastapi.responses import StreamingResponse
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ import asyncio
10
+ import logging
11
+
12
+ from app.config import Config
13
+ from app.services.session_manager import session_manager
14
+ from app.services.video_service import video_service
15
+ from app.services.processing_service import processing_service
16
+ from app.api.websocket import manager
17
+ from app.models.responses import (
18
+ HealthResponse,
19
+ SessionListResponse,
20
+ ResultsResponse
21
+ )
22
+ from app.utils.file_utils import generate_session_id
23
+ from app.services.cleanup_service import cleanup_service
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Create router
28
+ router = APIRouter()
29
+
30
+ @router.get("/health", response_model=HealthResponse)
31
+ async def health_check():
32
+ """Health check endpoint"""
33
+ return HealthResponse(
34
+ status="healthy",
35
+ models_loaded=video_service.processor is not None,
36
+ models_ready=True,
37
+ timestamp=datetime.now().isoformat(),
38
+ active_sessions=session_manager.get_active_count()
39
+ )
40
+
41
+
42
+ @router.post("/api/upload")
43
+ async def upload_video(file: UploadFile = File(...)):
44
+ """Upload a video file for processing"""
45
+ try:
46
+ session_id = generate_session_id()
47
+
48
+ # Validate file
49
+ validation = video_service.validate_video(file.filename)
50
+ if not validation["valid"]:
51
+ raise HTTPException(status_code=400, detail=validation["error"])
52
+
53
+ # Save uploaded file
54
+ upload_path = video_service.save_upload(file, session_id, file.filename)
55
+
56
+ # Load video info
57
+ video_info = video_service.load_video_info(upload_path)
58
+
59
+ # Create session
60
+ session_manager.create_session(
61
+ session_id=session_id,
62
+ filename=file.filename,
63
+ upload_path=str(upload_path),
64
+ video_info=video_info
65
+ )
66
+
67
+ logger.info(f"File uploaded: {session_id} - {file.filename}")
68
+
69
+ return {
70
+ "success": True,
71
+ "session_id": session_id,
72
+ **video_info
73
+ }
74
+
75
+ except Exception as e:
76
+ logger.error(f"Upload error: {str(e)}")
77
+ raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
78
+
79
+
80
+ # @router.post("/api/analyze/{session_id}")
81
+ # async def analyze_video(session_id: str):
82
+ # """Start video analysis for uploaded file"""
83
+ # try:
84
+ # # Check if session exists
85
+ # session = session_manager.get_session(session_id)
86
+ # if not session:
87
+ # raise HTTPException(status_code=404, detail="Session not found")
88
+
89
+ # if session["status"] != "uploaded":
90
+ # raise HTTPException(
91
+ # status_code=400,
92
+ # detail=f"Invalid session status: {session['status']}"
93
+ # )
94
+
95
+ # # Update status
96
+ # session_manager.update_session(session_id, {
97
+ # "status": "processing",
98
+ # "start_time": datetime.now().isoformat()
99
+ # })
100
+
101
+ # # Start async processing
102
+ # asyncio.create_task(
103
+ # processing_service.process_video_async(session_id, manager)
104
+ # )
105
+
106
+ # return {
107
+ # "success": True,
108
+ # "message": "Analysis started",
109
+ # "session_id": session_id,
110
+ # "websocket_url": f"/ws/{session_id}"
111
+ # }
112
+
113
+ # except HTTPException:
114
+ # raise
115
+ # except Exception as e:
116
+ # logger.error(f"Analysis error: {str(e)}")
117
+ # raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
118
+
119
+
120
+ @router.post("/api/analyze/{session_id}")
121
+ async def analyze_video(session_id: str):
122
+ """Start video analysis with Celery worker"""
123
+ try:
124
+ session = session_manager.get_session(session_id)
125
+ if not session:
126
+ raise HTTPException(status_code=404, detail="Session not found")
127
+
128
+ if session["status"] != "uploaded":
129
+ raise HTTPException(
130
+ status_code=400,
131
+ detail=f"Invalid session status: {session['status']}"
132
+ )
133
+
134
+ # Start processing with Celery
135
+ task_info = processing_service.start_processing(session_id)
136
+
137
+ return {
138
+ "success": True,
139
+ "message": "Analysis queued",
140
+ "session_id": session_id,
141
+ "task_id": task_info["task_id"],
142
+ "poll_url": f"/api/task/{task_info['task_id']}"
143
+ }
144
+
145
+ except HTTPException:
146
+ raise
147
+ except Exception as e:
148
+ logger.error(f"Analysis error: {str(e)}")
149
+ raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
150
+
151
+
152
+ @router.get("/api/task/{task_id}")
153
+ async def get_task_status(task_id: str):
154
+ """Get Celery task status (polling endpoint)"""
155
+ try:
156
+ status = processing_service.get_task_status(task_id)
157
+ return {
158
+ "success": True,
159
+ "task_id": task_id,
160
+ **status
161
+ }
162
+ except Exception as e:
163
+ raise HTTPException(status_code=500, detail=str(e))
164
+
165
+
166
+ @router.get("/api/results/{session_id}", response_model=ResultsResponse)
167
+ async def get_results(session_id: str):
168
+ """Get analysis results for a session"""
169
+ session = session_manager.get_session(session_id)
170
+ if not session:
171
+ raise HTTPException(status_code=404, detail="Session not found")
172
+
173
+ if session["status"] != "completed":
174
+ return ResultsResponse(
175
+ success=False,
176
+ session_id=session_id,
177
+ status=session["status"],
178
+ results=None,
179
+ download_url=None
180
+ )
181
+
182
+ # Convert results to ensure JSON serialization
183
+ from app.services.processing_service import convert_to_native_bool
184
+ safe_results = convert_to_native_bool(session.get("results", {}))
185
+
186
+ return ResultsResponse(
187
+ success=True,
188
+ session_id=session_id,
189
+ status=session["status"],
190
+ results=safe_results,
191
+ download_url=f"/api/download/{session_id}"
192
+ )
193
+
194
+
195
+ @router.get("/api/download/{session_id}")
196
+ async def download_video(session_id: str):
197
+ """Download processed video"""
198
+ session = session_manager.get_session(session_id)
199
+ if not session:
200
+ raise HTTPException(status_code=404, detail="Session not found")
201
+
202
+ if session["status"] != "completed":
203
+ raise HTTPException(status_code=400, detail="Processing not complete")
204
+
205
+ output_path = Path(session["output_path"])
206
+
207
+ if not output_path.exists():
208
+ raise HTTPException(status_code=404, detail="Output file not found")
209
+
210
+ # Get file size
211
+ file_size = output_path.stat().st_size
212
+
213
+ # Stream video
214
+ def iterfile():
215
+ with open(output_path, mode="rb") as file_like:
216
+ chunk_size = 8192
217
+ while True:
218
+ chunk = file_like.read(chunk_size)
219
+ if not chunk:
220
+ break
221
+ yield chunk
222
+
223
+ return StreamingResponse(
224
+ iterfile(),
225
+ media_type="video/mp4",
226
+ headers={
227
+ "Accept-Ranges": "bytes",
228
+ "Content-Length": str(file_size),
229
+ "Content-Disposition": f'inline; filename="analyzed_{session["filename"]}"',
230
+ "Cache-Control": "no-cache",
231
+ "X-Content-Type-Options": "nosniff"
232
+ }
233
+ )
234
+
235
+
236
+ @router.delete("/api/session/{session_id}")
237
+ async def delete_session(session_id: str):
238
+ """Delete a session and its files"""
239
+ session = session_manager.get_session(session_id)
240
+ if not session:
241
+ raise HTTPException(status_code=404, detail="Session not found")
242
+
243
+ # Delete files
244
+ video_service.cleanup_session_files(session)
245
+
246
+ # Remove session
247
+ session_manager.delete_session(session_id)
248
+
249
+ return {
250
+ "success": True,
251
+ "message": "Session deleted",
252
+ "session_id": session_id
253
+ }
254
+
255
+
256
+ @router.get("/api/sessions", response_model=SessionListResponse)
257
+ async def list_sessions():
258
+ """List all active sessions"""
259
+ sessions = session_manager.list_sessions()
260
+
261
+ return SessionListResponse(
262
+ success=True,
263
+ count=len(sessions),
264
+ sessions=sessions
265
+ )
266
+
267
+
268
+ @router.websocket("/ws/{session_id}")
269
+ async def websocket_endpoint(websocket: WebSocket, session_id: str):
270
+ """WebSocket endpoint for real-time updates"""
271
+ await manager.connect(session_id, websocket)
272
+
273
+ try:
274
+ # Send initial connection message
275
+ await websocket.send_json({
276
+ "type": "connected",
277
+ "message": "WebSocket connected",
278
+ "session_id": session_id
279
+ })
280
+
281
+ # Keep connection alive
282
+ while True:
283
+ try:
284
+ data = await asyncio.wait_for(websocket.receive_text(), timeout=30.0)
285
+
286
+ # Echo heartbeat
287
+ if data == "ping":
288
+ await websocket.send_json({"type": "pong"})
289
+
290
+ except asyncio.TimeoutError:
291
+ # Send keepalive
292
+ await websocket.send_json({"type": "keepalive"})
293
+
294
+ except WebSocketDisconnect:
295
+ manager.disconnect(session_id)
296
+ logger.info(f"Client disconnected: {session_id}")
297
+ except Exception as e:
298
+ logger.error(f"WebSocket error: {str(e)}")
299
+ manager.disconnect(session_id)
300
+
301
+ # Add this endpoint
302
+ @router.get("/api/admin/storage")
303
+ async def get_storage_stats():
304
+ """Get storage statistics (admin endpoint)"""
305
+ return cleanup_service.get_storage_stats()
306
+
307
+
308
+ @router.post("/api/admin/cleanup")
309
+ async def trigger_cleanup():
310
+ """Manually trigger cleanup (admin endpoint)"""
311
+ result = await cleanup_service.run_cleanup()
312
+ return {
313
+ "success": True,
314
+ "message": "Cleanup completed",
315
+ **result
316
+ }
backend/app/api/websocket.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WebSocket connection management
3
+ """
4
+
5
+ from typing import Dict
6
+ from fastapi import WebSocket
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ConnectionManager:
13
+ """Manages WebSocket connections for real-time updates"""
14
+
15
+ def __init__(self):
16
+ self.active_connections: Dict[str, WebSocket] = {}
17
+
18
+ async def connect(self, session_id: str, websocket: WebSocket):
19
+ await websocket.accept()
20
+ self.active_connections[session_id] = websocket
21
+ logger.info(f"WebSocket connected: {session_id}")
22
+
23
+ def disconnect(self, session_id: str):
24
+ if session_id in self.active_connections:
25
+ del self.active_connections[session_id]
26
+ logger.info(f"WebSocket disconnected: {session_id}")
27
+
28
+ async def send_message(self, session_id: str, message: dict):
29
+ if session_id in self.active_connections:
30
+ try:
31
+ await self.active_connections[session_id].send_json(message)
32
+ except Exception as e:
33
+ logger.error(f"Error sending message to {session_id}: {e}")
34
+ self.disconnect(session_id)
35
+
36
+ async def broadcast(self, message: dict):
37
+ """Send message to all connected clients"""
38
+ disconnected = []
39
+ for session_id, connection in self.active_connections.items():
40
+ try:
41
+ await connection.send_json(message)
42
+ except Exception:
43
+ disconnected.append(session_id)
44
+
45
+ for session_id in disconnected:
46
+ self.disconnect(session_id)
47
+
48
+
49
+ # Global manager instance
50
+ manager = ConnectionManager()
backend/app/celery_app.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Celery application for background task processing
3
+ """
4
+
5
+ from celery import Celery
6
+ from app.config import Config
7
+ import os
8
+
9
+ # Redis URL from environment or default
10
+ REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
11
+
12
+ # Create Celery app
13
+ celery_app = Celery(
14
+ "dance_analyzer",
15
+ broker=REDIS_URL,
16
+ backend=REDIS_URL,
17
+ include=['app.tasks']
18
+ )
19
+
20
+ # Celery configuration
21
+ celery_app.conf.update(
22
+ task_serializer='json',
23
+ accept_content=['json'],
24
+ result_serializer='json',
25
+ timezone='UTC',
26
+ enable_utc=True,
27
+ task_track_started=True,
28
+ task_time_limit=600, # 10 minutes max per task
29
+ task_soft_time_limit=540, # 9 minutes soft limit
30
+ worker_prefetch_multiplier=1, # Take one task at a time
31
+ worker_max_tasks_per_child=10, # Restart worker after 10 tasks (prevent memory leaks)
32
+ )
backend/app/core/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core business logic for pose detection and movement analysis
3
+ """
4
+
5
+ from .pose_analyzer import PoseAnalyzer, PoseKeypoints
6
+ from .movement_classifier import MovementClassifier, MovementType, MovementMetrics
7
+ from .video_processor import VideoProcessor
8
+
9
+ __all__ = [
10
+ 'PoseAnalyzer',
11
+ 'PoseKeypoints',
12
+ 'MovementClassifier',
13
+ 'MovementType',
14
+ 'MovementMetrics',
15
+ 'VideoProcessor'
16
+ ]
backend/app/{movement_classifier.py → core/movement_classifier.py} RENAMED
@@ -9,9 +9,9 @@ from dataclasses import dataclass
9
  from enum import Enum
10
  import logging
11
 
12
- from .config import Config
13
- from .pose_analyzer import PoseKeypoints
14
- from .utils import safe_divide
15
 
16
  logger = logging.getLogger(__name__)
17
 
 
9
  from enum import Enum
10
  import logging
11
 
12
+ from app.config import Config
13
+ from app.core.pose_analyzer import PoseKeypoints
14
+ from app.utils.validation import safe_divide
15
 
16
  logger = logging.getLogger(__name__)
17
 
backend/app/{pose_analyzer.py → core/pose_analyzer.py} RENAMED
@@ -10,8 +10,8 @@ from typing import List, Tuple, Optional, Dict, Any
10
  from dataclasses import dataclass
11
  import logging
12
 
13
- from .config import Config
14
- from .utils import timing_decorator
15
 
16
  logger = logging.getLogger(__name__)
17
 
 
10
  from dataclasses import dataclass
11
  import logging
12
 
13
+ from app.config import Config
14
+ from app.utils.helpers import timing_decorator
15
 
16
  logger = logging.getLogger(__name__)
17
 
backend/app/{video_processor.py → core/video_processor.py} RENAMED
@@ -9,10 +9,11 @@ from pathlib import Path
9
  from typing import Optional, Callable, Dict, Any, List, Tuple
10
  import logging
11
 
12
- from .config import Config
13
- from .pose_analyzer import PoseAnalyzer, PoseKeypoints
14
- from .movement_classifier import MovementClassifier, MovementMetrics
15
- from .utils import timing_decorator, format_file_size
 
16
 
17
  logger = logging.getLogger(__name__)
18
 
 
9
  from typing import Optional, Callable, Dict, Any, List, Tuple
10
  import logging
11
 
12
+ from app.config import Config
13
+ from app.core.pose_analyzer import PoseAnalyzer, PoseKeypoints
14
+ from app.core.movement_classifier import MovementClassifier, MovementMetrics
15
+ from app.utils.helpers import timing_decorator
16
+ from app.utils.file_utils import format_file_size
17
 
18
  logger = logging.getLogger(__name__)
19
 
backend/app/main.py CHANGED
@@ -1,27 +1,20 @@
1
  """
2
- FastAPI Application with Optimized Startup for Hugging Face Spaces
3
  """
4
 
5
- from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect, HTTPException, Request
6
- from fastapi.responses import FileResponse, JSONResponse, HTMLResponse, StreamingResponse
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from fastapi.staticfiles import StaticFiles
 
9
  from contextlib import asynccontextmanager
10
  from pathlib import Path
11
- import asyncio
12
- import json
13
- import uuid
14
- import sys
15
- import shutil
16
- from typing import Optional, Dict, Any
17
  import logging
18
- from datetime import datetime
19
- import numpy as np
20
 
21
  from .config import Config
22
- from .video_processor import VideoProcessor
23
- from .utils import validate_file_extension, format_file_size, timing_decorator
24
- from fastapi.templating import Jinja2Templates
25
 
26
  # Configure logging
27
  logging.basicConfig(
@@ -30,23 +23,16 @@ logging.basicConfig(
30
  )
31
  logger = logging.getLogger(__name__)
32
 
33
- # Global processor instance (initialized on startup)
34
- global_processor: Optional[VideoProcessor] = None
35
-
36
  @asynccontextmanager
37
  async def lifespan(app: FastAPI):
38
- """Lifespan event handler - lazy model loading"""
39
-
40
  # Startup
41
  logger.info("🚀 Starting Dance Movement Analyzer...")
42
-
43
- # Initialize folders
44
  Config.initialize_folders()
45
  logger.info("✅ Folders initialized")
46
 
47
- # Don't initialize VideoProcessor here - let it lazy load on first request
48
- # This avoids the permission error during startup
49
- logger.info("📦 Models pre-downloaded, will initialize on first request")
50
 
51
  logger.info("🎉 Application startup complete!")
52
 
@@ -54,11 +40,12 @@ async def lifespan(app: FastAPI):
54
 
55
  # Shutdown
56
  logger.info("👋 Shutting down application...")
 
57
 
58
- # Initialize FastAPI app with lifespan
59
  app = FastAPI(
60
  title="Dance Movement Analysis API",
61
- description="AI-powered dance movement analysis with pose detection and classification",
62
  version="1.0.0",
63
  docs_url="/api/docs",
64
  redoc_url="/api/redoc",
@@ -74,92 +61,32 @@ app.add_middleware(
74
  allow_headers=["*"],
75
  )
76
 
77
- # Compute the frontend folder path correctly.
78
- # backend/app/main.py -> go up two levels to repo root then /frontend
79
- static_path = Path(__file__).parent.parent / "frontend"
80
-
81
- # Debugging helper (optional) to log the resolved path during startup
82
- import logging
83
- logging.getLogger("uvicorn").info(f"Resolved frontend path: {static_path.resolve()}")
84
 
85
  if static_path.exists() and static_path.is_dir():
86
- # Mount the entire frontend folder at /static so references like /static/css/styles.css work.
87
  app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
88
- # Point the Jinja2 templates loader to the frontend directory so index.html is found.
89
  templates = Jinja2Templates(directory=str(static_path))
90
  else:
91
- # If frontend folder missing, fall back to templates pointing to an empty dir
92
- templates = Jinja2Templates(directory=str(static_path)) # will throw at request time
93
- # Optionally log a warning so you notice in logs
94
- import logging
95
- logging.getLogger("uvicorn.error").warning(f"Frontend directory not found at {static_path.resolve()}")
96
-
97
-
98
- # Active WebSocket connections
99
- active_connections: Dict[str, WebSocket] = {}
100
-
101
- # Processing sessions
102
- processing_sessions: Dict[str, Dict[str, Any]] = {}
103
-
104
- def convert_to_native_bool(obj):
105
- """Recursively convert numpy types to native Python types."""
106
- if isinstance(obj, np.bool_):
107
- return bool(obj)
108
- elif isinstance(obj, (np.integer, np.floating)):
109
- return obj.item()
110
- elif isinstance(obj, dict):
111
- return {k: convert_to_native_bool(v) for k, v in obj.items()}
112
- elif isinstance(obj, (list, tuple)):
113
- return [convert_to_native_bool(v) for v in obj]
114
- else:
115
- return obj
116
 
117
- class ConnectionManager:
118
- """Manages WebSocket connections for real-time updates"""
119
-
120
- def __init__(self):
121
- self.active_connections: Dict[str, WebSocket] = {}
122
-
123
- async def connect(self, session_id: str, websocket: WebSocket):
124
- await websocket.accept()
125
- self.active_connections[session_id] = websocket
126
- logger.info(f"WebSocket connected: {session_id}")
127
-
128
- def disconnect(self, session_id: str):
129
- if session_id in self.active_connections:
130
- del self.active_connections[session_id]
131
- logger.info(f"WebSocket disconnected: {session_id}")
132
-
133
- async def send_message(self, session_id: str, message: dict):
134
- if session_id in self.active_connections:
135
- try:
136
- await self.active_connections[session_id].send_json(message)
137
- except Exception as e:
138
- logger.error(f"Error sending message to {session_id}: {e}")
139
- self.disconnect(session_id)
140
-
141
- async def broadcast(self, message: dict):
142
- """Send message to all connected clients"""
143
- disconnected = []
144
- for session_id, connection in self.active_connections.items():
145
- try:
146
- await connection.send_json(message)
147
- except Exception:
148
- disconnected.append(session_id)
149
-
150
- for session_id in disconnected:
151
- self.disconnect(session_id)
152
 
153
- manager = ConnectionManager()
154
 
155
  @app.get("/", response_class=HTMLResponse)
156
  async def home(request: Request):
157
- """Home page."""
158
  return templates.TemplateResponse("index.html", {"request": request})
159
 
 
160
  @app.get("/info")
161
  async def root():
162
- """Root endpoint - serves API info"""
 
 
163
  return {
164
  "name": "Dance Movement Analysis API",
165
  "version": "1.0.0",
@@ -174,477 +101,20 @@ async def root():
174
  }
175
  }
176
 
177
- @app.get("/health")
178
- async def health_check():
179
- """Health check endpoint"""
180
- return {
181
- "status": "healthy",
182
- "models_loaded": global_processor is not None,
183
- "models_ready": True, # Models are pre-downloaded
184
- "timestamp": datetime.now().isoformat(),
185
- "active_sessions": len(processing_sessions)
186
- }
187
-
188
- def get_video_processor() -> VideoProcessor:
189
- """Get or create the global VideoProcessor instance"""
190
- global global_processor
191
- if global_processor is None:
192
- logger.info("Initializing VideoProcessor (first use)...")
193
- global_processor = VideoProcessor()
194
- logger.info("✅ VideoProcessor initialized")
195
- return global_processor
196
-
197
- @app.post("/api/upload")
198
- async def upload_video(file: UploadFile = File(...)):
199
- """Upload a video file for processing"""
200
- from typing import List
201
- allowed_extensions: List[str] = [".mp4", ".avi", ".mov", ".mkv", ".webm"]
202
-
203
- try:
204
- session_id = str(uuid.uuid4())
205
-
206
- # Validate file
207
- validation = validate_file_extension(file.filename, allowed_extensions)
208
- if not validation["valid"]:
209
- raise HTTPException(status_code=400, detail=validation["error"])
210
-
211
- # Save uploaded file
212
- upload_path = Config.UPLOAD_FOLDER / f"{session_id}_{file.filename}"
213
-
214
- with open(upload_path, "wb") as buffer:
215
- shutil.copyfileobj(file.file, buffer)
216
-
217
- # Use pre-initialized processor
218
- processor = global_processor or VideoProcessor()
219
- video_info = processor.load_video(upload_path)
220
-
221
- # Store session info
222
- processing_sessions[session_id] = {
223
- "filename": file.filename,
224
- "upload_path": str(upload_path),
225
- "upload_time": datetime.now().isoformat(),
226
- "status": "uploaded",
227
- "video_info": video_info
228
- }
229
-
230
- logger.info(f"File uploaded: {session_id} - {file.filename}")
231
-
232
- return {
233
- "success": True,
234
- "session_id": session_id,
235
- "filename": file.filename,
236
- "size": format_file_size(video_info["size_bytes"]),
237
- "duration": f"{video_info['duration']:.1f}s",
238
- "resolution": f"{video_info['width']}x{video_info['height']}",
239
- "fps": video_info["fps"],
240
- "frame_count": video_info["frame_count"]
241
- }
242
-
243
- except Exception as e:
244
- logger.error(f"Upload error: {str(e)}")
245
- raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
246
-
247
-
248
- @app.post("/api/analyze/{session_id}")
249
- async def analyze_video(session_id: str):
250
- """
251
- Start video analysis for uploaded file
252
-
253
- Args:
254
- session_id: Session ID from upload
255
-
256
- Returns:
257
- JSON indicating analysis started
258
- """
259
- try:
260
- # Check if session exists
261
- if session_id not in processing_sessions:
262
- raise HTTPException(status_code=404, detail="Session not found")
263
-
264
- session = processing_sessions[session_id]
265
-
266
- if session["status"] != "uploaded":
267
- raise HTTPException(
268
- status_code=400,
269
- detail=f"Invalid session status: {session['status']}"
270
- )
271
-
272
- # Update status
273
- session["status"] = "processing"
274
- session["start_time"] = datetime.now().isoformat()
275
-
276
- # Start async processing
277
- asyncio.create_task(process_video_async(session_id))
278
-
279
- return {
280
- "success": True,
281
- "message": "Analysis started",
282
- "session_id": session_id,
283
- "websocket_url": f"/ws/{session_id}"
284
- }
285
-
286
- except HTTPException:
287
- raise
288
- except Exception as e:
289
- logger.error(f"Analysis error: {str(e)}")
290
- raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
291
-
292
-
293
- # async def process_video_async(session_id: str):
294
- # """
295
- # Async video processing task
296
-
297
- # Args:
298
- # session_id: Session ID to process
299
- # """
300
- # try:
301
- # session = processing_sessions[session_id]
302
- # input_path = Path(session["upload_path"])
303
- # output_path = Config.OUTPUT_FOLDER / f"analyzed_{session_id}.mp4"
304
- # results_path = Config.OUTPUT_FOLDER / f"results_{session_id}.json"
305
-
306
- # # Create processor
307
- # processor = VideoProcessor()
308
-
309
- # # Create progress callback
310
- # async def progress_cb(progress: float, message: str):
311
- # await manager.send_message(session_id, {
312
- # "type": "progress",
313
- # "progress": progress,
314
- # "message": message,
315
- # "timestamp": datetime.now().isoformat()
316
- # })
317
-
318
- # # Process video
319
- # await manager.send_message(session_id, {
320
- # "type": "status",
321
- # "status": "processing",
322
- # "message": "Starting pose detection..."
323
- # })
324
-
325
- # # Run processing in thread pool to avoid blocking
326
- # loop = asyncio.get_event_loop()
327
- # results = await loop.run_in_executor(
328
- # None,
329
- # lambda: processor.process_video(
330
- # video_path=input_path,
331
- # output_path=output_path,
332
- # progress_callback=lambda p, m: asyncio.run(progress_cb(p, m))
333
- # )
334
- # )
335
-
336
- # # ✅ Convert NumPy objects before saving or storing
337
- # results = convert_to_native_bool(raw_results)
338
-
339
- # # Save clean JSON results
340
- # with open(results_path, 'w') as f:
341
- # json.dump(results, f, indent=2, default=str)
342
-
343
- # # Update session
344
- # session["status"] = "completed"
345
- # session["output_path"] = str(output_path)
346
- # session["results_path"] = str(results_path)
347
- # session["end_time"] = datetime.now().isoformat()
348
- # session["results"] = results
349
-
350
- # # Before sending the message, convert results:
351
- # print("Before sending the message, we convert results here")
352
- # results = convert_to_native_bool(results)
353
-
354
- # # Send completion message
355
- # await manager.send_message(session_id, {
356
- # "type": "complete",
357
- # "status": "completed",
358
- # "message": "Analysis complete!",
359
- # "results": results,
360
- # "download_url": f"/api/download/{session_id}"
361
- # })
362
-
363
- # logger.info(f"Processing completed: {session_id}")
364
-
365
- # except Exception as e:
366
- # logger.error(f"Processing error for {session_id}: {str(e)}")
367
-
368
- # session["status"] = "failed"
369
- # session["error"] = str(e)
370
-
371
- # await manager.send_message(session_id, {
372
- # "type": "error",
373
- # "status": "failed",
374
- # "message": f"Processing failed: {str(e)}"
375
- # })
376
-
377
- async def process_video_async(session_id: str):
378
- try:
379
- session = processing_sessions[session_id]
380
- input_path = Path(session["upload_path"])
381
- output_path = Config.OUTPUT_FOLDER / f"analyzed_{session_id}.mp4"
382
- results_path = Config.OUTPUT_FOLDER / f"results_{session_id}.json"
383
-
384
- processor = VideoProcessor()
385
-
386
- async def progress_cb(progress: float, message: str):
387
- await manager.send_message(session_id, {
388
- "type": "progress",
389
- "progress": progress,
390
- "message": message,
391
- "timestamp": datetime.now().isoformat()
392
- })
393
-
394
- await manager.send_message(session_id, {
395
- "type": "status",
396
- "status": "processing",
397
- "message": "Starting pose detection..."
398
- })
399
-
400
- loop = asyncio.get_event_loop()
401
- raw_results = await loop.run_in_executor(
402
- None,
403
- lambda: processor.process_video(
404
- video_path=input_path,
405
- output_path=output_path,
406
- progress_callback=lambda p, m: asyncio.run(progress_cb(p, m))
407
- )
408
- )
409
-
410
- # ✅ Convert NumPy objects before saving or storing
411
- results = convert_to_native_bool(raw_results)
412
-
413
- # Save clean JSON results
414
- with open(results_path, 'w') as f:
415
- json.dump(results, f, indent=2, default=str)
416
-
417
- session.update({
418
- "status": "completed",
419
- "output_path": str(output_path),
420
- "results_path": str(results_path),
421
- "end_time": datetime.now().isoformat(),
422
- "results": results
423
- })
424
-
425
- # Send final WebSocket message
426
- await manager.send_message(session_id, {
427
- "type": "complete",
428
- "status": "completed",
429
- "message": "Analysis complete!",
430
- "results": results,
431
- "download_url": f"/api/download/{session_id}"
432
- })
433
-
434
- logger.info(f"Processing completed: {session_id}")
435
-
436
- except Exception as e:
437
- logger.error(f"Processing error for {session_id}: {str(e)}")
438
- session["status"] = "failed"
439
- session["error"] = str(e)
440
-
441
- await manager.send_message(session_id, {
442
- "type": "error",
443
- "status": "failed",
444
- "message": f"Processing failed: {str(e)}"
445
- })
446
-
447
- @app.get("/api/results/{session_id}")
448
- async def get_results(session_id: str):
449
- """
450
- Get analysis results for a session
451
-
452
- Args:
453
- session_id: Session ID
454
-
455
- Returns:
456
- JSON with analysis results
457
- """
458
- if session_id not in processing_sessions:
459
- raise HTTPException(status_code=404, detail="Session not found")
460
-
461
- session = processing_sessions[session_id]
462
-
463
- if session["status"] != "completed":
464
- return {
465
- "status": session["status"],
466
- "message": "Processing not complete"
467
- }
468
-
469
- # ✅ Ensure safe serialization
470
- safe_results = convert_to_native_bool(session.get("results", {}))
471
-
472
- return {
473
- "success": True,
474
- "session_id": session_id,
475
- "status": session["status"],
476
- # "results": session.get("results", {}),
477
- "results": safe_results,
478
- "download_url": f"/api/download/{session_id}"
479
- }
480
-
481
-
482
- @app.get("/api/download/{session_id}")
483
- async def download_video(session_id: str):
484
- """
485
- Download processed video
486
-
487
- Args:
488
- session_id: Session ID
489
-
490
- Returns:
491
- Video file
492
- """
493
- if session_id not in processing_sessions:
494
- raise HTTPException(status_code=404, detail="Session not found")
495
-
496
- session = processing_sessions[session_id]
497
-
498
- if session["status"] != "completed":
499
- raise HTTPException(status_code=400, detail="Processing not complete")
500
-
501
- output_path = Path(session["output_path"])
502
-
503
- if not output_path.exists():
504
- raise HTTPException(status_code=404, detail="Output file not found")
505
-
506
- # Get file size for Content-Length header
507
- file_size = output_path.stat().st_size
508
-
509
- # Use StreamingResponse with proper headers for browser video playback
510
- def iterfile():
511
- with open(output_path, mode="rb") as file_like:
512
- chunk_size = 8192 # 8KB chunks
513
- while True:
514
- chunk = file_like.read(chunk_size)
515
- if not chunk:
516
- break
517
- yield chunk
518
-
519
- return StreamingResponse(
520
- iterfile(),
521
- media_type="video/mp4",
522
- headers={
523
- "Accept-Ranges": "bytes",
524
- "Content-Length": str(file_size),
525
- "Content-Disposition": f'inline; filename="analyzed_{session["filename"]}"',
526
- "Cache-Control": "no-cache",
527
- "X-Content-Type-Options": "nosniff"
528
- }
529
- )
530
-
531
-
532
- @app.websocket("/ws/{session_id}")
533
- async def websocket_endpoint(websocket: WebSocket, session_id: str):
534
- """
535
- WebSocket endpoint for real-time updates
536
-
537
- Args:
538
- websocket: WebSocket connection
539
- session_id: Session ID to monitor
540
- """
541
- await manager.connect(session_id, websocket)
542
-
543
- try:
544
- # Send initial connection message
545
- await websocket.send_json({
546
- "type": "connected",
547
- "message": "WebSocket connected",
548
- "session_id": session_id
549
- })
550
-
551
- # Keep connection alive
552
- while True:
553
- # Wait for messages (heartbeat)
554
- try:
555
- data = await asyncio.wait_for(websocket.receive_text(), timeout=30.0)
556
-
557
- # Echo heartbeat
558
- if data == "ping":
559
- await websocket.send_json({"type": "pong"})
560
-
561
- except asyncio.TimeoutError:
562
- # Send keepalive
563
- await websocket.send_json({"type": "keepalive"})
564
-
565
- except WebSocketDisconnect:
566
- manager.disconnect(session_id)
567
- logger.info(f"Client disconnected: {session_id}")
568
- except Exception as e:
569
- logger.error(f"WebSocket error: {str(e)}")
570
- manager.disconnect(session_id)
571
-
572
-
573
- @app.delete("/api/session/{session_id}")
574
- async def delete_session(session_id: str):
575
- """
576
- Delete a session and its files
577
-
578
- Args:
579
- session_id: Session ID to delete
580
-
581
- Returns:
582
- Success message
583
- """
584
- if session_id not in processing_sessions:
585
- raise HTTPException(status_code=404, detail="Session not found")
586
-
587
- session = processing_sessions[session_id]
588
-
589
- # Delete files
590
- try:
591
- if "upload_path" in session:
592
- Path(session["upload_path"]).unlink(missing_ok=True)
593
- if "output_path" in session:
594
- Path(session["output_path"]).unlink(missing_ok=True)
595
- if "results_path" in session:
596
- Path(session["results_path"]).unlink(missing_ok=True)
597
- except Exception as e:
598
- logger.error(f"Error deleting files: {str(e)}")
599
-
600
- # Remove session
601
- del processing_sessions[session_id]
602
-
603
- return {
604
- "success": True,
605
- "message": "Session deleted",
606
- "session_id": session_id
607
- }
608
-
609
-
610
- @app.get("/api/sessions")
611
- async def list_sessions():
612
- """
613
- List all active sessions
614
-
615
- Returns:
616
- List of sessions with their status
617
- """
618
- sessions = []
619
-
620
- for session_id, session in processing_sessions.items():
621
- sessions.append({
622
- "session_id": session_id,
623
- "filename": session["filename"],
624
- "status": session["status"],
625
- "upload_time": session["upload_time"]
626
- })
627
-
628
- return {
629
- "success": True,
630
- "count": len(sessions),
631
- "sessions": sessions
632
- }
633
 
634
  def start_web_app():
635
- """Start the web application."""
636
  logger.info('🌐 Starting web application...')
637
 
638
  try:
639
  import uvicorn
640
  logger.info('✅ Uvicorn imported successfully')
641
 
642
- # Start the server
643
  uvicorn.run(
644
  app,
645
  host=Config.API_HOST,
646
  port=Config.API_PORT,
647
- workers=1,
648
  log_level='info',
649
  access_log=True
650
  )
@@ -655,7 +125,6 @@ def start_web_app():
655
  logger.error(f'❌ Failed to start web application: {e}')
656
  sys.exit(1)
657
 
658
- if __name__ == "__main__":
659
 
660
- # Start the web application
661
  start_web_app()
 
1
  """
2
+ FastAPI Application - Main Entry Point
3
  """
4
 
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.responses import HTMLResponse
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from fastapi.staticfiles import StaticFiles
9
+ from fastapi.templating import Jinja2Templates
10
  from contextlib import asynccontextmanager
11
  from pathlib import Path
 
 
 
 
 
 
12
  import logging
13
+ import sys
 
14
 
15
  from .config import Config
16
+ from .api.routes import router
17
+ from .services.cleanup_service import cleanup_service
 
18
 
19
  # Configure logging
20
  logging.basicConfig(
 
23
  )
24
  logger = logging.getLogger(__name__)
25
 
 
 
 
26
  @asynccontextmanager
27
  async def lifespan(app: FastAPI):
28
+ """Lifespan event handler"""
 
29
  # Startup
30
  logger.info("🚀 Starting Dance Movement Analyzer...")
 
 
31
  Config.initialize_folders()
32
  logger.info("✅ Folders initialized")
33
 
34
+ # Start cleanup service
35
+ await cleanup_service.start()
 
36
 
37
  logger.info("🎉 Application startup complete!")
38
 
 
40
 
41
  # Shutdown
42
  logger.info("👋 Shutting down application...")
43
+ await cleanup_service.stop()
44
 
45
+ # Initialize FastAPI app
46
  app = FastAPI(
47
  title="Dance Movement Analysis API",
48
+ description="AI-powered dance movement analysis with pose detection",
49
  version="1.0.0",
50
  docs_url="/api/docs",
51
  redoc_url="/api/redoc",
 
61
  allow_headers=["*"],
62
  )
63
 
64
+ # Mount static files (frontend)
65
+ static_path = Path(__file__).parent.parent.parent / "frontend"
66
+ logger.info(f"Frontend path: {static_path.resolve()}")
 
 
 
 
67
 
68
  if static_path.exists() and static_path.is_dir():
 
69
  app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
 
70
  templates = Jinja2Templates(directory=str(static_path))
71
  else:
72
+ templates = Jinja2Templates(directory=str(static_path))
73
+ logger.warning(f"Frontend directory not found at {static_path.resolve()}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
+ # Include API routes
76
+ app.include_router(router)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
 
78
 
79
  @app.get("/", response_class=HTMLResponse)
80
  async def home(request: Request):
81
+ """Home page"""
82
  return templates.TemplateResponse("index.html", {"request": request})
83
 
84
+
85
  @app.get("/info")
86
  async def root():
87
+ """Root endpoint - API info"""
88
+ from app.api.dependencies import global_processor
89
+
90
  return {
91
  "name": "Dance Movement Analysis API",
92
  "version": "1.0.0",
 
101
  }
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  def start_web_app():
106
+ """Start the web application"""
107
  logger.info('🌐 Starting web application...')
108
 
109
  try:
110
  import uvicorn
111
  logger.info('✅ Uvicorn imported successfully')
112
 
 
113
  uvicorn.run(
114
  app,
115
  host=Config.API_HOST,
116
  port=Config.API_PORT,
117
+ workers=1,
118
  log_level='info',
119
  access_log=True
120
  )
 
125
  logger.error(f'❌ Failed to start web application: {e}')
126
  sys.exit(1)
127
 
 
128
 
129
+ if __name__ == "__main__":
130
  start_web_app()
backend/app/models/__init__.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for API requests and responses
3
+ """
4
+
5
+ from .requests import (
6
+ VideoUploadResponse,
7
+ AnalysisStartRequest,
8
+ AnalysisStartResponse
9
+ )
10
+
11
+ from .responses import (
12
+ HealthResponse,
13
+ SessionInfo,
14
+ SessionListResponse,
15
+ ResultsResponse,
16
+ ErrorResponse
17
+ )
18
+
19
+ __all__ = [
20
+ # Requests
21
+ 'VideoUploadResponse',
22
+ 'AnalysisStartRequest',
23
+ 'AnalysisStartResponse',
24
+
25
+ # Responses
26
+ 'HealthResponse',
27
+ 'SessionInfo',
28
+ 'SessionListResponse',
29
+ 'ResultsResponse',
30
+ 'ErrorResponse'
31
+ ]
backend/app/models/requests.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Request models for API validation
3
+ """
4
+
5
+ from typing import Optional
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class VideoUploadResponse(BaseModel):
10
+ """Response model for video upload"""
11
+ success: bool
12
+ session_id: str
13
+ filename: str
14
+ size: str
15
+ duration: str
16
+ resolution: str
17
+ fps: float
18
+ frame_count: int
19
+
20
+
21
+ class AnalysisStartRequest(BaseModel):
22
+ """Request model to start analysis"""
23
+ session_id: str = Field(..., description="Session ID from upload")
24
+
25
+
26
+ class AnalysisStartResponse(BaseModel):
27
+ """Response model for analysis start"""
28
+ success: bool
29
+ message: str
30
+ session_id: str
31
+ websocket_url: str
backend/app/models/responses.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Response models for API
3
+ """
4
+
5
+ from typing import Dict, Any, Optional, List
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class HealthResponse(BaseModel):
10
+ """Health check response"""
11
+ status: str
12
+ models_loaded: bool
13
+ models_ready: bool
14
+ timestamp: str
15
+ active_sessions: int
16
+
17
+
18
+ class SessionInfo(BaseModel):
19
+ """Session information"""
20
+ session_id: str
21
+ filename: str
22
+ status: str
23
+ upload_time: str
24
+
25
+
26
+ class SessionListResponse(BaseModel):
27
+ """List of sessions response"""
28
+ success: bool
29
+ count: int
30
+ sessions: List[SessionInfo]
31
+
32
+
33
+ class ResultsResponse(BaseModel):
34
+ """Analysis results response"""
35
+ success: bool
36
+ session_id: str
37
+ status: str
38
+ results: Optional[Dict[str, Any]] = None
39
+ download_url: Optional[str] = None
40
+
41
+
42
+ class ErrorResponse(BaseModel):
43
+ """Error response model"""
44
+ success: bool = False
45
+ error: str
46
+ details: Optional[str] = None
backend/app/services/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Business services
3
+ """
4
+
5
+ from .session_manager import session_manager, SessionManager
6
+ from .video_service import video_service, VideoService
7
+ from .processing_service import processing_service, ProcessingService
8
+ from .cleanup_service import cleanup_service, CleanupService
9
+
10
+ __all__ = [
11
+ 'session_manager',
12
+ 'SessionManager',
13
+ 'video_service',
14
+ 'VideoService',
15
+ 'processing_service',
16
+ 'ProcessingService',
17
+ 'cleanup_service',
18
+ 'CleanupService'
19
+ ]
backend/app/services/cleanup_service.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ File cleanup service for managing storage
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ from pathlib import Path
8
+ from datetime import datetime, timedelta
9
+ from typing import Optional
10
+
11
+ from app.config import Config
12
+ from app.services.session_manager import session_manager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class CleanupService:
18
+ """Manages automatic file cleanup"""
19
+
20
+ def __init__(
21
+ self,
22
+ max_file_age_hours: int = 24,
23
+ cleanup_interval_minutes: int = 60,
24
+ max_storage_gb: float = 10.0
25
+ ):
26
+ """
27
+ Args:
28
+ max_file_age_hours: Delete files older than this
29
+ cleanup_interval_minutes: Run cleanup every N minutes
30
+ max_storage_gb: Maximum storage allowed (GB)
31
+ """
32
+ self.max_file_age_hours = max_file_age_hours
33
+ self.cleanup_interval = cleanup_interval_minutes * 60 # Convert to seconds
34
+ self.max_storage_bytes = max_storage_gb * 1024 * 1024 * 1024
35
+ self.cleanup_task: Optional[asyncio.Task] = None
36
+ self.is_running = False
37
+
38
+ async def start(self):
39
+ """Start background cleanup task"""
40
+ if self.is_running:
41
+ logger.warning("Cleanup service already running")
42
+ return
43
+
44
+ self.is_running = True
45
+ self.cleanup_task = asyncio.create_task(self._cleanup_loop())
46
+ logger.info(f"✅ Cleanup service started (interval: {self.cleanup_interval/60:.0f}min)")
47
+
48
+ async def stop(self):
49
+ """Stop background cleanup task"""
50
+ self.is_running = False
51
+ if self.cleanup_task:
52
+ self.cleanup_task.cancel()
53
+ try:
54
+ await self.cleanup_task
55
+ except asyncio.CancelledError:
56
+ pass
57
+ logger.info("🛑 Cleanup service stopped")
58
+
59
+ async def _cleanup_loop(self):
60
+ """Background loop for periodic cleanup"""
61
+ while self.is_running:
62
+ try:
63
+ await self.run_cleanup()
64
+ await asyncio.sleep(self.cleanup_interval)
65
+ except asyncio.CancelledError:
66
+ break
67
+ except Exception as e:
68
+ logger.error(f"Cleanup error: {e}")
69
+ await asyncio.sleep(60) # Wait 1 min before retry
70
+
71
+ async def run_cleanup(self):
72
+ """Run cleanup operation"""
73
+ logger.info("🧹 Starting cleanup operation...")
74
+
75
+ # Cleanup old files
76
+ deleted_by_age = await self._cleanup_old_files()
77
+
78
+ # Cleanup by storage limit
79
+ deleted_by_size = await self._cleanup_by_storage_limit()
80
+
81
+ # Cleanup orphaned sessions
82
+ cleaned_sessions = await self._cleanup_orphaned_sessions()
83
+
84
+ total = deleted_by_age + deleted_by_size
85
+ logger.info(
86
+ f"✅ Cleanup complete: {total} files deleted, "
87
+ f"{cleaned_sessions} sessions cleaned"
88
+ )
89
+
90
+ return {
91
+ "deleted_by_age": deleted_by_age,
92
+ "deleted_by_size": deleted_by_size,
93
+ "cleaned_sessions": cleaned_sessions,
94
+ "total_deleted": total
95
+ }
96
+
97
+ async def _cleanup_old_files(self) -> int:
98
+ """Delete files older than max_file_age_hours"""
99
+ deleted_count = 0
100
+ cutoff_time = datetime.now() - timedelta(hours=self.max_file_age_hours)
101
+
102
+ for folder in [Config.UPLOAD_FOLDER, Config.OUTPUT_FOLDER]:
103
+ if not folder.exists():
104
+ continue
105
+
106
+ for file_path in folder.iterdir():
107
+ if not file_path.is_file():
108
+ continue
109
+
110
+ # Check file age
111
+ file_mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
112
+
113
+ if file_mtime < cutoff_time:
114
+ try:
115
+ file_path.unlink()
116
+ deleted_count += 1
117
+ logger.info(f"🗑️ Deleted old file: {file_path.name}")
118
+ except Exception as e:
119
+ logger.error(f"Failed to delete {file_path.name}: {e}")
120
+
121
+ return deleted_count
122
+
123
+ async def _cleanup_by_storage_limit(self) -> int:
124
+ """Delete oldest files if storage exceeds limit"""
125
+ deleted_count = 0
126
+
127
+ # Calculate total storage
128
+ total_size = 0
129
+ file_list = []
130
+
131
+ for folder in [Config.UPLOAD_FOLDER, Config.OUTPUT_FOLDER]:
132
+ if not folder.exists():
133
+ continue
134
+
135
+ for file_path in folder.iterdir():
136
+ if file_path.is_file():
137
+ size = file_path.stat().st_size
138
+ mtime = file_path.stat().st_mtime
139
+ total_size += size
140
+ file_list.append((file_path, size, mtime))
141
+
142
+ # Check if over limit
143
+ if total_size <= self.max_storage_bytes:
144
+ return 0
145
+
146
+ # Sort by modification time (oldest first)
147
+ file_list.sort(key=lambda x: x[2])
148
+
149
+ # Delete oldest files until under limit
150
+ for file_path, size, _ in file_list:
151
+ if total_size <= self.max_storage_bytes:
152
+ break
153
+
154
+ try:
155
+ file_path.unlink()
156
+ total_size -= size
157
+ deleted_count += 1
158
+ logger.info(f"🗑️ Deleted for storage: {file_path.name}")
159
+ except Exception as e:
160
+ logger.error(f"Failed to delete {file_path.name}: {e}")
161
+
162
+ return deleted_count
163
+
164
+ async def _cleanup_orphaned_sessions(self) -> int:
165
+ """Remove sessions with missing files or old failed sessions"""
166
+ cleaned_count = 0
167
+ sessions_to_remove = []
168
+
169
+ for session_id, session in session_manager.sessions.items():
170
+ # Remove failed sessions older than 1 hour
171
+ if session.get("status") == "failed":
172
+ upload_time = datetime.fromisoformat(session["upload_time"])
173
+ if datetime.now() - upload_time > timedelta(hours=1):
174
+ sessions_to_remove.append(session_id)
175
+ continue
176
+
177
+ # Remove sessions with missing files
178
+ upload_path = session.get("upload_path")
179
+ if upload_path and not Path(upload_path).exists():
180
+ sessions_to_remove.append(session_id)
181
+
182
+ for session_id in sessions_to_remove:
183
+ session_manager.delete_session(session_id)
184
+ cleaned_count += 1
185
+ logger.info(f"🗑️ Cleaned orphaned session: {session_id}")
186
+
187
+ return cleaned_count
188
+
189
+ def get_storage_stats(self) -> dict:
190
+ """Get current storage statistics"""
191
+ total_size = 0
192
+ file_count = 0
193
+
194
+ for folder in [Config.UPLOAD_FOLDER, Config.OUTPUT_FOLDER]:
195
+ if not folder.exists():
196
+ continue
197
+
198
+ for file_path in folder.iterdir():
199
+ if file_path.is_file():
200
+ total_size += file_path.stat().st_size
201
+ file_count += 1
202
+
203
+ return {
204
+ "total_size_bytes": total_size,
205
+ "total_size_mb": total_size / (1024 * 1024),
206
+ "total_size_gb": total_size / (1024 * 1024 * 1024),
207
+ "file_count": file_count,
208
+ "max_storage_gb": self.max_storage_bytes / (1024 * 1024 * 1024),
209
+ "usage_percentage": (total_size / self.max_storage_bytes) * 100
210
+ }
211
+
212
+
213
+ # Global cleanup service instance
214
+ cleanup_service = CleanupService(
215
+ max_file_age_hours=24,
216
+ cleanup_interval_minutes=60,
217
+ max_storage_gb=10.0
218
+ )
backend/app/services/processing_service.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Video processing service - Updated for Celery
3
+ """
4
+
5
+ from app.tasks import process_video_task
6
+ from app.services.session_manager import session_manager
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ProcessingService:
13
+ """Handles video processing tasks"""
14
+
15
+ def start_processing(self, session_id: str) -> dict:
16
+ """
17
+ Start video processing with Celery
18
+
19
+ Args:
20
+ session_id: Session ID to process
21
+
22
+ Returns:
23
+ Dict with task info
24
+ """
25
+ try:
26
+ # Submit task to Celery
27
+ task = process_video_task.delay(session_id)
28
+
29
+ # Update session with task ID
30
+ session_manager.update_session(session_id, {
31
+ "status": "queued",
32
+ "celery_task_id": task.id
33
+ })
34
+
35
+ logger.info(f"✅ Task queued: {session_id} (task_id: {task.id})")
36
+
37
+ return {
38
+ "task_id": task.id,
39
+ "status": "queued"
40
+ }
41
+
42
+ except Exception as e:
43
+ logger.error(f"Failed to queue task: {e}")
44
+ raise
45
+
46
+ def get_task_status(self, task_id: str) -> dict:
47
+ """Get Celery task status"""
48
+ from celery.result import AsyncResult
49
+
50
+ task = AsyncResult(task_id, app=process_video_task.app)
51
+
52
+ if task.state == 'PENDING':
53
+ response = {
54
+ 'state': task.state,
55
+ 'progress': 0,
56
+ 'message': 'Task pending...'
57
+ }
58
+ elif task.state == 'PROGRESS':
59
+ response = {
60
+ 'state': task.state,
61
+ 'progress': task.info.get('progress', 0),
62
+ 'message': task.info.get('message', ''),
63
+ 'session_id': task.info.get('session_id', '')
64
+ }
65
+ elif task.state == 'SUCCESS':
66
+ response = {
67
+ 'state': task.state,
68
+ 'progress': 1.0,
69
+ 'result': task.info
70
+ }
71
+ else: # FAILURE, RETRY, etc.
72
+ response = {
73
+ 'state': task.state,
74
+ 'progress': 0,
75
+ 'error': str(task.info)
76
+ }
77
+
78
+ return response
79
+
80
+
81
+ # Global processing service instance
82
+ processing_service = ProcessingService()
backend/app/services/session_manager.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Session management service
3
+ """
4
+
5
+ from typing import Dict, Any, Optional
6
+ from datetime import datetime
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class SessionManager:
13
+ """Manages processing sessions"""
14
+
15
+ def __init__(self):
16
+ self.sessions: Dict[str, Dict[str, Any]] = {}
17
+
18
+ def create_session(self, session_id: str, filename: str, upload_path: str, video_info: Dict) -> Dict[str, Any]:
19
+ """Create a new session"""
20
+ session = {
21
+ "session_id": session_id,
22
+ "filename": filename,
23
+ "upload_path": upload_path,
24
+ "upload_time": datetime.now().isoformat(),
25
+ "status": "uploaded",
26
+ "video_info": video_info,
27
+ "results": None,
28
+ "progress": 0.0,
29
+ "message": ""
30
+ }
31
+
32
+ self.sessions[session_id] = session
33
+ logger.info(f"Created session: {session_id}")
34
+ return session
35
+
36
+ def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
37
+ """Get session by ID"""
38
+ return self.sessions.get(session_id)
39
+
40
+ def update_session(self, session_id: str, updates: Dict[str, Any]) -> bool:
41
+ """Update session data"""
42
+ if session_id in self.sessions:
43
+ self.sessions[session_id].update(updates)
44
+ return True
45
+ return False
46
+
47
+ def delete_session(self, session_id: str) -> bool:
48
+ """Delete a session"""
49
+ if session_id in self.sessions:
50
+ del self.sessions[session_id]
51
+ logger.info(f"Deleted session: {session_id}")
52
+ return True
53
+ return False
54
+
55
+ def list_sessions(self) -> list:
56
+ """List all sessions"""
57
+ return [
58
+ {
59
+ "session_id": sid,
60
+ "filename": s["filename"],
61
+ "status": s["status"],
62
+ "upload_time": s["upload_time"]
63
+ }
64
+ for sid, s in self.sessions.items()
65
+ ]
66
+
67
+ def get_active_count(self) -> int:
68
+ """Get count of active sessions"""
69
+ return len(self.sessions)
70
+
71
+
72
+ # Global session manager instance
73
+ session_manager = SessionManager()
backend/app/services/video_service.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Video handling service
3
+ """
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ import shutil
8
+ import logging
9
+
10
+ from app.config import Config
11
+ from app.utils.file_utils import validate_file_extension, format_file_size
12
+ from app.core.video_processor import VideoProcessor
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class VideoService:
18
+ """Handles video upload and validation"""
19
+
20
+ def __init__(self):
21
+ self.processor: Optional[VideoProcessor] = None
22
+
23
+ def get_processor(self) -> VideoProcessor:
24
+ """Get or create VideoProcessor instance"""
25
+ if self.processor is None:
26
+ logger.info("Initializing VideoProcessor...")
27
+ self.processor = VideoProcessor()
28
+ logger.info("✅ VideoProcessor initialized")
29
+ return self.processor
30
+
31
+ def validate_video(self, filename: str) -> dict:
32
+ """Validate video file"""
33
+ from typing import List
34
+ allowed_extensions: List[str] = [".mp4", ".avi", ".mov", ".mkv", ".webm"]
35
+ return validate_file_extension(filename, allowed_extensions)
36
+
37
+ def save_upload(self, file, session_id: str, filename: str) -> Path:
38
+ """Save uploaded file"""
39
+ upload_path = Config.UPLOAD_FOLDER / f"{session_id}_{filename}"
40
+
41
+ with open(upload_path, "wb") as buffer:
42
+ shutil.copyfileobj(file.file, buffer)
43
+
44
+ logger.info(f"Saved upload: {upload_path.name}")
45
+ return upload_path
46
+
47
+ def load_video_info(self, upload_path: Path) -> dict:
48
+ """Load video metadata"""
49
+ processor = self.get_processor()
50
+ video_info = processor.load_video(upload_path)
51
+
52
+ return {
53
+ "filename": upload_path.name,
54
+ "size": format_file_size(video_info["size_bytes"]),
55
+ "duration": f"{video_info['duration']:.1f}s",
56
+ "resolution": f"{video_info['width']}x{video_info['height']}",
57
+ "fps": video_info["fps"],
58
+ "frame_count": video_info["frame_count"]
59
+ }
60
+
61
+ def cleanup_session_files(self, session: dict):
62
+ """Delete session files"""
63
+ try:
64
+ if "upload_path" in session:
65
+ Path(session["upload_path"]).unlink(missing_ok=True)
66
+ if "output_path" in session:
67
+ Path(session["output_path"]).unlink(missing_ok=True)
68
+ if "results_path" in session:
69
+ Path(session["results_path"]).unlink(missing_ok=True)
70
+ except Exception as e:
71
+ logger.error(f"Error deleting files: {e}")
72
+
73
+
74
+ # Global video service instance
75
+ video_service = VideoService()
backend/app/tasks.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Celery background tasks
3
+ """
4
+
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+ import json
8
+ import logging
9
+
10
+ from app.celery_app import celery_app
11
+ from app.config import Config
12
+ from app.core.video_processor import VideoProcessor
13
+ from app.services.session_manager import session_manager
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def convert_to_native_bool(obj):
19
+ """Convert numpy types to native Python types"""
20
+ import numpy as np
21
+ if isinstance(obj, np.bool_):
22
+ return bool(obj)
23
+ elif isinstance(obj, (np.integer, np.floating)):
24
+ return obj.item()
25
+ elif isinstance(obj, dict):
26
+ return {k: convert_to_native_bool(v) for k, v in obj.items()}
27
+ elif isinstance(obj, (list, tuple)):
28
+ return [convert_to_native_bool(v) for v in obj]
29
+ else:
30
+ return obj
31
+
32
+
33
+ @celery_app.task(bind=True, name='process_video_task')
34
+ def process_video_task(self, session_id: str):
35
+ """
36
+ Celery task for video processing
37
+
38
+ Args:
39
+ self: Celery task instance
40
+ session_id: Session ID to process
41
+ """
42
+ try:
43
+ logger.info(f"🎬 Starting processing for session: {session_id}")
44
+
45
+ # Get session (need to recreate session_manager in worker)
46
+ # In production, you'd fetch from Redis/DB
47
+ session = session_manager.get_session(session_id)
48
+ if not session:
49
+ raise ValueError(f"Session not found: {session_id}")
50
+
51
+ input_path = Path(session["upload_path"])
52
+ output_path = Config.OUTPUT_FOLDER / f"analyzed_{session_id}.mp4"
53
+ results_path = Config.OUTPUT_FOLDER / f"results_{session_id}.json"
54
+
55
+ # Update session status
56
+ session_manager.update_session(session_id, {
57
+ "status": "processing",
58
+ "celery_task_id": self.request.id
59
+ })
60
+
61
+ # Create processor
62
+ processor = VideoProcessor()
63
+
64
+ # Progress callback (updates Celery task state)
65
+ def progress_callback(progress: float, message: str):
66
+ self.update_state(
67
+ state='PROGRESS',
68
+ meta={
69
+ 'progress': progress,
70
+ 'message': message,
71
+ 'session_id': session_id
72
+ }
73
+ )
74
+ logger.info(f"Progress {session_id}: {progress*100:.1f}% - {message}")
75
+
76
+ # Process video
77
+ logger.info(f"Processing video: {input_path}")
78
+ raw_results = processor.process_video(
79
+ video_path=input_path,
80
+ output_path=output_path,
81
+ progress_callback=progress_callback
82
+ )
83
+
84
+ # Convert results
85
+ results = convert_to_native_bool(raw_results)
86
+
87
+ # Save JSON
88
+ with open(results_path, 'w') as f:
89
+ json.dump(results, f, indent=2, default=str)
90
+
91
+ # Update session
92
+ session_manager.update_session(session_id, {
93
+ "status": "completed",
94
+ "output_path": str(output_path),
95
+ "results_path": str(results_path),
96
+ "end_time": datetime.now().isoformat(),
97
+ "results": results
98
+ })
99
+
100
+ logger.info(f"✅ Processing completed: {session_id}")
101
+
102
+ return {
103
+ "status": "completed",
104
+ "session_id": session_id,
105
+ "results": results,
106
+ "output_path": str(output_path)
107
+ }
108
+
109
+ except Exception as e:
110
+ logger.error(f"❌ Processing failed for {session_id}: {str(e)}")
111
+
112
+ session_manager.update_session(session_id, {
113
+ "status": "failed",
114
+ "error": str(e)
115
+ })
116
+
117
+ raise # Re-raise for Celery to mark as failed
backend/app/utils/__init__.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utility functions
3
+ """
4
+
5
+ from .file_utils import (
6
+ generate_session_id,
7
+ validate_file_extension,
8
+ validate_file_size,
9
+ format_file_size,
10
+ cleanup_old_files
11
+ )
12
+
13
+ from .validation import (
14
+ safe_divide,
15
+ calculate_percentage
16
+ )
17
+
18
+ from .helpers import (
19
+ timing_decorator,
20
+ create_success_response,
21
+ create_error_response
22
+ )
23
+
24
+ __all__ = [
25
+ # File utilities
26
+ 'generate_session_id',
27
+ 'validate_file_extension',
28
+ 'validate_file_size',
29
+ 'format_file_size',
30
+ 'cleanup_old_files',
31
+
32
+ # Validation
33
+ 'safe_divide',
34
+ 'calculate_percentage',
35
+
36
+ # Helpers
37
+ 'timing_decorator',
38
+ 'create_success_response',
39
+ 'create_error_response'
40
+ ]
backend/app/{utils.py → utils/file_utils.py} RENAMED
@@ -1,31 +1,18 @@
1
  """
2
- Utility functions for Dance Movement Analyzer
3
- Provides helper functions for validation, logging, and common operations
4
  """
5
 
6
  import os
7
  import uuid
8
- import time
9
- import logging
10
  from pathlib import Path
11
- from typing import Optional, Dict, Any
12
- from functools import wraps
13
- from typing import List
14
-
15
- # Configure logging
16
- logging.basicConfig(
17
- level=logging.INFO,
18
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
19
- )
20
- logger = logging.getLogger(__name__)
21
-
22
 
23
  def generate_session_id() -> str:
24
  """Generate unique session ID for tracking"""
25
  return str(uuid.uuid4())
26
 
27
 
28
- def validate_file_extension(filename: str, allowed_extensions: List[str] = [".mp4", ".avi", ".mov", ".mkv", ".webm"]) -> bool:
29
  """
30
  Validate if file has allowed extension
31
 
@@ -34,9 +21,8 @@ def validate_file_extension(filename: str, allowed_extensions: List[str] = [".mp
34
  allowed_extensions: List of allowed extensions (e.g., ['.mp4', '.avi'])
35
 
36
  Returns:
37
- True if valid, False otherwise
38
  """
39
-
40
  ext = Path(filename).suffix.lower()
41
  if ext in allowed_extensions:
42
  return {"valid": True, "error": ""}
@@ -77,84 +63,19 @@ def format_file_size(size_bytes: int) -> str:
77
  return f"{size_bytes:.1f} TB"
78
 
79
 
80
- def timing_decorator(func):
81
- """
82
- Decorator to measure function execution time
83
- Useful for performance monitoring
84
- """
85
- @wraps(func)
86
- def wrapper(*args, **kwargs):
87
- start_time = time.time()
88
- result = func(*args, **kwargs)
89
- end_time = time.time()
90
- execution_time = end_time - start_time
91
- logger.info(f"{func.__name__} executed in {execution_time:.2f} seconds")
92
- return result
93
- return wrapper
94
-
95
-
96
- def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
97
- """
98
- Safely divide two numbers, returning default if denominator is zero
99
-
100
- Args:
101
- numerator: Number to divide
102
- denominator: Number to divide by
103
- default: Default value if division by zero
104
-
105
- Returns:
106
- Result of division or default value
107
- """
108
- return numerator / denominator if denominator != 0 else default
109
-
110
-
111
- def create_success_response(data: Any, message: str = "Success") -> Dict[str, Any]:
112
- """
113
- Create standardized success response
114
-
115
- Args:
116
- data: Response data
117
- message: Success message
118
-
119
- Returns:
120
- Formatted response dictionary
121
- """
122
- return {
123
- "status": "success",
124
- "message": message,
125
- "data": data
126
- }
127
-
128
-
129
- def create_error_response(error: str, details: Optional[str] = None) -> Dict[str, Any]:
130
- """
131
- Create standardized error response
132
-
133
- Args:
134
- error: Error message
135
- details: Additional error details
136
-
137
- Returns:
138
- Formatted error dictionary
139
- """
140
- response = {
141
- "status": "error",
142
- "error": error
143
- }
144
- if details:
145
- response["details"] = details
146
- return response
147
-
148
-
149
  def cleanup_old_files(directory: Path, max_age_hours: int = 24):
150
  """
151
  Clean up files older than specified hours
152
- Useful for managing temporary upload/output files
153
 
154
  Args:
155
  directory: Directory to clean
156
  max_age_hours: Maximum file age in hours
157
  """
 
 
 
 
 
158
  if not directory.exists():
159
  return
160
 
@@ -169,18 +90,4 @@ def cleanup_old_files(directory: Path, max_age_hours: int = 24):
169
  file_path.unlink()
170
  logger.info(f"Deleted old file: {file_path.name}")
171
  except Exception as e:
172
- logger.error(f"Error deleting {file_path.name}: {e}")
173
-
174
-
175
- def calculate_percentage(part: float, whole: float) -> float:
176
- """
177
- Calculate percentage with safe division
178
-
179
- Args:
180
- part: Part value
181
- whole: Whole value
182
-
183
- Returns:
184
- Percentage (0-100)
185
- """
186
- return safe_divide(part * 100, whole, 0.0)
 
1
  """
2
+ File handling utilities
 
3
  """
4
 
5
  import os
6
  import uuid
 
 
7
  from pathlib import Path
8
+ from typing import Dict, List
 
 
 
 
 
 
 
 
 
 
9
 
10
  def generate_session_id() -> str:
11
  """Generate unique session ID for tracking"""
12
  return str(uuid.uuid4())
13
 
14
 
15
+ def validate_file_extension(filename: str, allowed_extensions: List[str] = [".mp4", ".avi", ".mov", ".mkv", ".webm"]) -> Dict:
16
  """
17
  Validate if file has allowed extension
18
 
 
21
  allowed_extensions: List of allowed extensions (e.g., ['.mp4', '.avi'])
22
 
23
  Returns:
24
+ Dict with valid status and error message
25
  """
 
26
  ext = Path(filename).suffix.lower()
27
  if ext in allowed_extensions:
28
  return {"valid": True, "error": ""}
 
63
  return f"{size_bytes:.1f} TB"
64
 
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  def cleanup_old_files(directory: Path, max_age_hours: int = 24):
67
  """
68
  Clean up files older than specified hours
 
69
 
70
  Args:
71
  directory: Directory to clean
72
  max_age_hours: Maximum file age in hours
73
  """
74
+ import time
75
+ import logging
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
  if not directory.exists():
80
  return
81
 
 
90
  file_path.unlink()
91
  logger.info(f"Deleted old file: {file_path.name}")
92
  except Exception as e:
93
+ logger.error(f"Error deleting {file_path.name}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/utils/helpers.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ General helper functions
3
+ """
4
+
5
+ import time
6
+ import logging
7
+ from functools import wraps
8
+ from typing import Any, Dict, Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def timing_decorator(func):
14
+ """
15
+ Decorator to measure function execution time
16
+ """
17
+ @wraps(func)
18
+ def wrapper(*args, **kwargs):
19
+ start_time = time.time()
20
+ result = func(*args, **kwargs)
21
+ end_time = time.time()
22
+ execution_time = end_time - start_time
23
+ logger.info(f"{func.__name__} executed in {execution_time:.2f} seconds")
24
+ return result
25
+ return wrapper
26
+
27
+
28
+ def create_success_response(data: Any, message: str = "Success") -> Dict[str, Any]:
29
+ """
30
+ Create standardized success response
31
+ """
32
+ return {
33
+ "status": "success",
34
+ "message": message,
35
+ "data": data
36
+ }
37
+
38
+
39
+ def create_error_response(error: str, details: Optional[str] = None) -> Dict[str, Any]:
40
+ """
41
+ Create standardized error response
42
+ """
43
+ response = {
44
+ "status": "error",
45
+ "error": error
46
+ }
47
+ if details:
48
+ response["details"] = details
49
+ return response
backend/app/utils/throttle.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Progress throttling utility
3
+ """
4
+
5
+ import time
6
+ from typing import Callable, Optional
7
+
8
+
9
+ class ProgressThrottler:
10
+ """Throttles progress updates to reduce message frequency"""
11
+
12
+ def __init__(
13
+ self,
14
+ callback: Callable,
15
+ min_interval: float = 0.5, # Minimum 0.5s between updates
16
+ key_milestones: list = [0.0, 0.3, 0.5, 0.7, 0.9, 1.0]
17
+ ):
18
+ """
19
+ Args:
20
+ callback: Function to call with (progress, message)
21
+ min_interval: Minimum seconds between updates
22
+ key_milestones: Always send these progress values
23
+ """
24
+ self.callback = callback
25
+ self.min_interval = min_interval
26
+ self.key_milestones = set(key_milestones)
27
+ self.last_update_time = 0
28
+ self.last_progress = -1
29
+
30
+ async def update(self, progress: float, message: str):
31
+ """
32
+ Send progress update only if:
33
+ 1. It's a key milestone (0%, 30%, 50%, 70%, 90%, 100%), OR
34
+ 2. Enough time has passed since last update
35
+ """
36
+ current_time = time.time()
37
+
38
+ # Round progress to 2 decimals for milestone comparison
39
+ progress_rounded = round(progress, 2)
40
+
41
+ # Always send key milestones
42
+ if progress_rounded in self.key_milestones:
43
+ await self.callback(progress, message)
44
+ self.last_update_time = current_time
45
+ self.last_progress = progress
46
+ return
47
+
48
+ # Send if enough time passed
49
+ if current_time - self.last_update_time >= self.min_interval:
50
+ await self.callback(progress, message)
51
+ self.last_update_time = current_time
52
+ self.last_progress = progress
backend/app/utils/validation.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data validation utilities
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+
8
+ def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
9
+ """
10
+ Safely divide two numbers, returning default if denominator is zero
11
+
12
+ Args:
13
+ numerator: Number to divide
14
+ denominator: Number to divide by
15
+ default: Default value if division by zero
16
+
17
+ Returns:
18
+ Result of division or default value
19
+ """
20
+ return numerator / denominator if denominator != 0 else default
21
+
22
+
23
+ def calculate_percentage(part: float, whole: float) -> float:
24
+ """
25
+ Calculate percentage with safe division
26
+
27
+ Args:
28
+ part: Part value
29
+ whole: Whole value
30
+
31
+ Returns:
32
+ Percentage (0-100)
33
+ """
34
+ return safe_divide(part * 100, whole, 0.0)
backend/requirements.txt CHANGED
@@ -28,4 +28,10 @@ websockets==12.0
28
  python-jose==3.3.0
29
 
30
  # Templating Support
31
- jinja2==3.1.2 # Add this line
 
 
 
 
 
 
 
28
  python-jose==3.3.0
29
 
30
  # Templating Support
31
+ jinja2==3.1.2
32
+
33
+ # Celery and Redis
34
+ celery==5.5.3
35
+ redis==7.0.
36
+
37
+ flower==2.0.1 # Optional: Celery monitoring UI
docker_compose.yml CHANGED
@@ -1,6 +1,19 @@
1
  version: '3.8'
2
 
3
  services:
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  dance-analyzer:
5
  build:
6
  context: .
@@ -9,35 +22,59 @@ services:
9
  ports:
10
  - "7860:7860"
11
  volumes:
12
- # Mount for persistent storage
13
  - ./uploads:/app/uploads
14
  - ./outputs:/app/outputs
15
  - ./logs:/app/logs
16
  environment:
17
- # API Configuration
18
  - API_HOST=0.0.0.0
19
  - API_PORT=7860
20
  - DEBUG=false
21
-
22
- # File Limits
23
  - MAX_FILE_SIZE=104857600
24
  - MAX_VIDEO_DURATION=60
25
-
26
- # MediaPipe Settings
27
  - MEDIAPIPE_MODEL_COMPLEXITY=1
28
- - MEDIAPIPE_MIN_DETECTION_CONFIDENCE=0.5
29
- - MEDIAPIPE_MIN_TRACKING_CONFIDENCE=0.5
30
- - MEDIAPIPE_SMOOTH_LANDMARKS=true
31
-
32
- # Processing Settings
33
  - MAX_WORKERS=2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  restart: unless-stopped
35
- healthcheck:
36
- test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:7860/health')"]
37
- interval: 30s
38
- timeout: 10s
39
- retries: 3
40
- start_period: 40s
41
  networks:
42
  - dance-analyzer-network
43
 
@@ -46,6 +83,7 @@ networks:
46
  driver: bridge
47
 
48
  volumes:
 
49
  uploads:
50
  outputs:
51
- logs:
 
1
  version: '3.8'
2
 
3
  services:
4
+ # Redis for Celery broker
5
+ redis:
6
+ image: redis:7-alpine
7
+ container_name: dance-analyzer-redis
8
+ ports:
9
+ - "6379:6379"
10
+ volumes:
11
+ - redis_data:/data
12
+ restart: unless-stopped
13
+ networks:
14
+ - dance-analyzer-network
15
+
16
+ # Main web application
17
  dance-analyzer:
18
  build:
19
  context: .
 
22
  ports:
23
  - "7860:7860"
24
  volumes:
 
25
  - ./uploads:/app/uploads
26
  - ./outputs:/app/outputs
27
  - ./logs:/app/logs
28
  environment:
 
29
  - API_HOST=0.0.0.0
30
  - API_PORT=7860
31
  - DEBUG=false
32
+ - REDIS_URL=redis://redis:6379/0
 
33
  - MAX_FILE_SIZE=104857600
34
  - MAX_VIDEO_DURATION=60
 
 
35
  - MEDIAPIPE_MODEL_COMPLEXITY=1
 
 
 
 
 
36
  - MAX_WORKERS=2
37
+ depends_on:
38
+ - redis
39
+ restart: unless-stopped
40
+ networks:
41
+ - dance-analyzer-network
42
+
43
+ # Celery worker
44
+ celery-worker:
45
+ build:
46
+ context: .
47
+ dockerfile: Dockerfile
48
+ container_name: dance-analyzer-worker
49
+ command: celery -A app.celery_app worker --loglevel=info --concurrency=2
50
+ volumes:
51
+ - ./uploads:/app/uploads
52
+ - ./outputs:/app/outputs
53
+ - ./logs:/app/logs
54
+ environment:
55
+ - REDIS_URL=redis://redis:6379/0
56
+ - MEDIAPIPE_MODEL_COMPLEXITY=1
57
+ depends_on:
58
+ - redis
59
+ restart: unless-stopped
60
+ networks:
61
+ - dance-analyzer-network
62
+
63
+ # Flower (Celery monitoring UI) - Optional
64
+ flower:
65
+ build:
66
+ context: .
67
+ dockerfile: Dockerfile
68
+ container_name: dance-analyzer-flower
69
+ command: celery -A app.celery_app flower --port=5555
70
+ ports:
71
+ - "5555:5555"
72
+ environment:
73
+ - REDIS_URL=redis://redis:6379/0
74
+ depends_on:
75
+ - redis
76
+ - celery-worker
77
  restart: unless-stopped
 
 
 
 
 
 
78
  networks:
79
  - dance-analyzer-network
80
 
 
83
  driver: bridge
84
 
85
  volumes:
86
+ redis_data:
87
  uploads:
88
  outputs:
89
+ logs:
frontend/index.html CHANGED
@@ -79,6 +79,10 @@
79
  <span class="stat-label">Elapsed:</span>
80
  <span class="stat-value" id="elapsedTime">0s</span>
81
  </div>
 
 
 
 
82
  </div>
83
  </div>
84
  </section>
@@ -235,9 +239,6 @@
235
  <div class="toast" id="toast"></div>
236
 
237
  <!-- Scripts -->
238
- <script src="/static/js/visualization.js"></script>
239
- <script src="/static/js/video-handler.js"></script>
240
- <script src="/static/js/websocket-client.js"></script>
241
- <script src="/static/js/app.js"></script>
242
  </body>
243
  </html>
 
79
  <span class="stat-label">Elapsed:</span>
80
  <span class="stat-value" id="elapsedTime">0s</span>
81
  </div>
82
+ <div class="stat-item">
83
+ <span class="stat-label">⏱️ ETA:</span>
84
+ <span class="stat-value" id="etaTime">--</span>
85
+ </div>
86
  </div>
87
  </div>
88
  </section>
 
239
  <div class="toast" id="toast"></div>
240
 
241
  <!-- Scripts -->
242
+ <script type="module" src="/static/js/main.js"></script>
 
 
 
243
  </body>
244
  </html>
frontend/js/app.js DELETED
@@ -1,617 +0,0 @@
1
- /**
2
- * Main Application Logic
3
- * Handles UI state, file uploads, and result display
4
- */
5
-
6
- const API_BASE_URL = window.location.origin;
7
-
8
- // Application State
9
- const AppState = {
10
- sessionId: null,
11
- uploadedFile: null,
12
- videoInfo: null,
13
- results: null,
14
- ws: null,
15
- startTime: null
16
- };
17
-
18
- // DOM Elements
19
- const elements = {
20
- uploadZone: document.getElementById('uploadZone'),
21
- fileInput: document.getElementById('fileInput'),
22
- fileInfo: document.getElementById('fileInfo'),
23
- fileName: document.getElementById('fileName'),
24
- fileMeta: document.getElementById('fileMeta'),
25
- analyzeBtn: document.getElementById('analyzeBtn'),
26
-
27
- uploadSection: document.getElementById('uploadSection'),
28
- processingSection: document.getElementById('processingSection'),
29
- resultsSection: document.getElementById('resultsSection'),
30
-
31
- progressFill: document.getElementById('progressFill'),
32
- progressText: document.getElementById('progressText'),
33
- processingMessage: document.getElementById('processingMessage'),
34
- statusValue: document.getElementById('statusValue'),
35
- elapsedTime: document.getElementById('elapsedTime'),
36
-
37
- originalVideo: document.getElementById('originalVideo'),
38
- analyzedVideo: document.getElementById('analyzedVideo'),
39
- videoFallback: document.getElementById('videoFallback'),
40
- downloadBtn: document.getElementById('downloadBtn'),
41
-
42
- movementType: document.getElementById('movementType'),
43
- intensityValue: document.getElementById('intensityValue'),
44
- intensityFill: document.getElementById('intensityFill'),
45
- detectionRate: document.getElementById('detectionRate'),
46
- framesDetected: document.getElementById('framesDetected'),
47
- totalFrames: document.getElementById('totalFrames'),
48
- confidenceScore: document.getElementById('confidenceScore'),
49
- smoothnessScore: document.getElementById('smoothnessScore'),
50
-
51
- bodyParts: document.getElementById('bodyParts'),
52
- rhythmCard: document.getElementById('rhythmCard'),
53
- bpmValue: document.getElementById('bpmValue'),
54
- consistencyValue: document.getElementById('consistencyValue'),
55
-
56
- newAnalysisBtn: document.getElementById('newAnalysisBtn'),
57
- shareBtn: document.getElementById('shareBtn'),
58
- toast: document.getElementById('toast')
59
- };
60
-
61
- // Initialize Application
62
- function initApp() {
63
- setupEventListeners();
64
- checkBrowserCompatibility();
65
- showToast('Ready to analyze dance videos!', 'info');
66
- }
67
-
68
- // Setup Event Listeners
69
- function setupEventListeners() {
70
- // Upload zone events
71
- elements.uploadZone.addEventListener('click', () => elements.fileInput.click());
72
- elements.uploadZone.addEventListener('dragover', handleDragOver);
73
- elements.uploadZone.addEventListener('dragleave', handleDragLeave);
74
- elements.uploadZone.addEventListener('drop', handleDrop);
75
-
76
- // File input change
77
- elements.fileInput.addEventListener('change', handleFileSelect);
78
-
79
- // Analyze button
80
- elements.analyzeBtn.addEventListener('click', startAnalysis);
81
-
82
- // Download button
83
- // elements.downloadBtn.addEventListener('click', downloadVideo);
84
-
85
- // New analysis button
86
- elements.newAnalysisBtn.addEventListener('click', resetApp);
87
-
88
- // Share button
89
- elements.shareBtn.addEventListener('click', shareResults);
90
- }
91
-
92
- // File Upload Handlers
93
- function handleDragOver(e) {
94
- e.preventDefault();
95
- elements.uploadZone.classList.add('drag-over');
96
- }
97
-
98
- function handleDragLeave(e) {
99
- e.preventDefault();
100
- elements.uploadZone.classList.remove('drag-over');
101
- }
102
-
103
- function handleDrop(e) {
104
- e.preventDefault();
105
- elements.uploadZone.classList.remove('drag-over');
106
-
107
- const files = e.dataTransfer.files;
108
- if (files.length > 0) {
109
- handleFile(files[0]);
110
- }
111
- }
112
-
113
- function handleFileSelect(e) {
114
- const files = e.target.files;
115
- if (files.length > 0) {
116
- handleFile(files[0]);
117
- }
118
- }
119
-
120
- // Validate and Handle File
121
- async function handleFile(file) {
122
- // Validate file type
123
- const validTypes = ['video/mp4', 'video/webm', 'video/avi'];
124
- if (!validTypes.includes(file.type)) {
125
- showToast('Please upload a valid video file (MP4, WebM, AVI)', 'error');
126
- return;
127
- }
128
-
129
- // Validate file size (100MB)
130
- const maxSize = 100 * 1024 * 1024;
131
- if (file.size > maxSize) {
132
- showToast('File size exceeds 100MB limit', 'error');
133
- return;
134
- }
135
-
136
- AppState.uploadedFile = file;
137
-
138
- // Display file info
139
- elements.fileName.textContent = file.name;
140
- elements.fileMeta.textContent = `${formatFileSize(file.size)} • ${file.type}`;
141
- elements.fileInfo.style.display = 'flex';
142
-
143
- // Upload file to server
144
- await uploadFile(file);
145
- }
146
-
147
- // Upload File to Server
148
- async function uploadFile(file) {
149
- try {
150
- elements.analyzeBtn.disabled = true;
151
- elements.analyzeBtn.textContent = '⏳ Uploading...';
152
-
153
- const formData = new FormData();
154
- formData.append('file', file);
155
-
156
- const response = await fetch(`${API_BASE_URL}/api/upload`, {
157
- method: 'POST',
158
- body: formData
159
- });
160
-
161
- if (!response.ok) {
162
- throw new Error('Upload failed');
163
- }
164
-
165
- const data = await response.json();
166
-
167
- AppState.sessionId = data.session_id;
168
- AppState.videoInfo = data;
169
-
170
- // Create object URL for original video preview
171
- const videoURL = URL.createObjectURL(file);
172
- elements.originalVideo.src = videoURL;
173
-
174
- elements.analyzeBtn.disabled = false;
175
- elements.analyzeBtn.textContent = '✨ Start Analysis';
176
-
177
- showToast('Video uploaded successfully!', 'success');
178
-
179
- } catch (error) {
180
- console.error('Upload error:', error);
181
- showToast('Failed to upload video. Please try again.', 'error');
182
- elements.analyzeBtn.disabled = false;
183
- elements.analyzeBtn.textContent = '✨ Start Analysis';
184
- }
185
- }
186
-
187
- // Start Analysis
188
- async function startAnalysis() {
189
- if (!AppState.sessionId) {
190
- showToast('Please upload a video first', 'error');
191
- return;
192
- }
193
-
194
- try {
195
- // Show processing section
196
- elements.uploadSection.style.display = 'none';
197
- elements.processingSection.style.display = 'block';
198
-
199
- // Initialize WebSocket
200
- initWebSocket(AppState.sessionId);
201
-
202
- // Start analysis
203
- const response = await fetch(`${API_BASE_URL}/api/analyze/${AppState.sessionId}`, {
204
- method: 'POST'
205
- });
206
-
207
- if (!response.ok) {
208
- throw new Error('Analysis failed to start');
209
- }
210
-
211
- const data = await response.json();
212
-
213
- AppState.startTime = Date.now();
214
- startElapsedTimer();
215
-
216
- showToast('Analysis started!', 'info');
217
-
218
- } catch (error) {
219
- console.error('Analysis error:', error);
220
- showToast('Failed to start analysis. Please try again.', 'error');
221
- elements.uploadSection.style.display = 'block';
222
- elements.processingSection.style.display = 'none';
223
- }
224
- }
225
-
226
- // Initialize WebSocket
227
- function initWebSocket(sessionId) {
228
- const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
229
- const wsUrl = `${wsProtocol}//${window.location.host}/ws/${sessionId}`;
230
-
231
- AppState.ws = new WebSocket(wsUrl);
232
-
233
- AppState.ws.onopen = () => {
234
- console.log('WebSocket connected');
235
- };
236
-
237
- AppState.ws.onmessage = (event) => {
238
- const message = JSON.parse(event.data);
239
- handleWebSocketMessage(message);
240
- };
241
-
242
- AppState.ws.onerror = (error) => {
243
- console.error('WebSocket error:', error);
244
- };
245
-
246
- AppState.ws.onclose = () => {
247
- console.log('WebSocket closed');
248
- };
249
-
250
- // Send heartbeat every 20 seconds
251
- setInterval(() => {
252
- if (AppState.ws && AppState.ws.readyState === WebSocket.OPEN) {
253
- AppState.ws.send('ping');
254
- }
255
- }, 20000);
256
- }
257
-
258
- // Handle WebSocket Messages
259
- function handleWebSocketMessage(message) {
260
- switch (message.type) {
261
- case 'connected':
262
- console.log('WebSocket ready');
263
- break;
264
-
265
- case 'progress':
266
- updateProgress(message.progress, message.message);
267
- break;
268
-
269
- case 'status':
270
- elements.statusValue.textContent = message.status;
271
- elements.processingMessage.textContent = message.message;
272
- break;
273
-
274
- case 'complete':
275
- handleAnalysisComplete(message);
276
- break;
277
-
278
- case 'error':
279
- handleAnalysisError(message);
280
- break;
281
-
282
- case 'pong':
283
- // Heartbeat response
284
- break;
285
- }
286
- }
287
-
288
- // Update Progress
289
- function updateProgress(progress, message) {
290
- const percentage = Math.round(progress * 100);
291
- elements.progressFill.style.width = `${percentage}%`;
292
- elements.progressText.textContent = `${percentage}%`;
293
- elements.processingMessage.textContent = message;
294
- }
295
-
296
- // Start Elapsed Timer
297
- function startElapsedTimer() {
298
- const timer = setInterval(() => {
299
- if (AppState.startTime) {
300
- const elapsed = Math.floor((Date.now() - AppState.startTime) / 1000);
301
- elements.elapsedTime.textContent = `${elapsed}s`;
302
- } else {
303
- clearInterval(timer);
304
- }
305
- }, 1000);
306
- }
307
-
308
- // Handle Analysis Complete
309
- async function handleAnalysisComplete(message) {
310
- AppState.startTime = null;
311
-
312
- // Fetch complete results from API
313
- try {
314
- const response = await fetch(`${API_BASE_URL}/api/results/${AppState.sessionId}`);
315
-
316
- if (!response.ok) {
317
- throw new Error('Failed to fetch results');
318
- }
319
-
320
- const data = await response.json();
321
- AppState.results = data.results || message.results;
322
-
323
- } catch (error) {
324
- console.error('Error fetching results:', error);
325
- AppState.results = message.results;
326
- }
327
-
328
- // Hide processing, show results
329
- elements.processingSection.style.display = 'none';
330
- elements.resultsSection.style.display = 'block';
331
-
332
- // Load analyzed video with proper error handling
333
- const videoUrl = `${API_BASE_URL}/api/download/${AppState.sessionId}`;
334
-
335
- // Set up error handler BEFORE setting src
336
- elements.analyzedVideo.onerror = (e) => {
337
- console.error("Analyzed video failed to load:", e);
338
- console.log("Video URL:", videoUrl);
339
- elements.videoFallback.style.display = 'block';
340
- elements.analyzedVideo.style.display = 'none';
341
- document.getElementById('downloadFallback').href = videoUrl;
342
- document.getElementById('downloadFallback').download = `analyzed_${AppState.uploadedFile?.name || 'video.mp4'}`;
343
- };
344
-
345
- // Set up success handler
346
- elements.analyzedVideo.onloadedmetadata = () => {
347
- console.log("✅ Analyzed video loaded successfully");
348
- console.log("Video duration:", elements.analyzedVideo.duration);
349
- console.log("Video dimensions:", elements.analyzedVideo.videoWidth, 'x', elements.analyzedVideo.videoHeight);
350
- elements.videoFallback.style.display = 'none';
351
- elements.analyzedVideo.style.display = 'block';
352
- };
353
-
354
- // Set video source
355
- console.log("Loading analyzed video from:", videoUrl);
356
- elements.analyzedVideo.src = videoUrl;
357
- elements.analyzedVideo.load(); // Force reload
358
-
359
- // Display results
360
- displayResults(AppState.results);
361
-
362
- // Setup video sync
363
- setupVideoSync();
364
-
365
- // Close WebSocket
366
- if (AppState.ws) {
367
- AppState.ws.close();
368
- AppState.ws = null;
369
- }
370
-
371
- // Scroll to results
372
- elements.resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
373
-
374
- showToast('Analysis complete! 🎉', 'success');
375
- }
376
-
377
- // Handle Analysis Error
378
- function handleAnalysisError(message) {
379
- showToast(message.message, 'error');
380
- elements.uploadSection.style.display = 'block';
381
- elements.processingSection.style.display = 'none';
382
- AppState.startTime = null;
383
- }
384
-
385
- // Display Results
386
- function displayResults(results) {
387
- console.log('Displaying results:', results);
388
-
389
- // Ensure results object exists
390
- if (!results) {
391
- console.error('No results to display');
392
- showToast('Error: No results available', 'error');
393
- return;
394
- }
395
-
396
- // Movement Classification
397
- const movement = results.movement_analysis;
398
- if (movement) {
399
- elements.movementType.textContent = movement.movement_type || 'Unknown';
400
- const intensity = Math.round(movement.intensity || 0);
401
- elements.intensityValue.textContent = intensity;
402
- elements.intensityFill.style.width = `${intensity}%`;
403
- } else {
404
- elements.movementType.textContent = 'N/A';
405
- elements.intensityValue.textContent = '0';
406
- elements.intensityFill.style.width = '0%';
407
- }
408
-
409
- // Detection Stats
410
- const processing = results.processing;
411
- if (processing) {
412
- const detectionRate = ((processing.detection_rate || 0) * 100).toFixed(1);
413
- elements.detectionRate.textContent = `${detectionRate}%`;
414
- elements.framesDetected.textContent = processing.frames_with_pose || 0;
415
- elements.totalFrames.textContent = processing.total_frames || 0;
416
- } else {
417
- elements.detectionRate.textContent = 'N/A';
418
- elements.framesDetected.textContent = '0';
419
- elements.totalFrames.textContent = '0';
420
- }
421
-
422
- // Confidence
423
- const poseAnalysis = results.pose_analysis;
424
- if (poseAnalysis) {
425
- const confidence = (poseAnalysis.average_confidence || 0).toFixed(2);
426
- elements.confidenceScore.textContent = confidence;
427
- } else {
428
- elements.confidenceScore.textContent = 'N/A';
429
- }
430
-
431
- // Smoothness
432
- const smoothness = Math.round(results.smoothness_score || 0);
433
- elements.smoothnessScore.textContent = smoothness;
434
-
435
- // Body Parts
436
- if (movement && movement.body_part_activity) {
437
- displayBodyParts(movement.body_part_activity);
438
- } else {
439
- elements.bodyParts.innerHTML = '<p style="text-align: center; color: var(--text-muted);">No body part data available</p>';
440
- }
441
-
442
- // Rhythm
443
- const rhythm = results.rhythm_analysis;
444
- if (rhythm && rhythm.has_rhythm) {
445
- elements.rhythmCard.style.display = 'block';
446
- elements.bpmValue.textContent = Math.round(rhythm.estimated_bpm || 0);
447
- elements.consistencyValue.textContent = `${Math.round((rhythm.rhythm_consistency || 0) * 100)}%`;
448
- } else {
449
- elements.rhythmCard.style.display = 'none';
450
- }
451
-
452
- console.log('Results displayed successfully');
453
- }
454
-
455
- // Setup Video Synchronization
456
- function setupVideoSync() {
457
- if (!elements.originalVideo || !elements.analyzedVideo) {
458
- return;
459
- }
460
-
461
- // Initialize video handler
462
- if (window.videoHandler) {
463
- videoHandler.init('originalVideo', 'analyzedVideo');
464
- }
465
-
466
- // Ensure both videos are ready
467
- elements.analyzedVideo.addEventListener('loadeddata', () => {
468
- console.log('Analyzed video loaded and ready');
469
- // Auto-play both videos when analyzed video is loaded
470
- if (elements.originalVideo.readyState >= 3) {
471
- // Both videos ready, can play
472
- console.log('Both videos ready for playback');
473
- }
474
- });
475
-
476
- elements.analyzedVideo.onerror = () => {
477
- console.warn("Analyzed video failed to load — showing fallback");
478
- elements.videoFallback.style.display = 'block';
479
- document.getElementById('downloadFallback').href = `${API_BASE_URL}/api/download/${AppState.sessionId}`;
480
- };
481
-
482
- elements.originalVideo.addEventListener('loadeddata', () => {
483
- console.log('Original video loaded and ready');
484
- });
485
- }
486
-
487
- // Display Body Parts Activity
488
- function displayBodyParts(bodyParts) {
489
- elements.bodyParts.innerHTML = '';
490
-
491
- for (const [part, activity] of Object.entries(bodyParts)) {
492
- const item = document.createElement('div');
493
- item.className = 'body-part-item';
494
-
495
- const name = document.createElement('div');
496
- name.className = 'body-part-name';
497
- name.textContent = part.replace('_', ' ');
498
-
499
- const bar = document.createElement('div');
500
- bar.className = 'body-part-bar';
501
-
502
- const fill = document.createElement('div');
503
- fill.className = 'body-part-fill';
504
- fill.style.width = `${activity}%`;
505
- fill.textContent = `${Math.round(activity)}`;
506
-
507
- bar.appendChild(fill);
508
- item.appendChild(name);
509
- item.appendChild(bar);
510
- elements.bodyParts.appendChild(item);
511
- }
512
- }
513
-
514
- /*
515
- // Download Video
516
- function downloadVideo() {
517
- if (!AppState.sessionId) {
518
- showToast('No video available to download', 'error');
519
- return;
520
- }
521
-
522
- const url = `${API_BASE_URL}/api/download/${AppState.sessionId}`;
523
- const filename = `analyzed_${AppState.uploadedFile?.name || 'video.mp4'}`;
524
-
525
- // Create temporary anchor element for download
526
- const a = document.createElement('a');
527
- a.style.display = 'none';
528
- a.href = url;
529
- a.download = filename;
530
-
531
- document.body.appendChild(a);
532
- a.click();
533
-
534
- // Cleanup
535
- setTimeout(() => {
536
- document.body.removeChild(a);
537
- }, 100);
538
-
539
- showToast('Download started! 💾', 'success');
540
- }
541
- */
542
-
543
- // Share Results
544
- function shareResults() {
545
- const text = `Check out my dance movement analysis! Movement: ${elements.movementType.textContent}, Intensity: ${elements.intensityValue.textContent}/100`;
546
-
547
- if (navigator.share) {
548
- navigator.share({
549
- title: 'Dance Movement Analysis',
550
- text: text
551
- }).catch(console.error);
552
- } else {
553
- // Fallback: copy to clipboard
554
- navigator.clipboard.writeText(text).then(() => {
555
- showToast('Results copied to clipboard!', 'success');
556
- }).catch(() => {
557
- showToast('Could not share results', 'error');
558
- });
559
- }
560
- }
561
-
562
- // Reset App
563
- function resetApp() {
564
- // Reset state
565
- AppState.sessionId = null;
566
- AppState.uploadedFile = null;
567
- AppState.videoInfo = null;
568
- AppState.results = null;
569
- AppState.startTime = null;
570
-
571
- // Reset UI
572
- elements.fileInfo.style.display = 'none';
573
- elements.uploadSection.style.display = 'block';
574
- elements.processingSection.style.display = 'none';
575
- elements.resultsSection.style.display = 'none';
576
-
577
- elements.fileInput.value = '';
578
- elements.progressFill.style.width = '0%';
579
- elements.progressText.textContent = '0%';
580
-
581
- // Clear videos
582
- elements.originalVideo.src = '';
583
- elements.analyzedVideo.src = '';
584
-
585
- showToast('Ready for new analysis!', 'info');
586
- }
587
-
588
- // Utility Functions
589
- function formatFileSize(bytes) {
590
- if (bytes === 0) return '0 Bytes';
591
- const k = 1024;
592
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
593
- const i = Math.floor(Math.log(bytes) / Math.log(k));
594
- return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
595
- }
596
-
597
- function showToast(message, type = 'info') {
598
- elements.toast.textContent = message;
599
- elements.toast.className = `toast ${type} show`;
600
-
601
- setTimeout(() => {
602
- elements.toast.classList.remove('show');
603
- }, 3000);
604
- }
605
-
606
- function checkBrowserCompatibility() {
607
- if (!window.WebSocket) {
608
- showToast('Your browser does not support WebSocket. Real-time updates may not work.', 'error');
609
- }
610
-
611
- if (!window.FileReader) {
612
- showToast('Your browser does not support file reading.', 'error');
613
- }
614
- }
615
-
616
- // Initialize on DOM load
617
- document.addEventListener('DOMContentLoaded', initApp);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/config.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Frontend Configuration
3
+ */
4
+
5
+ export const APP_CONFIG = {
6
+ API_BASE_URL: window.location.origin,
7
+ WS_PROTOCOL: window.location.protocol === 'https:' ? 'wss:' : 'ws:',
8
+
9
+ // Polling configuration (for Celery)
10
+ POLLING_INTERVAL: 2000, // 2 seconds
11
+ MAX_POLL_ATTEMPTS: 300, // 10 minutes max
12
+
13
+ // File validation
14
+ MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB
15
+ ALLOWED_EXTENSIONS: ['.mp4', '.webm', '.avi', '.mov'],
16
+ ALLOWED_MIME_TYPES: ['video/mp4', 'video/webm', 'video/x-msvideo', 'video/quicktime'],
17
+
18
+ // UI
19
+ TOAST_DURATION: 3000,
20
+ PROGRESS_UPDATE_THROTTLE: 500,
21
+
22
+ // Features
23
+ USE_WEBSOCKET: false, // Set to true to use WebSocket instead of polling
24
+ SHOW_ETA: true
25
+ };
frontend/js/core/state.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Application State Management
3
+ */
4
+
5
+ export class AppState {
6
+ constructor() {
7
+ this.sessionId = null;
8
+ this.uploadedFile = null;
9
+ this.videoInfo = null;
10
+ this.results = null;
11
+ this.taskId = null;
12
+ this.pollingInterval = null;
13
+ this.startTime = null;
14
+ this.eta = null;
15
+ }
16
+
17
+ reset() {
18
+ this.sessionId = null;
19
+ this.uploadedFile = null;
20
+ this.videoInfo = null;
21
+ this.results = null;
22
+ this.taskId = null;
23
+ this.startTime = null;
24
+ this.eta = null;
25
+
26
+ if (this.pollingInterval) {
27
+ clearInterval(this.pollingInterval);
28
+ this.pollingInterval = null;
29
+ }
30
+ }
31
+
32
+ setSession(sessionId, fileInfo) {
33
+ this.sessionId = sessionId;
34
+ this.videoInfo = fileInfo;
35
+ }
36
+
37
+ setTask(taskId) {
38
+ this.taskId = taskId;
39
+ this.startTime = Date.now();
40
+ }
41
+
42
+ updateETA(progress) {
43
+ if (!this.startTime || progress <= 0) return null;
44
+
45
+ const elapsed = Date.now() - this.startTime;
46
+ const estimatedTotal = elapsed / progress;
47
+ const remaining = estimatedTotal - elapsed;
48
+
49
+ this.eta = Math.max(0, Math.ceil(remaining / 1000)); // seconds
50
+ return this.eta;
51
+ }
52
+ }
53
+
54
+ // Global state instance
55
+ export const appState = new AppState();
frontend/js/{video-handler.js → handlers/video-handler.js} RENAMED
@@ -3,7 +3,7 @@
3
  * Utilities for video validation, preview, and synchronization
4
  */
5
 
6
- class VideoHandler {
7
  constructor() {
8
  this.originalVideo = null;
9
  this.analyzedVideo = null;
@@ -154,9 +154,4 @@ class VideoHandler {
154
  }
155
 
156
  // Export for use in main app
157
- const videoHandler = new VideoHandler();
158
-
159
- // Initialize when DOM is ready
160
- document.addEventListener('DOMContentLoaded', () => {
161
- videoHandler.init('originalVideo', 'analyzedVideo');
162
- });
 
3
  * Utilities for video validation, preview, and synchronization
4
  */
5
 
6
+ export class VideoHandler {
7
  constructor() {
8
  this.originalVideo = null;
9
  this.analyzedVideo = null;
 
154
  }
155
 
156
  // Export for use in main app
157
+ export const videoHandler = new VideoHandler();
 
 
 
 
 
frontend/js/{old_app.js → main.js} RENAMED
@@ -1,19 +1,14 @@
1
  /**
2
- * Main Application Logic
3
- * Handles UI state, file uploads, and result display
4
  */
5
 
6
- const API_BASE_URL = window.location.origin;
7
-
8
- // Application State
9
- const AppState = {
10
- sessionId: null,
11
- uploadedFile: null,
12
- videoInfo: null,
13
- results: null,
14
- ws: null,
15
- startTime: null
16
- };
17
 
18
  // DOM Elements
19
  const elements = {
@@ -28,15 +23,8 @@ const elements = {
28
  processingSection: document.getElementById('processingSection'),
29
  resultsSection: document.getElementById('resultsSection'),
30
 
31
- progressFill: document.getElementById('progressFill'),
32
- progressText: document.getElementById('progressText'),
33
- processingMessage: document.getElementById('processingMessage'),
34
- statusValue: document.getElementById('statusValue'),
35
- elapsedTime: document.getElementById('elapsedTime'),
36
-
37
  originalVideo: document.getElementById('originalVideo'),
38
  analyzedVideo: document.getElementById('analyzedVideo'),
39
- downloadBtn: document.getElementById('downloadBtn'),
40
 
41
  movementType: document.getElementById('movementType'),
42
  intensityValue: document.getElementById('intensityValue'),
@@ -48,47 +36,28 @@ const elements = {
48
  smoothnessScore: document.getElementById('smoothnessScore'),
49
 
50
  bodyParts: document.getElementById('bodyParts'),
51
- rhythmCard: document.getElementById('rhythmCard'),
52
- bpmValue: document.getElementById('bpmValue'),
53
- consistencyValue: document.getElementById('consistencyValue'),
54
 
55
  newAnalysisBtn: document.getElementById('newAnalysisBtn'),
56
- shareBtn: document.getElementById('shareBtn'),
57
- toast: document.getElementById('toast')
58
  };
59
 
60
- // Initialize Application
61
- function initApp() {
62
  setupEventListeners();
63
- checkBrowserCompatibility();
64
- showToast('Ready to analyze dance videos!', 'info');
65
  }
66
 
67
- // Setup Event Listeners
68
  function setupEventListeners() {
69
- // Upload zone events
70
  elements.uploadZone.addEventListener('click', () => elements.fileInput.click());
71
  elements.uploadZone.addEventListener('dragover', handleDragOver);
72
  elements.uploadZone.addEventListener('dragleave', handleDragLeave);
73
  elements.uploadZone.addEventListener('drop', handleDrop);
74
-
75
- // File input change
76
  elements.fileInput.addEventListener('change', handleFileSelect);
77
-
78
- // Analyze button
79
  elements.analyzeBtn.addEventListener('click', startAnalysis);
80
-
81
- // Download button
82
- elements.downloadBtn.addEventListener('click', downloadVideo);
83
-
84
- // New analysis button
85
  elements.newAnalysisBtn.addEventListener('click', resetApp);
86
-
87
- // Share button
88
- elements.shareBtn.addEventListener('click', shareResults);
89
  }
90
 
91
- // File Upload Handlers
92
  function handleDragOver(e) {
93
  e.preventDefault();
94
  elements.uploadZone.classList.add('drag-over');
@@ -102,91 +71,66 @@ function handleDragLeave(e) {
102
  function handleDrop(e) {
103
  e.preventDefault();
104
  elements.uploadZone.classList.remove('drag-over');
105
-
106
  const files = e.dataTransfer.files;
107
- if (files.length > 0) {
108
- handleFile(files[0]);
109
- }
110
  }
111
 
112
  function handleFileSelect(e) {
113
  const files = e.target.files;
114
- if (files.length > 0) {
115
- handleFile(files[0]);
116
- }
117
  }
118
 
119
- // Validate and Handle File
120
  async function handleFile(file) {
121
- // Validate file type
122
- const validTypes = ['video/mp4', 'video/webm', 'video/avi'];
123
- if (!validTypes.includes(file.type)) {
124
- showToast('Please upload a valid video file (MP4, WebM, AVI)', 'error');
125
  return;
126
  }
127
 
128
- // Validate file size (100MB)
129
- const maxSize = 100 * 1024 * 1024;
130
- if (file.size > maxSize) {
131
- showToast('File size exceeds 100MB limit', 'error');
132
  return;
133
  }
134
 
135
- AppState.uploadedFile = file;
136
 
137
  // Display file info
138
  elements.fileName.textContent = file.name;
139
  elements.fileMeta.textContent = `${formatFileSize(file.size)} • ${file.type}`;
140
  elements.fileInfo.style.display = 'flex';
141
 
142
- // Upload file to server
143
  await uploadFile(file);
144
  }
145
 
146
- // Upload File to Server
147
  async function uploadFile(file) {
148
  try {
149
  elements.analyzeBtn.disabled = true;
150
  elements.analyzeBtn.textContent = '⏳ Uploading...';
151
 
152
- const formData = new FormData();
153
- formData.append('file', file);
154
-
155
- const response = await fetch(`${API_BASE_URL}/api/upload`, {
156
- method: 'POST',
157
- body: formData
158
- });
159
-
160
- if (!response.ok) {
161
- throw new Error('Upload failed');
162
- }
163
 
164
- const data = await response.json();
165
 
166
- AppState.sessionId = data.session_id;
167
- AppState.videoInfo = data;
168
-
169
- // Create object URL for original video preview
170
  const videoURL = URL.createObjectURL(file);
171
  elements.originalVideo.src = videoURL;
172
 
173
  elements.analyzeBtn.disabled = false;
174
  elements.analyzeBtn.textContent = '✨ Start Analysis';
175
 
176
- showToast('Video uploaded successfully!', 'success');
177
-
178
  } catch (error) {
179
  console.error('Upload error:', error);
180
- showToast('Failed to upload video. Please try again.', 'error');
181
  elements.analyzeBtn.disabled = false;
182
  elements.analyzeBtn.textContent = '✨ Start Analysis';
183
  }
184
  }
185
 
186
- // Start Analysis
187
  async function startAnalysis() {
188
- if (!AppState.sessionId) {
189
- showToast('Please upload a video first', 'error');
190
  return;
191
  }
192
 
@@ -195,187 +139,104 @@ async function startAnalysis() {
195
  elements.uploadSection.style.display = 'none';
196
  elements.processingSection.style.display = 'block';
197
 
198
- // Initialize WebSocket
199
- initWebSocket(AppState.sessionId);
200
-
201
  // Start analysis
202
- const response = await fetch(`${API_BASE_URL}/api/analyze/${AppState.sessionId}`, {
203
- method: 'POST'
204
- });
205
 
206
- if (!response.ok) {
207
- throw new Error('Analysis failed to start');
208
- }
209
 
210
- const data = await response.json();
211
 
212
- AppState.startTime = Date.now();
213
- startElapsedTimer();
214
-
215
- showToast('Analysis started!', 'info');
 
 
 
 
 
 
 
 
216
 
217
  } catch (error) {
218
  console.error('Analysis error:', error);
219
- showToast('Failed to start analysis. Please try again.', 'error');
220
  elements.uploadSection.style.display = 'block';
221
  elements.processingSection.style.display = 'none';
222
  }
223
  }
224
 
225
- // Initialize WebSocket
226
- function initWebSocket(sessionId) {
227
- const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
228
- const wsUrl = `${wsProtocol}//${window.location.host}/ws/${sessionId}`;
229
-
230
- AppState.ws = new WebSocket(wsUrl);
231
-
232
- AppState.ws.onopen = () => {
233
- console.log('WebSocket connected');
234
- };
235
-
236
- AppState.ws.onmessage = (event) => {
237
- const message = JSON.parse(event.data);
238
- handleWebSocketMessage(message);
239
- };
240
-
241
- AppState.ws.onerror = (error) => {
242
- console.error('WebSocket error:', error);
243
- };
244
 
245
- AppState.ws.onclose = () => {
246
- console.log('WebSocket closed');
247
- };
248
-
249
- // Send heartbeat every 20 seconds
250
- setInterval(() => {
251
- if (AppState.ws && AppState.ws.readyState === WebSocket.OPEN) {
252
- AppState.ws.send('ping');
253
- }
254
- }, 20000);
255
- }
256
-
257
- // Handle WebSocket Messages
258
- function handleWebSocketMessage(message) {
259
- switch (message.type) {
260
- case 'connected':
261
- console.log('WebSocket ready');
262
- break;
263
-
264
- case 'progress':
265
- updateProgress(message.progress, message.message);
266
- break;
267
-
268
- case 'status':
269
- elements.statusValue.textContent = message.status;
270
- elements.processingMessage.textContent = message.message;
271
- break;
272
-
273
- case 'complete':
274
- handleAnalysisComplete(message);
275
- break;
276
-
277
- case 'error':
278
- handleAnalysisError(message);
279
- break;
280
-
281
- case 'pong':
282
- // Heartbeat response
283
- break;
284
- }
285
- }
286
-
287
- // Update Progress
288
- function updateProgress(progress, message) {
289
- const percentage = Math.round(progress * 100);
290
- elements.progressFill.style.width = `${percentage}%`;
291
- elements.progressText.textContent = `${percentage}%`;
292
- elements.processingMessage.textContent = message;
293
- }
294
-
295
- // Start Elapsed Timer
296
- function startElapsedTimer() {
297
- const timer = setInterval(() => {
298
- if (AppState.startTime) {
299
- const elapsed = Math.floor((Date.now() - AppState.startTime) / 1000);
300
- elements.elapsedTime.textContent = `${elapsed}s`;
301
- } else {
302
- clearInterval(timer);
303
- }
304
- }, 1000);
305
- }
306
-
307
- // Handle Analysis Complete
308
- async function handleAnalysisComplete(message) {
309
- AppState.results = message.results;
310
- AppState.startTime = null;
311
 
312
- // Hide processing, show results
313
  elements.processingSection.style.display = 'none';
314
  elements.resultsSection.style.display = 'block';
315
 
316
  // Load analyzed video
317
- const videoUrl = `${API_BASE_URL}/api/download/${AppState.sessionId}`;
318
  elements.analyzedVideo.src = videoUrl;
319
 
320
- // Display results
321
- displayResults(AppState.results);
322
 
323
- // Close WebSocket
324
- if (AppState.ws) {
325
- AppState.ws.close();
326
- AppState.ws = null;
327
- }
328
 
329
- showToast('Analysis complete!', 'success');
330
  }
331
 
332
- // Handle Analysis Error
333
- function handleAnalysisError(message) {
334
- showToast(message.message, 'error');
 
335
  elements.uploadSection.style.display = 'block';
336
  elements.processingSection.style.display = 'none';
337
- AppState.startTime = null;
338
  }
339
 
340
- // Display Results
341
  function displayResults(results) {
342
- // Movement Classification
 
 
343
  const movement = results.movement_analysis;
344
  if (movement) {
345
- elements.movementType.textContent = movement.movement_type;
346
- elements.intensityValue.textContent = Math.round(movement.intensity);
347
- elements.intensityFill.style.width = `${movement.intensity}%`;
 
348
  }
349
 
350
- // Detection Stats
351
  const processing = results.processing;
352
- const detectionRate = (processing.detection_rate * 100).toFixed(1);
353
- elements.detectionRate.textContent = `${detectionRate}%`;
354
- elements.framesDetected.textContent = processing.frames_with_pose;
355
- elements.totalFrames.textContent = processing.total_frames;
 
 
356
 
357
  // Confidence
358
- const poseAnalysis = results.pose_analysis;
359
- elements.confidenceScore.textContent = poseAnalysis.average_confidence.toFixed(2);
 
 
360
 
361
  // Smoothness
362
- elements.smoothnessScore.textContent = Math.round(results.smoothness_score);
363
 
364
- // Body Parts
365
  if (movement && movement.body_part_activity) {
366
  displayBodyParts(movement.body_part_activity);
367
  }
368
-
369
- // Rhythm
370
- const rhythm = results.rhythm_analysis;
371
- if (rhythm && rhythm.has_rhythm) {
372
- elements.rhythmCard.style.display = 'block';
373
- elements.bpmValue.textContent = Math.round(rhythm.estimated_bpm);
374
- elements.consistencyValue.textContent = `${Math.round(rhythm.rhythm_consistency * 100)}%`;
375
- }
376
  }
377
 
378
- // Display Body Parts Activity
379
  function displayBodyParts(bodyParts) {
380
  elements.bodyParts.innerHTML = '';
381
 
@@ -402,67 +263,23 @@ function displayBodyParts(bodyParts) {
402
  }
403
  }
404
 
405
- // Download Video
406
- function downloadVideo() {
407
- if (!AppState.sessionId) return;
408
-
409
- const url = `${API_BASE_URL}/api/download/${AppState.sessionId}`;
410
- const a = document.createElement('a');
411
- a.href = url;
412
- a.download = `analyzed_${AppState.uploadedFile?.name || 'video.mp4'}`;
413
- document.body.appendChild(a);
414
- a.click();
415
- document.body.removeChild(a);
416
-
417
- showToast('Download started!', 'success');
418
- }
419
-
420
- // Share Results
421
- function shareResults() {
422
- const text = `Check out my dance movement analysis! Movement: ${elements.movementType.textContent}, Intensity: ${elements.intensityValue.textContent}/100`;
423
-
424
- if (navigator.share) {
425
- navigator.share({
426
- title: 'Dance Movement Analysis',
427
- text: text
428
- }).catch(console.error);
429
- } else {
430
- // Fallback: copy to clipboard
431
- navigator.clipboard.writeText(text).then(() => {
432
- showToast('Results copied to clipboard!', 'success');
433
- }).catch(() => {
434
- showToast('Could not share results', 'error');
435
- });
436
- }
437
- }
438
-
439
- // Reset App
440
  function resetApp() {
441
- // Reset state
442
- AppState.sessionId = null;
443
- AppState.uploadedFile = null;
444
- AppState.videoInfo = null;
445
- AppState.results = null;
446
- AppState.startTime = null;
447
 
448
- // Reset UI
449
  elements.fileInfo.style.display = 'none';
450
  elements.uploadSection.style.display = 'block';
451
  elements.processingSection.style.display = 'none';
452
  elements.resultsSection.style.display = 'none';
453
 
454
  elements.fileInput.value = '';
455
- elements.progressFill.style.width = '0%';
456
- elements.progressText.textContent = '0%';
457
-
458
- // Clear videos
459
  elements.originalVideo.src = '';
460
  elements.analyzedVideo.src = '';
461
 
462
- showToast('Ready for new analysis!', 'info');
463
  }
464
 
465
- // Utility Functions
466
  function formatFileSize(bytes) {
467
  if (bytes === 0) return '0 Bytes';
468
  const k = 1024;
@@ -471,24 +288,5 @@ function formatFileSize(bytes) {
471
  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
472
  }
473
 
474
- function showToast(message, type = 'info') {
475
- elements.toast.textContent = message;
476
- elements.toast.className = `toast ${type} show`;
477
-
478
- setTimeout(() => {
479
- elements.toast.classList.remove('show');
480
- }, 3000);
481
- }
482
-
483
- function checkBrowserCompatibility() {
484
- if (!window.WebSocket) {
485
- showToast('Your browser does not support WebSocket. Real-time updates may not work.', 'error');
486
- }
487
-
488
- if (!window.FileReader) {
489
- showToast('Your browser does not support file reading.', 'error');
490
- }
491
- }
492
-
493
- // Initialize on DOM load
494
- document.addEventListener('DOMContentLoaded', initApp);
 
1
  /**
2
+ * Main Application Entry Point (Simplified with Polling)
 
3
  */
4
 
5
+ import { APP_CONFIG } from './core/config.js';
6
+ import { appState } from './core/state.js';
7
+ import { apiService } from './services/api-service.js';
8
+ import { pollingService } from './services/polling-service.js';
9
+ import { toast } from './ui/toast.js';
10
+ import { progressManager } from './ui/progress.js';
11
+ import { videoHandler } from './handlers/video-handler.js';
 
 
 
 
12
 
13
  // DOM Elements
14
  const elements = {
 
23
  processingSection: document.getElementById('processingSection'),
24
  resultsSection: document.getElementById('resultsSection'),
25
 
 
 
 
 
 
 
26
  originalVideo: document.getElementById('originalVideo'),
27
  analyzedVideo: document.getElementById('analyzedVideo'),
 
28
 
29
  movementType: document.getElementById('movementType'),
30
  intensityValue: document.getElementById('intensityValue'),
 
36
  smoothnessScore: document.getElementById('smoothnessScore'),
37
 
38
  bodyParts: document.getElementById('bodyParts'),
 
 
 
39
 
40
  newAnalysisBtn: document.getElementById('newAnalysisBtn'),
 
 
41
  };
42
 
43
+ // Initialize
44
+ function init() {
45
  setupEventListeners();
46
+ toast.info('Ready to analyze dance videos!');
 
47
  }
48
 
49
+ // Event Listeners
50
  function setupEventListeners() {
 
51
  elements.uploadZone.addEventListener('click', () => elements.fileInput.click());
52
  elements.uploadZone.addEventListener('dragover', handleDragOver);
53
  elements.uploadZone.addEventListener('dragleave', handleDragLeave);
54
  elements.uploadZone.addEventListener('drop', handleDrop);
 
 
55
  elements.fileInput.addEventListener('change', handleFileSelect);
 
 
56
  elements.analyzeBtn.addEventListener('click', startAnalysis);
 
 
 
 
 
57
  elements.newAnalysisBtn.addEventListener('click', resetApp);
 
 
 
58
  }
59
 
60
+ // File Handling
61
  function handleDragOver(e) {
62
  e.preventDefault();
63
  elements.uploadZone.classList.add('drag-over');
 
71
  function handleDrop(e) {
72
  e.preventDefault();
73
  elements.uploadZone.classList.remove('drag-over');
 
74
  const files = e.dataTransfer.files;
75
+ if (files.length > 0) handleFile(files[0]);
 
 
76
  }
77
 
78
  function handleFileSelect(e) {
79
  const files = e.target.files;
80
+ if (files.length > 0) handleFile(files[0]);
 
 
81
  }
82
 
 
83
  async function handleFile(file) {
84
+ // Validate
85
+ if (!APP_CONFIG.ALLOWED_MIME_TYPES.includes(file.type)) {
86
+ toast.error('Invalid file type. Please upload MP4, WebM, or AVI.');
 
87
  return;
88
  }
89
 
90
+ if (file.size > APP_CONFIG.MAX_FILE_SIZE) {
91
+ toast.error('File exceeds 100MB limit.');
 
 
92
  return;
93
  }
94
 
95
+ appState.uploadedFile = file;
96
 
97
  // Display file info
98
  elements.fileName.textContent = file.name;
99
  elements.fileMeta.textContent = `${formatFileSize(file.size)} • ${file.type}`;
100
  elements.fileInfo.style.display = 'flex';
101
 
102
+ // Upload
103
  await uploadFile(file);
104
  }
105
 
 
106
  async function uploadFile(file) {
107
  try {
108
  elements.analyzeBtn.disabled = true;
109
  elements.analyzeBtn.textContent = '⏳ Uploading...';
110
 
111
+ const data = await apiService.uploadVideo(file);
 
 
 
 
 
 
 
 
 
 
112
 
113
+ appState.setSession(data.session_id, data);
114
 
115
+ // Preview original video
 
 
 
116
  const videoURL = URL.createObjectURL(file);
117
  elements.originalVideo.src = videoURL;
118
 
119
  elements.analyzeBtn.disabled = false;
120
  elements.analyzeBtn.textContent = '✨ Start Analysis';
121
 
122
+ toast.success('Video uploaded successfully!');
 
123
  } catch (error) {
124
  console.error('Upload error:', error);
125
+ toast.error(`Upload failed: ${error.message}`);
126
  elements.analyzeBtn.disabled = false;
127
  elements.analyzeBtn.textContent = '✨ Start Analysis';
128
  }
129
  }
130
 
 
131
  async function startAnalysis() {
132
+ if (!appState.sessionId) {
133
+ toast.error('Please upload a video first');
134
  return;
135
  }
136
 
 
139
  elements.uploadSection.style.display = 'none';
140
  elements.processingSection.style.display = 'block';
141
 
 
 
 
142
  // Start analysis
143
+ const data = await apiService.startAnalysis(appState.sessionId);
 
 
144
 
145
+ appState.setTask(data.task_id);
146
+ progressManager.start();
 
147
 
148
+ toast.info('Analysis started!');
149
 
150
+ // Start polling
151
+ pollingService.startPolling(data.task_id, {
152
+ onProgress: (progress, message) => {
153
+ progressManager.update(progress, message);
154
+ },
155
+ onComplete: async (result) => {
156
+ await handleAnalysisComplete(result);
157
+ },
158
+ onError: (error) => {
159
+ handleAnalysisError(error);
160
+ }
161
+ });
162
 
163
  } catch (error) {
164
  console.error('Analysis error:', error);
165
+ toast.error(`Failed to start: ${error.message}`);
166
  elements.uploadSection.style.display = 'block';
167
  elements.processingSection.style.display = 'none';
168
  }
169
  }
170
 
171
+ async function handleAnalysisComplete(result) {
172
+ progressManager.complete();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
+ // Fetch complete results
175
+ const data = await apiService.getResults(appState.sessionId);
176
+ appState.results = data.results;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
+ // Show results section
179
  elements.processingSection.style.display = 'none';
180
  elements.resultsSection.style.display = 'block';
181
 
182
  // Load analyzed video
183
+ const videoUrl = apiService.getDownloadURL(appState.sessionId);
184
  elements.analyzedVideo.src = videoUrl;
185
 
186
+ // Initialize video sync ← ADD THIS
187
+ videoHandler.init('originalVideo', 'analyzedVideo'); // ← ADD THIS
188
 
189
+ // Display results
190
+ displayResults(appState.results);
 
 
 
191
 
192
+ toast.success('Analysis complete! 🎉');
193
  }
194
 
195
+
196
+
197
+ function handleAnalysisError(error) {
198
+ toast.error(`Analysis failed: ${error.message}`);
199
  elements.uploadSection.style.display = 'block';
200
  elements.processingSection.style.display = 'none';
201
+ progressManager.reset();
202
  }
203
 
 
204
  function displayResults(results) {
205
+ if (!results) return;
206
+
207
+ // Movement
208
  const movement = results.movement_analysis;
209
  if (movement) {
210
+ elements.movementType.textContent = movement.movement_type || 'Unknown';
211
+ const intensity = Math.round(movement.intensity || 0);
212
+ elements.intensityValue.textContent = intensity;
213
+ elements.intensityFill.style.width = `${intensity}%`;
214
  }
215
 
216
+ // Detection
217
  const processing = results.processing;
218
+ if (processing) {
219
+ const rate = ((processing.detection_rate || 0) * 100).toFixed(1);
220
+ elements.detectionRate.textContent = `${rate}%`;
221
+ elements.framesDetected.textContent = processing.frames_with_pose || 0;
222
+ elements.totalFrames.textContent = processing.total_frames || 0;
223
+ }
224
 
225
  // Confidence
226
+ const pose = results.pose_analysis;
227
+ if (pose) {
228
+ elements.confidenceScore.textContent = (pose.average_confidence || 0).toFixed(2);
229
+ }
230
 
231
  // Smoothness
232
+ elements.smoothnessScore.textContent = Math.round(results.smoothness_score || 0);
233
 
234
+ // Body parts
235
  if (movement && movement.body_part_activity) {
236
  displayBodyParts(movement.body_part_activity);
237
  }
 
 
 
 
 
 
 
 
238
  }
239
 
 
240
  function displayBodyParts(bodyParts) {
241
  elements.bodyParts.innerHTML = '';
242
 
 
263
  }
264
  }
265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  function resetApp() {
267
+ appState.reset();
268
+ progressManager.reset();
269
+ pollingService.stopPolling();
 
 
 
270
 
 
271
  elements.fileInfo.style.display = 'none';
272
  elements.uploadSection.style.display = 'block';
273
  elements.processingSection.style.display = 'none';
274
  elements.resultsSection.style.display = 'none';
275
 
276
  elements.fileInput.value = '';
 
 
 
 
277
  elements.originalVideo.src = '';
278
  elements.analyzedVideo.src = '';
279
 
280
+ toast.info('Ready for new analysis!');
281
  }
282
 
 
283
  function formatFileSize(bytes) {
284
  if (bytes === 0) return '0 Bytes';
285
  const k = 1024;
 
288
  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
289
  }
290
 
291
+ // Initialize on load
292
+ document.addEventListener('DOMContentLoaded', init);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/services/api-service.js ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * API Communication Service
3
+ */
4
+
5
+ import { APP_CONFIG } from '../core/config.js';
6
+
7
+ export class APIService {
8
+ constructor(baseURL = APP_CONFIG.API_BASE_URL) {
9
+ this.baseURL = baseURL;
10
+ }
11
+
12
+ async uploadVideo(file) {
13
+ const formData = new FormData();
14
+ formData.append('file', file);
15
+
16
+ const response = await fetch(`${this.baseURL}/api/upload`, {
17
+ method: 'POST',
18
+ body: formData
19
+ });
20
+
21
+ if (!response.ok) {
22
+ const error = await response.json();
23
+ throw new Error(error.detail || 'Upload failed');
24
+ }
25
+
26
+ return await response.json();
27
+ }
28
+
29
+ async startAnalysis(sessionId) {
30
+ const response = await fetch(`${this.baseURL}/api/analyze/${sessionId}`, {
31
+ method: 'POST'
32
+ });
33
+
34
+ if (!response.ok) {
35
+ const error = await response.json();
36
+ throw new Error(error.detail || 'Analysis failed to start');
37
+ }
38
+
39
+ return await response.json();
40
+ }
41
+
42
+ async getTaskStatus(taskId) {
43
+ const response = await fetch(`${this.baseURL}/api/task/${taskId}`);
44
+
45
+ if (!response.ok) {
46
+ throw new Error('Failed to get task status');
47
+ }
48
+
49
+ return await response.json();
50
+ }
51
+
52
+ async getResults(sessionId) {
53
+ const response = await fetch(`${this.baseURL}/api/results/${sessionId}`);
54
+
55
+ if (!response.ok) {
56
+ throw new Error('Failed to get results');
57
+ }
58
+
59
+ return await response.json();
60
+ }
61
+
62
+ getDownloadURL(sessionId) {
63
+ return `${this.baseURL}/api/download/${sessionId}`;
64
+ }
65
+
66
+ async getStorageStats() {
67
+ const response = await fetch(`${this.baseURL}/api/admin/storage`);
68
+ return await response.json();
69
+ }
70
+ }
71
+
72
+ // Global API service instance
73
+ export const apiService = new APIService();
frontend/js/services/polling-service.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Task Polling Service (replaces WebSocket for Celery)
3
+ */
4
+
5
+ import { APP_CONFIG } from '../core/config.js';
6
+ import { apiService } from './api-service.js';
7
+
8
+ export class PollingService {
9
+ constructor() {
10
+ this.pollInterval = null;
11
+ this.onProgress = null;
12
+ this.onComplete = null;
13
+ this.onError = null;
14
+ this.pollCount = 0;
15
+ }
16
+
17
+ startPolling(taskId, callbacks = {}) {
18
+ this.onProgress = callbacks.onProgress || (() => {});
19
+ this.onComplete = callbacks.onComplete || (() => {});
20
+ this.onError = callbacks.onError || (() => {});
21
+ this.pollCount = 0;
22
+
23
+ console.log(`Starting polling for task: ${taskId}`);
24
+
25
+ this.pollInterval = setInterval(async () => {
26
+ try {
27
+ this.pollCount++;
28
+
29
+ // Max poll attempts check
30
+ if (this.pollCount > APP_CONFIG.MAX_POLL_ATTEMPTS) {
31
+ this.stopPolling();
32
+ this.onError(new Error('Polling timeout'));
33
+ return;
34
+ }
35
+
36
+ const status = await apiService.getTaskStatus(taskId);
37
+
38
+ if (status.state === 'PROGRESS' || status.state === 'PENDING') {
39
+ this.onProgress(status.progress || 0, status.message || 'Processing...');
40
+ } else if (status.state === 'SUCCESS') {
41
+ this.stopPolling();
42
+ this.onComplete(status.result);
43
+ } else if (status.state === 'FAILURE') {
44
+ this.stopPolling();
45
+ this.onError(new Error(status.error || 'Task failed'));
46
+ }
47
+
48
+ } catch (error) {
49
+ console.error('Polling error:', error);
50
+ // Don't stop on single error, retry
51
+ }
52
+ }, APP_CONFIG.POLLING_INTERVAL);
53
+ }
54
+
55
+ stopPolling() {
56
+ if (this.pollInterval) {
57
+ clearInterval(this.pollInterval);
58
+ this.pollInterval = null;
59
+ console.log('Polling stopped');
60
+ }
61
+ }
62
+ }
63
+
64
+ // Global polling service instance
65
+ export const pollingService = new PollingService();
frontend/js/ui/progress.js ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Progress Bar Manager
3
+ */
4
+
5
+ export class ProgressManager {
6
+ constructor() {
7
+ this.elements = {
8
+ fill: document.getElementById('progressFill'),
9
+ text: document.getElementById('progressText'),
10
+ message: document.getElementById('processingMessage'),
11
+ elapsed: document.getElementById('elapsedTime'),
12
+ eta: document.getElementById('etaTime') // New element for ETA
13
+ };
14
+ this.startTime = null;
15
+ this.interval = null;
16
+ }
17
+
18
+ start() {
19
+ this.startTime = Date.now();
20
+ this.updateElapsedTime();
21
+
22
+ // Update elapsed time every second
23
+ this.interval = setInterval(() => {
24
+ this.updateElapsedTime();
25
+ }, 1000);
26
+ }
27
+
28
+ update(progress, message = '') {
29
+ const percentage = Math.round(progress * 100);
30
+
31
+ if (this.elements.fill) {
32
+ this.elements.fill.style.width = `${percentage}%`;
33
+ }
34
+
35
+ if (this.elements.text) {
36
+ this.elements.text.textContent = `${percentage}%`;
37
+ }
38
+
39
+ if (this.elements.message && message) {
40
+ this.elements.message.textContent = message;
41
+ }
42
+
43
+ // Update ETA
44
+ this.updateETA(progress);
45
+ }
46
+
47
+ updateElapsedTime() {
48
+ if (!this.startTime || !this.elements.elapsed) return;
49
+
50
+ const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
51
+ this.elements.elapsed.textContent = this.formatTime(elapsed);
52
+ }
53
+
54
+ updateETA(progress) {
55
+ if (!this.startTime || !this.elements.eta || progress <= 0) return;
56
+
57
+ const elapsed = Date.now() - this.startTime;
58
+ const estimatedTotal = elapsed / progress;
59
+ const remaining = Math.max(0, estimatedTotal - elapsed);
60
+ const etaSeconds = Math.ceil(remaining / 1000);
61
+
62
+ this.elements.eta.textContent = this.formatTime(etaSeconds);
63
+ }
64
+
65
+ formatTime(seconds) {
66
+ if (seconds < 60) {
67
+ return `${seconds}s`;
68
+ } else if (seconds < 3600) {
69
+ const mins = Math.floor(seconds / 60);
70
+ const secs = seconds % 60;
71
+ return `${mins}m ${secs}s`;
72
+ } else {
73
+ const hours = Math.floor(seconds / 3600);
74
+ const mins = Math.floor((seconds % 3600) / 60);
75
+ return `${hours}h ${mins}m`;
76
+ }
77
+ }
78
+
79
+ complete() {
80
+ this.update(1.0, 'Analysis complete!');
81
+ this.stop();
82
+ }
83
+
84
+ stop() {
85
+ if (this.interval) {
86
+ clearInterval(this.interval);
87
+ this.interval = null;
88
+ }
89
+ }
90
+
91
+ reset() {
92
+ this.stop();
93
+ this.startTime = null;
94
+
95
+ if (this.elements.fill) this.elements.fill.style.width = '0%';
96
+ if (this.elements.text) this.elements.text.textContent = '0%';
97
+ if (this.elements.message) this.elements.message.textContent = '';
98
+ if (this.elements.elapsed) this.elements.elapsed.textContent = '0s';
99
+ if (this.elements.eta) this.elements.eta.textContent = '--';
100
+ }
101
+ }
102
+
103
+ // Global progress manager
104
+ export const progressManager = new ProgressManager();
frontend/js/ui/toast.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Toast Notification System
3
+ */
4
+
5
+ import { APP_CONFIG } from '../core/config.js';
6
+
7
+ export class ToastManager {
8
+ constructor(toastElement) {
9
+ this.toastElement = toastElement || document.getElementById('toast');
10
+ }
11
+
12
+ show(message, type = 'info') {
13
+ if (!this.toastElement) return;
14
+
15
+ this.toastElement.textContent = message;
16
+ this.toastElement.className = `toast ${type} show`;
17
+
18
+ setTimeout(() => {
19
+ this.toastElement.classList.remove('show');
20
+ }, APP_CONFIG.TOAST_DURATION);
21
+ }
22
+
23
+ success(message) {
24
+ this.show(message, 'success');
25
+ }
26
+
27
+ error(message) {
28
+ this.show(message, 'error');
29
+ }
30
+
31
+ info(message) {
32
+ this.show(message, 'info');
33
+ }
34
+
35
+ warning(message) {
36
+ this.show(message, 'warning');
37
+ }
38
+ }
39
+
40
+ // Global toast manager
41
+ export const toast = new ToastManager();
frontend/js/{visualization.js → utils/visualization.js} RENAMED
@@ -3,7 +3,7 @@
3
  * Handles canvas rendering, skeleton overlay, and visual effects
4
  */
5
 
6
- class Visualizer {
7
  constructor() {
8
  this.canvas = null;
9
  this.ctx = null;
@@ -419,9 +419,4 @@ class Visualizer {
419
  }
420
 
421
  // Create global instance
422
- const visualizer = new Visualizer();
423
-
424
- // Export for use in other modules
425
- if (typeof module !== 'undefined' && module.exports) {
426
- module.exports = Visualizer;
427
- }
 
3
  * Handles canvas rendering, skeleton overlay, and visual effects
4
  */
5
 
6
+ export class Visualizer {
7
  constructor() {
8
  this.canvas = null;
9
  this.ctx = null;
 
419
  }
420
 
421
  // Create global instance
422
+ export const visualizer = new Visualizer();
 
 
 
 
 
frontend/js/websocket-client.js DELETED
@@ -1,194 +0,0 @@
1
- /**
2
- * WebSocket Client Manager
3
- * Handles real-time communication with server
4
- */
5
-
6
- class WebSocketClient {
7
- constructor() {
8
- this.ws = null;
9
- this.reconnectAttempts = 0;
10
- this.maxReconnectAttempts = 5;
11
- this.reconnectDelay = 2000;
12
- this.heartbeatInterval = null;
13
- this.callbacks = {
14
- onOpen: null,
15
- onClose: null,
16
- onError: null,
17
- onMessage: null,
18
- onProgress: null,
19
- onComplete: null
20
- };
21
- }
22
-
23
- /**
24
- * Connect to WebSocket server
25
- */
26
- connect(sessionId) {
27
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
28
- const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}`;
29
-
30
- console.log(`Connecting to WebSocket: ${wsUrl}`);
31
-
32
- this.ws = new WebSocket(wsUrl);
33
-
34
- this.ws.onopen = (event) => {
35
- console.log('WebSocket connected');
36
- this.reconnectAttempts = 0;
37
- this.startHeartbeat();
38
-
39
- if (this.callbacks.onOpen) {
40
- this.callbacks.onOpen(event);
41
- }
42
- };
43
-
44
- this.ws.onmessage = (event) => {
45
- try {
46
- const message = JSON.parse(event.data);
47
- this.handleMessage(message);
48
- } catch (error) {
49
- console.error('Failed to parse WebSocket message:', error);
50
- }
51
- };
52
-
53
- this.ws.onerror = (error) => {
54
- console.error('WebSocket error:', error);
55
-
56
- if (this.callbacks.onError) {
57
- this.callbacks.onError(error);
58
- }
59
- };
60
-
61
- this.ws.onclose = (event) => {
62
- console.log('WebSocket closed');
63
- this.stopHeartbeat();
64
-
65
- if (this.callbacks.onClose) {
66
- this.callbacks.onClose(event);
67
- }
68
-
69
- // Attempt reconnection
70
- if (this.reconnectAttempts < this.maxReconnectAttempts) {
71
- this.reconnectAttempts++;
72
- console.log(`Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
73
-
74
- setTimeout(() => {
75
- this.connect(sessionId);
76
- }, this.reconnectDelay);
77
- }
78
- };
79
- }
80
-
81
- /**
82
- * Handle incoming messages
83
- */
84
- handleMessage(message) {
85
- console.log('WebSocket message:', message);
86
-
87
- if (this.callbacks.onMessage) {
88
- this.callbacks.onMessage(message);
89
- }
90
-
91
- switch (message.type) {
92
- case 'connected':
93
- console.log('WebSocket connection confirmed');
94
- break;
95
-
96
- case 'progress':
97
- if (this.callbacks.onProgress) {
98
- this.callbacks.onProgress(message.progress, message.message);
99
- }
100
- break;
101
-
102
- case 'status':
103
- console.log('Status update:', message.status, message.message);
104
- break;
105
-
106
- case 'complete':
107
- if (this.callbacks.onComplete) {
108
- this.callbacks.onComplete(message);
109
- }
110
- this.close();
111
- break;
112
-
113
- case 'error':
114
- console.error('Server error:', message.message);
115
- if (this.callbacks.onError) {
116
- this.callbacks.onError(new Error(message.message));
117
- }
118
- break;
119
-
120
- case 'pong':
121
- // Heartbeat response
122
- break;
123
-
124
- case 'keepalive':
125
- // Server keepalive
126
- break;
127
- }
128
- }
129
-
130
- /**
131
- * Send message to server
132
- */
133
- send(message) {
134
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
135
- if (typeof message === 'object') {
136
- this.ws.send(JSON.stringify(message));
137
- } else {
138
- this.ws.send(message);
139
- }
140
- } else {
141
- console.warn('WebSocket is not open. Cannot send message.');
142
- }
143
- }
144
-
145
- /**
146
- * Start heartbeat to keep connection alive
147
- */
148
- startHeartbeat() {
149
- this.heartbeatInterval = setInterval(() => {
150
- this.send('ping');
151
- }, 20000); // Every 20 seconds
152
- }
153
-
154
- /**
155
- * Stop heartbeat
156
- */
157
- stopHeartbeat() {
158
- if (this.heartbeatInterval) {
159
- clearInterval(this.heartbeatInterval);
160
- this.heartbeatInterval = null;
161
- }
162
- }
163
-
164
- /**
165
- * Close WebSocket connection
166
- */
167
- close() {
168
- this.stopHeartbeat();
169
-
170
- if (this.ws) {
171
- this.ws.close();
172
- this.ws = null;
173
- }
174
- }
175
-
176
- /**
177
- * Check if WebSocket is connected
178
- */
179
- isConnected() {
180
- return this.ws && this.ws.readyState === WebSocket.OPEN;
181
- }
182
-
183
- /**
184
- * Set callback handlers
185
- */
186
- on(event, callback) {
187
- if (this.callbacks.hasOwnProperty(`on${event.charAt(0).toUpperCase() + event.slice(1)}`)) {
188
- this.callbacks[`on${event.charAt(0).toUpperCase() + event.slice(1)}`] = callback;
189
- }
190
- }
191
- }
192
-
193
- // Export for use in main app
194
- const wsClient = new WebSocketClient();