Factor Studios commited on
Commit
aeb6030
·
verified ·
1 Parent(s): c4dc870

Upload 25 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM nvidia/cuda:11.8.0-runtime-ubuntu22.04
2
+
3
+ FROM python:3.11-slim-bullseye
4
+
5
+ WORKDIR /app
6
+
7
+ # Enable contrib and non-free repos, and install system dependencies
8
+ RUN sed -i 's/main/main contrib non-free/' /etc/apt/sources.list && \
9
+ apt-get update && \
10
+ apt-get install -y --no-install-recommends \
11
+ unrar \
12
+ libgl1 \
13
+ libglib2.0-0 \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+
17
+ ENV DEBIAN_FRONTEND=noninteractive
18
+
19
+ # Python + dependencies
20
+ RUN apt-get update && apt-get install -y python3 python3-pip git && \
21
+ pip3 install --upgrade pip
22
+
23
+ # Set working dir
24
+ WORKDIR /app
25
+
26
+ # Copy and install requirements
27
+ COPY requirements.txt ./
28
+ RUN pip install --no-cache-dir -r requirements.txt
29
+
30
+ # Copy app code
31
+ COPY . .
32
+
33
+ # ybyjngamhtcuaupc gsmt
34
+
35
+ # Make the entire /app directory fully writeable for all users
36
+ RUN chmod -R 777 /app
37
+
38
+ # Ensure the app runs as the same user as the Space UI
39
+ RUN useradd -m -u 1000 user
40
+ USER user
41
+
42
+ # Launch FastAPI download server on container start
43
+ CMD ["uvicorn", "download_api:app", "--host", "0.0.0.0", "--port", "7860"]
annotations/readme.md ADDED
@@ -0,0 +1 @@
 
 
1
+ #Hi there
cursor_tracker.py ADDED
@@ -0,0 +1,601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import requests
4
+ import subprocess
5
+ import shutil
6
+ import time
7
+ import re
8
+ import threading
9
+ from typing import Dict, List, Set, Optional
10
+ from huggingface_hub import HfApi, list_repo_files
11
+
12
+ import cv2
13
+ import numpy as np
14
+ from pathlib import Path
15
+ import smtplib
16
+ from email.message import EmailMessage
17
+
18
+ # ==== CONFIGURATION ====
19
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
20
+ SOURCE_REPO_ID = os.getenv("SOURCE_REPO", "Fred808/BG1")
21
+
22
+ # Path Configuration
23
+ DOWNLOAD_FOLDER = "downloads"
24
+ EXTRACT_FOLDER = "extracted"
25
+ FRAMES_OUTPUT_FOLDER = "extracted_frames" # New folder for extracted frames
26
+ CURSOR_TRACKING_OUTPUT_FOLDER = "cursor_tracking_results" # New folder for cursor tracking results
27
+ CURSOR_TEMPLATES_DIR = "cursors"
28
+
29
+ os.makedirs(DOWNLOAD_FOLDER, exist_ok=True)
30
+ os.makedirs(EXTRACT_FOLDER, exist_ok=True)
31
+ os.makedirs(FRAMES_OUTPUT_FOLDER, exist_ok=True)
32
+ os.makedirs(CURSOR_TRACKING_OUTPUT_FOLDER, exist_ok=True)
33
+ os.makedirs(CURSOR_TEMPLATES_DIR, exist_ok=True) # Ensure cursor templates directory exists
34
+
35
+ # State Files
36
+ DOWNLOAD_STATE_FILE = "download_progress.json"
37
+ PROCESS_STATE_FILE = "process_progress.json"
38
+ FAILED_FILES_LOG = "failed_files.log"
39
+
40
+ # Processing Parameters
41
+ CHUNK_SIZE = 1
42
+ PROCESSING_DELAY = 2
43
+ MAX_RETRIES = 3
44
+ MIN_FREE_SPACE_GB = 2 # Minimum free space in GB before processing
45
+
46
+ # Frame Extraction Parameters
47
+ DEFAULT_FPS = 3 # Default frames per second for extraction
48
+
49
+ # Cursor Tracking Parameters
50
+ CURSOR_THRESHOLD = 0.8
51
+
52
+ # Initialize HF API
53
+ hf_api = HfApi(token=HF_TOKEN)
54
+
55
+ # Global State
56
+ processing_status = {
57
+ "is_running": False,
58
+ "current_file": None,
59
+ "total_files": 0,
60
+ "processed_files": 0,
61
+ "failed_files": 0,
62
+ "extracted_courses": 0,
63
+ "extracted_videos": 0,
64
+ "extracted_frames_count": 0,
65
+ "tracked_cursors_count": 0,
66
+ "last_update": None,
67
+ "logs": []
68
+ }
69
+
70
+ def log_message(message: str):
71
+ """Log messages with timestamp"""
72
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
73
+ log_entry = f"[{timestamp}] {message}"
74
+ print(log_entry)
75
+ processing_status["logs"].append(log_entry)
76
+ processing_status["last_update"] = timestamp
77
+ if len(processing_status["logs"]) > 100:
78
+ processing_status["logs"] = processing_status["logs"][-100:]
79
+
80
+ def log_failed_file(filename: str, error: str):
81
+ """Log failed files to persistent file"""
82
+ with open(FAILED_FILES_LOG, "a") as f:
83
+ f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - {filename}: {error}\n")
84
+
85
+ def get_disk_usage(path: str) -> Dict[str, float]:
86
+ """Get disk usage statistics in GB"""
87
+ statvfs = os.statvfs(path)
88
+ total = statvfs.f_frsize * statvfs.f_blocks / (1024**3)
89
+ free = statvfs.f_frsize * statvfs.f_bavail / (1024**3)
90
+ used = total - free
91
+ return {"total": total, "free": free, "used": used}
92
+
93
+ def check_disk_space(path: str = ".") -> bool:
94
+ """Check if there's enough disk space"""
95
+ disk_info = get_disk_usage(path)
96
+ if disk_info["free"] < MIN_FREE_SPACE_GB:
97
+ log_message(f'⚠️ Low disk space: {disk_info["free"]:.2f}GB free, {disk_info["used"]:.2f}GB used')
98
+ return False
99
+ return True
100
+
101
+ def cleanup_temp_files():
102
+ """Clean up temporary files to free space"""
103
+ log_message("🧹 Cleaning up temporary files...")
104
+
105
+ # Clean old downloads (keep only current processing file)
106
+ current_file = processing_status.get("current_file")
107
+ for file in os.listdir(DOWNLOAD_FOLDER):
108
+ if file != current_file and file.endswith((".rar", ".zip")):
109
+ try:
110
+ os.remove(os.path.join(DOWNLOAD_FOLDER, file))
111
+ log_message(f"🗑️ Removed old download: {file}")
112
+ except:
113
+ pass
114
+
115
+ def load_json_state(file_path: str, default_value):
116
+ """Load state from JSON file"""
117
+ if os.path.exists(file_path):
118
+ try:
119
+ with open(file_path, "r") as f:
120
+ return json.load(f)
121
+ except json.JSONDecodeError:
122
+ log_message(f"⚠️ Corrupted state file: {file_path}")
123
+ return default_value
124
+
125
+ def save_json_state(file_path: str, data):
126
+ """Save state to JSON file"""
127
+ with open(file_path, "w") as f:
128
+ json.dump(data, f, indent=2)
129
+
130
+ def download_with_retry(url: str, dest_path: str, max_retries: int = 3) -> bool:
131
+ """Download file with retry logic and disk space checking"""
132
+ if not check_disk_space():
133
+ cleanup_temp_files()
134
+ if not check_disk_space():
135
+ log_message("❌ Insufficient disk space even after cleanup")
136
+ return False
137
+
138
+ headers = {"Authorization": f"Bearer {HF_TOKEN}"}
139
+ for attempt in range(max_retries):
140
+ try:
141
+ with requests.get(url, headers=headers, stream=True) as r:
142
+ r.raise_for_status()
143
+
144
+ # Check content length if available
145
+ content_length = r.headers.get("content-length")
146
+ if content_length:
147
+ size_gb = int(content_length) / (1024**3)
148
+ disk_info = get_disk_usage(".")
149
+ if size_gb > disk_info["free"] - 0.5: # Leave 0.5GB buffer
150
+ log_message(f'❌ File too large: {size_gb:.2f}GB, only {disk_info["free"]:.2f}GB free')
151
+ return False
152
+
153
+ with open(dest_path, "wb") as f:
154
+ for chunk in r.iter_content(chunk_size=8192):
155
+ f.write(chunk)
156
+ return True
157
+ except Exception as e:
158
+ if attempt < max_retries - 1:
159
+ time.sleep(2 ** attempt)
160
+ continue
161
+ log_message(f"❌ Download failed after {max_retries} attempts: {e}")
162
+ return False
163
+ return False
164
+
165
+ def is_multipart_rar(filename: str) -> bool:
166
+ """Check if this is a multi-part RAR file"""
167
+ return ".part" in filename.lower() and filename.lower().endswith(".rar")
168
+
169
+ def get_rar_part_base(filename: str) -> str:
170
+ """Get the base name for multi-part RAR files"""
171
+ if ".part" in filename.lower():
172
+ return filename.split(".part")[0]
173
+ return filename.replace(".rar", "")
174
+
175
+ def extract_with_retry(rar_path: str, output_dir: str, max_retries: int = 2) -> bool:
176
+ """Extract RAR with retry and recovery, handling multi-part archives"""
177
+ filename = os.path.basename(rar_path)
178
+
179
+ # For multi-part RARs, we need the first part
180
+ if is_multipart_rar(filename):
181
+ base_name = get_rar_part_base(filename)
182
+ first_part = f"{base_name}.part01.rar"
183
+ first_part_path = os.path.join(os.path.dirname(rar_path), first_part)
184
+
185
+ if not os.path.exists(first_part_path):
186
+ log_message(f"⚠️ Multi-part RAR detected but first part not found: {first_part}")
187
+ return False
188
+
189
+ rar_path = first_part_path
190
+ log_message(f"📦 Processing multi-part RAR starting with: {first_part}")
191
+
192
+ for attempt in range(max_retries):
193
+ try:
194
+ # Test RAR first
195
+ test_cmd = ["unrar", "t", rar_path]
196
+ test_result = subprocess.run(test_cmd, capture_output=True, text=True)
197
+ if test_result.returncode != 0:
198
+ log_message(f"⚠️ RAR test failed: {test_result.stderr}")
199
+ if attempt == max_retries - 1:
200
+ return False
201
+ continue
202
+
203
+ # Extract RAR
204
+ cmd = ["unrar", "x", "-o+", rar_path, output_dir]
205
+ if attempt > 0: # Try recovery on subsequent attempts
206
+ cmd.insert(2, "-kb")
207
+
208
+ result = subprocess.run(cmd, capture_output=True, text=True)
209
+ if result.returncode == 0:
210
+ log_message(f"✅ Successfully extracted: {os.path.basename(rar_path)}")
211
+ return True
212
+ else:
213
+ error_msg = result.stderr or result.stdout
214
+ log_message(f"⚠️ Extraction attempt {attempt + 1} failed: {error_msg}")
215
+
216
+ if "checksum error" in error_msg.lower() or "CRC failed" in error_msg:
217
+ log_message(f"⚠️ Data corruption detected, attempt {attempt + 1}")
218
+ elif result.returncode == 10:
219
+ log_message(f"⚠️ No files to extract (exit code 10)")
220
+ return False
221
+ elif result.returncode == 1:
222
+ log_message(f"⚠️ Non-fatal error (exit code 1)")
223
+
224
+ except Exception as e:
225
+ log_message(f"❌ Extraction exception: {str(e)}")
226
+ if attempt == max_retries - 1:
227
+ return False
228
+ time.sleep(1)
229
+
230
+ return False
231
+
232
+ # --- Frame Extraction Utilities ---
233
+ def ensure_dir(path):
234
+ os.makedirs(path, exist_ok=True)
235
+
236
+ def extract_frames(video_path, output_dir, fps=DEFAULT_FPS):
237
+ """Extract frames from video at the specified frames per second (fps)."""
238
+ log_message(f"[INFO] Extracting frames from {video_path} to {output_dir} at {fps} fps...")
239
+ ensure_dir(output_dir)
240
+ cap = cv2.VideoCapture(str(video_path))
241
+ if not cap.isOpened():
242
+ log_message(f"[ERROR] Failed to open video file: {video_path}")
243
+ return 0
244
+ video_fps = cap.get(cv2.CAP_PROP_FPS)
245
+ # log_message(f"[DEBUG] Video FPS: {video_fps}")
246
+ if not video_fps or video_fps <= 0:
247
+ video_fps = 30 # fallback if FPS is not available
248
+ log_message(f"[WARN] Using fallback FPS: {video_fps}")
249
+ frame_interval = int(round(video_fps / fps))
250
+ # log_message(f"[DEBUG] Frame interval: {frame_interval}")
251
+ frame_idx = 0
252
+ saved_idx = 1
253
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
254
+ log_message(f"[DEBUG] Total frames in video: {total_frames}")
255
+ while cap.isOpened():
256
+ ret, frame = cap.read()
257
+ if not ret:
258
+ # log_message(f"[DEBUG] No more frames to read at frame_idx {frame_idx}.")
259
+ break
260
+ if frame_idx % frame_interval == 0:
261
+ frame_name = f"{saved_idx:04d}.png"
262
+ cv2.imwrite(str(Path(output_dir) / frame_name), frame)
263
+ # log_message(f"[DEBUG] Saved frame {frame_idx} as {frame_name}")
264
+ saved_idx += 1
265
+ frame_idx += 1
266
+ cap.release()
267
+ log_message(f"Extracted {saved_idx-1} frames from {video_path} to {output_dir}")
268
+ return saved_idx - 1
269
+
270
+ # --- Cursor Tracking Utilities ---
271
+ def to_rgb(img):
272
+ if img is None:
273
+ return None
274
+ if len(img.shape) == 2:
275
+ return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
276
+ if img.shape[2] == 4:
277
+ return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
278
+ return img
279
+
280
+ def get_mask_from_alpha(template_img):
281
+ if template_img is not None and len(template_img.shape) == 3 and template_img.shape[2] == 4:
282
+ # Use alpha channel as mask (nonzero alpha = 255)
283
+ return (template_img[:, :, 3] > 0).astype(np.uint8) * 255
284
+ return None
285
+
286
+ def detect_cursor_in_frame_multi(frame, cursor_templates, threshold=CURSOR_THRESHOLD):
287
+ """Detect cursor position in a frame using multiple templates. Returns best match above threshold."""
288
+ best_pos = None
289
+ best_conf = -1
290
+ best_template_name = None
291
+ frame_rgb = to_rgb(frame)
292
+ for template_name, cursor_template in cursor_templates.items():
293
+ template_rgb = to_rgb(cursor_template)
294
+ mask = get_mask_from_alpha(cursor_template)
295
+ if template_rgb is None or frame_rgb is None or template_rgb.shape[2] != frame_rgb.shape[2]:
296
+ log_message(f"[WARN] Skipping template {template_name} due to channel mismatch or load error.")
297
+ continue
298
+ try:
299
+ result = cv2.matchTemplate(frame_rgb, template_rgb, cv2.TM_CCOEFF_NORMED, mask=mask)
300
+ except Exception as e:
301
+ log_message(f"[WARN] matchTemplate failed for {template_name}: {e}")
302
+ continue
303
+ min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
304
+ if max_val > best_conf:
305
+ best_conf = max_val
306
+ if max_val >= threshold:
307
+ cursor_w, cursor_h = template_rgb.shape[1], template_rgb.shape[0]
308
+ cursor_x = max_loc[0] + cursor_w // 2
309
+ cursor_y = max_loc[1] + cursor_h // 2
310
+ best_pos = (cursor_x, cursor_y)
311
+ best_template_name = template_name
312
+ if best_conf >= threshold:
313
+ return best_pos, best_conf, best_template_name
314
+ return None, best_conf, None
315
+
316
+ def send_email_with_attachment(subject, body, to_email, from_email, app_password, attachment_path):
317
+ msg = EmailMessage()
318
+ msg["Subject"] = subject
319
+ msg["From"] = from_email
320
+ msg["To"] = to_email
321
+ msg.set_content(body)
322
+ with open(attachment_path, "rb") as f:
323
+ file_data = f.read()
324
+ file_name = Path(attachment_path).name
325
+ msg.add_attachment(file_data, maintype="application", subtype="octet-stream", filename=file_name)
326
+ try:
327
+ with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp:
328
+ smtp.login(from_email, app_password)
329
+ smtp.send_message(msg)
330
+ log_message(f"[SUCCESS] Email sent to {to_email}")
331
+ except Exception as e:
332
+ log_message(f"[ERROR] Failed to send email: {e}")
333
+
334
+ def track_cursor(frames_dir, cursor_templates_dir, output_json_path, threshold=CURSOR_THRESHOLD, start_frame=1, email_results=False):
335
+ """Detect cursor in each frame using multiple templates, print status, and write positions to a JSON file."""
336
+ log_message(f"[INFO] Tracking cursors in {frames_dir}...")
337
+ frames_dir = Path(frames_dir).resolve()
338
+ output_json_path = Path(output_json_path).resolve()
339
+ cursor_templates_dir = Path(cursor_templates_dir).resolve()
340
+ # log_message(f"[DEBUG] frames_dir: {frames_dir}")
341
+ # log_message(f"[DEBUG] cursor_templates_dir: {cursor_templates_dir}")
342
+ # log_message(f"[DEBUG] output_json_path: {output_json_path}")
343
+ ensure_dir(frames_dir)
344
+ ensure_dir(output_json_path.parent)
345
+ # Load all PNG templates from the cursor_templates_dir
346
+ cursor_templates = {}
347
+ for template_file in cursor_templates_dir.glob("*.png"):
348
+ template_img = cv2.imread(str(template_file), cv2.IMREAD_UNCHANGED)
349
+ if template_img is not None:
350
+ cursor_templates[template_file.name] = template_img
351
+ else:
352
+ log_message(f"[WARN] Could not load template: {template_file}")
353
+ if not cursor_templates:
354
+ log_message(f"[ERROR] No cursor templates found in: {cursor_templates_dir}")
355
+ return 0
356
+ results = []
357
+ tracked_count = 0
358
+ for frame_file in sorted(frames_dir.glob("*.png")):
359
+ frame_num = int(frame_file.stem)
360
+ if frame_num < start_frame:
361
+ continue
362
+ frame = cv2.imread(str(frame_file), cv2.IMREAD_UNCHANGED)
363
+ if frame is None:
364
+ log_message(f"[WARN] Could not load frame: {frame_file}")
365
+ continue
366
+ pos, conf, template_name = detect_cursor_in_frame_multi(frame, cursor_templates, threshold)
367
+ if pos is not None:
368
+ # log_message(f"{frame_file.name}: Cursor at {pos} (template: {template_name})")
369
+ results.append({
370
+ "frame": frame_file.name,
371
+ "cursor_active": True,
372
+ "x": pos[0],
373
+ "y": pos[1],
374
+ "confidence": conf,
375
+ "template": template_name
376
+ })
377
+ tracked_count += 1
378
+ else:
379
+ # log_message(f"{frame_file.name}: Cursor disabled")
380
+ results.append({
381
+ "frame": frame_file.name,
382
+ "cursor_active": False,
383
+ "x": None,
384
+ "y": None,
385
+ "confidence": conf,
386
+ "template": None
387
+ })
388
+ try:
389
+ with open(output_json_path, "w") as f:
390
+ json.dump(results, f, indent=2)
391
+ log_message(f"[SUCCESS] Cursor tracking results saved to {output_json_path}")
392
+ if email_results:
393
+ log_message("[INFO] Preparing to email results...")
394
+ to_email = os.environ.get("TO_EMAIL")
395
+ from_email = os.environ.get("FROM_EMAIL")
396
+ app_password = os.environ.get("GMAIL_APP_PASSWORD")
397
+ if not (to_email and from_email and app_password):
398
+ log_message("[ERROR] Email environment variables not set. Please set TO_EMAIL, FROM_EMAIL, and GMAIL_APP_PASSWORD.")
399
+ # return tracked_count # Don't return here, just log error
400
+ else:
401
+ send_email_with_attachment(
402
+ subject="Cursor Tracking Results",
403
+ body="See attached JSON results.",
404
+ to_email=to_email,
405
+ from_email=from_email,
406
+ app_password=app_password,
407
+ attachment_path=output_json_path
408
+ )
409
+ except Exception as e:
410
+ log_message(f"[ERROR] Failed to write output JSON: {e}")
411
+ # raise # Don't raise, just log error
412
+ return tracked_count
413
+
414
+ def process_rar_file(rar_path: str) -> bool:
415
+ """Process a single RAR file - extract, then process videos for frames and cursor tracking"""
416
+ filename = os.path.basename(rar_path)
417
+ processing_status["current_file"] = filename
418
+
419
+ # Handle multi-part RAR naming
420
+ if is_multipart_rar(filename):
421
+ course_name = get_rar_part_base(filename)
422
+ else:
423
+ course_name = filename.replace(".rar", "")
424
+
425
+ extract_dir = os.path.join(EXTRACT_FOLDER, course_name)
426
+
427
+ try:
428
+ log_message(f"🔄 Processing: {filename}")
429
+
430
+ # Clean up any existing directory
431
+ if os.path.exists(extract_dir):
432
+ shutil.rmtree(extract_dir, ignore_errors=True)
433
+
434
+ # Extract RAR
435
+ os.makedirs(extract_dir, exist_ok=True)
436
+ if not extract_with_retry(rar_path, extract_dir):
437
+ raise Exception("RAR extraction failed")
438
+
439
+ # Count extracted files
440
+ file_count = 0
441
+ video_files_found = []
442
+ for root, dirs, files in os.walk(extract_dir):
443
+ for file in files:
444
+ file_count += 1
445
+ if file.lower().endswith((".mp4", ".avi", ".mov", ".mkv")):
446
+ video_files_found.append(os.path.join(root, file))
447
+
448
+ processing_status["extracted_courses"] += 1
449
+ log_message(f"✅ Successfully extracted \'{course_name}\' ({file_count} files, {len(video_files_found)} videos)")
450
+
451
+ # Process video files for frame extraction and cursor tracking
452
+ for video_path in video_files_found:
453
+ video_filename = Path(video_path).name
454
+ # Create a unique output directory for frames for each video
455
+ frames_output_dir = os.path.join(FRAMES_OUTPUT_FOLDER, f"{course_name}_{video_filename.replace('.', '_')}_frames")
456
+ ensure_dir(frames_output_dir)
457
+
458
+ extracted_frames_count = extract_frames(video_path, frames_output_dir, fps=DEFAULT_FPS)
459
+ processing_status["extracted_frames_count"] += extracted_frames_count
460
+ if extracted_frames_count > 0:
461
+ processing_status["extracted_videos"] += 1
462
+ log_message(f"[INFO] Extracted {extracted_frames_count} frames from {video_filename}")
463
+
464
+ # Perform cursor tracking on the extracted frames
465
+ cursor_output_json = os.path.join(CURSOR_TRACKING_OUTPUT_FOLDER, f"{course_name}_{video_filename.replace('.', '_')}_cursor_data.json")
466
+ tracked_cursors = track_cursor(frames_output_dir, CURSOR_TEMPLATES_DIR, cursor_output_json, threshold=CURSOR_THRESHOLD)
467
+ processing_status["tracked_cursors_count"] += tracked_cursors
468
+ log_message(f"[INFO] Tracked {tracked_cursors} cursors in frames from {video_filename}")
469
+ else:
470
+ log_message(f"[WARN] No frames extracted from {video_filename}")
471
+
472
+ return True
473
+
474
+ except Exception as e:
475
+ error_msg = str(e)
476
+ log_message(f"❌ Processing failed: {error_msg}")
477
+ log_failed_file(filename, error_msg)
478
+ return False
479
+
480
+ finally:
481
+ processing_status["current_file"] = None
482
+
483
+ def main_processing_loop(start_index: int = 0):
484
+ """Main processing workflow - extraction, frame extraction, and cursor tracking"""
485
+ processing_status["is_running"] = True
486
+
487
+ try:
488
+ # Load state
489
+ processed_rars = load_json_state(PROCESS_STATE_FILE, {"processed_rars": []})["processed_rars"]
490
+ download_state = load_json_state(DOWNLOAD_STATE_FILE, {"next_download_index": 0})
491
+
492
+ # Use start_index if provided, otherwise use the saved state
493
+ next_index = start_index if start_index > 0 else download_state["next_download_index"]
494
+
495
+ log_message(f"📊 Starting from index {next_index}")
496
+ log_message(f"📊 Previously processed: {len(processed_rars)} files")
497
+
498
+ # Get file list
499
+ try:
500
+ files = list(hf_api.list_repo_files(repo_id=SOURCE_REPO_ID, repo_type="dataset"))
501
+ rar_files = sorted([f for f in files if f.endswith(".rar")])
502
+
503
+ processing_status["total_files"] = len(rar_files)
504
+ log_message(f"📁 Found {len(rar_files)} RAR files in repository")
505
+
506
+ if next_index >= len(rar_files):
507
+ log_message("✅ All files have been processed!")
508
+ return
509
+
510
+ except Exception as e:
511
+ log_message(f"❌ Failed to get file list: {str(e)}")
512
+ return
513
+
514
+ # Process only one file per run
515
+ if next_index < len(rar_files):
516
+ rar_file = rar_files[next_index]
517
+ filename = os.path.basename(rar_file)
518
+
519
+ if filename in processed_rars:
520
+ log_message(f"⏭️ Skipping already processed: {filename}")
521
+ processing_status["processed_files"] += 1
522
+ # Move to next file
523
+ next_index += 1
524
+ save_json_state(DOWNLOAD_STATE_FILE, {"next_download_index": next_index})
525
+ log_message(f"📊 Moving to next file. Progress: {next_index}/{len(rar_files)}")
526
+ return
527
+
528
+ log_message(f"📥 Downloading: {filename}")
529
+ dest_path = os.path.join(DOWNLOAD_FOLDER, filename)
530
+
531
+ # Download file
532
+ download_url = f"https://huggingface.co/datasets/{SOURCE_REPO_ID}/resolve/main/{rar_file}"
533
+ if download_with_retry(download_url, dest_path):
534
+ # Process file
535
+ if process_rar_file(dest_path):
536
+ processed_rars.append(filename)
537
+ save_json_state(PROCESS_STATE_FILE, {"processed_rars": processed_rars})
538
+ log_message(f"✅ Successfully processed: {filename}")
539
+ processing_status["processed_files"] += 1
540
+ else:
541
+ log_message(f"❌ Failed to process: {filename}")
542
+ processing_status["failed_files"] += 1
543
+
544
+ # Clean up downloaded file
545
+ try:
546
+ os.remove(dest_path)
547
+ log_message(f"🗑️ Cleaned up download: {filename}")
548
+ except:
549
+ pass
550
+ else:
551
+ log_message(f"❌ Failed to download: {filename}")
552
+ processing_status["failed_files"] += 1
553
+
554
+ # Update download state for next run
555
+ next_index += 1
556
+ save_json_state(DOWNLOAD_STATE_FILE, {"next_download_index": next_index})
557
+
558
+ # Status update
559
+ log_message(f"📊 Progress: {next_index}/{len(rar_files)} files processed")
560
+ log_message(f'📊 Extracted: {processing_status["extracted_courses"]} courses')
561
+ log_message(f'📊 Videos Processed: {processing_status["extracted_videos"]}')
562
+ log_message(f'📊 Frames Extracted: {processing_status["extracted_frames_count"]}')
563
+ log_message(f'📊 Cursors Tracked: {processing_status["tracked_cursors_count"]}')
564
+ log_message(f'📊 Failed: {processing_status["failed_files"]} files')
565
+
566
+ if next_index < len(rar_files):
567
+ log_message(f"🔄 Run the script again to process the next file: {os.path.basename(rar_files[next_index])}")
568
+ else:
569
+ log_message("🎉 All files have been processed!")
570
+ else:
571
+ log_message("✅ All files have been processed!")
572
+
573
+ log_message("🎉 Processing complete!")
574
+ log_message(f'📊 Final stats: {processing_status["extracted_courses"]} courses extracted, {processing_status["extracted_videos"]} videos processed, {processing_status["extracted_frames_count"]} frames extracted, {processing_status["tracked_cursors_count"]} cursors tracked')
575
+
576
+ except KeyboardInterrupt:
577
+ log_message("⏹️ Processing interrupted by user")
578
+ except Exception as e:
579
+ log_message(f"❌ Fatal error: {str(e)}")
580
+ finally:
581
+ processing_status["is_running"] = False
582
+ cleanup_temp_files()
583
+
584
+ # Expose necessary functions and variables for download_api.py
585
+ __all__ = [
586
+ "main_processing_loop",
587
+ "processing_status",
588
+ "CURSOR_TRACKING_OUTPUT_FOLDER",
589
+ "CURSOR_TEMPLATES_DIR",
590
+ "log_message",
591
+ "send_email_with_attachment",
592
+ "track_cursor",
593
+ "extract_frames",
594
+ "DEFAULT_FPS",
595
+ "CURSOR_THRESHOLD",
596
+ "ensure_dir"
597
+ ]
598
+
599
+
600
+
601
+
cursors/1.png ADDED
cursors/10.png ADDED
cursors/11.png ADDED
cursors/12.png ADDED
cursors/13.png ADDED
cursors/14.png ADDED
cursors/15.png ADDED
cursors/16.png ADDED
cursors/2.png ADDED
cursors/3.png ADDED
cursors/4.png ADDED
cursors/5.png ADDED
cursors/6.png ADDED
cursors/7.png ADDED
cursors/8.png ADDED
cursors/9.png ADDED
download_api.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import time
4
+ import threading
5
+ from fastapi import FastAPI, HTTPException, BackgroundTasks
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import JSONResponse
8
+ import uvicorn
9
+ from typing import Dict
10
+ from pathlib import Path
11
+ from datetime import datetime
12
+ from fastapi.responses import FileResponse
13
+
14
+ # Import from cursor_tracker
15
+ from cursor_tracker import (
16
+ main_processing_loop,
17
+ processing_status,
18
+ CURSOR_TRACKING_OUTPUT_FOLDER,
19
+ CURSOR_TEMPLATES_DIR,
20
+ log_message
21
+ )
22
+
23
+
24
+
25
+ # FastAPI App Definition
26
+ app = FastAPI(title="Cursor Tracking API", description="API to access cursor tracking results", version="1.0.0")
27
+
28
+ # Add CORS middleware to allow cross-origin requests
29
+ app.add_middleware(
30
+ CORSMiddleware,
31
+ allow_origins=["*"], # Allows all origins
32
+ allow_credentials=True,
33
+ allow_methods=["*"], # Allows all methods
34
+ allow_headers=["*"],
35
+ )
36
+
37
+ # Global variable to track if processing is running
38
+ processing_thread = None
39
+
40
+ def log_message(message):
41
+ """Add a log message with timestamp"""
42
+ timestamp = datetime.now().strftime("%H:%M:%S")
43
+ log_entry = f"[{timestamp}] {message}"
44
+ processing_status["logs"].append(log_entry)
45
+
46
+ # Keep only the last 100 logs
47
+ if len(processing_status["logs"]) > 100:
48
+ processing_status["logs"] = processing_status["logs"][-100:]
49
+
50
+ print(log_entry)
51
+
52
+ @app.on_event("startup")
53
+ async def startup_event():
54
+ """Run the processing loop in the background when the API starts"""
55
+ global processing_thread
56
+ if not (processing_thread and processing_thread.is_alive()):
57
+ log_message("🚀 Starting RAR extraction, frame extraction, and cursor tracking pipeline in background...")
58
+ processing_thread = threading.Thread(target=main_processing_loop)
59
+ processing_thread.daemon = True
60
+ processing_thread.start()
61
+
62
+ from fastapi.staticfiles import StaticFiles
63
+
64
+ # app.mount("/static", StaticFiles(directory="static"), name="static")
65
+
66
+ # Serve your main HTML file
67
+ @app.get("/")
68
+ async def root():
69
+ return ()
70
+
71
+ # return FileResponse("index.html")
72
+
73
+ # # Optional: If you need to serve other static files individually
74
+ # @app.get("/{filename}")
75
+ # async def serve_file(filename: str):
76
+ # if filename in ['style.css', 'script.js']:
77
+ # return FileResponse(f"static/{filename}")
78
+ # return FileResponse(f"static/{filename}")
79
+
80
+
81
+
82
+ @app.get("/status")
83
+ async def get_status():
84
+ """Get current processing status"""
85
+ return {
86
+ "processing_status": processing_status,
87
+ "cursor_tracking_folder": CURSOR_TRACKING_OUTPUT_FOLDER,
88
+ "folder_exists": os.path.exists(CURSOR_TRACKING_OUTPUT_FOLDER)
89
+ }
90
+
91
+ @app.get("/cursor-data")
92
+ async def list_cursor_data():
93
+ """List all available cursor tracking JSON files"""
94
+ if not os.path.exists(CURSOR_TRACKING_OUTPUT_FOLDER):
95
+ return {"files": [], "message": "Cursor tracking output folder does not exist yet"}
96
+
97
+ json_files = []
98
+ for file in os.listdir(CURSOR_TRACKING_OUTPUT_FOLDER):
99
+ if file.endswith(".json"):
100
+ file_path = os.path.join(CURSOR_TRACKING_OUTPUT_FOLDER, file)
101
+ file_stats = os.stat(file_path)
102
+ json_files.append({
103
+ "filename": file,
104
+ "size_bytes": file_stats.st_size,
105
+ "modified_time": time.ctime(file_stats.st_mtime),
106
+ "download_url": f"/cursor-data/{file}"
107
+ })
108
+
109
+ return {
110
+ "files": json_files,
111
+ "total_files": len(json_files),
112
+ "folder_path": CURSOR_TRACKING_OUTPUT_FOLDER
113
+ }
114
+
115
+ from fastapi.encoders import jsonable_encoder
116
+
117
+ def get_disk_usage(path: str) -> Dict[str, float]:
118
+ """Get disk usage statistics in GB"""
119
+ statvfs = os.statvfs(path)
120
+ total = statvfs.f_frsize * statvfs.f_blocks / (1024**3)
121
+ free = statvfs.f_frsize * statvfs.f_bavail / (1024**3)
122
+ used = total - free
123
+ return {"total": total, "free": free, "used": used}
124
+
125
+ class SafeJSONEncoder(json.JSONEncoder):
126
+ def default(self, obj):
127
+ try:
128
+ if isinstance(obj, float):
129
+ if obj != obj: # Check for NaN
130
+ return None
131
+ if obj == float('inf') or obj == float('-inf'):
132
+ return None
133
+ return super().default(obj)
134
+ except:
135
+ return None
136
+
137
+ @app.get("/cursor-data/{filename}")
138
+ async def get_cursor_data(filename: str):
139
+ """Get specific cursor tracking data by filename"""
140
+ if not filename.endswith(".json"):
141
+ raise HTTPException(status_code=400, detail="File must be a JSON file")
142
+
143
+ file_path = os.path.join(CURSOR_TRACKING_OUTPUT_FOLDER, filename)
144
+
145
+ if not os.path.exists(file_path):
146
+ raise HTTPException(status_code=404, detail=f"File {filename} not found")
147
+
148
+ try:
149
+ with open(file_path, "r") as f:
150
+ data = json.load(f)
151
+
152
+ # Clean the data of any NaN or infinity values
153
+ def clean_floats(obj):
154
+ if isinstance(obj, float):
155
+ if obj != obj: # NaN
156
+ return None
157
+ if obj == float('inf') or obj == float('-inf'):
158
+ return None
159
+ return obj
160
+ elif isinstance(obj, dict):
161
+ return {k: clean_floats(v) for k, v in obj.items()}
162
+ elif isinstance(obj, list):
163
+ return [clean_floats(v) for v in obj]
164
+ return obj
165
+
166
+ cleaned_data = clean_floats(data)
167
+
168
+ # Add metadata
169
+ file_stats = os.stat(file_path)
170
+ response_data = {
171
+ "filename": filename,
172
+ "file_size_bytes": file_stats.st_size,
173
+ "modified_time": time.ctime(file_stats.st_mtime),
174
+ "total_frames": len(cleaned_data),
175
+ "cursor_active_frames": len([frame for frame in cleaned_data if frame.get("cursor_active", False)]),
176
+ "data": cleaned_data
177
+ }
178
+
179
+ return JSONResponse(content=jsonable_encoder(response_data))
180
+
181
+ except json.JSONDecodeError:
182
+ raise HTTPException(status_code=500, detail=f"Invalid JSON in file {filename}")
183
+ except Exception as e:
184
+ raise HTTPException(status_code=500, detail=f"Error reading file {filename}: {str(e)}")
185
+
186
+ @app.post("/start-processing")
187
+ async def start_processing(background_tasks: BackgroundTasks, start_index: int = 0):
188
+ """Start the RAR processing pipeline in the background"""
189
+ global processing_thread
190
+
191
+ if processing_thread and processing_thread.is_alive():
192
+ return {"message": "Processing is already running", "status": "already_running"}
193
+
194
+ if processing_status["is_running"]:
195
+ return {"message": "Processing is already running", "status": "already_running"}
196
+
197
+ # Start processing in a background thread
198
+ processing_thread = threading.Thread(target=main_processing_loop, args=(start_index,))
199
+ processing_thread.daemon = True
200
+ processing_thread.start()
201
+
202
+ return {"message": f"Processing started in background from index {start_index}", "status": "started"}
203
+
204
+ @app.post("/stop-processing")
205
+ async def stop_processing():
206
+ """Stop the RAR processing pipeline"""
207
+ global processing_thread
208
+
209
+ if not processing_status["is_running"] and (not processing_thread or not processing_thread.is_alive()):
210
+ return {"message": "No processing is currently running", "status": "not_running"}
211
+
212
+ # Note: This is a graceful stop request. The actual stopping depends on the processing loop
213
+ # checking the processing_status["is_running"] flag
214
+ processing_status["is_running"] = False
215
+
216
+ return {"message": "Stop signal sent to processing pipeline", "status": "stop_requested"}
217
+
218
+ @app.get("/cursor-data/{filename}/summary")
219
+ async def get_cursor_data_summary(filename: str):
220
+ """Get a summary of cursor tracking data without the full frame data"""
221
+ if not filename.endswith(".json"):
222
+ raise HTTPException(status_code=400, detail="File must be a JSON file")
223
+
224
+ file_path = os.path.join(CURSOR_TRACKING_OUTPUT_FOLDER, filename)
225
+
226
+ if not os.path.exists(file_path):
227
+ raise HTTPException(status_code=404, detail=f"File {filename} not found")
228
+
229
+ try:
230
+ with open(file_path, "r") as f:
231
+ data = json.load(f)
232
+
233
+ # Clean the data first
234
+ def clean_floats(obj):
235
+ if isinstance(obj, float):
236
+ if obj != obj: # NaN
237
+ return None
238
+ if obj == float('inf') or obj == float('-inf'):
239
+ return None
240
+ return obj
241
+ elif isinstance(obj, dict):
242
+ return {k: clean_floats(v) for k, v in obj.items()}
243
+ elif isinstance(obj, list):
244
+ return [clean_floats(v) for v in obj]
245
+ return obj
246
+
247
+ cleaned_data = clean_floats(data)
248
+
249
+ # Calculate summary statistics
250
+ total_frames = len(cleaned_data)
251
+ cursor_active_frames = len([frame for frame in cleaned_data if frame.get("cursor_active", False)])
252
+ cursor_inactive_frames = total_frames - cursor_active_frames
253
+
254
+ # Get unique templates used
255
+ templates_used = set()
256
+ confidence_scores = []
257
+
258
+ for frame in cleaned_data:
259
+ if frame.get("cursor_active", False) and frame.get("template"):
260
+ templates_used.add(frame["template"])
261
+ if frame.get("confidence") is not None:
262
+ # Ensure confidence is a valid number
263
+ try:
264
+ conf = float(frame["confidence"])
265
+ if not (conf != conf or conf == float('inf') or conf == float('-inf')):
266
+ confidence_scores.append(conf)
267
+ except (ValueError, TypeError):
268
+ pass
269
+
270
+ # Calculate confidence statistics
271
+ avg_confidence = sum(confidence_scores) / len(confidence_scores) if confidence_scores else 0
272
+ max_confidence = max(confidence_scores) if confidence_scores else 0
273
+ min_confidence = min(confidence_scores) if confidence_scores else 0
274
+
275
+ file_stats = os.stat(file_path)
276
+
277
+ summary = {
278
+ "filename": filename,
279
+ "file_size_bytes": file_stats.st_size,
280
+ "modified_time": time.ctime(file_stats.st_mtime),
281
+ "total_frames": total_frames,
282
+ "cursor_active_frames": cursor_active_frames,
283
+ "cursor_inactive_frames": cursor_inactive_frames,
284
+ "cursor_detection_rate": cursor_active_frames / total_frames if total_frames > 0 else 0,
285
+ "templates_used": list(templates_used),
286
+ "confidence_stats": {
287
+ "average": avg_confidence,
288
+ "maximum": max_confidence,
289
+ "minimum": min_confidence,
290
+ "total_measurements": len(confidence_scores)
291
+ }
292
+ }
293
+
294
+ return JSONResponse(content=jsonable_encoder(summary))
295
+
296
+ except json.JSONDecodeError:
297
+ raise HTTPException(status_code=500, detail=f"Invalid JSON in file {filename}")
298
+ except Exception as e:
299
+ raise HTTPException(status_code=500, detail=f"Error reading file {filename}: {str(e)}")
300
+
301
+ if __name__ == "__main__":
302
+ # Start the FastAPI server
303
+ print("Starting Cursor Tracking FastAPI Server...")
304
+ print("API Documentation will be available at: http://localhost:8000/docs")
305
+ print("API Root endpoint: http://localhost:8000/")
306
+
307
+ # Ensure the cursor tracking output folder exists
308
+ os.makedirs(CURSOR_TRACKING_OUTPUT_FOLDER, exist_ok=True)
309
+
310
+ uvicorn.run(
311
+ app,
312
+ host="0.0.0.0",
313
+ port=8000,
314
+ log_level="info",
315
+ reload=False # Set to False for production
316
+ )
317
+
318
+
319
+
index.html ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="description" content="Advanced web-based dashboard for monitoring and managing cursor tracking operations on video datasets. Real-time processing status, file management, and control interface.">
7
+ <meta name="keywords" content="cursor tracking, video processing, dashboard, monitoring, API interface">
8
+ <meta name="author" content="Cursor Tracking Dashboard">
9
+ <title>Cursor Tracking Dashboard - Real-time Video Processing Monitor</title>
10
+ <link rel="stylesheet" href="/static/style.css">
11
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
12
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
13
+ </head>
14
+ <body>
15
+ <div class="container">
16
+ <!-- Header -->
17
+ <header class="header">
18
+ <div class="header-content">
19
+ <div class="logo">
20
+ <i class="fas fa-mouse-pointer"></i>
21
+ <h1>Cursor Tracking Dashboard</h1>
22
+ </div>
23
+ <div class="header-actions">
24
+ <button id="refreshBtn" class="btn btn-secondary">
25
+ <i class="fas fa-sync-alt"></i>
26
+ Refresh
27
+ </button>
28
+ <div class="theme-toggle">
29
+ <button id="themeToggle" class="btn btn-icon">
30
+ <i class="fas fa-moon"></i>
31
+ </button>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </header>
36
+
37
+ <!-- Main Content -->
38
+ <main class="main-content">
39
+ <!-- Status Section -->
40
+ <section class="status-section">
41
+ <div class="card">
42
+ <div class="card-header">
43
+ <h2><i class="fas fa-chart-line"></i> Processing Status</h2>
44
+ <div class="status-indicator" id="statusIndicator">
45
+ <span class="status-dot"></span>
46
+ <span class="status-text">Loading...</span>
47
+ </div>
48
+ </div>
49
+ <div class="card-content">
50
+ <div class="stats-grid">
51
+ <div class="stat-item">
52
+ <div class="stat-value" id="totalFiles">-</div>
53
+ <div class="stat-label">Total Files</div>
54
+ </div>
55
+ <div class="stat-item">
56
+ <div class="stat-value" id="processedFiles">-</div>
57
+ <div class="stat-label">Processed</div>
58
+ </div>
59
+ <div class="stat-item">
60
+ <div class="stat-value" id="extractedCourses">-</div>
61
+ <div class="stat-label">Courses</div>
62
+ </div>
63
+ <div class="stat-item">
64
+ <div class="stat-value" id="extractedVideos">-</div>
65
+ <div class="stat-label">Videos</div>
66
+ </div>
67
+ <div class="stat-item">
68
+ <div class="stat-value" id="extractedFrames">-</div>
69
+ <div class="stat-label">Frames</div>
70
+ </div>
71
+ <div class="stat-item">
72
+ <div class="stat-value" id="trackedCursors">-</div>
73
+ <div class="stat-label">Cursors Tracked</div>
74
+ </div>
75
+ </div>
76
+
77
+ <div class="progress-section">
78
+ <div class="progress-info">
79
+ <span>Current File:</span>
80
+ <span id="currentFile" class="current-file">None</span>
81
+ </div>
82
+ <div class="progress-bar">
83
+ <div class="progress-fill" id="progressFill"></div>
84
+ </div>
85
+ <div class="progress-text" id="progressText">0%</div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </section>
90
+
91
+ <!-- Control Section -->
92
+ <section class="control-section">
93
+ <div class="card">
94
+ <div class="card-header">
95
+ <h2><i class="fas fa-cogs"></i> Processing Controls</h2>
96
+ </div>
97
+ <div class="card-content">
98
+ <div class="control-group">
99
+ <div class="input-group">
100
+ <label for="startIndex">Start Index for RAR Fetching:</label>
101
+ <input type="number" id="startIndex" min="0" value="0" class="input">
102
+ <span class="input-help">Specify which index to start processing from</span>
103
+ </div>
104
+ <div class="button-group">
105
+ <button id="startProcessing" class="btn btn-primary">
106
+ <i class="fas fa-play"></i>
107
+ Start Processing
108
+ </button>
109
+ <button id="stopProcessing" class="btn btn-danger">
110
+ <i class="fas fa-stop"></i>
111
+ Stop Processing
112
+ </button>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </section>
118
+
119
+ <!-- Cursor Data Files Section -->
120
+ <section class="files-section">
121
+ <div class="card">
122
+ <div class="card-header">
123
+ <h2><i class="fas fa-file-alt"></i> Cursor Tracking Results</h2>
124
+ <div class="file-count" id="fileCount">0 files</div>
125
+ </div>
126
+ <div class="card-content">
127
+ <div class="files-grid" id="filesGrid">
128
+ <!-- Files will be populated here -->
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </section>
133
+
134
+ <!-- Logs Section -->
135
+ <section class="logs-section">
136
+ <div class="card">
137
+ <div class="card-header">
138
+ <h2><i class="fas fa-terminal"></i> Processing Logs</h2>
139
+ <div class="log-controls">
140
+ <button id="clearLogs" class="btn btn-secondary btn-sm">
141
+ <i class="fas fa-trash"></i>
142
+ Clear
143
+ </button>
144
+ <button id="autoScroll" class="btn btn-secondary btn-sm active">
145
+ <i class="fas fa-arrow-down"></i>
146
+ Auto-scroll
147
+ </button>
148
+ </div>
149
+ </div>
150
+ <div class="card-content">
151
+ <div class="logs-container" id="logsContainer">
152
+ <div class="log-entry">
153
+ <span class="log-time">[Loading...]</span>
154
+ <span class="log-message">Initializing dashboard...</span>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ </section>
160
+ </main>
161
+ </div>
162
+
163
+ <!-- File Details Modal -->
164
+ <div id="fileModal" class="modal">
165
+ <div class="modal-content">
166
+ <div class="modal-header">
167
+ <h3 id="modalTitle">File Details</h3>
168
+ <button class="modal-close" id="modalClose">
169
+ <i class="fas fa-times"></i>
170
+ </button>
171
+ </div>
172
+ <div class="modal-body" id="modalBody">
173
+ <!-- File details will be populated here -->
174
+ </div>
175
+ <div class="modal-footer">
176
+ <button id="downloadFile" class="btn btn-primary">
177
+ <i class="fas fa-download"></i>
178
+ Download JSON
179
+ </button>
180
+ <button id="viewFrames" class="btn btn-secondary">
181
+ <i class="fas fa-images"></i>
182
+ View Frames
183
+ </button>
184
+ </div>
185
+ </div>
186
+ </div>
187
+
188
+ <!-- Loading Overlay -->
189
+ <div id="loadingOverlay" class="loading-overlay">
190
+ <div class="loading-spinner">
191
+ <i class="fas fa-spinner fa-spin"></i>
192
+ <p>Loading...</p>
193
+ </div>
194
+ </div>
195
+
196
+ <!-- Toast Notifications -->
197
+ <div id="toastContainer" class="toast-container"></div>
198
+
199
+
200
+ <script src="/static/script.js"></script>
201
+ </body>
202
+ </html>
203
+
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ torch
2
+ transformers
3
+ accelerate
4
+ fastapi
5
+ uvicorn
6
+ opencv-python-headless
7
+ numpy
8
+ pathlib
9
+ huggingface_hub
10
+ pillow
static/readme.md ADDED
File without changes
static/script.js ADDED
@@ -0,0 +1,655 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Configuration
2
+ const API_BASE_URL = window.location.origin; // Use same origin as the UI
3
+ const REFRESH_INTERVAL = 10000; // 10 seconds (increased for demo)
4
+ const MAX_LOGS = 100;
5
+
6
+ // Global state
7
+ let refreshInterval;
8
+ let autoScrollEnabled = true;
9
+ let currentFiles = [];
10
+ let selectedFile = null;
11
+ let apiConnected = false;
12
+
13
+ // DOM Elements
14
+ const elements = {
15
+ statusIndicator: document.getElementById('statusIndicator'),
16
+ totalFiles: document.getElementById('totalFiles'),
17
+ processedFiles: document.getElementById('processedFiles'),
18
+ extractedCourses: document.getElementById('extractedCourses'),
19
+ extractedVideos: document.getElementById('extractedVideos'),
20
+ extractedFrames: document.getElementById('extractedFrames'),
21
+ trackedCursors: document.getElementById('trackedCursors'),
22
+ currentFile: document.getElementById('currentFile'),
23
+ progressFill: document.getElementById('progressFill'),
24
+ progressText: document.getElementById('progressText'),
25
+ startIndex: document.getElementById('startIndex'),
26
+ startProcessing: document.getElementById('startProcessing'),
27
+ stopProcessing: document.getElementById('stopProcessing'),
28
+ refreshBtn: document.getElementById('refreshBtn'),
29
+ themeToggle: document.getElementById('themeToggle'),
30
+ fileCount: document.getElementById('fileCount'),
31
+ filesGrid: document.getElementById('filesGrid'),
32
+ logsContainer: document.getElementById('logsContainer'),
33
+ clearLogs: document.getElementById('clearLogs'),
34
+ autoScroll: document.getElementById('autoScroll'),
35
+ fileModal: document.getElementById('fileModal'),
36
+ modalTitle: document.getElementById('modalTitle'),
37
+ modalBody: document.getElementById('modalBody'),
38
+ modalClose: document.getElementById('modalClose'),
39
+ downloadFile: document.getElementById('downloadFile'),
40
+ viewFrames: document.getElementById('viewFrames'),
41
+ loadingOverlay: document.getElementById('loadingOverlay'),
42
+ toastContainer: document.getElementById('toastContainer')
43
+ };
44
+
45
+ // Initialize the application
46
+ document.addEventListener('DOMContentLoaded', function() {
47
+ initializeTheme();
48
+ setupEventListeners();
49
+ startAutoRefresh();
50
+ fetchInitialData();
51
+ });
52
+
53
+ // Theme Management
54
+ function initializeTheme() {
55
+ const savedTheme = localStorage.getItem('theme') || 'light';
56
+ document.documentElement.setAttribute('data-theme', savedTheme);
57
+ updateThemeIcon(savedTheme);
58
+ }
59
+
60
+ function toggleTheme() {
61
+ const currentTheme = document.documentElement.getAttribute('data-theme');
62
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
63
+ document.documentElement.setAttribute('data-theme', newTheme);
64
+ localStorage.setItem('theme', newTheme);
65
+ updateThemeIcon(newTheme);
66
+ }
67
+
68
+ function updateThemeIcon(theme) {
69
+ const icon = elements.themeToggle.querySelector('i');
70
+ icon.className = theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
71
+ }
72
+
73
+ // Event Listeners
74
+ function setupEventListeners() {
75
+ elements.themeToggle.addEventListener('click', toggleTheme);
76
+ elements.refreshBtn.addEventListener('click', () => {
77
+ showToast('Refreshing data...', 'info');
78
+ fetchAllData();
79
+ });
80
+
81
+ elements.startProcessing.addEventListener('click', startProcessing);
82
+ elements.stopProcessing.addEventListener('click', stopProcessing);
83
+
84
+ elements.clearLogs.addEventListener('click', clearLogs);
85
+ elements.autoScroll.addEventListener('click', toggleAutoScroll);
86
+
87
+ elements.modalClose.addEventListener('click', closeModal);
88
+ elements.fileModal.addEventListener('click', (e) => {
89
+ if (e.target === elements.fileModal) closeModal();
90
+ });
91
+
92
+ elements.downloadFile.addEventListener('click', downloadSelectedFile);
93
+ elements.viewFrames.addEventListener('click', viewFrames);
94
+
95
+ // Keyboard shortcuts
96
+ document.addEventListener('keydown', (e) => {
97
+ if (e.key === 'Escape') closeModal();
98
+ if (e.key === 'F5') {
99
+ e.preventDefault();
100
+ fetchAllData();
101
+ }
102
+ });
103
+ }
104
+
105
+ // API Functions
106
+ async function apiRequest(endpoint, options = {}) {
107
+ try {
108
+ showLoading();
109
+ const response = await fetch(`${API_BASE_URL}${endpoint}`, {
110
+ headers: {
111
+ 'Content-Type': 'application/json',
112
+ ...options.headers
113
+ },
114
+ ...options
115
+ });
116
+
117
+ if (!response.ok) {
118
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
119
+ }
120
+
121
+ apiConnected = true;
122
+ return await response.json();
123
+ } catch (error) {
124
+ console.error('API request failed:', error);
125
+ apiConnected = false;
126
+
127
+ // Show different messages based on error type
128
+ if (error.name === 'TypeError' && error.message.includes('fetch')) {
129
+ showToast('API server not running. Please start the cursor tracking API on port 8000.', 'warning');
130
+ } else {
131
+ showToast(`API Error: ${error.message}`, 'error');
132
+ }
133
+ throw error;
134
+ } finally {
135
+ hideLoading();
136
+ }
137
+ }
138
+
139
+ async function fetchStatus() {
140
+ try {
141
+ const data = await apiRequest('/status');
142
+ updateStatusDisplay(data.processing_status);
143
+ return data;
144
+ } catch (error) {
145
+ // Show demo data when API is not connected
146
+ const demoStatus = {
147
+ is_running: false,
148
+ total_files: 150,
149
+ processed_files: 45,
150
+ extracted_courses: 12,
151
+ extracted_videos: 89,
152
+ extracted_frames_count: 15420,
153
+ tracked_cursors_count: 8934,
154
+ current_file: null,
155
+ logs: [
156
+ '[Demo Mode] API server not connected',
157
+ '[Demo Mode] This is a demonstration of the UI',
158
+ '[Demo Mode] Start the API server on port 8000 to see real data'
159
+ ]
160
+ };
161
+ updateStatusDisplay(demoStatus);
162
+ }
163
+ }
164
+
165
+ async function fetchCursorData() {
166
+ try {
167
+ const data = await apiRequest('/cursor-data');
168
+ currentFiles = data.files || [];
169
+ updateFilesDisplay(currentFiles);
170
+ return data;
171
+ } catch (error) {
172
+ // Show demo files when API is not connected
173
+ const demoFiles = [
174
+ {
175
+ filename: 'course_1_video_1_mp4_cursor_data.json',
176
+ size_bytes: 45678,
177
+ modified_time: 'Sun Jul 13 19:30:15 2025'
178
+ },
179
+ {
180
+ filename: 'course_2_video_3_mp4_cursor_data.json',
181
+ size_bytes: 67890,
182
+ modified_time: 'Sun Jul 13 18:45:22 2025'
183
+ },
184
+ {
185
+ filename: 'course_3_video_2_mp4_cursor_data.json',
186
+ size_bytes: 34567,
187
+ modified_time: 'Sun Jul 13 17:20:10 2025'
188
+ }
189
+ ];
190
+ currentFiles = demoFiles;
191
+ updateFilesDisplay(demoFiles);
192
+ }
193
+ }
194
+
195
+ async function fetchFileDetails(filename) {
196
+ try {
197
+ const data = await apiRequest(`/cursor-data/${filename}/summary`);
198
+ return data;
199
+ } catch (error) {
200
+ showToast(`Failed to fetch details for ${filename}`, 'error');
201
+ return null;
202
+ }
203
+ }
204
+
205
+ async function startProcessing() {
206
+ try {
207
+ const startIndex = parseInt(elements.startIndex.value) || 0;
208
+ const data = await apiRequest('/start-processing', {
209
+ method: 'POST',
210
+ body: JSON.stringify({ start_index: startIndex })
211
+ });
212
+
213
+ showToast(data.message, data.status === 'started' ? 'success' : 'info');
214
+
215
+ if (data.status === 'started') {
216
+ elements.startProcessing.disabled = true;
217
+ elements.stopProcessing.disabled = false;
218
+ }
219
+ } catch (error) {
220
+ showToast('Failed to start processing', 'error');
221
+ }
222
+ }
223
+
224
+ async function stopProcessing() {
225
+ try {
226
+ const data = await apiRequest('/stop-processing', {
227
+ method: 'POST'
228
+ });
229
+
230
+ showToast(data.message, 'info');
231
+ elements.startProcessing.disabled = false;
232
+ elements.stopProcessing.disabled = true;
233
+ } catch (error) {
234
+ showToast('Failed to stop processing', 'error');
235
+ }
236
+ }
237
+
238
+ // Display Update Functions
239
+ function updateStatusDisplay(status) {
240
+ // Update status indicator
241
+ const statusDot = elements.statusIndicator.querySelector('.status-dot');
242
+ const statusText = elements.statusIndicator.querySelector('.status-text');
243
+
244
+ if (status.is_running) {
245
+ statusDot.className = 'status-dot running';
246
+ statusText.textContent = 'Processing';
247
+ elements.startProcessing.disabled = true;
248
+ elements.stopProcessing.disabled = false;
249
+ } else {
250
+ statusDot.className = 'status-dot stopped';
251
+ statusText.textContent = 'Idle';
252
+ elements.startProcessing.disabled = false;
253
+ elements.stopProcessing.disabled = true;
254
+ }
255
+
256
+ // Update statistics
257
+ elements.totalFiles.textContent = status.total_files || 0;
258
+ elements.processedFiles.textContent = status.processed_files || 0;
259
+ elements.extractedCourses.textContent = status.extracted_courses || 0;
260
+ elements.extractedVideos.textContent = status.extracted_videos || 0;
261
+ elements.extractedFrames.textContent = status.extracted_frames_count || 0;
262
+ elements.trackedCursors.textContent = status.tracked_cursors_count || 0;
263
+
264
+ // Update current file and progress
265
+ const currentFile = status.current_file || 'None';
266
+ elements.currentFile.textContent = currentFile;
267
+
268
+ const progress = status.total_files > 0 ?
269
+ Math.round((status.processed_files / status.total_files) * 100) : 0;
270
+ elements.progressFill.style.width = `${progress}%`;
271
+ elements.progressText.textContent = `${progress}%`;
272
+
273
+ // Update logs
274
+ if (status.logs && status.logs.length > 0) {
275
+ updateLogs(status.logs);
276
+ }
277
+ }
278
+
279
+ function updateFilesDisplay(files) {
280
+ elements.fileCount.textContent = `${files.length} files`;
281
+
282
+ if (files.length === 0) {
283
+ elements.filesGrid.innerHTML = `
284
+ <div class="no-files">
285
+ <i class="fas fa-folder-open" style="font-size: 3rem; color: var(--text-muted); margin-bottom: 1rem;"></i>
286
+ <p style="color: var(--text-muted);">No cursor tracking files found yet.</p>
287
+ <p style="color: var(--text-muted); font-size: 0.875rem;">Files will appear here after processing completes.</p>
288
+ </div>
289
+ `;
290
+ return;
291
+ }
292
+
293
+ elements.filesGrid.innerHTML = files.map(file => `
294
+ <div class="file-card" onclick="openFileModal('${file.filename}')">
295
+ <div class="file-header">
296
+ <div class="file-name">${file.filename}</div>
297
+ <div class="file-size">${formatFileSize(file.size_bytes)}</div>
298
+ </div>
299
+ <div class="file-stats">
300
+ <div class="file-stat">
301
+ <span class="file-stat-label">Modified:</span>
302
+ <span class="file-stat-value">${formatDate(file.modified_time)}</span>
303
+ </div>
304
+ </div>
305
+ <div class="file-actions">
306
+ <button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); downloadFile('${file.filename}')">
307
+ <i class="fas fa-download"></i>
308
+ Download
309
+ </button>
310
+ <button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); openFileModal('${file.filename}')">
311
+ <i class="fas fa-eye"></i>
312
+ Details
313
+ </button>
314
+ </div>
315
+ </div>
316
+ `).join('');
317
+ }
318
+
319
+ function updateLogs(logs) {
320
+ const container = elements.logsContainer;
321
+
322
+ // Clear existing logs if we have new ones
323
+ if (logs.length > 0) {
324
+ container.innerHTML = '';
325
+ }
326
+
327
+ logs.slice(-MAX_LOGS).forEach(log => {
328
+ const logEntry = document.createElement('div');
329
+ logEntry.className = 'log-entry';
330
+
331
+ // Determine log type based on content
332
+ let logType = '';
333
+ if (log.includes('❌') || log.includes('ERROR') || log.includes('Failed')) {
334
+ logType = 'error';
335
+ } else if (log.includes('✅') || log.includes('SUCCESS') || log.includes('Successfully')) {
336
+ logType = 'success';
337
+ } else if (log.includes('⚠️') || log.includes('WARN')) {
338
+ logType = 'warning';
339
+ }
340
+
341
+ if (logType) {
342
+ logEntry.classList.add(logType);
343
+ }
344
+
345
+ // Extract timestamp and message
346
+ const timestampMatch = log.match(/^\[([^\]]+)\]/);
347
+ const timestamp = timestampMatch ? timestampMatch[1] : new Date().toLocaleTimeString();
348
+ const message = timestampMatch ? log.substring(timestampMatch[0].length).trim() : log;
349
+
350
+ logEntry.innerHTML = `
351
+ <span class="log-time">[${timestamp}]</span>
352
+ <span class="log-message">${escapeHtml(message)}</span>
353
+ `;
354
+
355
+ container.appendChild(logEntry);
356
+ });
357
+
358
+ // Auto-scroll to bottom if enabled
359
+ if (autoScrollEnabled) {
360
+ container.scrollTop = container.scrollHeight;
361
+ }
362
+ }
363
+
364
+ // Modal Functions
365
+ async function openFileModal(filename) {
366
+ selectedFile = filename;
367
+ elements.modalTitle.textContent = `File Details: ${filename}`;
368
+
369
+ showModal();
370
+
371
+ const details = await fetchFileDetails(filename);
372
+ if (details) {
373
+ elements.modalBody.innerHTML = `
374
+ <div class="file-details">
375
+ <div class="detail-section">
376
+ <h4>File Information</h4>
377
+ <div class="detail-grid">
378
+ <div class="detail-item">
379
+ <span class="detail-label">File Size:</span>
380
+ <span class="detail-value">${formatFileSize(details.file_size_bytes)}</span>
381
+ </div>
382
+ <div class="detail-item">
383
+ <span class="detail-label">Modified:</span>
384
+ <span class="detail-value">${details.modified_time}</span>
385
+ </div>
386
+ </div>
387
+ </div>
388
+
389
+ <div class="detail-section">
390
+ <h4>Frame Statistics</h4>
391
+ <div class="detail-grid">
392
+ <div class="detail-item">
393
+ <span class="detail-label">Total Frames:</span>
394
+ <span class="detail-value">${details.total_frames}</span>
395
+ </div>
396
+ <div class="detail-item">
397
+ <span class="detail-label">Cursor Active:</span>
398
+ <span class="detail-value">${details.cursor_active_frames}</span>
399
+ </div>
400
+ <div class="detail-item">
401
+ <span class="detail-label">Cursor Inactive:</span>
402
+ <span class="detail-value">${details.cursor_inactive_frames}</span>
403
+ </div>
404
+ <div class="detail-item">
405
+ <span class="detail-label">Detection Rate:</span>
406
+ <span class="detail-value">${(details.cursor_detection_rate * 100).toFixed(1)}%</span>
407
+ </div>
408
+ </div>
409
+ </div>
410
+
411
+ <div class="detail-section">
412
+ <h4>Confidence Statistics</h4>
413
+ <div class="detail-grid">
414
+ <div class="detail-item">
415
+ <span class="detail-label">Average:</span>
416
+ <span class="detail-value">${details.confidence_stats.average.toFixed(3)}</span>
417
+ </div>
418
+ <div class="detail-item">
419
+ <span class="detail-label">Maximum:</span>
420
+ <span class="detail-value">${details.confidence_stats.maximum.toFixed(3)}</span>
421
+ </div>
422
+ <div class="detail-item">
423
+ <span class="detail-label">Minimum:</span>
424
+ <span class="detail-value">${details.confidence_stats.minimum.toFixed(3)}</span>
425
+ </div>
426
+ <div class="detail-item">
427
+ <span class="detail-label">Measurements:</span>
428
+ <span class="detail-value">${details.confidence_stats.total_measurements}</span>
429
+ </div>
430
+ </div>
431
+ </div>
432
+
433
+ <div class="detail-section">
434
+ <h4>Templates Used</h4>
435
+ <div class="template-list">
436
+ ${details.templates_used.length > 0 ?
437
+ details.templates_used.map(template => `<span class="template-tag">${template}</span>`).join('') :
438
+ '<span class="no-templates">No templates detected</span>'
439
+ }
440
+ </div>
441
+ </div>
442
+ </div>
443
+
444
+ <style>
445
+ .file-details { font-size: 0.875rem; }
446
+ .detail-section { margin-bottom: 1.5rem; }
447
+ .detail-section h4 {
448
+ margin-bottom: 0.75rem;
449
+ color: var(--accent-primary);
450
+ font-weight: 600;
451
+ }
452
+ .detail-grid {
453
+ display: grid;
454
+ grid-template-columns: 1fr 1fr;
455
+ gap: 0.5rem;
456
+ }
457
+ .detail-item {
458
+ display: flex;
459
+ justify-content: space-between;
460
+ padding: 0.5rem;
461
+ background: var(--bg-secondary);
462
+ border-radius: var(--radius);
463
+ }
464
+ .detail-label { color: var(--text-secondary); }
465
+ .detail-value { font-weight: 500; }
466
+ .template-list {
467
+ display: flex;
468
+ flex-wrap: wrap;
469
+ gap: 0.5rem;
470
+ }
471
+ .template-tag {
472
+ background: var(--accent-primary);
473
+ color: white;
474
+ padding: 0.25rem 0.5rem;
475
+ border-radius: var(--radius);
476
+ font-size: 0.75rem;
477
+ }
478
+ .no-templates {
479
+ color: var(--text-muted);
480
+ font-style: italic;
481
+ }
482
+ </style>
483
+ `;
484
+ } else {
485
+ elements.modalBody.innerHTML = '<p>Failed to load file details.</p>';
486
+ }
487
+ }
488
+
489
+ function showModal() {
490
+ elements.fileModal.classList.add('show');
491
+ document.body.style.overflow = 'hidden';
492
+ }
493
+
494
+ function closeModal() {
495
+ elements.fileModal.classList.remove('show');
496
+ document.body.style.overflow = '';
497
+ selectedFile = null;
498
+ }
499
+
500
+ // File Operations
501
+ async function downloadFile(filename) {
502
+ try {
503
+ const response = await fetch(`${API_BASE_URL}/cursor-data/${filename}`);
504
+ if (!response.ok) throw new Error('Download failed');
505
+
506
+ const blob = await response.blob();
507
+ const url = window.URL.createObjectURL(blob);
508
+ const a = document.createElement('a');
509
+ a.href = url;
510
+ a.download = filename;
511
+ document.body.appendChild(a);
512
+ a.click();
513
+ document.body.removeChild(a);
514
+ window.URL.revokeObjectURL(url);
515
+
516
+ showToast(`Downloaded ${filename}`, 'success');
517
+ } catch (error) {
518
+ showToast(`Failed to download ${filename}`, 'error');
519
+ }
520
+ }
521
+
522
+ function downloadSelectedFile() {
523
+ if (selectedFile) {
524
+ downloadFile(selectedFile);
525
+ }
526
+ }
527
+
528
+ function viewFrames() {
529
+ if (selectedFile) {
530
+ showToast('Frame viewer feature coming soon!', 'info');
531
+ }
532
+ }
533
+
534
+ // Utility Functions
535
+ function formatFileSize(bytes) {
536
+ if (bytes === 0) return '0 B';
537
+ const k = 1024;
538
+ const sizes = ['B', 'KB', 'MB', 'GB'];
539
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
540
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
541
+ }
542
+
543
+ function formatDate(dateString) {
544
+ try {
545
+ return new Date(dateString).toLocaleDateString();
546
+ } catch {
547
+ return dateString;
548
+ }
549
+ }
550
+
551
+ function escapeHtml(text) {
552
+ const div = document.createElement('div');
553
+ div.textContent = text;
554
+ return div.innerHTML;
555
+ }
556
+
557
+ // Log Management
558
+ function clearLogs() {
559
+ elements.logsContainer.innerHTML = '<div class="log-entry"><span class="log-time">[' +
560
+ new Date().toLocaleTimeString() + ']</span><span class="log-message">Logs cleared</span></div>';
561
+ showToast('Logs cleared', 'info');
562
+ }
563
+
564
+ function toggleAutoScroll() {
565
+ autoScrollEnabled = !autoScrollEnabled;
566
+ elements.autoScroll.classList.toggle('active', autoScrollEnabled);
567
+
568
+ if (autoScrollEnabled) {
569
+ elements.logsContainer.scrollTop = elements.logsContainer.scrollHeight;
570
+ }
571
+ }
572
+
573
+ // Loading and Toast Functions
574
+ function showLoading() {
575
+ elements.loadingOverlay.classList.add('show');
576
+ }
577
+
578
+ function hideLoading() {
579
+ elements.loadingOverlay.classList.remove('show');
580
+ }
581
+
582
+ function showToast(message, type = 'info', duration = 5000) {
583
+ const toast = document.createElement('div');
584
+ toast.className = `toast ${type}`;
585
+
586
+ const icons = {
587
+ success: 'fas fa-check-circle',
588
+ error: 'fas fa-exclamation-circle',
589
+ warning: 'fas fa-exclamation-triangle',
590
+ info: 'fas fa-info-circle'
591
+ };
592
+
593
+ toast.innerHTML = `
594
+ <i class="toast-icon ${icons[type]}"></i>
595
+ <div class="toast-content">
596
+ <div class="toast-message">${escapeHtml(message)}</div>
597
+ </div>
598
+ <button class="toast-close">
599
+ <i class="fas fa-times"></i>
600
+ </button>
601
+ `;
602
+
603
+ const closeBtn = toast.querySelector('.toast-close');
604
+ closeBtn.addEventListener('click', () => removeToast(toast));
605
+
606
+ elements.toastContainer.appendChild(toast);
607
+
608
+ // Auto-remove after duration
609
+ setTimeout(() => removeToast(toast), duration);
610
+ }
611
+
612
+ function removeToast(toast) {
613
+ if (toast && toast.parentNode) {
614
+ toast.style.animation = 'slideInRight 0.3s ease reverse';
615
+ setTimeout(() => {
616
+ if (toast.parentNode) {
617
+ toast.parentNode.removeChild(toast);
618
+ }
619
+ }, 300);
620
+ }
621
+ }
622
+
623
+ // Auto-refresh Management
624
+ function startAutoRefresh() {
625
+ fetchAllData(); // Initial fetch
626
+ refreshInterval = setInterval(fetchAllData, REFRESH_INTERVAL);
627
+ }
628
+
629
+ function stopAutoRefresh() {
630
+ if (refreshInterval) {
631
+ clearInterval(refreshInterval);
632
+ refreshInterval = null;
633
+ }
634
+ }
635
+
636
+ async function fetchInitialData() {
637
+ await fetchAllData();
638
+ }
639
+
640
+ async function fetchAllData() {
641
+ try {
642
+ await Promise.all([
643
+ fetchStatus(),
644
+ fetchCursorData()
645
+ ]);
646
+ } catch (error) {
647
+ console.error('Failed to fetch data:', error);
648
+ }
649
+ }
650
+
651
+ // Cleanup on page unload
652
+ window.addEventListener('beforeunload', () => {
653
+ stopAutoRefresh();
654
+ });
655
+
static/style.css ADDED
@@ -0,0 +1,770 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Reset and Base Styles */
2
+ * {
3
+ margin: 0;
4
+ padding: 0;
5
+ box-sizing: border-box;
6
+ }
7
+
8
+ :root {
9
+ /* Light theme colors */
10
+ --bg-primary: #ffffff;
11
+ --bg-secondary: #f8fafc;
12
+ --bg-tertiary: #f1f5f9;
13
+ --text-primary: #1e293b;
14
+ --text-secondary: #64748b;
15
+ --text-muted: #94a3b8;
16
+ --border-color: #e2e8f0;
17
+ --accent-primary: #3b82f6;
18
+ --accent-secondary: #8b5cf6;
19
+ --success: #10b981;
20
+ --warning: #f59e0b;
21
+ --danger: #ef4444;
22
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
23
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
24
+ --radius: 8px;
25
+ --radius-lg: 12px;
26
+ --transition: all 0.2s ease-in-out;
27
+ }
28
+
29
+ [data-theme="dark"] {
30
+ --bg-primary: #0f172a;
31
+ --bg-secondary: #1e293b;
32
+ --bg-tertiary: #334155;
33
+ --text-primary: #f8fafc;
34
+ --text-secondary: #cbd5e1;
35
+ --text-muted: #64748b;
36
+ --border-color: #334155;
37
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3);
38
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3);
39
+ }
40
+
41
+ body {
42
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
43
+ background: var(--bg-secondary);
44
+ color: var(--text-primary);
45
+ line-height: 1.6;
46
+ transition: var(--transition);
47
+ }
48
+
49
+ /* Container */
50
+ .container {
51
+ max-width: 1400px;
52
+ margin: 0 auto;
53
+ padding: 0 1rem;
54
+ }
55
+
56
+ /* Header */
57
+ .header {
58
+ background: var(--bg-primary);
59
+ border-bottom: 1px solid var(--border-color);
60
+ padding: 1rem 0;
61
+ position: sticky;
62
+ top: 0;
63
+ z-index: 100;
64
+ backdrop-filter: blur(10px);
65
+ }
66
+
67
+ .header-content {
68
+ display: flex;
69
+ justify-content: space-between;
70
+ align-items: center;
71
+ }
72
+
73
+ .logo {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 0.75rem;
77
+ }
78
+
79
+ .logo i {
80
+ font-size: 1.5rem;
81
+ color: var(--accent-primary);
82
+ }
83
+
84
+ .logo h1 {
85
+ font-size: 1.5rem;
86
+ font-weight: 600;
87
+ color: var(--text-primary);
88
+ }
89
+
90
+ .header-actions {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 1rem;
94
+ }
95
+
96
+ /* Main Content */
97
+ .main-content {
98
+ padding: 2rem 0;
99
+ display: flex;
100
+ flex-direction: column;
101
+ gap: 2rem;
102
+ }
103
+
104
+ /* Cards */
105
+ .card {
106
+ background: var(--bg-primary);
107
+ border: 1px solid var(--border-color);
108
+ border-radius: var(--radius-lg);
109
+ box-shadow: var(--shadow);
110
+ overflow: hidden;
111
+ transition: var(--transition);
112
+ }
113
+
114
+ .card:hover {
115
+ box-shadow: var(--shadow-lg);
116
+ }
117
+
118
+ .card-header {
119
+ padding: 1.5rem;
120
+ border-bottom: 1px solid var(--border-color);
121
+ display: flex;
122
+ justify-content: space-between;
123
+ align-items: center;
124
+ }
125
+
126
+ .card-header h2 {
127
+ font-size: 1.25rem;
128
+ font-weight: 600;
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 0.5rem;
132
+ }
133
+
134
+ .card-header i {
135
+ color: var(--accent-primary);
136
+ }
137
+
138
+ .card-content {
139
+ padding: 1.5rem;
140
+ }
141
+
142
+ /* Status Section */
143
+ .status-indicator {
144
+ display: flex;
145
+ align-items: center;
146
+ gap: 0.5rem;
147
+ font-size: 0.875rem;
148
+ font-weight: 500;
149
+ }
150
+
151
+ .status-dot {
152
+ width: 8px;
153
+ height: 8px;
154
+ border-radius: 50%;
155
+ background: var(--text-muted);
156
+ animation: pulse 2s infinite;
157
+ }
158
+
159
+ .status-dot.running {
160
+ background: var(--success);
161
+ }
162
+
163
+ .status-dot.stopped {
164
+ background: var(--danger);
165
+ }
166
+
167
+ .status-dot.idle {
168
+ background: var(--warning);
169
+ }
170
+
171
+ @keyframes pulse {
172
+ 0%, 100% { opacity: 1; }
173
+ 50% { opacity: 0.5; }
174
+ }
175
+
176
+ .stats-grid {
177
+ display: grid;
178
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
179
+ gap: 1.5rem;
180
+ margin-bottom: 2rem;
181
+ }
182
+
183
+ .stat-item {
184
+ text-align: center;
185
+ padding: 1rem;
186
+ background: var(--bg-secondary);
187
+ border-radius: var(--radius);
188
+ transition: var(--transition);
189
+ }
190
+
191
+ .stat-item:hover {
192
+ transform: translateY(-2px);
193
+ box-shadow: var(--shadow);
194
+ }
195
+
196
+ .stat-value {
197
+ font-size: 2rem;
198
+ font-weight: 700;
199
+ color: var(--accent-primary);
200
+ margin-bottom: 0.25rem;
201
+ }
202
+
203
+ .stat-label {
204
+ font-size: 0.875rem;
205
+ color: var(--text-secondary);
206
+ font-weight: 500;
207
+ }
208
+
209
+ .progress-section {
210
+ background: var(--bg-secondary);
211
+ padding: 1.5rem;
212
+ border-radius: var(--radius);
213
+ }
214
+
215
+ .progress-info {
216
+ display: flex;
217
+ justify-content: space-between;
218
+ align-items: center;
219
+ margin-bottom: 1rem;
220
+ font-size: 0.875rem;
221
+ }
222
+
223
+ .current-file {
224
+ font-weight: 600;
225
+ color: var(--accent-primary);
226
+ max-width: 300px;
227
+ overflow: hidden;
228
+ text-overflow: ellipsis;
229
+ white-space: nowrap;
230
+ }
231
+
232
+ .progress-bar {
233
+ height: 8px;
234
+ background: var(--bg-tertiary);
235
+ border-radius: 4px;
236
+ overflow: hidden;
237
+ margin-bottom: 0.5rem;
238
+ }
239
+
240
+ .progress-fill {
241
+ height: 100%;
242
+ background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
243
+ border-radius: 4px;
244
+ transition: width 0.3s ease;
245
+ width: 0%;
246
+ }
247
+
248
+ .progress-text {
249
+ text-align: center;
250
+ font-size: 0.875rem;
251
+ font-weight: 600;
252
+ color: var(--text-secondary);
253
+ }
254
+
255
+ /* Control Section */
256
+ .control-group {
257
+ display: flex;
258
+ flex-direction: column;
259
+ gap: 1.5rem;
260
+ }
261
+
262
+ .input-group {
263
+ display: flex;
264
+ flex-direction: column;
265
+ gap: 0.5rem;
266
+ }
267
+
268
+ .input-group label {
269
+ font-weight: 500;
270
+ color: var(--text-primary);
271
+ }
272
+
273
+ .input {
274
+ padding: 0.75rem;
275
+ border: 1px solid var(--border-color);
276
+ border-radius: var(--radius);
277
+ background: var(--bg-primary);
278
+ color: var(--text-primary);
279
+ font-size: 0.875rem;
280
+ transition: var(--transition);
281
+ max-width: 200px;
282
+ }
283
+
284
+ .input:focus {
285
+ outline: none;
286
+ border-color: var(--accent-primary);
287
+ box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1);
288
+ }
289
+
290
+ .input-help {
291
+ font-size: 0.75rem;
292
+ color: var(--text-muted);
293
+ }
294
+
295
+ .button-group {
296
+ display: flex;
297
+ gap: 1rem;
298
+ flex-wrap: wrap;
299
+ }
300
+
301
+ /* Buttons */
302
+ .btn {
303
+ display: inline-flex;
304
+ align-items: center;
305
+ gap: 0.5rem;
306
+ padding: 0.75rem 1.5rem;
307
+ border: none;
308
+ border-radius: var(--radius);
309
+ font-size: 0.875rem;
310
+ font-weight: 500;
311
+ cursor: pointer;
312
+ transition: var(--transition);
313
+ text-decoration: none;
314
+ white-space: nowrap;
315
+ }
316
+
317
+ .btn:disabled {
318
+ opacity: 0.5;
319
+ cursor: not-allowed;
320
+ }
321
+
322
+ .btn-primary {
323
+ background: var(--accent-primary);
324
+ color: white;
325
+ }
326
+
327
+ .btn-primary:hover:not(:disabled) {
328
+ background: #2563eb;
329
+ transform: translateY(-1px);
330
+ box-shadow: var(--shadow);
331
+ }
332
+
333
+ .btn-secondary {
334
+ background: var(--bg-secondary);
335
+ color: var(--text-primary);
336
+ border: 1px solid var(--border-color);
337
+ }
338
+
339
+ .btn-secondary:hover:not(:disabled) {
340
+ background: var(--bg-tertiary);
341
+ transform: translateY(-1px);
342
+ }
343
+
344
+ .btn-danger {
345
+ background: var(--danger);
346
+ color: white;
347
+ }
348
+
349
+ .btn-danger:hover:not(:disabled) {
350
+ background: #dc2626;
351
+ transform: translateY(-1px);
352
+ box-shadow: var(--shadow);
353
+ }
354
+
355
+ .btn-sm {
356
+ padding: 0.5rem 1rem;
357
+ font-size: 0.75rem;
358
+ }
359
+
360
+ .btn-icon {
361
+ padding: 0.75rem;
362
+ width: auto;
363
+ height: auto;
364
+ }
365
+
366
+ .btn.active {
367
+ background: var(--accent-primary);
368
+ color: white;
369
+ }
370
+
371
+ /* Files Section */
372
+ .file-count {
373
+ font-size: 0.875rem;
374
+ color: var(--text-secondary);
375
+ font-weight: 500;
376
+ }
377
+
378
+ .files-grid {
379
+ display: grid;
380
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
381
+ gap: 1rem;
382
+ }
383
+
384
+ .file-card {
385
+ background: var(--bg-secondary);
386
+ border: 1px solid var(--border-color);
387
+ border-radius: var(--radius);
388
+ padding: 1rem;
389
+ transition: var(--transition);
390
+ cursor: pointer;
391
+ }
392
+
393
+ .file-card:hover {
394
+ transform: translateY(-2px);
395
+ box-shadow: var(--shadow);
396
+ border-color: var(--accent-primary);
397
+ }
398
+
399
+ .file-header {
400
+ display: flex;
401
+ justify-content: space-between;
402
+ align-items: flex-start;
403
+ margin-bottom: 0.75rem;
404
+ }
405
+
406
+ .file-name {
407
+ font-weight: 600;
408
+ color: var(--text-primary);
409
+ font-size: 0.875rem;
410
+ word-break: break-word;
411
+ flex: 1;
412
+ margin-right: 0.5rem;
413
+ }
414
+
415
+ .file-size {
416
+ font-size: 0.75rem;
417
+ color: var(--text-muted);
418
+ white-space: nowrap;
419
+ }
420
+
421
+ .file-stats {
422
+ display: grid;
423
+ grid-template-columns: 1fr 1fr;
424
+ gap: 0.5rem;
425
+ margin-bottom: 0.75rem;
426
+ }
427
+
428
+ .file-stat {
429
+ display: flex;
430
+ justify-content: space-between;
431
+ font-size: 0.75rem;
432
+ }
433
+
434
+ .file-stat-label {
435
+ color: var(--text-secondary);
436
+ }
437
+
438
+ .file-stat-value {
439
+ color: var(--text-primary);
440
+ font-weight: 500;
441
+ }
442
+
443
+ .file-actions {
444
+ display: flex;
445
+ gap: 0.5rem;
446
+ }
447
+
448
+ .file-actions .btn {
449
+ padding: 0.5rem 0.75rem;
450
+ font-size: 0.75rem;
451
+ }
452
+
453
+ /* Logs Section */
454
+ .log-controls {
455
+ display: flex;
456
+ gap: 0.5rem;
457
+ }
458
+
459
+ .logs-container {
460
+ background: var(--bg-secondary);
461
+ border: 1px solid var(--border-color);
462
+ border-radius: var(--radius);
463
+ height: 300px;
464
+ overflow-y: auto;
465
+ padding: 1rem;
466
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
467
+ font-size: 0.75rem;
468
+ line-height: 1.4;
469
+ }
470
+
471
+ .log-entry {
472
+ margin-bottom: 0.5rem;
473
+ display: flex;
474
+ gap: 0.5rem;
475
+ }
476
+
477
+ .log-time {
478
+ color: var(--text-muted);
479
+ white-space: nowrap;
480
+ }
481
+
482
+ .log-message {
483
+ color: var(--text-primary);
484
+ word-break: break-word;
485
+ }
486
+
487
+ .log-entry.error .log-message {
488
+ color: var(--danger);
489
+ }
490
+
491
+ .log-entry.success .log-message {
492
+ color: var(--success);
493
+ }
494
+
495
+ .log-entry.warning .log-message {
496
+ color: var(--warning);
497
+ }
498
+
499
+ /* Modal */
500
+ .modal {
501
+ display: none;
502
+ position: fixed;
503
+ top: 0;
504
+ left: 0;
505
+ width: 100%;
506
+ height: 100%;
507
+ background: rgba(0, 0, 0, 0.5);
508
+ z-index: 1000;
509
+ backdrop-filter: blur(4px);
510
+ }
511
+
512
+ .modal.show {
513
+ display: flex;
514
+ align-items: center;
515
+ justify-content: center;
516
+ animation: fadeIn 0.2s ease;
517
+ }
518
+
519
+ .modal-content {
520
+ background: var(--bg-primary);
521
+ border-radius: var(--radius-lg);
522
+ box-shadow: var(--shadow-lg);
523
+ max-width: 600px;
524
+ width: 90%;
525
+ max-height: 80vh;
526
+ overflow: hidden;
527
+ animation: slideUp 0.2s ease;
528
+ }
529
+
530
+ .modal-header {
531
+ padding: 1.5rem;
532
+ border-bottom: 1px solid var(--border-color);
533
+ display: flex;
534
+ justify-content: space-between;
535
+ align-items: center;
536
+ }
537
+
538
+ .modal-header h3 {
539
+ font-size: 1.25rem;
540
+ font-weight: 600;
541
+ }
542
+
543
+ .modal-close {
544
+ background: none;
545
+ border: none;
546
+ font-size: 1.25rem;
547
+ cursor: pointer;
548
+ color: var(--text-muted);
549
+ padding: 0.25rem;
550
+ border-radius: var(--radius);
551
+ transition: var(--transition);
552
+ }
553
+
554
+ .modal-close:hover {
555
+ background: var(--bg-secondary);
556
+ color: var(--text-primary);
557
+ }
558
+
559
+ .modal-body {
560
+ padding: 1.5rem;
561
+ max-height: 400px;
562
+ overflow-y: auto;
563
+ }
564
+
565
+ .modal-footer {
566
+ padding: 1.5rem;
567
+ border-top: 1px solid var(--border-color);
568
+ display: flex;
569
+ gap: 1rem;
570
+ justify-content: flex-end;
571
+ }
572
+
573
+ /* Loading Overlay */
574
+ .loading-overlay {
575
+ display: none;
576
+ position: fixed;
577
+ top: 0;
578
+ left: 0;
579
+ width: 100%;
580
+ height: 100%;
581
+ background: rgba(0, 0, 0, 0.3);
582
+ z-index: 2000;
583
+ backdrop-filter: blur(2px);
584
+ }
585
+
586
+ .loading-overlay.show {
587
+ display: flex;
588
+ align-items: center;
589
+ justify-content: center;
590
+ }
591
+
592
+ .loading-spinner {
593
+ background: var(--bg-primary);
594
+ padding: 2rem;
595
+ border-radius: var(--radius-lg);
596
+ text-align: center;
597
+ box-shadow: var(--shadow-lg);
598
+ }
599
+
600
+ .loading-spinner i {
601
+ font-size: 2rem;
602
+ color: var(--accent-primary);
603
+ margin-bottom: 1rem;
604
+ }
605
+
606
+ .loading-spinner p {
607
+ color: var(--text-secondary);
608
+ font-weight: 500;
609
+ }
610
+
611
+ /* Toast Notifications */
612
+ .toast-container {
613
+ position: fixed;
614
+ top: 1rem;
615
+ right: 1rem;
616
+ z-index: 3000;
617
+ display: flex;
618
+ flex-direction: column;
619
+ gap: 0.5rem;
620
+ }
621
+
622
+ .toast {
623
+ background: var(--bg-primary);
624
+ border: 1px solid var(--border-color);
625
+ border-radius: var(--radius);
626
+ padding: 1rem;
627
+ box-shadow: var(--shadow-lg);
628
+ min-width: 300px;
629
+ animation: slideInRight 0.3s ease;
630
+ display: flex;
631
+ align-items: center;
632
+ gap: 0.75rem;
633
+ }
634
+
635
+ .toast.success {
636
+ border-left: 4px solid var(--success);
637
+ }
638
+
639
+ .toast.error {
640
+ border-left: 4px solid var(--danger);
641
+ }
642
+
643
+ .toast.warning {
644
+ border-left: 4px solid var(--warning);
645
+ }
646
+
647
+ .toast.info {
648
+ border-left: 4px solid var(--accent-primary);
649
+ }
650
+
651
+ .toast-icon {
652
+ font-size: 1.25rem;
653
+ }
654
+
655
+ .toast.success .toast-icon {
656
+ color: var(--success);
657
+ }
658
+
659
+ .toast.error .toast-icon {
660
+ color: var(--danger);
661
+ }
662
+
663
+ .toast.warning .toast-icon {
664
+ color: var(--warning);
665
+ }
666
+
667
+ .toast.info .toast-icon {
668
+ color: var(--accent-primary);
669
+ }
670
+
671
+ .toast-content {
672
+ flex: 1;
673
+ }
674
+
675
+ .toast-title {
676
+ font-weight: 600;
677
+ margin-bottom: 0.25rem;
678
+ }
679
+
680
+ .toast-message {
681
+ font-size: 0.875rem;
682
+ color: var(--text-secondary);
683
+ }
684
+
685
+ .toast-close {
686
+ background: none;
687
+ border: none;
688
+ color: var(--text-muted);
689
+ cursor: pointer;
690
+ padding: 0.25rem;
691
+ border-radius: var(--radius);
692
+ transition: var(--transition);
693
+ }
694
+
695
+ .toast-close:hover {
696
+ background: var(--bg-secondary);
697
+ color: var(--text-primary);
698
+ }
699
+
700
+ /* Animations */
701
+ @keyframes fadeIn {
702
+ from { opacity: 0; }
703
+ to { opacity: 1; }
704
+ }
705
+
706
+ @keyframes slideUp {
707
+ from { transform: translateY(20px); opacity: 0; }
708
+ to { transform: translateY(0); opacity: 1; }
709
+ }
710
+
711
+ @keyframes slideInRight {
712
+ from { transform: translateX(100%); opacity: 0; }
713
+ to { transform: translateX(0); opacity: 1; }
714
+ }
715
+
716
+ /* Responsive Design */
717
+ @media (max-width: 768px) {
718
+ .container {
719
+ padding: 0 0.5rem;
720
+ }
721
+
722
+ .header-content {
723
+ flex-direction: column;
724
+ gap: 1rem;
725
+ align-items: flex-start;
726
+ }
727
+
728
+ .stats-grid {
729
+ grid-template-columns: repeat(2, 1fr);
730
+ }
731
+
732
+ .button-group {
733
+ flex-direction: column;
734
+ }
735
+
736
+ .files-grid {
737
+ grid-template-columns: 1fr;
738
+ }
739
+
740
+ .modal-content {
741
+ width: 95%;
742
+ margin: 1rem;
743
+ }
744
+
745
+ .toast {
746
+ min-width: 280px;
747
+ }
748
+
749
+ .toast-container {
750
+ left: 1rem;
751
+ right: 1rem;
752
+ }
753
+ }
754
+
755
+ @media (max-width: 480px) {
756
+ .stats-grid {
757
+ grid-template-columns: 1fr;
758
+ }
759
+
760
+ .progress-info {
761
+ flex-direction: column;
762
+ align-items: flex-start;
763
+ gap: 0.5rem;
764
+ }
765
+
766
+ .current-file {
767
+ max-width: 100%;
768
+ }
769
+ }
770
+