Indrajit Ari commited on
Commit
f020d6c
Β·
1 Parent(s): 5433093

chore: add HF Spaces Docker deployment

Browse files
Dockerfile ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # Hugging Face Spaces β€” Docker SDK
3
+ # Architecture:
4
+ # supervisord manages two processes:
5
+ # - Next.js standalone server on :3000
6
+ # - FastAPI (uvicorn) on :8000
7
+ # nginx on :7860 routes:
8
+ # /api/* and /ws/* β†’ FastAPI :8000
9
+ # everything else β†’ Next.js :3000
10
+ # ─────────────────────────────────────────────────────────────────────────────
11
+
12
+ # ── Stage 1: Build Next.js (standalone output) ─────────────────────────────
13
+ FROM node:20-slim AS frontend-builder
14
+
15
+ WORKDIR /build/frontend
16
+ COPY frontend/package*.json ./
17
+ RUN npm ci
18
+
19
+ COPY frontend/ ./
20
+ # Empty API URL β†’ all /api/* and /ws/* go through nginx to FastAPI
21
+ ENV NEXT_PUBLIC_API_URL=""
22
+ # Enable standalone output (required for Docker; skipped in local dev)
23
+ ENV BUILD_STANDALONE=1
24
+ RUN npm run build
25
+
26
+ # ── Stage 2: Runtime ────────────────────────────────────────────────────────
27
+ FROM python:3.10-slim
28
+
29
+ # System deps: ffmpeg + OpenCV libs + nginx + supervisor + Node.js runtime
30
+ RUN apt-get update && apt-get install -y \
31
+ ffmpeg libgl1 libglib2.0-0 \
32
+ nginx supervisor curl \
33
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
34
+ && apt-get install -y nodejs \
35
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
36
+
37
+ WORKDIR /app
38
+
39
+ # ── Python: CPU-only torch first (layer cache) ──────────────────────────────
40
+ RUN pip install --no-cache-dir \
41
+ torch==2.1.2 torchvision==0.16.2 \
42
+ --index-url https://download.pytorch.org/whl/cpu
43
+
44
+ # ── Python: app dependencies ────────────────────────────────────────────────
45
+ RUN pip install --no-cache-dir \
46
+ "fastapi>=0.110.0" \
47
+ "uvicorn[standard]>=0.29.0" \
48
+ "python-multipart>=0.0.9" \
49
+ "aiofiles>=23.0.0" \
50
+ "opencv-python-headless>=4.9.0" \
51
+ "Pillow>=10.0.0" \
52
+ "numpy>=1.24.0,<2.0" \
53
+ "imageio>=2.33.0" \
54
+ "imageio-ffmpeg>=0.4.9"
55
+
56
+ # ── Copy app code ────────────────────────────────────────────────────────────
57
+ COPY backend/ ./backend/
58
+
59
+ # ── Copy Next.js standalone build ───────────────────────────────────────────
60
+ COPY --from=frontend-builder /build/frontend/.next/standalone ./frontend/
61
+ COPY --from=frontend-builder /build/frontend/.next/static ./frontend/.next/static
62
+ COPY --from=frontend-builder /build/frontend/public ./frontend/public
63
+
64
+ # ── nginx config ────────────────────────────────────────────────────────────
65
+ COPY nginx.conf /etc/nginx/nginx.conf
66
+
67
+ # ── supervisord config ───────────────────────────────────────────────────────
68
+ COPY supervisord.conf /etc/supervisor/conf.d/app.conf
69
+
70
+ # ── Directories ─────────────────────────────────────────────────────────────
71
+ RUN mkdir -p /tmp/video_seg/uploads /tmp/video_seg/outputs \
72
+ && mkdir -p /var/log/supervisor
73
+
74
+ # HF Spaces requires port 7860
75
+ EXPOSE 7860
76
+
77
+ CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"]
README.md CHANGED
@@ -1,196 +1,15 @@
1
- # SegVision β€” AI Video Segmentation App
2
-
3
- > Upload any video β†’ get real-time semantic segmentation with 21-class PASCAL VOC overlay.
4
- > Powered by **DeepLabV3 + ResNet-50**, **FastAPI**, **Celery**, **Redis**, and **Next.js 14**.
5
-
6
- ---
7
-
8
- ## Architecture
9
-
10
- ```
11
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” HTTP/WS β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
12
- β”‚ Next.js 14 │◄─────────────────►│ FastAPI β”‚
13
- β”‚ (port 3000) β”‚ upload/status β”‚ (port 8000) β”‚
14
- β”‚ Dark UI β”‚ WS progress β”‚ DeepLabV3 model β”‚
15
- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
16
- β”‚ Celery tasks
17
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
18
- β”‚ Redis β”‚
19
- β”‚ (broker + backend) β”‚
20
- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
21
- β”‚
22
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
23
- β”‚ Celery Worker β”‚
24
- β”‚ (GPU inference) β”‚
25
- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
26
- ```
27
-
28
- ---
29
-
30
- ## Quick Start (Local Dev)
31
-
32
- ### Prerequisites
33
- - Python 3.10+
34
- - Node.js 18+ (for frontend)
35
- - Redis (or Docker to run Redis)
36
- - Optional: CUDA-capable GPU
37
-
38
- ### One-command start
39
-
40
- ```bash
41
- bash start.sh
42
- ```
43
-
44
- This will:
45
- 1. Start Redis (via Docker if not installed locally)
46
- 2. Create Python venv + install backend deps
47
- 3. Start Celery worker
48
- 4. Start FastAPI on `:8000`
49
- 5. Start Next.js on `:3000`
50
-
51
- Then open **http://localhost:3000** πŸŽ‰
52
-
53
- ---
54
-
55
- ## Manual Setup
56
-
57
- ### Backend
58
-
59
- ```bash
60
- cd backend
61
- python3 -m venv .venv
62
- source .venv/bin/activate # Windows: .venv\Scripts\activate
63
- pip install -r requirements.txt
64
-
65
- # Terminal 1 β€” API server
66
- uvicorn main:app --reload --port 8000
67
-
68
- # Terminal 2 β€” Celery worker
69
- celery -A tasks worker --loglevel=info
70
- ```
71
-
72
- ### Frontend
73
-
74
- ```bash
75
- cd frontend
76
- npm install
77
- npm run dev
78
- ```
79
-
80
- ### Redis (if not installed)
81
-
82
- ```bash
83
- docker run -d -p 6379:6379 redis:7-alpine
84
- ```
85
-
86
  ---
87
-
88
- ## Docker (Production)
89
-
90
- ```bash
91
- docker-compose up --build
92
- ```
93
-
94
- Services:
95
- | Service | Port | Description |
96
- |---|---|---|
97
- | `frontend` | 3000 | Next.js UI |
98
- | `backend` | 8000 | FastAPI + model |
99
- | `worker` | β€” | Celery inference worker |
100
- | `redis` | 6379 | Message broker |
101
-
102
  ---
103
 
104
- ## API Reference
105
-
106
- | Method | Endpoint | Description |
107
- |---|---|---|
108
- | `POST` | `/api/upload` | Upload video β†’ returns `job_id` |
109
- | `GET` | `/api/status/{job_id}` | Job progress (0–100%) + detected classes |
110
- | `GET` | `/api/video/{job_id}` | Stream segmented MP4 |
111
- | `DELETE` | `/api/job/{job_id}` | Cleanup files |
112
- | `WS` | `/ws/{job_id}` | Real-time progress stream |
113
- | `GET` | `/api/health` | Health check + device info |
114
- | `GET` | `/docs` | Interactive Swagger UI |
115
-
116
- ### Upload Response
117
- ```json
118
- {
119
- "job_id": "uuid",
120
- "status": "queued",
121
- "filename": "my_video.mp4",
122
- "size_mb": 12.5
123
- }
124
- ```
125
-
126
- ### Status Response
127
- ```json
128
- {
129
- "job_id": "uuid",
130
- "status": "processing",
131
- "pct": 42.7,
132
- "detected": ["person", "car", "dog"]
133
- }
134
- ```
135
-
136
- ---
137
-
138
- ## PASCAL VOC Classes (21)
139
-
140
- | ID | Class | Colour |
141
- |---|---|---|
142
- | 0 | background | ⬛ black |
143
- | 1 | aeroplane | πŸ”΅ sky blue |
144
- | 2 | bicycle | 🟠 orange |
145
- | 3 | bird | 🟑 gold |
146
- | 4 | boat | πŸ’™ deep sky blue |
147
- | 5 | bottle | 🟣 dark violet |
148
- | 6 | bus | 🩷 deep pink |
149
- | 7 | car | πŸ”΄ crimson |
150
- | 8 | cat | 🟠 dark orange |
151
- | 9 | chair | 🟀 saddle brown |
152
- | 10 | cow | 🟑 yellow |
153
- | 11 | diningtable | 🟀 chocolate |
154
- | 12 | dog | 🟣 medium orchid |
155
- | 13 | horse | 🩷 hot pink |
156
- | 14 | motorbike | 🟒 spring green |
157
- | 15 | person | πŸ”΄ red-orange |
158
- | 16 | potted plant | 🟒 forest green |
159
- | 17 | sheep | 🟑 khaki |
160
- | 18 | sofa | 🩡 dark turquoise |
161
- | 19 | train | πŸ”΅ blue |
162
- | 20 | tv/monitor | 🩡 aquamarine |
163
-
164
- ---
165
-
166
- ## Performance Tips
167
-
168
- - **GPU**: Set `DEVICE=cuda` β€” inference is ~10Γ— faster
169
- - **Video length**: Works best on clips ≀ 2 min (longer = queued async)
170
- - **Resolution**: Frames are resized to max 640px β€” keeps quality + speed balanced
171
- - **Workers**: Increase `--concurrency` in Celery for parallel jobs
172
-
173
- ---
174
 
175
- ## Project Structure
176
 
177
- ```
178
- video-seg-app/
179
- β”œβ”€β”€ backend/
180
- β”‚ β”œβ”€β”€ inference.py # DeepLabV3 model + frame segmentation
181
- β”‚ β”œβ”€β”€ tasks.py # Celery task (async video processing)
182
- β”‚ β”œβ”€β”€ main.py # FastAPI: upload / status / video / WS
183
- β”‚ β”œβ”€β”€ requirements.txt
184
- β”‚ └── Dockerfile
185
- β”œβ”€β”€ frontend/
186
- β”‚ β”œβ”€β”€ src/app/
187
- β”‚ β”‚ β”œβ”€β”€ page.tsx # Upload UI (drag & drop)
188
- β”‚ β”‚ β”œβ”€β”€ processing/[id]/ # Real-time progress page
189
- β”‚ β”‚ └── result/[id]/ # Video player + class legend
190
- β”‚ β”œβ”€β”€ src/app/globals.css # Dark mode design system
191
- β”‚ β”œβ”€β”€ tailwind.config.js
192
- β”‚ └── Dockerfile
193
- β”œβ”€β”€ docker-compose.yml
194
- β”œβ”€β”€ start.sh # One-command local dev
195
- └── README.md
196
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SegVision
3
+ emoji: 🎬
4
+ colorFrom: orange
5
+ colorTo: yellow
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: AI Video Segmentation β€” DeepLabV3 + ResNet-50 side-by-side comparison
 
 
 
 
 
 
 
 
9
  ---
10
 
11
+ # SegVision β€” AI Video Segmentation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
+ Upload any video and watch AI identify every object in real-time, outputting a stunning side-by-side comparison with coloured overlays.
14
 
15
+ **Powered by:** DeepLabV3 + ResNet-50 Β· PASCAL VOC 21 Classes Β· FastAPI Β· Next.js
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README_HF.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: SegVision
3
+ emoji: 🎬
4
+ colorFrom: orange
5
+ colorTo: yellow
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: AI Video Segmentation β€” DeepLabV3 + ResNet-50 side-by-side comparison
9
+ ---
10
+
11
+ # SegVision β€” AI Video Segmentation
12
+
13
+ Upload any video and watch AI identify every object in real-time, outputting a stunning side-by-side comparison with coloured overlays.
14
+
15
+ **Powered by:** DeepLabV3 + ResNet-50 Β· PASCAL VOC 21 Classes Β· FastAPI Β· Next.js
backend/app_hf.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app_hf.py β€” Simplified FastAPI backend for Hugging Face Spaces.
3
+
4
+ Differences from main.py:
5
+ - No Celery / Redis required.
6
+ - In-memory job registry (jobs dict).
7
+ - ThreadPoolExecutor runs inference in background thread.
8
+ - Serves Next.js static export from ../frontend/out/ on all non-API routes.
9
+ """
10
+
11
+ import os
12
+ import uuid
13
+ import asyncio
14
+ import logging
15
+ from pathlib import Path
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from typing import Any, Dict
18
+
19
+ from fastapi import FastAPI, UploadFile, File, HTTPException, WebSocket, WebSocketDisconnect
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.responses import FileResponse
22
+ from fastapi.staticfiles import StaticFiles
23
+
24
+ from inference import process_video, get_model, VOC_CLASSES
25
+
26
+ logging.basicConfig(level=logging.INFO)
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # ─── Paths ────────────────────────────────────────────────────────────────────
30
+
31
+ UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/tmp/video_seg/uploads"))
32
+ OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/tmp/video_seg/outputs"))
33
+ STATIC_DIR = Path(__file__).parent.parent / "frontend" / "out"
34
+
35
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
36
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
37
+
38
+ ALLOWED_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm"}
39
+ MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "200"))
40
+
41
+ # ─── In-memory job registry ───────────────────────────────────────────────────
42
+
43
+ jobs: Dict[str, Dict[str, Any]] = {}
44
+ executor = ThreadPoolExecutor(max_workers=2)
45
+
46
+ # ─── App ─────────────────────────────────────────────────────────────────────
47
+
48
+ app = FastAPI(title="SegVision HF API", version="1.0.0")
49
+
50
+ app.add_middleware(
51
+ CORSMiddleware,
52
+ allow_origins=["*"],
53
+ allow_methods=["*"],
54
+ allow_headers=["*"],
55
+ )
56
+
57
+
58
+ @app.on_event("startup")
59
+ async def startup():
60
+ logger.info("Loading segmentation model…")
61
+ loop = asyncio.get_event_loop()
62
+ await loop.run_in_executor(executor, get_model)
63
+ logger.info("Model ready.")
64
+
65
+
66
+ # ─── Background inference runner ─────────────────────────────────────────────
67
+
68
+ def _run_inference(job_id: str, input_path: str, output_path: str):
69
+ """Run video segmentation synchronously (called in thread pool)."""
70
+ jobs[job_id]["status"] = "processing"
71
+
72
+ def on_progress(pct: float, detected_names: list):
73
+ jobs[job_id].update({"pct": pct, "detected": detected_names})
74
+
75
+ try:
76
+ detected_ids = process_video(
77
+ input_path, output_path, progress_callback=on_progress
78
+ )
79
+ detected_names = [
80
+ VOC_CLASSES[c] for c in sorted(detected_ids) if c < len(VOC_CLASSES)
81
+ ]
82
+ jobs[job_id].update({
83
+ "status": "done",
84
+ "pct": 100.0,
85
+ "detected": detected_names,
86
+ })
87
+ logger.info(f"[{job_id}] Done β€” detected: {detected_names}")
88
+ except Exception as exc:
89
+ logger.exception(f"[{job_id}] Inference failed")
90
+ jobs[job_id].update({"status": "error", "error": str(exc)})
91
+
92
+
93
+ # ─── API Endpoints ────────────────────────────────────────────────────────────
94
+
95
+ @app.post("/api/upload")
96
+ async def upload_video(file: UploadFile = File(...)):
97
+ ext = Path(file.filename or "x.mp4").suffix.lower()
98
+ if ext not in ALLOWED_EXTENSIONS:
99
+ raise HTTPException(400, f"Unsupported format '{ext}'.")
100
+
101
+ content = await file.read()
102
+ size_mb = len(content) / (1024 * 1024)
103
+ if size_mb > MAX_FILE_SIZE_MB:
104
+ raise HTTPException(413, f"File too large ({size_mb:.1f} MB). Max {MAX_FILE_SIZE_MB} MB.")
105
+
106
+ job_id = str(uuid.uuid4())
107
+ input_path = UPLOAD_DIR / f"{job_id}{ext}"
108
+ output_path = OUTPUT_DIR / f"{job_id}_output.mp4"
109
+
110
+ with open(input_path, "wb") as f:
111
+ f.write(content)
112
+
113
+ jobs[job_id] = {"status": "queued", "pct": 0.0, "detected": []}
114
+
115
+ loop = asyncio.get_event_loop()
116
+ loop.run_in_executor(executor, _run_inference, job_id, str(input_path), str(output_path))
117
+
118
+ logger.info(f"[{job_id}] Queued: {file.filename} ({size_mb:.1f} MB)")
119
+ return {"job_id": job_id, "status": "queued"}
120
+
121
+
122
+ @app.get("/api/status/{job_id}")
123
+ async def get_status(job_id: str):
124
+ if job_id in jobs:
125
+ return {"job_id": job_id, **jobs[job_id]}
126
+
127
+ # Fallback: check if the output file exists (handles server restart)
128
+ out = OUTPUT_DIR / f"{job_id}_output.mp4"
129
+ if out.exists():
130
+ return {"job_id": job_id, "status": "done", "pct": 100.0, "detected": []}
131
+
132
+ raise HTTPException(404, "Job not found")
133
+
134
+
135
+ @app.head("/api/video/{job_id}")
136
+ @app.get("/api/video/{job_id}")
137
+ async def get_video(job_id: str):
138
+ output_path = OUTPUT_DIR / f"{job_id}_output.mp4"
139
+ if not output_path.exists():
140
+ raise HTTPException(404, "Result not ready yet")
141
+ return FileResponse(
142
+ str(output_path),
143
+ media_type="video/mp4",
144
+ filename=f"segmented_{job_id[:8]}.mp4",
145
+ )
146
+
147
+
148
+ @app.delete("/api/job/{job_id}")
149
+ async def delete_job(job_id: str):
150
+ jobs.pop(job_id, None)
151
+ for path in UPLOAD_DIR.glob(f"{job_id}*"):
152
+ path.unlink(missing_ok=True)
153
+ for path in OUTPUT_DIR.glob(f"{job_id}*"):
154
+ path.unlink(missing_ok=True)
155
+ return {"job_id": job_id, "status": "deleted"}
156
+
157
+
158
+ @app.get("/api/health")
159
+ async def health():
160
+ import torch
161
+ return {"status": "ok", "device": "cuda" if torch.cuda.is_available() else "cpu"}
162
+
163
+
164
+ # ─── WebSocket progress ───────────────────────────────────────────────────────
165
+
166
+ @app.websocket("/ws/{job_id}")
167
+ async def websocket_progress(ws: WebSocket, job_id: str):
168
+ await ws.accept()
169
+ try:
170
+ while True:
171
+ if job_id in jobs:
172
+ job = jobs[job_id]
173
+ await ws.send_json({"job_id": job_id, **job})
174
+ if job["status"] in ("done", "error"):
175
+ break
176
+ else:
177
+ out = OUTPUT_DIR / f"{job_id}_output.mp4"
178
+ if out.exists():
179
+ await ws.send_json({"status": "done", "pct": 100.0, "detected": []})
180
+ break
181
+ await ws.send_json({"status": "queued", "pct": 0.0, "detected": []})
182
+ await asyncio.sleep(0.8)
183
+ except WebSocketDisconnect:
184
+ pass
185
+
186
+
187
+ # ─── Serve Next.js static export ─────────────────────────────────────────────
188
+
189
+ if STATIC_DIR.exists():
190
+ # Serve Next.js _next/ assets (JS, CSS, images)
191
+ _next_dir = STATIC_DIR / "_next"
192
+ if _next_dir.exists():
193
+ app.mount("/_next", StaticFiles(directory=str(_next_dir)), name="nextjs-assets")
194
+
195
+ @app.get("/{full_path:path}")
196
+ async def serve_spa(full_path: str):
197
+ """
198
+ SPA catch-all: try to serve the exact static file, then .html,
199
+ then fall back to index.html so client-side routing works.
200
+ """
201
+ # Exact file match (images, etc.)
202
+ candidate = STATIC_DIR / full_path
203
+ if candidate.is_file():
204
+ return FileResponse(str(candidate))
205
+
206
+ # Next.js static export adds .html per route
207
+ html_candidate = STATIC_DIR / f"{full_path}.html"
208
+ if html_candidate.is_file():
209
+ return FileResponse(str(html_candidate))
210
+
211
+ # For dynamic segments like /processing/[id], Next.js generates
212
+ # processing/[id].html β€” look for that pattern
213
+ parts = full_path.split("/")
214
+ if len(parts) == 2:
215
+ segment_html = STATIC_DIR / parts[0] / "[id].html"
216
+ if segment_html.is_file():
217
+ return FileResponse(str(segment_html))
218
+
219
+ # Final fallback: root index.html (SPA entry)
220
+ index = STATIC_DIR / "index.html"
221
+ if index.is_file():
222
+ return FileResponse(str(index))
223
+
224
+ raise HTTPException(404, "Not found")
225
+ else:
226
+ @app.get("/")
227
+ async def root():
228
+ return {"message": "SegVision API is running. Frontend not found β€” build it first."}
backend/main.py CHANGED
@@ -75,7 +75,10 @@ class ConnectionManager:
75
 
76
  def disconnect(self, job_id: str, ws: WebSocket):
77
  if job_id in self.active:
78
- self.active[job_id].discard(ws)
 
 
 
79
 
80
  async def broadcast(self, job_id: str, data: dict):
81
  for ws in list(self.active.get(job_id, [])):
@@ -185,6 +188,7 @@ async def get_status(job_id: str):
185
  return {"job_id": job_id, "status": state.lower()}
186
 
187
 
 
188
  @app.get("/api/video/{job_id}")
189
  async def get_video(job_id: str):
190
  """Stream the processed video file."""
 
75
 
76
  def disconnect(self, job_id: str, ws: WebSocket):
77
  if job_id in self.active:
78
+ try:
79
+ self.active[job_id].remove(ws)
80
+ except ValueError:
81
+ pass
82
 
83
  async def broadcast(self, job_id: str, data: dict):
84
  for ws in list(self.active.get(job_id, [])):
 
188
  return {"job_id": job_id, "status": state.lower()}
189
 
190
 
191
+ @app.head("/api/video/{job_id}")
192
  @app.get("/api/video/{job_id}")
193
  async def get_video(job_id: str):
194
  """Stream the processed video file."""
frontend/next.config.js CHANGED
@@ -1,19 +1,14 @@
1
  /** @type {import('next').NextConfig} */
2
  const nextConfig = {
 
 
3
  env: {
4
- NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000',
5
- },
6
- // Allow streaming video from the backend
7
- async headers() {
8
- return [
9
- {
10
- source: '/api/:path*',
11
- headers: [
12
- { key: 'Access-Control-Allow-Origin', value: '*' },
13
- ],
14
- },
15
- ]
16
  },
 
 
 
 
17
  }
18
 
19
  module.exports = nextConfig
 
1
  /** @type {import('next').NextConfig} */
2
  const nextConfig = {
3
+ // Local dev: talks directly to FastAPI on :8000
4
+ // Docker/HF build: NEXT_PUBLIC_API_URL="" β€” nginx routes /api/* to FastAPI
5
  env: {
6
+ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000',
 
 
 
 
 
 
 
 
 
 
 
7
  },
8
+
9
+ // Standalone output is needed for Docker (HF Spaces).
10
+ // Set BUILD_STANDALONE=1 in Dockerfile; omit for local dev.
11
+ ...(process.env.BUILD_STANDALONE === '1' ? { output: 'standalone' } : {}),
12
  }
13
 
14
  module.exports = nextConfig
frontend/package-lock.json ADDED
@@ -0,0 +1,1632 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "video-seg-frontend",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "video-seg-frontend",
9
+ "version": "0.1.0",
10
+ "dependencies": {
11
+ "next": "14.2.3",
12
+ "react": "^18",
13
+ "react-dom": "^18"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "^20",
17
+ "@types/react": "^18",
18
+ "@types/react-dom": "^18",
19
+ "autoprefixer": "^10.0.1",
20
+ "postcss": "^8",
21
+ "tailwindcss": "^3.3.0",
22
+ "typescript": "^5"
23
+ }
24
+ },
25
+ "node_modules/@alloc/quick-lru": {
26
+ "version": "5.2.0",
27
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
28
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
29
+ "dev": true,
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=10"
33
+ },
34
+ "funding": {
35
+ "url": "https://github.com/sponsors/sindresorhus"
36
+ }
37
+ },
38
+ "node_modules/@jridgewell/gen-mapping": {
39
+ "version": "0.3.13",
40
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
41
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
42
+ "dev": true,
43
+ "license": "MIT",
44
+ "dependencies": {
45
+ "@jridgewell/sourcemap-codec": "^1.5.0",
46
+ "@jridgewell/trace-mapping": "^0.3.24"
47
+ }
48
+ },
49
+ "node_modules/@jridgewell/resolve-uri": {
50
+ "version": "3.1.2",
51
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
52
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
53
+ "dev": true,
54
+ "license": "MIT",
55
+ "engines": {
56
+ "node": ">=6.0.0"
57
+ }
58
+ },
59
+ "node_modules/@jridgewell/sourcemap-codec": {
60
+ "version": "1.5.5",
61
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
62
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
63
+ "dev": true,
64
+ "license": "MIT"
65
+ },
66
+ "node_modules/@jridgewell/trace-mapping": {
67
+ "version": "0.3.31",
68
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
69
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
70
+ "dev": true,
71
+ "license": "MIT",
72
+ "dependencies": {
73
+ "@jridgewell/resolve-uri": "^3.1.0",
74
+ "@jridgewell/sourcemap-codec": "^1.4.14"
75
+ }
76
+ },
77
+ "node_modules/@next/env": {
78
+ "version": "14.2.3",
79
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz",
80
+ "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==",
81
+ "license": "MIT"
82
+ },
83
+ "node_modules/@next/swc-darwin-arm64": {
84
+ "version": "14.2.3",
85
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz",
86
+ "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==",
87
+ "cpu": [
88
+ "arm64"
89
+ ],
90
+ "license": "MIT",
91
+ "optional": true,
92
+ "os": [
93
+ "darwin"
94
+ ],
95
+ "engines": {
96
+ "node": ">= 10"
97
+ }
98
+ },
99
+ "node_modules/@next/swc-darwin-x64": {
100
+ "version": "14.2.3",
101
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz",
102
+ "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==",
103
+ "cpu": [
104
+ "x64"
105
+ ],
106
+ "license": "MIT",
107
+ "optional": true,
108
+ "os": [
109
+ "darwin"
110
+ ],
111
+ "engines": {
112
+ "node": ">= 10"
113
+ }
114
+ },
115
+ "node_modules/@next/swc-linux-arm64-gnu": {
116
+ "version": "14.2.3",
117
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz",
118
+ "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==",
119
+ "cpu": [
120
+ "arm64"
121
+ ],
122
+ "license": "MIT",
123
+ "optional": true,
124
+ "os": [
125
+ "linux"
126
+ ],
127
+ "engines": {
128
+ "node": ">= 10"
129
+ }
130
+ },
131
+ "node_modules/@next/swc-linux-arm64-musl": {
132
+ "version": "14.2.3",
133
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz",
134
+ "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==",
135
+ "cpu": [
136
+ "arm64"
137
+ ],
138
+ "license": "MIT",
139
+ "optional": true,
140
+ "os": [
141
+ "linux"
142
+ ],
143
+ "engines": {
144
+ "node": ">= 10"
145
+ }
146
+ },
147
+ "node_modules/@next/swc-linux-x64-gnu": {
148
+ "version": "14.2.3",
149
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz",
150
+ "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==",
151
+ "cpu": [
152
+ "x64"
153
+ ],
154
+ "license": "MIT",
155
+ "optional": true,
156
+ "os": [
157
+ "linux"
158
+ ],
159
+ "engines": {
160
+ "node": ">= 10"
161
+ }
162
+ },
163
+ "node_modules/@next/swc-linux-x64-musl": {
164
+ "version": "14.2.3",
165
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz",
166
+ "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==",
167
+ "cpu": [
168
+ "x64"
169
+ ],
170
+ "license": "MIT",
171
+ "optional": true,
172
+ "os": [
173
+ "linux"
174
+ ],
175
+ "engines": {
176
+ "node": ">= 10"
177
+ }
178
+ },
179
+ "node_modules/@next/swc-win32-arm64-msvc": {
180
+ "version": "14.2.3",
181
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz",
182
+ "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==",
183
+ "cpu": [
184
+ "arm64"
185
+ ],
186
+ "license": "MIT",
187
+ "optional": true,
188
+ "os": [
189
+ "win32"
190
+ ],
191
+ "engines": {
192
+ "node": ">= 10"
193
+ }
194
+ },
195
+ "node_modules/@next/swc-win32-ia32-msvc": {
196
+ "version": "14.2.3",
197
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz",
198
+ "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==",
199
+ "cpu": [
200
+ "ia32"
201
+ ],
202
+ "license": "MIT",
203
+ "optional": true,
204
+ "os": [
205
+ "win32"
206
+ ],
207
+ "engines": {
208
+ "node": ">= 10"
209
+ }
210
+ },
211
+ "node_modules/@next/swc-win32-x64-msvc": {
212
+ "version": "14.2.3",
213
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz",
214
+ "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==",
215
+ "cpu": [
216
+ "x64"
217
+ ],
218
+ "license": "MIT",
219
+ "optional": true,
220
+ "os": [
221
+ "win32"
222
+ ],
223
+ "engines": {
224
+ "node": ">= 10"
225
+ }
226
+ },
227
+ "node_modules/@nodelib/fs.scandir": {
228
+ "version": "2.1.5",
229
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
230
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
231
+ "dev": true,
232
+ "license": "MIT",
233
+ "dependencies": {
234
+ "@nodelib/fs.stat": "2.0.5",
235
+ "run-parallel": "^1.1.9"
236
+ },
237
+ "engines": {
238
+ "node": ">= 8"
239
+ }
240
+ },
241
+ "node_modules/@nodelib/fs.stat": {
242
+ "version": "2.0.5",
243
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
244
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
245
+ "dev": true,
246
+ "license": "MIT",
247
+ "engines": {
248
+ "node": ">= 8"
249
+ }
250
+ },
251
+ "node_modules/@nodelib/fs.walk": {
252
+ "version": "1.2.8",
253
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
254
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
255
+ "dev": true,
256
+ "license": "MIT",
257
+ "dependencies": {
258
+ "@nodelib/fs.scandir": "2.1.5",
259
+ "fastq": "^1.6.0"
260
+ },
261
+ "engines": {
262
+ "node": ">= 8"
263
+ }
264
+ },
265
+ "node_modules/@swc/counter": {
266
+ "version": "0.1.3",
267
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
268
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
269
+ "license": "Apache-2.0"
270
+ },
271
+ "node_modules/@swc/helpers": {
272
+ "version": "0.5.5",
273
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
274
+ "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
275
+ "license": "Apache-2.0",
276
+ "dependencies": {
277
+ "@swc/counter": "^0.1.3",
278
+ "tslib": "^2.4.0"
279
+ }
280
+ },
281
+ "node_modules/@types/node": {
282
+ "version": "20.19.39",
283
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
284
+ "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
285
+ "dev": true,
286
+ "license": "MIT",
287
+ "dependencies": {
288
+ "undici-types": "~6.21.0"
289
+ }
290
+ },
291
+ "node_modules/@types/prop-types": {
292
+ "version": "15.7.15",
293
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
294
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
295
+ "dev": true,
296
+ "license": "MIT"
297
+ },
298
+ "node_modules/@types/react": {
299
+ "version": "18.3.28",
300
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
301
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
302
+ "dev": true,
303
+ "license": "MIT",
304
+ "dependencies": {
305
+ "@types/prop-types": "*",
306
+ "csstype": "^3.2.2"
307
+ }
308
+ },
309
+ "node_modules/@types/react-dom": {
310
+ "version": "18.3.7",
311
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
312
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
313
+ "dev": true,
314
+ "license": "MIT",
315
+ "peerDependencies": {
316
+ "@types/react": "^18.0.0"
317
+ }
318
+ },
319
+ "node_modules/any-promise": {
320
+ "version": "1.3.0",
321
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
322
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
323
+ "dev": true,
324
+ "license": "MIT"
325
+ },
326
+ "node_modules/anymatch": {
327
+ "version": "3.1.3",
328
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
329
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
330
+ "dev": true,
331
+ "license": "ISC",
332
+ "dependencies": {
333
+ "normalize-path": "^3.0.0",
334
+ "picomatch": "^2.0.4"
335
+ },
336
+ "engines": {
337
+ "node": ">= 8"
338
+ }
339
+ },
340
+ "node_modules/arg": {
341
+ "version": "5.0.2",
342
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
343
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
344
+ "dev": true,
345
+ "license": "MIT"
346
+ },
347
+ "node_modules/autoprefixer": {
348
+ "version": "10.5.0",
349
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
350
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
351
+ "dev": true,
352
+ "funding": [
353
+ {
354
+ "type": "opencollective",
355
+ "url": "https://opencollective.com/postcss/"
356
+ },
357
+ {
358
+ "type": "tidelift",
359
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
360
+ },
361
+ {
362
+ "type": "github",
363
+ "url": "https://github.com/sponsors/ai"
364
+ }
365
+ ],
366
+ "license": "MIT",
367
+ "dependencies": {
368
+ "browserslist": "^4.28.2",
369
+ "caniuse-lite": "^1.0.30001787",
370
+ "fraction.js": "^5.3.4",
371
+ "picocolors": "^1.1.1",
372
+ "postcss-value-parser": "^4.2.0"
373
+ },
374
+ "bin": {
375
+ "autoprefixer": "bin/autoprefixer"
376
+ },
377
+ "engines": {
378
+ "node": "^10 || ^12 || >=14"
379
+ },
380
+ "peerDependencies": {
381
+ "postcss": "^8.1.0"
382
+ }
383
+ },
384
+ "node_modules/baseline-browser-mapping": {
385
+ "version": "2.10.20",
386
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz",
387
+ "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==",
388
+ "dev": true,
389
+ "license": "Apache-2.0",
390
+ "bin": {
391
+ "baseline-browser-mapping": "dist/cli.cjs"
392
+ },
393
+ "engines": {
394
+ "node": ">=6.0.0"
395
+ }
396
+ },
397
+ "node_modules/binary-extensions": {
398
+ "version": "2.3.0",
399
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
400
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
401
+ "dev": true,
402
+ "license": "MIT",
403
+ "engines": {
404
+ "node": ">=8"
405
+ },
406
+ "funding": {
407
+ "url": "https://github.com/sponsors/sindresorhus"
408
+ }
409
+ },
410
+ "node_modules/braces": {
411
+ "version": "3.0.3",
412
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
413
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
414
+ "dev": true,
415
+ "license": "MIT",
416
+ "dependencies": {
417
+ "fill-range": "^7.1.1"
418
+ },
419
+ "engines": {
420
+ "node": ">=8"
421
+ }
422
+ },
423
+ "node_modules/browserslist": {
424
+ "version": "4.28.2",
425
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
426
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
427
+ "dev": true,
428
+ "funding": [
429
+ {
430
+ "type": "opencollective",
431
+ "url": "https://opencollective.com/browserslist"
432
+ },
433
+ {
434
+ "type": "tidelift",
435
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
436
+ },
437
+ {
438
+ "type": "github",
439
+ "url": "https://github.com/sponsors/ai"
440
+ }
441
+ ],
442
+ "license": "MIT",
443
+ "dependencies": {
444
+ "baseline-browser-mapping": "^2.10.12",
445
+ "caniuse-lite": "^1.0.30001782",
446
+ "electron-to-chromium": "^1.5.328",
447
+ "node-releases": "^2.0.36",
448
+ "update-browserslist-db": "^1.2.3"
449
+ },
450
+ "bin": {
451
+ "browserslist": "cli.js"
452
+ },
453
+ "engines": {
454
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
455
+ }
456
+ },
457
+ "node_modules/busboy": {
458
+ "version": "1.6.0",
459
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
460
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
461
+ "dependencies": {
462
+ "streamsearch": "^1.1.0"
463
+ },
464
+ "engines": {
465
+ "node": ">=10.16.0"
466
+ }
467
+ },
468
+ "node_modules/camelcase-css": {
469
+ "version": "2.0.1",
470
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
471
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
472
+ "dev": true,
473
+ "license": "MIT",
474
+ "engines": {
475
+ "node": ">= 6"
476
+ }
477
+ },
478
+ "node_modules/caniuse-lite": {
479
+ "version": "1.0.30001788",
480
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
481
+ "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
482
+ "funding": [
483
+ {
484
+ "type": "opencollective",
485
+ "url": "https://opencollective.com/browserslist"
486
+ },
487
+ {
488
+ "type": "tidelift",
489
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
490
+ },
491
+ {
492
+ "type": "github",
493
+ "url": "https://github.com/sponsors/ai"
494
+ }
495
+ ],
496
+ "license": "CC-BY-4.0"
497
+ },
498
+ "node_modules/chokidar": {
499
+ "version": "3.6.0",
500
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
501
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
502
+ "dev": true,
503
+ "license": "MIT",
504
+ "dependencies": {
505
+ "anymatch": "~3.1.2",
506
+ "braces": "~3.0.2",
507
+ "glob-parent": "~5.1.2",
508
+ "is-binary-path": "~2.1.0",
509
+ "is-glob": "~4.0.1",
510
+ "normalize-path": "~3.0.0",
511
+ "readdirp": "~3.6.0"
512
+ },
513
+ "engines": {
514
+ "node": ">= 8.10.0"
515
+ },
516
+ "funding": {
517
+ "url": "https://paulmillr.com/funding/"
518
+ },
519
+ "optionalDependencies": {
520
+ "fsevents": "~2.3.2"
521
+ }
522
+ },
523
+ "node_modules/chokidar/node_modules/glob-parent": {
524
+ "version": "5.1.2",
525
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
526
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
527
+ "dev": true,
528
+ "license": "ISC",
529
+ "dependencies": {
530
+ "is-glob": "^4.0.1"
531
+ },
532
+ "engines": {
533
+ "node": ">= 6"
534
+ }
535
+ },
536
+ "node_modules/client-only": {
537
+ "version": "0.0.1",
538
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
539
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
540
+ "license": "MIT"
541
+ },
542
+ "node_modules/commander": {
543
+ "version": "4.1.1",
544
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
545
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
546
+ "dev": true,
547
+ "license": "MIT",
548
+ "engines": {
549
+ "node": ">= 6"
550
+ }
551
+ },
552
+ "node_modules/cssesc": {
553
+ "version": "3.0.0",
554
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
555
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
556
+ "dev": true,
557
+ "license": "MIT",
558
+ "bin": {
559
+ "cssesc": "bin/cssesc"
560
+ },
561
+ "engines": {
562
+ "node": ">=4"
563
+ }
564
+ },
565
+ "node_modules/csstype": {
566
+ "version": "3.2.3",
567
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
568
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
569
+ "dev": true,
570
+ "license": "MIT"
571
+ },
572
+ "node_modules/didyoumean": {
573
+ "version": "1.2.2",
574
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
575
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
576
+ "dev": true,
577
+ "license": "Apache-2.0"
578
+ },
579
+ "node_modules/dlv": {
580
+ "version": "1.1.3",
581
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
582
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
583
+ "dev": true,
584
+ "license": "MIT"
585
+ },
586
+ "node_modules/electron-to-chromium": {
587
+ "version": "1.5.340",
588
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz",
589
+ "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==",
590
+ "dev": true,
591
+ "license": "ISC"
592
+ },
593
+ "node_modules/es-errors": {
594
+ "version": "1.3.0",
595
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
596
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
597
+ "dev": true,
598
+ "license": "MIT",
599
+ "engines": {
600
+ "node": ">= 0.4"
601
+ }
602
+ },
603
+ "node_modules/escalade": {
604
+ "version": "3.2.0",
605
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
606
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
607
+ "dev": true,
608
+ "license": "MIT",
609
+ "engines": {
610
+ "node": ">=6"
611
+ }
612
+ },
613
+ "node_modules/fast-glob": {
614
+ "version": "3.3.3",
615
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
616
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
617
+ "dev": true,
618
+ "license": "MIT",
619
+ "dependencies": {
620
+ "@nodelib/fs.stat": "^2.0.2",
621
+ "@nodelib/fs.walk": "^1.2.3",
622
+ "glob-parent": "^5.1.2",
623
+ "merge2": "^1.3.0",
624
+ "micromatch": "^4.0.8"
625
+ },
626
+ "engines": {
627
+ "node": ">=8.6.0"
628
+ }
629
+ },
630
+ "node_modules/fast-glob/node_modules/glob-parent": {
631
+ "version": "5.1.2",
632
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
633
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
634
+ "dev": true,
635
+ "license": "ISC",
636
+ "dependencies": {
637
+ "is-glob": "^4.0.1"
638
+ },
639
+ "engines": {
640
+ "node": ">= 6"
641
+ }
642
+ },
643
+ "node_modules/fastq": {
644
+ "version": "1.20.1",
645
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
646
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
647
+ "dev": true,
648
+ "license": "ISC",
649
+ "dependencies": {
650
+ "reusify": "^1.0.4"
651
+ }
652
+ },
653
+ "node_modules/fill-range": {
654
+ "version": "7.1.1",
655
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
656
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
657
+ "dev": true,
658
+ "license": "MIT",
659
+ "dependencies": {
660
+ "to-regex-range": "^5.0.1"
661
+ },
662
+ "engines": {
663
+ "node": ">=8"
664
+ }
665
+ },
666
+ "node_modules/fraction.js": {
667
+ "version": "5.3.4",
668
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
669
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
670
+ "dev": true,
671
+ "license": "MIT",
672
+ "engines": {
673
+ "node": "*"
674
+ },
675
+ "funding": {
676
+ "type": "github",
677
+ "url": "https://github.com/sponsors/rawify"
678
+ }
679
+ },
680
+ "node_modules/fsevents": {
681
+ "version": "2.3.3",
682
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
683
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
684
+ "dev": true,
685
+ "hasInstallScript": true,
686
+ "license": "MIT",
687
+ "optional": true,
688
+ "os": [
689
+ "darwin"
690
+ ],
691
+ "engines": {
692
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
693
+ }
694
+ },
695
+ "node_modules/function-bind": {
696
+ "version": "1.1.2",
697
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
698
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
699
+ "dev": true,
700
+ "license": "MIT",
701
+ "funding": {
702
+ "url": "https://github.com/sponsors/ljharb"
703
+ }
704
+ },
705
+ "node_modules/glob-parent": {
706
+ "version": "6.0.2",
707
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
708
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
709
+ "dev": true,
710
+ "license": "ISC",
711
+ "dependencies": {
712
+ "is-glob": "^4.0.3"
713
+ },
714
+ "engines": {
715
+ "node": ">=10.13.0"
716
+ }
717
+ },
718
+ "node_modules/graceful-fs": {
719
+ "version": "4.2.11",
720
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
721
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
722
+ "license": "ISC"
723
+ },
724
+ "node_modules/hasown": {
725
+ "version": "2.0.3",
726
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
727
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
728
+ "dev": true,
729
+ "license": "MIT",
730
+ "dependencies": {
731
+ "function-bind": "^1.1.2"
732
+ },
733
+ "engines": {
734
+ "node": ">= 0.4"
735
+ }
736
+ },
737
+ "node_modules/is-binary-path": {
738
+ "version": "2.1.0",
739
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
740
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
741
+ "dev": true,
742
+ "license": "MIT",
743
+ "dependencies": {
744
+ "binary-extensions": "^2.0.0"
745
+ },
746
+ "engines": {
747
+ "node": ">=8"
748
+ }
749
+ },
750
+ "node_modules/is-core-module": {
751
+ "version": "2.16.1",
752
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
753
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
754
+ "dev": true,
755
+ "license": "MIT",
756
+ "dependencies": {
757
+ "hasown": "^2.0.2"
758
+ },
759
+ "engines": {
760
+ "node": ">= 0.4"
761
+ },
762
+ "funding": {
763
+ "url": "https://github.com/sponsors/ljharb"
764
+ }
765
+ },
766
+ "node_modules/is-extglob": {
767
+ "version": "2.1.1",
768
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
769
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
770
+ "dev": true,
771
+ "license": "MIT",
772
+ "engines": {
773
+ "node": ">=0.10.0"
774
+ }
775
+ },
776
+ "node_modules/is-glob": {
777
+ "version": "4.0.3",
778
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
779
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
780
+ "dev": true,
781
+ "license": "MIT",
782
+ "dependencies": {
783
+ "is-extglob": "^2.1.1"
784
+ },
785
+ "engines": {
786
+ "node": ">=0.10.0"
787
+ }
788
+ },
789
+ "node_modules/is-number": {
790
+ "version": "7.0.0",
791
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
792
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
793
+ "dev": true,
794
+ "license": "MIT",
795
+ "engines": {
796
+ "node": ">=0.12.0"
797
+ }
798
+ },
799
+ "node_modules/jiti": {
800
+ "version": "1.21.7",
801
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
802
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
803
+ "dev": true,
804
+ "license": "MIT",
805
+ "bin": {
806
+ "jiti": "bin/jiti.js"
807
+ }
808
+ },
809
+ "node_modules/js-tokens": {
810
+ "version": "4.0.0",
811
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
812
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
813
+ "license": "MIT"
814
+ },
815
+ "node_modules/lilconfig": {
816
+ "version": "3.1.3",
817
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
818
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
819
+ "dev": true,
820
+ "license": "MIT",
821
+ "engines": {
822
+ "node": ">=14"
823
+ },
824
+ "funding": {
825
+ "url": "https://github.com/sponsors/antonk52"
826
+ }
827
+ },
828
+ "node_modules/lines-and-columns": {
829
+ "version": "1.2.4",
830
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
831
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
832
+ "dev": true,
833
+ "license": "MIT"
834
+ },
835
+ "node_modules/loose-envify": {
836
+ "version": "1.4.0",
837
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
838
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
839
+ "license": "MIT",
840
+ "dependencies": {
841
+ "js-tokens": "^3.0.0 || ^4.0.0"
842
+ },
843
+ "bin": {
844
+ "loose-envify": "cli.js"
845
+ }
846
+ },
847
+ "node_modules/merge2": {
848
+ "version": "1.4.1",
849
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
850
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
851
+ "dev": true,
852
+ "license": "MIT",
853
+ "engines": {
854
+ "node": ">= 8"
855
+ }
856
+ },
857
+ "node_modules/micromatch": {
858
+ "version": "4.0.8",
859
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
860
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
861
+ "dev": true,
862
+ "license": "MIT",
863
+ "dependencies": {
864
+ "braces": "^3.0.3",
865
+ "picomatch": "^2.3.1"
866
+ },
867
+ "engines": {
868
+ "node": ">=8.6"
869
+ }
870
+ },
871
+ "node_modules/mz": {
872
+ "version": "2.7.0",
873
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
874
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
875
+ "dev": true,
876
+ "license": "MIT",
877
+ "dependencies": {
878
+ "any-promise": "^1.0.0",
879
+ "object-assign": "^4.0.1",
880
+ "thenify-all": "^1.0.0"
881
+ }
882
+ },
883
+ "node_modules/nanoid": {
884
+ "version": "3.3.11",
885
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
886
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
887
+ "funding": [
888
+ {
889
+ "type": "github",
890
+ "url": "https://github.com/sponsors/ai"
891
+ }
892
+ ],
893
+ "license": "MIT",
894
+ "bin": {
895
+ "nanoid": "bin/nanoid.cjs"
896
+ },
897
+ "engines": {
898
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
899
+ }
900
+ },
901
+ "node_modules/next": {
902
+ "version": "14.2.3",
903
+ "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz",
904
+ "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==",
905
+ "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
906
+ "license": "MIT",
907
+ "dependencies": {
908
+ "@next/env": "14.2.3",
909
+ "@swc/helpers": "0.5.5",
910
+ "busboy": "1.6.0",
911
+ "caniuse-lite": "^1.0.30001579",
912
+ "graceful-fs": "^4.2.11",
913
+ "postcss": "8.4.31",
914
+ "styled-jsx": "5.1.1"
915
+ },
916
+ "bin": {
917
+ "next": "dist/bin/next"
918
+ },
919
+ "engines": {
920
+ "node": ">=18.17.0"
921
+ },
922
+ "optionalDependencies": {
923
+ "@next/swc-darwin-arm64": "14.2.3",
924
+ "@next/swc-darwin-x64": "14.2.3",
925
+ "@next/swc-linux-arm64-gnu": "14.2.3",
926
+ "@next/swc-linux-arm64-musl": "14.2.3",
927
+ "@next/swc-linux-x64-gnu": "14.2.3",
928
+ "@next/swc-linux-x64-musl": "14.2.3",
929
+ "@next/swc-win32-arm64-msvc": "14.2.3",
930
+ "@next/swc-win32-ia32-msvc": "14.2.3",
931
+ "@next/swc-win32-x64-msvc": "14.2.3"
932
+ },
933
+ "peerDependencies": {
934
+ "@opentelemetry/api": "^1.1.0",
935
+ "@playwright/test": "^1.41.2",
936
+ "react": "^18.2.0",
937
+ "react-dom": "^18.2.0",
938
+ "sass": "^1.3.0"
939
+ },
940
+ "peerDependenciesMeta": {
941
+ "@opentelemetry/api": {
942
+ "optional": true
943
+ },
944
+ "@playwright/test": {
945
+ "optional": true
946
+ },
947
+ "sass": {
948
+ "optional": true
949
+ }
950
+ }
951
+ },
952
+ "node_modules/next/node_modules/postcss": {
953
+ "version": "8.4.31",
954
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
955
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
956
+ "funding": [
957
+ {
958
+ "type": "opencollective",
959
+ "url": "https://opencollective.com/postcss/"
960
+ },
961
+ {
962
+ "type": "tidelift",
963
+ "url": "https://tidelift.com/funding/github/npm/postcss"
964
+ },
965
+ {
966
+ "type": "github",
967
+ "url": "https://github.com/sponsors/ai"
968
+ }
969
+ ],
970
+ "license": "MIT",
971
+ "dependencies": {
972
+ "nanoid": "^3.3.6",
973
+ "picocolors": "^1.0.0",
974
+ "source-map-js": "^1.0.2"
975
+ },
976
+ "engines": {
977
+ "node": "^10 || ^12 || >=14"
978
+ }
979
+ },
980
+ "node_modules/node-releases": {
981
+ "version": "2.0.37",
982
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
983
+ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
984
+ "dev": true,
985
+ "license": "MIT"
986
+ },
987
+ "node_modules/normalize-path": {
988
+ "version": "3.0.0",
989
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
990
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
991
+ "dev": true,
992
+ "license": "MIT",
993
+ "engines": {
994
+ "node": ">=0.10.0"
995
+ }
996
+ },
997
+ "node_modules/object-assign": {
998
+ "version": "4.1.1",
999
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
1000
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
1001
+ "dev": true,
1002
+ "license": "MIT",
1003
+ "engines": {
1004
+ "node": ">=0.10.0"
1005
+ }
1006
+ },
1007
+ "node_modules/object-hash": {
1008
+ "version": "3.0.0",
1009
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
1010
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
1011
+ "dev": true,
1012
+ "license": "MIT",
1013
+ "engines": {
1014
+ "node": ">= 6"
1015
+ }
1016
+ },
1017
+ "node_modules/path-parse": {
1018
+ "version": "1.0.7",
1019
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
1020
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
1021
+ "dev": true,
1022
+ "license": "MIT"
1023
+ },
1024
+ "node_modules/picocolors": {
1025
+ "version": "1.1.1",
1026
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1027
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1028
+ "license": "ISC"
1029
+ },
1030
+ "node_modules/picomatch": {
1031
+ "version": "2.3.2",
1032
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
1033
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
1034
+ "dev": true,
1035
+ "license": "MIT",
1036
+ "engines": {
1037
+ "node": ">=8.6"
1038
+ },
1039
+ "funding": {
1040
+ "url": "https://github.com/sponsors/jonschlinkert"
1041
+ }
1042
+ },
1043
+ "node_modules/pify": {
1044
+ "version": "2.3.0",
1045
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
1046
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
1047
+ "dev": true,
1048
+ "license": "MIT",
1049
+ "engines": {
1050
+ "node": ">=0.10.0"
1051
+ }
1052
+ },
1053
+ "node_modules/pirates": {
1054
+ "version": "4.0.7",
1055
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
1056
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
1057
+ "dev": true,
1058
+ "license": "MIT",
1059
+ "engines": {
1060
+ "node": ">= 6"
1061
+ }
1062
+ },
1063
+ "node_modules/postcss": {
1064
+ "version": "8.5.10",
1065
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
1066
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
1067
+ "dev": true,
1068
+ "funding": [
1069
+ {
1070
+ "type": "opencollective",
1071
+ "url": "https://opencollective.com/postcss/"
1072
+ },
1073
+ {
1074
+ "type": "tidelift",
1075
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1076
+ },
1077
+ {
1078
+ "type": "github",
1079
+ "url": "https://github.com/sponsors/ai"
1080
+ }
1081
+ ],
1082
+ "license": "MIT",
1083
+ "dependencies": {
1084
+ "nanoid": "^3.3.11",
1085
+ "picocolors": "^1.1.1",
1086
+ "source-map-js": "^1.2.1"
1087
+ },
1088
+ "engines": {
1089
+ "node": "^10 || ^12 || >=14"
1090
+ }
1091
+ },
1092
+ "node_modules/postcss-import": {
1093
+ "version": "15.1.0",
1094
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
1095
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
1096
+ "dev": true,
1097
+ "license": "MIT",
1098
+ "dependencies": {
1099
+ "postcss-value-parser": "^4.0.0",
1100
+ "read-cache": "^1.0.0",
1101
+ "resolve": "^1.1.7"
1102
+ },
1103
+ "engines": {
1104
+ "node": ">=14.0.0"
1105
+ },
1106
+ "peerDependencies": {
1107
+ "postcss": "^8.0.0"
1108
+ }
1109
+ },
1110
+ "node_modules/postcss-js": {
1111
+ "version": "4.1.0",
1112
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
1113
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
1114
+ "dev": true,
1115
+ "funding": [
1116
+ {
1117
+ "type": "opencollective",
1118
+ "url": "https://opencollective.com/postcss/"
1119
+ },
1120
+ {
1121
+ "type": "github",
1122
+ "url": "https://github.com/sponsors/ai"
1123
+ }
1124
+ ],
1125
+ "license": "MIT",
1126
+ "dependencies": {
1127
+ "camelcase-css": "^2.0.1"
1128
+ },
1129
+ "engines": {
1130
+ "node": "^12 || ^14 || >= 16"
1131
+ },
1132
+ "peerDependencies": {
1133
+ "postcss": "^8.4.21"
1134
+ }
1135
+ },
1136
+ "node_modules/postcss-load-config": {
1137
+ "version": "6.0.1",
1138
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
1139
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
1140
+ "dev": true,
1141
+ "funding": [
1142
+ {
1143
+ "type": "opencollective",
1144
+ "url": "https://opencollective.com/postcss/"
1145
+ },
1146
+ {
1147
+ "type": "github",
1148
+ "url": "https://github.com/sponsors/ai"
1149
+ }
1150
+ ],
1151
+ "license": "MIT",
1152
+ "dependencies": {
1153
+ "lilconfig": "^3.1.1"
1154
+ },
1155
+ "engines": {
1156
+ "node": ">= 18"
1157
+ },
1158
+ "peerDependencies": {
1159
+ "jiti": ">=1.21.0",
1160
+ "postcss": ">=8.0.9",
1161
+ "tsx": "^4.8.1",
1162
+ "yaml": "^2.4.2"
1163
+ },
1164
+ "peerDependenciesMeta": {
1165
+ "jiti": {
1166
+ "optional": true
1167
+ },
1168
+ "postcss": {
1169
+ "optional": true
1170
+ },
1171
+ "tsx": {
1172
+ "optional": true
1173
+ },
1174
+ "yaml": {
1175
+ "optional": true
1176
+ }
1177
+ }
1178
+ },
1179
+ "node_modules/postcss-nested": {
1180
+ "version": "6.2.0",
1181
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
1182
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
1183
+ "dev": true,
1184
+ "funding": [
1185
+ {
1186
+ "type": "opencollective",
1187
+ "url": "https://opencollective.com/postcss/"
1188
+ },
1189
+ {
1190
+ "type": "github",
1191
+ "url": "https://github.com/sponsors/ai"
1192
+ }
1193
+ ],
1194
+ "license": "MIT",
1195
+ "dependencies": {
1196
+ "postcss-selector-parser": "^6.1.1"
1197
+ },
1198
+ "engines": {
1199
+ "node": ">=12.0"
1200
+ },
1201
+ "peerDependencies": {
1202
+ "postcss": "^8.2.14"
1203
+ }
1204
+ },
1205
+ "node_modules/postcss-selector-parser": {
1206
+ "version": "6.1.2",
1207
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
1208
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
1209
+ "dev": true,
1210
+ "license": "MIT",
1211
+ "dependencies": {
1212
+ "cssesc": "^3.0.0",
1213
+ "util-deprecate": "^1.0.2"
1214
+ },
1215
+ "engines": {
1216
+ "node": ">=4"
1217
+ }
1218
+ },
1219
+ "node_modules/postcss-value-parser": {
1220
+ "version": "4.2.0",
1221
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
1222
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
1223
+ "dev": true,
1224
+ "license": "MIT"
1225
+ },
1226
+ "node_modules/queue-microtask": {
1227
+ "version": "1.2.3",
1228
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
1229
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
1230
+ "dev": true,
1231
+ "funding": [
1232
+ {
1233
+ "type": "github",
1234
+ "url": "https://github.com/sponsors/feross"
1235
+ },
1236
+ {
1237
+ "type": "patreon",
1238
+ "url": "https://www.patreon.com/feross"
1239
+ },
1240
+ {
1241
+ "type": "consulting",
1242
+ "url": "https://feross.org/support"
1243
+ }
1244
+ ],
1245
+ "license": "MIT"
1246
+ },
1247
+ "node_modules/react": {
1248
+ "version": "18.3.1",
1249
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1250
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1251
+ "license": "MIT",
1252
+ "dependencies": {
1253
+ "loose-envify": "^1.1.0"
1254
+ },
1255
+ "engines": {
1256
+ "node": ">=0.10.0"
1257
+ }
1258
+ },
1259
+ "node_modules/react-dom": {
1260
+ "version": "18.3.1",
1261
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1262
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1263
+ "license": "MIT",
1264
+ "dependencies": {
1265
+ "loose-envify": "^1.1.0",
1266
+ "scheduler": "^0.23.2"
1267
+ },
1268
+ "peerDependencies": {
1269
+ "react": "^18.3.1"
1270
+ }
1271
+ },
1272
+ "node_modules/read-cache": {
1273
+ "version": "1.0.0",
1274
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
1275
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
1276
+ "dev": true,
1277
+ "license": "MIT",
1278
+ "dependencies": {
1279
+ "pify": "^2.3.0"
1280
+ }
1281
+ },
1282
+ "node_modules/readdirp": {
1283
+ "version": "3.6.0",
1284
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
1285
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
1286
+ "dev": true,
1287
+ "license": "MIT",
1288
+ "dependencies": {
1289
+ "picomatch": "^2.2.1"
1290
+ },
1291
+ "engines": {
1292
+ "node": ">=8.10.0"
1293
+ }
1294
+ },
1295
+ "node_modules/resolve": {
1296
+ "version": "1.22.12",
1297
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
1298
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
1299
+ "dev": true,
1300
+ "license": "MIT",
1301
+ "dependencies": {
1302
+ "es-errors": "^1.3.0",
1303
+ "is-core-module": "^2.16.1",
1304
+ "path-parse": "^1.0.7",
1305
+ "supports-preserve-symlinks-flag": "^1.0.0"
1306
+ },
1307
+ "bin": {
1308
+ "resolve": "bin/resolve"
1309
+ },
1310
+ "engines": {
1311
+ "node": ">= 0.4"
1312
+ },
1313
+ "funding": {
1314
+ "url": "https://github.com/sponsors/ljharb"
1315
+ }
1316
+ },
1317
+ "node_modules/reusify": {
1318
+ "version": "1.1.0",
1319
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
1320
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
1321
+ "dev": true,
1322
+ "license": "MIT",
1323
+ "engines": {
1324
+ "iojs": ">=1.0.0",
1325
+ "node": ">=0.10.0"
1326
+ }
1327
+ },
1328
+ "node_modules/run-parallel": {
1329
+ "version": "1.2.0",
1330
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
1331
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
1332
+ "dev": true,
1333
+ "funding": [
1334
+ {
1335
+ "type": "github",
1336
+ "url": "https://github.com/sponsors/feross"
1337
+ },
1338
+ {
1339
+ "type": "patreon",
1340
+ "url": "https://www.patreon.com/feross"
1341
+ },
1342
+ {
1343
+ "type": "consulting",
1344
+ "url": "https://feross.org/support"
1345
+ }
1346
+ ],
1347
+ "license": "MIT",
1348
+ "dependencies": {
1349
+ "queue-microtask": "^1.2.2"
1350
+ }
1351
+ },
1352
+ "node_modules/scheduler": {
1353
+ "version": "0.23.2",
1354
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1355
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1356
+ "license": "MIT",
1357
+ "dependencies": {
1358
+ "loose-envify": "^1.1.0"
1359
+ }
1360
+ },
1361
+ "node_modules/source-map-js": {
1362
+ "version": "1.2.1",
1363
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1364
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1365
+ "license": "BSD-3-Clause",
1366
+ "engines": {
1367
+ "node": ">=0.10.0"
1368
+ }
1369
+ },
1370
+ "node_modules/streamsearch": {
1371
+ "version": "1.1.0",
1372
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
1373
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
1374
+ "engines": {
1375
+ "node": ">=10.0.0"
1376
+ }
1377
+ },
1378
+ "node_modules/styled-jsx": {
1379
+ "version": "5.1.1",
1380
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
1381
+ "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
1382
+ "license": "MIT",
1383
+ "dependencies": {
1384
+ "client-only": "0.0.1"
1385
+ },
1386
+ "engines": {
1387
+ "node": ">= 12.0.0"
1388
+ },
1389
+ "peerDependencies": {
1390
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
1391
+ },
1392
+ "peerDependenciesMeta": {
1393
+ "@babel/core": {
1394
+ "optional": true
1395
+ },
1396
+ "babel-plugin-macros": {
1397
+ "optional": true
1398
+ }
1399
+ }
1400
+ },
1401
+ "node_modules/sucrase": {
1402
+ "version": "3.35.1",
1403
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
1404
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
1405
+ "dev": true,
1406
+ "license": "MIT",
1407
+ "dependencies": {
1408
+ "@jridgewell/gen-mapping": "^0.3.2",
1409
+ "commander": "^4.0.0",
1410
+ "lines-and-columns": "^1.1.6",
1411
+ "mz": "^2.7.0",
1412
+ "pirates": "^4.0.1",
1413
+ "tinyglobby": "^0.2.11",
1414
+ "ts-interface-checker": "^0.1.9"
1415
+ },
1416
+ "bin": {
1417
+ "sucrase": "bin/sucrase",
1418
+ "sucrase-node": "bin/sucrase-node"
1419
+ },
1420
+ "engines": {
1421
+ "node": ">=16 || 14 >=14.17"
1422
+ }
1423
+ },
1424
+ "node_modules/supports-preserve-symlinks-flag": {
1425
+ "version": "1.0.0",
1426
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
1427
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
1428
+ "dev": true,
1429
+ "license": "MIT",
1430
+ "engines": {
1431
+ "node": ">= 0.4"
1432
+ },
1433
+ "funding": {
1434
+ "url": "https://github.com/sponsors/ljharb"
1435
+ }
1436
+ },
1437
+ "node_modules/tailwindcss": {
1438
+ "version": "3.4.19",
1439
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
1440
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
1441
+ "dev": true,
1442
+ "license": "MIT",
1443
+ "dependencies": {
1444
+ "@alloc/quick-lru": "^5.2.0",
1445
+ "arg": "^5.0.2",
1446
+ "chokidar": "^3.6.0",
1447
+ "didyoumean": "^1.2.2",
1448
+ "dlv": "^1.1.3",
1449
+ "fast-glob": "^3.3.2",
1450
+ "glob-parent": "^6.0.2",
1451
+ "is-glob": "^4.0.3",
1452
+ "jiti": "^1.21.7",
1453
+ "lilconfig": "^3.1.3",
1454
+ "micromatch": "^4.0.8",
1455
+ "normalize-path": "^3.0.0",
1456
+ "object-hash": "^3.0.0",
1457
+ "picocolors": "^1.1.1",
1458
+ "postcss": "^8.4.47",
1459
+ "postcss-import": "^15.1.0",
1460
+ "postcss-js": "^4.0.1",
1461
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
1462
+ "postcss-nested": "^6.2.0",
1463
+ "postcss-selector-parser": "^6.1.2",
1464
+ "resolve": "^1.22.8",
1465
+ "sucrase": "^3.35.0"
1466
+ },
1467
+ "bin": {
1468
+ "tailwind": "lib/cli.js",
1469
+ "tailwindcss": "lib/cli.js"
1470
+ },
1471
+ "engines": {
1472
+ "node": ">=14.0.0"
1473
+ }
1474
+ },
1475
+ "node_modules/thenify": {
1476
+ "version": "3.3.1",
1477
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
1478
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
1479
+ "dev": true,
1480
+ "license": "MIT",
1481
+ "dependencies": {
1482
+ "any-promise": "^1.0.0"
1483
+ }
1484
+ },
1485
+ "node_modules/thenify-all": {
1486
+ "version": "1.6.0",
1487
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
1488
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
1489
+ "dev": true,
1490
+ "license": "MIT",
1491
+ "dependencies": {
1492
+ "thenify": ">= 3.1.0 < 4"
1493
+ },
1494
+ "engines": {
1495
+ "node": ">=0.8"
1496
+ }
1497
+ },
1498
+ "node_modules/tinyglobby": {
1499
+ "version": "0.2.16",
1500
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
1501
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
1502
+ "dev": true,
1503
+ "license": "MIT",
1504
+ "dependencies": {
1505
+ "fdir": "^6.5.0",
1506
+ "picomatch": "^4.0.4"
1507
+ },
1508
+ "engines": {
1509
+ "node": ">=12.0.0"
1510
+ },
1511
+ "funding": {
1512
+ "url": "https://github.com/sponsors/SuperchupuDev"
1513
+ }
1514
+ },
1515
+ "node_modules/tinyglobby/node_modules/fdir": {
1516
+ "version": "6.5.0",
1517
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1518
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1519
+ "dev": true,
1520
+ "license": "MIT",
1521
+ "engines": {
1522
+ "node": ">=12.0.0"
1523
+ },
1524
+ "peerDependencies": {
1525
+ "picomatch": "^3 || ^4"
1526
+ },
1527
+ "peerDependenciesMeta": {
1528
+ "picomatch": {
1529
+ "optional": true
1530
+ }
1531
+ }
1532
+ },
1533
+ "node_modules/tinyglobby/node_modules/picomatch": {
1534
+ "version": "4.0.4",
1535
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
1536
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
1537
+ "dev": true,
1538
+ "license": "MIT",
1539
+ "engines": {
1540
+ "node": ">=12"
1541
+ },
1542
+ "funding": {
1543
+ "url": "https://github.com/sponsors/jonschlinkert"
1544
+ }
1545
+ },
1546
+ "node_modules/to-regex-range": {
1547
+ "version": "5.0.1",
1548
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
1549
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
1550
+ "dev": true,
1551
+ "license": "MIT",
1552
+ "dependencies": {
1553
+ "is-number": "^7.0.0"
1554
+ },
1555
+ "engines": {
1556
+ "node": ">=8.0"
1557
+ }
1558
+ },
1559
+ "node_modules/ts-interface-checker": {
1560
+ "version": "0.1.13",
1561
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
1562
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
1563
+ "dev": true,
1564
+ "license": "Apache-2.0"
1565
+ },
1566
+ "node_modules/tslib": {
1567
+ "version": "2.8.1",
1568
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1569
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1570
+ "license": "0BSD"
1571
+ },
1572
+ "node_modules/typescript": {
1573
+ "version": "5.9.3",
1574
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1575
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1576
+ "dev": true,
1577
+ "license": "Apache-2.0",
1578
+ "bin": {
1579
+ "tsc": "bin/tsc",
1580
+ "tsserver": "bin/tsserver"
1581
+ },
1582
+ "engines": {
1583
+ "node": ">=14.17"
1584
+ }
1585
+ },
1586
+ "node_modules/undici-types": {
1587
+ "version": "6.21.0",
1588
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
1589
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
1590
+ "dev": true,
1591
+ "license": "MIT"
1592
+ },
1593
+ "node_modules/update-browserslist-db": {
1594
+ "version": "1.2.3",
1595
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
1596
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
1597
+ "dev": true,
1598
+ "funding": [
1599
+ {
1600
+ "type": "opencollective",
1601
+ "url": "https://opencollective.com/browserslist"
1602
+ },
1603
+ {
1604
+ "type": "tidelift",
1605
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1606
+ },
1607
+ {
1608
+ "type": "github",
1609
+ "url": "https://github.com/sponsors/ai"
1610
+ }
1611
+ ],
1612
+ "license": "MIT",
1613
+ "dependencies": {
1614
+ "escalade": "^3.2.0",
1615
+ "picocolors": "^1.1.1"
1616
+ },
1617
+ "bin": {
1618
+ "update-browserslist-db": "cli.js"
1619
+ },
1620
+ "peerDependencies": {
1621
+ "browserslist": ">= 4.21.0"
1622
+ }
1623
+ },
1624
+ "node_modules/util-deprecate": {
1625
+ "version": "1.0.2",
1626
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
1627
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
1628
+ "dev": true,
1629
+ "license": "MIT"
1630
+ }
1631
+ }
1632
+ }
frontend/src/app/globals.css CHANGED
@@ -1,147 +1,401 @@
1
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
2
 
3
  @tailwind base;
4
  @tailwind components;
5
  @tailwind utilities;
6
 
 
7
  :root {
8
- --bg: #0f1117;
9
- --surface: #1a1d27;
10
- --border: #2a2d3a;
11
- --brand: #6366f1;
12
- --brand-light: #818cf8;
 
 
 
 
13
  }
14
 
15
- * {
16
- box-sizing: border-box;
17
- padding: 0;
18
- margin: 0;
19
- }
20
 
21
  html, body {
22
  min-height: 100vh;
23
- background-color: var(--bg);
24
- color: #fff;
25
  font-family: 'Inter', system-ui, sans-serif;
26
  scroll-behavior: smooth;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  }
28
 
29
- /* Custom scrollbar */
30
- ::-webkit-scrollbar { width: 6px; }
31
- ::-webkit-scrollbar-track { background: var(--bg); }
32
- ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
33
- ::-webkit-scrollbar-thumb:hover { background: #3a3d4a; }
 
 
34
 
35
- /* Glass card */
36
- .glass {
37
- background: rgba(26, 29, 39, 0.8);
38
- backdrop-filter: blur(20px);
39
- -webkit-backdrop-filter: blur(20px);
40
- border: 1px solid rgba(255, 255, 255, 0.08);
41
  }
 
42
 
43
- /* Glow button */
44
- .btn-glow {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  position: relative;
46
- overflow: hidden;
47
- transition: all 0.3s ease;
48
  }
49
- .btn-glow::before {
50
  content: '';
51
  position: absolute;
52
- inset: -2px;
53
- border-radius: inherit;
54
- background: linear-gradient(135deg, #6366f1, #a855f7, #06b6d4);
55
- opacity: 0;
56
- transition: opacity 0.3s;
 
 
 
 
 
 
57
  z-index: -1;
58
  }
59
- .btn-glow:hover::before { opacity: 1; }
60
-
61
- /* Shimmer skeleton */
62
- .shimmer {
63
- background: linear-gradient(
64
- 90deg,
65
- rgba(255,255,255,0.03) 25%,
66
- rgba(255,255,255,0.08) 50%,
67
- rgba(255,255,255,0.03) 75%
68
- );
69
- background-size: 200% 100%;
70
- animation: shimmer 2s linear infinite;
71
  }
 
 
 
 
 
 
72
 
73
- /* Drop zone */
74
  .drop-zone {
75
- border: 2px dashed rgba(99, 102, 241, 0.4);
 
 
 
76
  transition: all 0.25s ease;
77
  }
78
  .drop-zone:hover,
79
  .drop-zone.drag-over {
80
- border-color: rgba(99, 102, 241, 0.9);
81
- background: rgba(99, 102, 241, 0.06);
82
- box-shadow: 0 0 40px rgba(99, 102, 241, 0.12);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  }
84
 
85
- /* Progress bar */
86
  .progress-track {
87
- background: rgba(255,255,255,0.06);
88
  border-radius: 999px;
89
  overflow: hidden;
90
  }
91
  .progress-fill {
92
  height: 100%;
93
  border-radius: 999px;
94
- background: linear-gradient(90deg, #6366f1, #a855f7);
95
- transition: width 0.5s ease-out;
 
 
96
  position: relative;
97
- overflow: hidden;
98
  }
99
  .progress-fill::after {
100
  content: '';
101
  position: absolute;
102
- top: 0; right: -100%; bottom: 0;
103
- width: 60%;
104
- background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
105
- animation: shimmer 1.5s linear infinite;
 
 
 
 
 
 
 
106
  }
107
 
108
- /* Class pill */
109
  .class-pill {
110
  display: inline-flex;
111
  align-items: center;
112
  gap: 6px;
113
- padding: 3px 10px;
114
  border-radius: 999px;
115
  font-size: 12px;
116
  font-weight: 500;
117
- background: rgba(255,255,255,0.07);
118
- border: 1px solid rgba(255,255,255,0.1);
119
- animation: slideUp 0.3s ease-out;
 
 
 
 
 
 
 
 
 
 
 
 
120
  }
121
 
122
- /* Video comparison */
123
- .video-wrapper {
 
 
 
 
 
124
  position: relative;
125
  overflow: hidden;
126
- border-radius: 12px;
127
- background: #000;
128
  }
129
- .video-wrapper video {
130
- width: 100%;
131
- height: 100%;
132
- object-fit: contain;
133
- display: block;
 
 
 
 
 
 
 
 
 
134
  }
 
135
 
136
- /* Stat card */
137
- .stat-card {
138
- background: rgba(255,255,255,0.04);
139
- border: 1px solid rgba(255,255,255,0.08);
140
- border-radius: 12px;
141
- padding: 16px 20px;
142
- transition: all 0.2s;
 
143
  }
144
- .stat-card:hover {
145
- background: rgba(255,255,255,0.07);
146
- border-color: rgba(99, 102, 241, 0.4);
 
 
 
 
 
 
 
 
 
147
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
2
 
3
  @tailwind base;
4
  @tailwind components;
5
  @tailwind utilities;
6
 
7
+ /* ─── Tokens ───────────────────────────────────────────────────────────────── */
8
  :root {
9
+ --bg: #ffffff;
10
+ --surface: #fafafa;
11
+ --border: #e5e7eb;
12
+ --border2: #f0f0f0;
13
+ --text: #0f172a;
14
+ --muted: #64748b;
15
+ --accent: #f97316;
16
+ --accent2: #fbbf24;
17
+ --radius: 16px;
18
  }
19
 
20
+ * { box-sizing: border-box; margin: 0; padding: 0; }
 
 
 
 
21
 
22
  html, body {
23
  min-height: 100vh;
24
+ background: var(--bg);
25
+ color: var(--text);
26
  font-family: 'Inter', system-ui, sans-serif;
27
  scroll-behavior: smooth;
28
+ overflow-x: hidden;
29
+ -webkit-font-smoothing: antialiased;
30
+ }
31
+
32
+ /* ─── Scrollbar ─────────────────────────────────────────────────────────────── */
33
+ ::-webkit-scrollbar { width: 5px; }
34
+ ::-webkit-scrollbar-track { background: #f1f5f9; }
35
+ ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
36
+ ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
37
+
38
+ /* ─── Subtle dot grid background ───────────────────────────────────────────── */
39
+ .page-bg {
40
+ position: fixed;
41
+ inset: 0;
42
+ z-index: 0;
43
+ pointer-events: none;
44
+ background-image: radial-gradient(circle, rgba(0,0,0,0.06) 1px, transparent 1px);
45
+ background-size: 24px 24px;
46
+ }
47
+
48
+ /* ─── ─── Scroll Animations ─────────────────────────────────────────────────── */
49
+ .scroll-hidden {
50
+ opacity: 0;
51
+ transform: translateY(28px);
52
+ transition: opacity 0.65s cubic-bezier(0.4, 0, 0.2, 1), transform 0.65s cubic-bezier(0.4, 0, 0.2, 1);
53
+ }
54
+ .scroll-hidden.delay-1 { transition-delay: 0.1s; }
55
+ .scroll-hidden.delay-2 { transition-delay: 0.2s; }
56
+ .scroll-hidden.delay-3 { transition-delay: 0.3s; }
57
+ .scroll-hidden.delay-4 { transition-delay: 0.4s; }
58
+
59
+ .scroll-visible {
60
+ opacity: 1 !important;
61
+ transform: translateY(0) !important;
62
  }
63
 
64
+ /* Fade in from left */
65
+ .scroll-left {
66
+ opacity: 0;
67
+ transform: translateX(-24px);
68
+ transition: opacity 0.6s ease, transform 0.6s ease;
69
+ }
70
+ .scroll-left.scroll-visible { opacity: 1; transform: translateX(0); }
71
 
72
+ /* Fade in from right */
73
+ .scroll-right {
74
+ opacity: 0;
75
+ transform: translateX(24px);
76
+ transition: opacity 0.6s ease, transform 0.6s ease;
 
77
  }
78
+ .scroll-right.scroll-visible { opacity: 1; transform: translateX(0); }
79
 
80
+ /* Scale in */
81
+ .scroll-scale {
82
+ opacity: 0;
83
+ transform: scale(0.93);
84
+ transition: opacity 0.55s ease, transform 0.55s cubic-bezier(0.34, 1.2, 0.64, 1);
85
+ }
86
+ .scroll-scale.scroll-visible { opacity: 1; transform: scale(1); }
87
+
88
+ /* ─── Navbar ────────────────────────────────────────────────────────────────── */
89
+ .navbar {
90
+ background: rgba(255, 255, 255, 0.88);
91
+ backdrop-filter: blur(16px) saturate(150%);
92
+ -webkit-backdrop-filter: blur(16px) saturate(150%);
93
+ border-bottom: 1px solid var(--border2);
94
+ }
95
+
96
+ /* ─── Cards ─────────────────────────────────────────────────────────────────── */
97
+ .card {
98
+ background: #ffffff;
99
+ border: 1px solid var(--border);
100
+ border-radius: var(--radius);
101
+ transition: box-shadow 0.25s, border-color 0.25s, transform 0.2s;
102
+ }
103
+ .card:hover {
104
+ box-shadow: 0 12px 40px rgba(0,0,0,0.09);
105
+ border-color: #d1d5db;
106
+ transform: translateY(-2px);
107
+ }
108
+
109
+ /* ─── Moving Border Card ────────────────────────────────────────────────────── */
110
+ .moving-border-card {
111
  position: relative;
112
+ background: #ffffff;
113
+ border-radius: var(--radius);
114
  }
115
+ .moving-border-card::before {
116
  content: '';
117
  position: absolute;
118
+ inset: -1px;
119
+ border-radius: calc(var(--radius) + 1px);
120
+ background: conic-gradient(
121
+ from var(--angle, 0deg),
122
+ transparent 75%,
123
+ #f97316 80%,
124
+ #fbbf24 85%,
125
+ #fb923c 90%,
126
+ transparent 95%
127
+ );
128
+ animation: border-spin 4s linear infinite;
129
  z-index: -1;
130
  }
131
+ .moving-border-card::after {
132
+ content: '';
133
+ position: absolute;
134
+ inset: 0;
135
+ border-radius: var(--radius);
136
+ background: #ffffff;
137
+ z-index: -1;
 
 
 
 
 
138
  }
139
+ @property --angle {
140
+ syntax: '<angle>';
141
+ initial-value: 0deg;
142
+ inherits: false;
143
+ }
144
+ @keyframes border-spin { to { --angle: 360deg; } }
145
 
146
+ /* ─── Drop Zone ─────────────────────────────────────────────────────────────── */
147
  .drop-zone {
148
+ border: 2px dashed #e5e7eb;
149
+ border-radius: 12px;
150
+ background: #fafafa;
151
+ cursor: pointer;
152
  transition: all 0.25s ease;
153
  }
154
  .drop-zone:hover,
155
  .drop-zone.drag-over {
156
+ border-color: #f97316;
157
+ background: #fff8f2;
158
+ box-shadow: 0 0 0 4px rgba(249,115,22,0.07);
159
+ }
160
+
161
+ /* ─── Black CTA Button ──────────────────────────────────────────────────────── */
162
+ .btn-primary {
163
+ position: relative;
164
+ overflow: hidden;
165
+ background: #0f172a;
166
+ border: 0;
167
+ border-radius: 12px;
168
+ color: #ffffff;
169
+ font-weight: 600;
170
+ cursor: pointer;
171
+ transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
172
+ letter-spacing: -0.01em;
173
+ }
174
+ .btn-primary::after {
175
+ content: '';
176
+ position: absolute;
177
+ top: -50%; left: -80%;
178
+ width: 60%; height: 200%;
179
+ background: linear-gradient(105deg, transparent 40%, rgba(255,255,255,0.15) 50%, transparent 60%);
180
+ transform: skewX(-20deg);
181
+ animation: shimmer-light 3s ease-in-out infinite;
182
+ }
183
+ @keyframes shimmer-light {
184
+ 0% { left: -80%; }
185
+ 100% { left: 140%; }
186
+ }
187
+ .btn-primary:hover {
188
+ background: #1e293b;
189
+ transform: translateY(-1px);
190
+ box-shadow: 0 8px 30px rgba(15,23,42,0.25);
191
+ }
192
+ .btn-primary:active { transform: translateY(0); }
193
+ .btn-primary:disabled {
194
+ background: #cbd5e1;
195
+ color: #94a3b8;
196
+ cursor: not-allowed;
197
+ transform: none;
198
+ box-shadow: none;
199
+ }
200
+ .btn-primary:disabled::after { display: none; }
201
+
202
+ /* ─── Outlined Button ───────────────────────────────────────────────────────── */
203
+ .btn-outline {
204
+ background: transparent;
205
+ border: 1.5px solid var(--border);
206
+ border-radius: 12px;
207
+ color: var(--muted);
208
+ font-weight: 500;
209
+ cursor: pointer;
210
+ transition: all 0.2s;
211
+ }
212
+ .btn-outline:hover {
213
+ border-color: #94a3b8;
214
+ color: var(--text);
215
+ background: #f8fafc;
216
  }
217
 
218
+ /* ─── Progress Bar ──────────────────────────────────────────────────────────── */
219
  .progress-track {
220
+ background: #f1f5f9;
221
  border-radius: 999px;
222
  overflow: hidden;
223
  }
224
  .progress-fill {
225
  height: 100%;
226
  border-radius: 999px;
227
+ background: linear-gradient(90deg, #f97316, #fbbf24, #fb923c);
228
+ background-size: 200% 100%;
229
+ animation: gradient-scroll 2s linear infinite;
230
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
231
  position: relative;
 
232
  }
233
  .progress-fill::after {
234
  content: '';
235
  position: absolute;
236
+ inset: 0;
237
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent);
238
+ animation: pill-shimmer 1.5s linear infinite;
239
+ }
240
+ @keyframes gradient-scroll {
241
+ 0% { background-position: 0% 50%; }
242
+ 100% { background-position: 200% 50%; }
243
+ }
244
+ @keyframes pill-shimmer {
245
+ 0% { transform: translateX(-100%); }
246
+ 100% { transform: translateX(400%); }
247
  }
248
 
249
+ /* ─── Class Pill ─────────────────────────────────────────────────────────────── */
250
  .class-pill {
251
  display: inline-flex;
252
  align-items: center;
253
  gap: 6px;
254
+ padding: 4px 10px;
255
  border-radius: 999px;
256
  font-size: 12px;
257
  font-weight: 500;
258
+ background: #f8fafc;
259
+ border: 1px solid #e5e7eb;
260
+ color: #475569;
261
+ transition: all 0.2s;
262
+ animation: pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
263
+ }
264
+ .class-pill:hover {
265
+ background: #fff8f2;
266
+ border-color: #fdba74;
267
+ color: #c2410c;
268
+ transform: scale(1.05);
269
+ }
270
+ @keyframes pop-in {
271
+ 0% { opacity: 0; transform: scale(0.7); }
272
+ 100% { opacity: 1; transform: scale(1); }
273
  }
274
 
275
+ /* ─── Stat Card ─────────────────────────────────────────────────────────────── */
276
+ .stat-card {
277
+ background: #ffffff;
278
+ border: 1px solid #f0f0f0;
279
+ border-radius: 14px;
280
+ padding: 20px;
281
+ transition: all 0.25s;
282
  position: relative;
283
  overflow: hidden;
284
+ cursor: default;
 
285
  }
286
+ .stat-card:hover {
287
+ border-color: #fdba74;
288
+ box-shadow: 0 8px 30px rgba(249,115,22,0.08);
289
+ transform: translateY(-3px);
290
+ }
291
+ .stat-card::before {
292
+ content: '';
293
+ position: absolute;
294
+ top: 0; left: 0; right: 0;
295
+ height: 2px;
296
+ background: linear-gradient(90deg, #f97316, #fbbf24);
297
+ transform: scaleX(0);
298
+ transform-origin: left;
299
+ transition: transform 0.3s ease;
300
  }
301
+ .stat-card:hover::before { transform: scaleX(1); }
302
 
303
+ /* ─── Gradient Heading Text ─────────────────────────────────────────────────── */
304
+ .text-gradient {
305
+ background: linear-gradient(135deg, #f97316 0%, #f59e0b 50%, #fb923c 100%);
306
+ background-clip: text;
307
+ -webkit-background-clip: text;
308
+ -webkit-text-fill-color: transparent;
309
+ background-size: 200% 200%;
310
+ animation: gradient-shift 4s ease infinite;
311
  }
312
+ @keyframes gradient-shift {
313
+ 0% { background-position: 0% 50%; }
314
+ 50% { background-position: 100% 50%; }
315
+ 100% { background-position: 0% 50%; }
316
+ }
317
+
318
+ /* ─── Word animation ────────────────────────────────────────────────────────── */
319
+ .word-animate {
320
+ display: inline-block;
321
+ opacity: 0;
322
+ transform: translateY(20px);
323
+ animation: word-in 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
324
  }
325
+ @keyframes word-in {
326
+ to { opacity: 1; transform: translateY(0); }
327
+ }
328
+
329
+ /* ─── Badge ─────────────────────────────────────────────────────────────────── */
330
+ .badge {
331
+ display: inline-flex;
332
+ align-items: center;
333
+ gap: 6px;
334
+ padding: 5px 14px;
335
+ border-radius: 999px;
336
+ border: 1px solid #fed7aa;
337
+ background: #fff7ed;
338
+ font-size: 12px;
339
+ font-weight: 500;
340
+ color: #c2410c;
341
+ }
342
+
343
+ /* ─── Divider ───────────────────────────────────────────────────────────────── */
344
+ .divider {
345
+ height: 1px;
346
+ background: linear-gradient(90deg, transparent, #e5e7eb 20%, #e5e7eb 80%, transparent);
347
+ }
348
+
349
+ /* ─── Step Indicator ────────────────────────────────────────────────────────── */
350
+ .step-dot {
351
+ width: 28px; height: 28px;
352
+ border-radius: 50%;
353
+ display: flex;
354
+ align-items: center;
355
+ justify-content: center;
356
+ font-size: 11px;
357
+ font-weight: 700;
358
+ transition: all 0.4s;
359
+ flex-shrink: 0;
360
+ }
361
+ .step-dot.done { background: #dcfce7; color: #15803d; border: 2px solid #86efac; }
362
+ .step-dot.active { background: #0f172a; color: white; border: 2px solid #0f172a; box-shadow: 0 0 0 3px rgba(15,23,42,0.12); }
363
+ .step-dot.pending { background: #f8fafc; color: #94a3b8; border: 2px solid #e5e7eb; }
364
+
365
+ /* ─── Bounce Dots ───────────────────────────────────────────────────────────── */
366
+ .bounce-dot {
367
+ display: inline-block;
368
+ width: 7px; height: 7px;
369
+ border-radius: 50%;
370
+ animation: bounce-dot 1.2s ease-in-out infinite;
371
+ }
372
+ .bounce-dot:nth-child(1) { animation-delay: 0s; }
373
+ .bounce-dot:nth-child(2) { animation-delay: 0.2s; }
374
+ .bounce-dot:nth-child(3) { animation-delay: 0.4s; }
375
+ @keyframes bounce-dot {
376
+ 0%, 80%, 100% { transform: scale(0.7); opacity: 0.4; }
377
+ 40% { transform: scale(1.2); opacity: 1; }
378
+ }
379
+
380
+ /* ─── Range Input ───────────────────────────────────────────────────────────── */
381
+ input[type="range"] {
382
+ -webkit-appearance: none;
383
+ appearance: none;
384
+ background: #e5e7eb;
385
+ border-radius: 999px;
386
+ cursor: pointer;
387
+ }
388
+ input[type="range"]::-webkit-slider-thumb {
389
+ -webkit-appearance: none;
390
+ width: 14px; height: 14px;
391
+ border-radius: 50%;
392
+ background: #0f172a;
393
+ cursor: pointer;
394
+ }
395
+
396
+ /* ─── Video UI ──────────────────────────────────────────────────────────────── */
397
+ .video-wrapper { background: #000; border-radius: 12px; overflow: hidden; }
398
+ .video-wrapper video { width: 100%; display: block; object-fit: contain; }
399
+
400
+ /* ─── Section spacing ───────────────────────────────────────────────────────── */
401
+ .section { padding: 80px 0; }
frontend/src/app/layout.tsx CHANGED
@@ -2,61 +2,74 @@ import type { Metadata } from 'next'
2
  import { Inter } from 'next/font/google'
3
  import './globals.css'
4
 
5
- const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
6
 
7
  export const metadata: Metadata = {
8
  title: 'SegVision β€” AI Video Segmentation',
9
- description:
10
- 'Upload any video and get real-time semantic segmentation with 21-class PASCAL VOC overlay. Powered by DeepLabV3 + ResNet-50.',
11
- keywords: ['video segmentation', 'AI', 'semantic segmentation', 'DeepLabV3', 'computer vision'],
12
- openGraph: {
13
- title: 'SegVision β€” AI Video Segmentation',
14
- description: 'Semantic segmentation overlay for any video, in seconds.',
15
- type: 'website',
16
- },
17
  }
18
 
19
  export default function RootLayout({ children }: { children: React.ReactNode }) {
20
  return (
21
- <html lang="en" className={inter.variable}>
22
- <head>
23
- <link rel="preconnect" href="https://fonts.googleapis.com" />
24
- </head>
25
- <body className="bg-surface text-white antialiased min-h-screen">
26
- {/* Ambient background glow */}
27
- <div className="fixed inset-0 pointer-events-none overflow-hidden">
28
- <div className="absolute -top-40 -left-40 w-96 h-96 bg-brand-600/20 rounded-full blur-3xl animate-pulse-slow" />
29
- <div className="absolute -bottom-40 -right-40 w-96 h-96 bg-purple-600/15 rounded-full blur-3xl animate-pulse-slow" style={{ animationDelay: '1.5s' }} />
30
- </div>
31
 
32
  {/* Navbar */}
33
- <nav className="relative z-10 border-b border-surface-border bg-surface/80 backdrop-blur-xl">
34
- <div className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
35
- <a href="/" className="flex items-center gap-2 group">
36
- <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-purple-600 flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
37
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5">
 
 
38
  <polygon points="5 3 19 12 5 21 5 3"/>
39
  </svg>
40
  </div>
41
- <span className="text-lg font-bold tracking-tight">
42
- Seg<span className="text-brand-400">Vision</span>
43
  </span>
44
  </a>
45
- <div className="flex items-center gap-4 text-sm text-gray-400">
46
- <span className="hidden sm:flex items-center gap-1.5">
47
- <span className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
48
- DeepLabV3 Β· ResNet-50 Β· PASCAL VOC 21
49
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
50
  </div>
51
  </div>
52
  </nav>
53
 
54
- <main className="relative z-10">
55
  {children}
56
  </main>
57
 
58
- <footer className="relative z-10 border-t border-surface-border mt-20 py-8 text-center text-sm text-gray-500">
59
- <p>SegVision Β· Semantic Video Segmentation Β· DeepLabV3 + ResNet-50</p>
 
 
 
 
 
 
 
 
 
 
 
60
  </footer>
61
  </body>
62
  </html>
 
2
  import { Inter } from 'next/font/google'
3
  import './globals.css'
4
 
5
+ const inter = Inter({ subsets: ['latin'] })
6
 
7
  export const metadata: Metadata = {
8
  title: 'SegVision β€” AI Video Segmentation',
9
+ description: 'Upload any video and get real-time semantic segmentation. Powered by DeepLabV3 + ResNet-50.',
 
 
 
 
 
 
 
10
  }
11
 
12
  export default function RootLayout({ children }: { children: React.ReactNode }) {
13
  return (
14
+ <html lang="en">
15
+ <body className={`${inter.className} bg-white text-slate-900 antialiased min-h-screen`}>
16
+
17
+ {/* Subtle dot grid */}
18
+ <div className="page-bg" aria-hidden="true" />
 
 
 
 
 
19
 
20
  {/* Navbar */}
21
+ <nav className="navbar fixed top-0 left-0 right-0 z-50">
22
+ <div className="max-w-6xl mx-auto px-5 h-16 flex items-center justify-between">
23
+
24
+ {/* Logo */}
25
+ <a href="/" className="flex items-center gap-2.5 group">
26
+ <div className="w-8 h-8 rounded-xl bg-slate-900 flex items-center justify-center group-hover:scale-105 transition-transform duration-200">
27
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round">
28
  <polygon points="5 3 19 12 5 21 5 3"/>
29
  </svg>
30
  </div>
31
+ <span className="text-base font-bold tracking-tight text-slate-900">
32
+ Seg<span className="text-gradient">Vision</span>
33
  </span>
34
  </a>
35
+
36
+ {/* Right */}
37
+ <div className="flex items-center gap-3">
38
+ <div className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-green-200 bg-green-50 text-xs font-medium text-green-700">
39
+ <span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse inline-block" />
40
+ Model Live
41
+ </div>
42
+ <a
43
+ href="https://github.com/mathsphile/video-segmentation-"
44
+ target="_blank"
45
+ rel="noopener noreferrer"
46
+ className="w-9 h-9 rounded-xl border border-slate-200 flex items-center justify-center text-slate-400 hover:text-slate-700 hover:border-slate-300 transition-all"
47
+ >
48
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
49
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
50
+ </svg>
51
+ </a>
52
  </div>
53
  </div>
54
  </nav>
55
 
56
+ <main className="relative z-10 pt-16">
57
  {children}
58
  </main>
59
 
60
+ <footer className="relative z-10 border-t border-slate-100 py-10 mt-20">
61
+ <div className="max-w-6xl mx-auto px-5 flex flex-col sm:flex-row items-center justify-between gap-4">
62
+ <div className="flex items-center gap-2">
63
+ <div className="w-6 h-6 rounded-lg bg-slate-900 flex items-center justify-center">
64
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
65
+ </div>
66
+ <span className="text-sm font-semibold text-slate-800">SegVision</span>
67
+ </div>
68
+ <p className="text-xs text-slate-400">DeepLabV3 + ResNet-50 Β· PASCAL VOC 21 Classes Β· H.264 Output</p>
69
+ <a href="https://github.com/mathsphile/video-segmentation-" target="_blank" className="text-xs text-slate-400 hover:text-slate-700 transition-colors">
70
+ GitHub β†—
71
+ </a>
72
+ </div>
73
  </footer>
74
  </body>
75
  </html>
frontend/src/app/page.tsx CHANGED
@@ -1,77 +1,113 @@
1
  'use client'
2
 
3
- import { useState, useRef, useCallback, DragEvent, ChangeEvent } from 'react'
4
  import { useRouter } from 'next/navigation'
5
 
6
  const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'
7
 
8
- const VOC_COLORS: Record<string, string> = {
9
- background: '#000000', aeroplane: '#87CEEB', bicycle: '#FFA500',
10
- bird: '#FFD700', boat: '#00BFFF', bottle: '#9400D3',
11
- bus: '#FF1493', car: '#DC143C', cat: '#FF8C00',
12
- chair: '#8B4513', cow: '#FFFF00', diningtable: '#D2691E',
13
- dog: '#BA55D3', horse: '#FF69B4', motorbike: '#00FF7F',
14
- person: '#FF4500', 'potted plant': '#228B22', sheep: '#F0E68C',
15
- sofa: '#00CED1', train: '#0000FF', 'tv/monitor': '#7FFFD4',
16
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- const formatBytes = (bytes: number) => {
19
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
20
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  }
22
 
23
  export default function HomePage() {
24
- const router = useRouter()
25
  const fileInputRef = useRef<HTMLInputElement>(null)
26
- const [dragging, setDragging] = useState(false)
27
- const [file, setFile] = useState<File | null>(null)
28
- const [preview, setPreview] = useState<string | null>(null)
 
29
  const [uploading, setUploading] = useState(false)
30
- const [error, setError] = useState<string | null>(null)
31
-
32
- const validate = (f: File): string | null => {
33
- const allowed = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm', 'video/x-matroska']
34
- if (!allowed.includes(f.type) && !f.name.match(/\.(mp4|mov|avi|webm|mkv)$/i))
35
- return 'Only MP4, MOV, AVI, WebM, MKV files are supported.'
36
- if (f.size > 200 * 1024 * 1024)
37
- return 'File too large. Maximum size is 200 MB.'
38
  return null
39
  }
40
 
41
  const selectFile = useCallback((f: File) => {
42
- const err = validate(f)
43
- if (err) { setError(err); return }
44
- setError(null)
45
- setFile(f)
46
- // Create video preview thumbnail
47
- const url = URL.createObjectURL(f)
48
- setPreview(url)
49
  }, [])
50
 
51
  const onDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
52
- e.preventDefault()
53
- setDragging(false)
54
- const f = e.dataTransfer.files[0]
55
- if (f) selectFile(f)
56
  }, [selectFile])
57
 
58
- const onFileChange = (e: ChangeEvent<HTMLInputElement>) => {
59
- const f = e.target.files?.[0]
60
- if (f) selectFile(f)
61
- }
62
-
63
  const handleUpload = async () => {
64
  if (!file) return
65
- setUploading(true)
66
- setError(null)
67
  try {
68
- const form = new FormData()
69
- form.append('file', file)
70
  const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: form })
71
- if (!res.ok) {
72
- const data = await res.json()
73
- throw new Error(data.detail ?? 'Upload failed')
74
- }
75
  const data = await res.json()
76
  router.push(`/processing/${data.job_id}`)
77
  } catch (e: any) {
@@ -81,153 +117,216 @@ export default function HomePage() {
81
  }
82
 
83
  return (
84
- <div className="max-w-4xl mx-auto px-4 py-16">
85
 
86
- {/* Hero */}
87
- <div className="text-center mb-14 animate-fade-in">
88
- <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium bg-brand-500/10 border border-brand-500/20 text-brand-400 mb-6">
89
- <span className="w-1.5 h-1.5 rounded-full bg-brand-400 animate-pulse"></span>
90
- Powered by DeepLabV3 Β· ResNet-50 Β· PASCAL VOC
 
 
 
 
91
  </div>
92
- <h1 className="text-5xl sm:text-6xl font-extrabold tracking-tight mb-5 leading-tight">
93
- AI Video
94
- <span className="block bg-gradient-to-r from-brand-400 via-purple-400 to-cyan-400 bg-clip-text text-transparent">
95
- Segmentation
96
- </span>
 
 
 
 
 
97
  </h1>
98
- <p className="text-lg text-gray-400 max-w-xl mx-auto leading-relaxed">
99
- Upload any video and watch AI detect and colour every object in real-time.
100
- Get a side-by-side comparison instantly.
 
 
 
 
101
  </p>
102
- </div>
103
-
104
- {/* Upload Card */}
105
- <div className="glass rounded-2xl p-8 shadow-2xl animate-slide-up">
106
-
107
- {!file ? (
108
- /* Drop Zone */
109
- <div
110
- className={`drop-zone rounded-xl p-12 flex flex-col items-center justify-center cursor-pointer min-h-[280px] ${dragging ? 'drag-over' : ''}`}
111
- onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
112
- onDragLeave={() => setDragging(false)}
113
- onDrop={onDrop}
114
- onClick={() => fileInputRef.current?.click()}
115
  >
116
- <div className={`w-20 h-20 rounded-2xl flex items-center justify-center mb-5 transition-all duration-300 ${dragging ? 'bg-brand-500/20 scale-110' : 'bg-brand-500/10'}`}>
117
- <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke={dragging ? '#818cf8' : '#6366f1'} strokeWidth="1.5">
118
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
119
- <polyline points="17 8 12 3 7 8"/>
120
- <line x1="12" y1="3" x2="12" y2="15"/>
121
- </svg>
122
- </div>
123
- <p className="text-xl font-semibold text-white mb-2">
124
- {dragging ? 'Drop it here!' : 'Drop your video here'}
125
- </p>
126
- <p className="text-gray-400 text-sm mb-5">or click to browse your files</p>
127
- <div className="flex items-center gap-2 text-xs text-gray-500">
128
- <span className="px-2 py-0.5 rounded bg-white/5 border border-white/10">MP4</span>
129
- <span className="px-2 py-0.5 rounded bg-white/5 border border-white/10">MOV</span>
130
- <span className="px-2 py-0.5 rounded bg-white/5 border border-white/10">AVI</span>
131
- <span className="px-2 py-0.5 rounded bg-white/5 border border-white/10">WebM</span>
132
- <span className="text-gray-600">Β· Max 200 MB</span>
133
- </div>
134
- </div>
135
- ) : (
136
- /* File Preview */
137
- <div className="animate-fade-in">
138
- <div className="video-wrapper mb-5 max-h-64">
139
- <video src={preview!} muted className="w-full max-h-64" controls />
140
  </div>
141
- <div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10 mb-5">
142
- <div className="flex items-center gap-3">
143
- <div className="w-10 h-10 rounded-lg bg-brand-500/15 flex items-center justify-center">
144
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2">
145
- <polygon points="5 3 19 12 5 21 5 3"/>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  </svg>
147
  </div>
148
- <div>
149
- <p className="font-medium text-white text-sm truncate max-w-[200px] sm:max-w-sm">{file.name}</p>
150
- <p className="text-xs text-gray-400">{formatBytes(file.size)}</p>
 
 
 
 
 
 
 
 
 
151
  </div>
152
  </div>
153
- <button
154
- onClick={() => { setFile(null); setPreview(null); setError(null) }}
155
- className="text-gray-400 hover:text-red-400 transition-colors p-1.5 rounded-lg hover:bg-red-500/10"
156
- >
157
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
158
- <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </svg>
160
- </button>
161
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  </div>
163
- )}
 
164
 
165
- <input ref={fileInputRef} type="file" accept="video/*" className="hidden" onChange={onFileChange} />
 
 
 
 
 
 
166
 
167
- {error && (
168
- <div className="mt-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
169
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
170
- <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
171
- </svg>
172
- {error}
173
- </div>
174
- )}
175
-
176
- <button
177
- onClick={handleUpload}
178
- disabled={!file || uploading}
179
- className="mt-6 w-full py-4 rounded-xl font-semibold text-white text-base transition-all duration-200
180
- bg-gradient-to-r from-brand-600 to-purple-600
181
- hover:from-brand-500 hover:to-purple-500
182
- disabled:opacity-40 disabled:cursor-not-allowed
183
- hover:shadow-lg hover:shadow-brand-500/25 hover:-translate-y-0.5
184
- active:translate-y-0 flex items-center justify-center gap-3"
185
- >
186
- {uploading ? (
187
- <>
188
- <svg className="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2">
189
- <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
190
- </svg>
191
- Uploading …
192
- </>
193
- ) : (
194
- <>
195
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2">
196
- <polygon points="5 3 19 12 5 21 5 3"/>
197
- </svg>
198
- Segment Video
199
- </>
200
- )}
201
- </button>
202
- </div>
203
-
204
- {/* Feature Cards */}
205
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-10 animate-fade-in">
206
- {[
207
- { icon: '🎯', title: '21 Object Classes', desc: 'People, cars, animals, furniture & more from PASCAL VOC' },
208
- { icon: '⚑', title: 'GPU Accelerated', desc: 'CUDA inference for fast frame-by-frame processing' },
209
- { icon: '🎬', title: 'Side-by-Side View', desc: 'Original vs segmented video with downloadable output' },
210
- ].map((f) => (
211
- <div key={f.title} className="stat-card">
212
- <div className="text-2xl mb-2">{f.icon}</div>
213
- <h3 className="font-semibold text-white text-sm mb-1">{f.title}</h3>
214
- <p className="text-xs text-gray-400 leading-relaxed">{f.desc}</p>
215
- </div>
216
- ))}
217
- </div>
218
-
219
- {/* Class palette preview */}
220
- <div className="mt-10 glass rounded-xl p-6 animate-fade-in">
221
- <h2 className="text-sm font-semibold text-gray-300 mb-4">Detectable Classes</h2>
222
- <div className="flex flex-wrap gap-2">
223
- {Object.entries(VOC_COLORS).filter(([k]) => k !== 'background').map(([cls, hex]) => (
224
- <span key={cls} className="class-pill">
225
- <span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: hex }} />
226
- {cls}
227
  </span>
228
  ))}
229
  </div>
230
- </div>
 
231
  </div>
232
  )
233
  }
 
1
  'use client'
2
 
3
+ import { useState, useRef, useCallback, useEffect, DragEvent } from 'react'
4
  import { useRouter } from 'next/navigation'
5
 
6
  const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'
7
 
8
+ const VOC_CLASSES = [
9
+ { name: 'aeroplane', color: '#87CEEB' }, { name: 'bicycle', color: '#FFA500' },
10
+ { name: 'bird', color: '#FFD700' }, { name: 'boat', color: '#00BFFF' },
11
+ { name: 'bottle', color: '#9400D3' }, { name: 'bus', color: '#FF1493' },
12
+ { name: 'car', color: '#DC143C' }, { name: 'cat', color: '#FF8C00' },
13
+ { name: 'chair', color: '#8B4513' }, { name: 'cow', color: '#D4A017' },
14
+ { name: 'diningtable', color: '#D2691E' },{ name: 'dog', color: '#BA55D3' },
15
+ { name: 'horse', color: '#FF69B4' }, { name: 'motorbike', color: '#22c55e' },
16
+ { name: 'person', color: '#FF4500' }, { name: 'potted plant', color: '#228B22' },
17
+ { name: 'sheep', color: '#B8A40A' }, { name: 'sofa', color: '#00CED1' },
18
+ { name: 'train', color: '#3b82f6' }, { name: 'tv/monitor', color: '#0D9488' },
19
+ ]
20
+
21
+ const STEPS = [
22
+ { num: '01', title: 'Upload', desc: 'Drag & drop or select your video file' },
23
+ { num: '02', title: 'Process', desc: 'AI segments every frame with DeepLabV3' },
24
+ { num: '03', title: 'Download', desc: 'Get H.264 side-by-side comparison MP4' },
25
+ ]
26
+
27
+ const FEATURES = [
28
+ {
29
+ icon: '🎯',
30
+ title: '21 Object Classes',
31
+ desc: 'Identifies people, cars, animals, furniture & more using PASCAL VOC labels.',
32
+ tag: 'PASCAL VOC'
33
+ },
34
+ {
35
+ icon: '⚑',
36
+ title: 'GPU Accelerated',
37
+ desc: 'CUDA-powered inference for real-time frame-by-frame segmentation.',
38
+ tag: 'PyTorch'
39
+ },
40
+ {
41
+ icon: '🎬',
42
+ title: 'Side-by-Side Output',
43
+ desc: 'Original and segmented frames combined into one comparison video.',
44
+ tag: 'H.264 MP4'
45
+ },
46
+ {
47
+ icon: 'πŸ“‘',
48
+ title: 'Live Progress',
49
+ desc: 'Real-time WebSocket updates showing segmentation progress as it runs.',
50
+ tag: 'WebSocket'
51
+ },
52
+ ]
53
+
54
+ const formatBytes = (b: number) => b < 1024*1024
55
+ ? `${(b/1024).toFixed(1)} KB`
56
+ : `${(b/(1024*1024)).toFixed(1)} MB`
57
 
58
+ /* ── Scroll animation hook ──────────────────────────────────────────────────── */
59
+ function useScrollReveal() {
60
+ useEffect(() => {
61
+ const targets = document.querySelectorAll('.scroll-hidden, .scroll-left, .scroll-right, .scroll-scale')
62
+ const observer = new IntersectionObserver(
63
+ (entries) => entries.forEach(e => {
64
+ if (e.isIntersecting) {
65
+ e.target.classList.add('scroll-visible')
66
+ observer.unobserve(e.target)
67
+ }
68
+ }),
69
+ { threshold: 0.12 }
70
+ )
71
+ targets.forEach(t => observer.observe(t))
72
+ return () => observer.disconnect()
73
+ }, [])
74
  }
75
 
76
  export default function HomePage() {
77
+ const router = useRouter()
78
  const fileInputRef = useRef<HTMLInputElement>(null)
79
+
80
+ const [dragging, setDragging] = useState(false)
81
+ const [file, setFile] = useState<File | null>(null)
82
+ const [preview, setPreview] = useState<string | null>(null)
83
  const [uploading, setUploading] = useState(false)
84
+ const [error, setError] = useState<string | null>(null)
85
+
86
+ useScrollReveal()
87
+
88
+ const validate = (f: File) => {
89
+ if (!f.name.match(/\.(mp4|mov|avi|webm|mkv)$/i)) return 'Only MP4, MOV, AVI, WebM, MKV supported.'
90
+ if (f.size > 200 * 1024 * 1024) return 'File too large. Max 200 MB.'
 
91
  return null
92
  }
93
 
94
  const selectFile = useCallback((f: File) => {
95
+ const err = validate(f); if (err) { setError(err); return }
96
+ setError(null); setFile(f); setPreview(URL.createObjectURL(f))
 
 
 
 
 
97
  }, [])
98
 
99
  const onDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
100
+ e.preventDefault(); setDragging(false)
101
+ const f = e.dataTransfer.files[0]; if (f) selectFile(f)
 
 
102
  }, [selectFile])
103
 
 
 
 
 
 
104
  const handleUpload = async () => {
105
  if (!file) return
106
+ setUploading(true); setError(null)
 
107
  try {
108
+ const form = new FormData(); form.append('file', file)
 
109
  const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: form })
110
+ if (!res.ok) { const d = await res.json(); throw new Error(d.detail ?? 'Upload failed') }
 
 
 
111
  const data = await res.json()
112
  router.push(`/processing/${data.job_id}`)
113
  } catch (e: any) {
 
117
  }
118
 
119
  return (
120
+ <div className="bg-white">
121
 
122
+ {/* ── Hero ───────────────────────────────────────────────────────────────── */}
123
+ <section className="max-w-5xl mx-auto px-5 pt-24 pb-16 text-center">
124
+ {/* Badge β€” animates immediately */}
125
+ <div
126
+ className="badge mx-auto mb-8 w-fit"
127
+ style={{ animation: 'word-in 0.5s ease forwards' }}
128
+ >
129
+ <span className="w-2 h-2 rounded-full bg-orange-400 animate-pulse inline-block" />
130
+ DeepLabV3 Β· ResNet-50 Β· PASCAL VOC 21 Classes
131
  </div>
132
+
133
+ {/* Headline */}
134
+ <h1 className="text-5xl sm:text-7xl font-black tracking-tight leading-[1.05] mb-6">
135
+ {'AI Video'.split('').map((c,i) => (
136
+ <span key={i} className="word-animate inline-block" style={{ animationDelay: `${i * 0.04}s` }}>
137
+ {c === ' ' ? '\u00a0' : c}
138
+ </span>
139
+ ))}
140
+ <br />
141
+ <span className="text-gradient">Segmentation</span>
142
  </h1>
143
+
144
+ <p
145
+ className="text-lg text-slate-500 max-w-xl mx-auto leading-relaxed mb-10"
146
+ style={{ animation: 'word-in 0.6s 0.4s ease forwards', opacity: 0 }}
147
+ >
148
+ Upload any video and watch AI identify, colour, and label
149
+ every object in real-time β€” delivered as a stunning side-by-side comparison.
150
  </p>
151
+
152
+ {/* CTA scroll hint */}
153
+ <div style={{ animation: 'word-in 0.5s 0.7s ease forwards', opacity: 0 }}>
154
+ <a
155
+ href="#upload"
156
+ className="btn-primary inline-flex items-center gap-2 px-7 py-3.5 text-sm"
157
+ onClick={e => { e.preventDefault(); document.getElementById('upload')?.scrollIntoView({ behavior:'smooth' }) }}
 
 
 
 
 
 
158
  >
159
+ Start Segmenting
160
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2">
161
+ <line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>
162
+ </svg>
163
+ </a>
164
+ </div>
165
+ </section>
166
+
167
+ {/* ── How it Works ──────────────────────────────────────────────────────── */}
168
+ <section className="max-w-5xl mx-auto px-5 py-16">
169
+ <div className="divider mb-16" />
170
+ <h2 className="text-2xl font-bold text-center text-slate-900 mb-12 scroll-hidden">
171
+ How it works
172
+ </h2>
173
+
174
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-px bg-slate-100 rounded-2xl overflow-hidden border border-slate-100">
175
+ {STEPS.map((step, i) => (
176
+ <div
177
+ key={i}
178
+ className={`bg-white p-8 scroll-hidden delay-${i+1} hover:bg-orange-50 transition-colors duration-300`}
179
+ >
180
+ <div className="text-4xl font-black text-gradient mb-4">{step.num}</div>
181
+ <h3 className="text-base font-bold text-slate-900 mb-2">{step.title}</h3>
182
+ <p className="text-sm text-slate-500 leading-relaxed">{step.desc}</p>
183
  </div>
184
+ ))}
185
+ </div>
186
+ </section>
187
+
188
+ {/* ── Upload Card ───────────────────────────────────────────────────────── */}
189
+ <section id="upload" className="max-w-2xl mx-auto px-5 py-8">
190
+ <h2 className="text-xl font-bold text-slate-900 text-center mb-8 scroll-hidden">
191
+ Upload your video
192
+ </h2>
193
+
194
+ {/* Moving border card β€” clean white */}
195
+ <div className="moving-border-card p-1 scroll-scale">
196
+ <div className="bg-white rounded-[15px] p-6">
197
+
198
+ {!file ? (
199
+ <div
200
+ className={`drop-zone p-12 flex flex-col items-center ${dragging ? 'drag-over' : ''}`}
201
+ onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
202
+ onDragLeave={() => setDragging(false)}
203
+ onDrop={onDrop}
204
+ onClick={() => fileInputRef.current?.click()}
205
+ >
206
+ {/* Upload icon */}
207
+ <div className={`w-16 h-16 rounded-2xl border-2 ${dragging ? 'border-orange-400 bg-orange-50' : 'border-slate-200 bg-slate-50'} flex items-center justify-center mb-5 transition-all duration-300 ${dragging ? 'scale-110' : ''}`}>
208
+ <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke={dragging ? '#f97316' : '#94a3b8'} strokeWidth="2" strokeLinecap="round">
209
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
210
+ <polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
211
  </svg>
212
  </div>
213
+
214
+ <p className="text-base font-semibold text-slate-800 mb-1">
215
+ {dragging ? 'Drop to upload' : 'Drop video here'}
216
+ </p>
217
+ <p className="text-sm text-slate-400 mb-5">or click to browse Β· Max 200 MB</p>
218
+
219
+ <div className="flex flex-wrap justify-center gap-2">
220
+ {['MP4', 'MOV', 'AVI', 'WebM', 'MKV'].map(f => (
221
+ <span key={f} className="px-2.5 py-1 rounded-lg bg-slate-100 text-slate-500 text-xs font-mono border border-slate-200">
222
+ {f}
223
+ </span>
224
+ ))}
225
  </div>
226
  </div>
227
+ ) : (
228
+ <div>
229
+ <div className="rounded-xl overflow-hidden border border-slate-200 mb-4 max-h-60 bg-black">
230
+ <video src={preview!} muted controls className="w-full max-h-60" />
231
+ </div>
232
+ <div className="flex items-center justify-between p-3.5 rounded-xl bg-slate-50 border border-slate-200">
233
+ <div className="flex items-center gap-3">
234
+ <div className="w-9 h-9 rounded-xl border border-slate-200 bg-white flex items-center justify-center">
235
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f97316" strokeWidth="2">
236
+ <polygon points="5 3 19 12 5 21 5 3"/>
237
+ </svg>
238
+ </div>
239
+ <div>
240
+ <p className="text-sm font-medium text-slate-800 truncate max-w-[200px] sm:max-w-xs">{file.name}</p>
241
+ <p className="text-xs text-slate-400">{formatBytes(file.size)}</p>
242
+ </div>
243
+ </div>
244
+ <button
245
+ onClick={() => { setFile(null); setPreview(null); setError(null) }}
246
+ className="w-8 h-8 rounded-lg border border-slate-200 hover:border-red-200 hover:bg-red-50 flex items-center justify-center text-slate-400 hover:text-red-500 transition-all"
247
+ >
248
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
249
+ <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
250
+ </svg>
251
+ </button>
252
+ </div>
253
+ </div>
254
+ )}
255
+
256
+ <input ref={fileInputRef} type="file" accept="video/*" className="hidden"
257
+ onChange={e => { const f = e.target.files?.[0]; if (f) selectFile(f) }} />
258
+
259
+ {error && (
260
+ <div className="mt-4 p-3.5 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm flex items-center gap-2">
261
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0">
262
+ <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><circle cx="12" cy="16" r="0.5" fill="currentColor"/>
263
  </svg>
264
+ {error}
265
+ </div>
266
+ )}
267
+
268
+ <button
269
+ onClick={handleUpload}
270
+ disabled={!file || uploading}
271
+ className="btn-primary mt-4 w-full py-3.5 text-sm flex items-center justify-center gap-2"
272
+ >
273
+ {uploading ? (
274
+ <>
275
+ <svg className="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5">
276
+ <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
277
+ </svg>
278
+ Uploading & queuing…
279
+ </>
280
+ ) : (
281
+ <>
282
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5">
283
+ <polygon points="5 3 19 12 5 21 5 3"/>
284
+ </svg>
285
+ Segment This Video
286
+ </>
287
+ )}
288
+ </button>
289
  </div>
290
+ </div>
291
+ </section>
292
 
293
+ {/* ── Feature Cards ─────────────────────────────────────────────────────── */}
294
+ <section className="max-w-5xl mx-auto px-5 py-16">
295
+ <div className="divider mb-16" />
296
+ <div className="flex items-center justify-between mb-10">
297
+ <h2 className="text-2xl font-bold text-slate-900 scroll-left">Features</h2>
298
+ <span className="badge scroll-right">PyTorch Β· FastAPI Β· Next.js</span>
299
+ </div>
300
 
301
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
302
+ {FEATURES.map((f, i) => (
303
+ <div key={f.title} className={`stat-card scroll-hidden delay-${i+1}`}>
304
+ <div className="text-2xl mb-3">{f.icon}</div>
305
+ <div className="text-[10px] font-bold text-orange-500 uppercase tracking-widest mb-2">{f.tag}</div>
306
+ <h3 className="text-sm font-bold text-slate-800 mb-2">{f.title}</h3>
307
+ <p className="text-xs text-slate-500 leading-relaxed">{f.desc}</p>
308
+ </div>
309
+ ))}
310
+ </div>
311
+ </section>
312
+
313
+ {/* ── Class Palette ─────────────────────────────────────────────────────── */}
314
+ <section className="max-w-5xl mx-auto px-5 pb-20">
315
+ <div className="divider mb-16" />
316
+ <div className="flex items-center justify-between mb-6 scroll-hidden">
317
+ <h2 className="text-2xl font-bold text-slate-900">Detectable Objects</h2>
318
+ <span className="text-sm text-slate-400 font-mono">{VOC_CLASSES.length} classes</span>
319
+ </div>
320
+ <div className="flex flex-wrap gap-2 scroll-hidden delay-1">
321
+ {VOC_CLASSES.map((c) => (
322
+ <span key={c.name} className="class-pill">
323
+ <span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: c.color }} />
324
+ {c.name}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  </span>
326
  ))}
327
  </div>
328
+ </section>
329
+
330
  </div>
331
  )
332
  }
frontend/src/app/processing/[id]/page.tsx CHANGED
@@ -6,48 +6,41 @@ import { useRouter, useParams } from 'next/navigation'
6
  const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'
7
 
8
  const VOC_COLORS: Record<string, string> = {
9
- aeroplane: '#87CEEB', bicycle: '#FFA500', bird: '#FFD700',
10
- boat: '#00BFFF', bottle: '#9400D3', bus: '#FF1493',
11
- car: '#DC143C', cat: '#FF8C00', chair: '#8B4513',
12
- cow: '#FFFF00', diningtable: '#D2691E', dog: '#BA55D3',
13
- horse: '#FF69B4', motorbike: '#00FF7F', person: '#FF4500',
14
- 'potted plant': '#228B22', sheep: '#F0E68C', sofa: '#00CED1',
15
- train: '#0000FF', 'tv/monitor': '#7FFFD4',
16
  }
17
 
18
- const STATUS_LABELS: Record<string, string> = {
19
- queued: 'Queued',
20
- processing: 'Segmenting frames …',
21
- done: 'Complete!',
22
- error: 'Error',
23
- }
24
 
25
  export default function ProcessingPage() {
26
- const router = useRouter()
27
- const params = useParams()
28
- const jobId = params?.id as string
 
29
 
30
- const [pct, setPct] = useState(0)
31
- const [status, setStatus] = useState<string>('queued')
32
  const [detected, setDetected] = useState<string[]>([])
33
- const [error, setError] = useState<string | null>(null)
34
- const [elapsed, setElapsed] = useState(0)
35
- const wsRef = useRef<WebSocket | null>(null)
36
  const timerRef = useRef<NodeJS.Timeout | null>(null)
37
 
38
  useEffect(() => {
39
- if (!jobId) return
 
 
40
 
41
- // Start elapsed timer
42
- const startTime = Date.now()
43
- timerRef.current = setInterval(() => {
44
- setElapsed(Math.floor((Date.now() - startTime) / 1000))
45
- }, 1000)
46
 
47
- // Open WebSocket
48
- const wsUrl = `${API_BASE.replace('http', 'ws')}/ws/${jobId}`
49
  const ws = new WebSocket(wsUrl)
50
- wsRef.current = ws
51
 
52
  ws.onmessage = (evt) => {
53
  const data = JSON.parse(evt.data)
@@ -55,164 +48,173 @@ export default function ProcessingPage() {
55
  if (data.pct !== undefined) setPct(data.pct)
56
  if (data.detected) setDetected(data.detected)
57
  if (data.status === 'done') {
58
- setPct(100)
59
- clearInterval(timerRef.current!)
60
  setTimeout(() => router.push(`/result/${jobId}`), 1200)
61
  }
62
- if (data.status === 'error') {
63
- setError(data.error ?? 'Segmentation failed.')
64
- clearInterval(timerRef.current!)
65
- }
66
- }
67
-
68
- ws.onerror = () => {
69
- // Fallback: poll via HTTP if WS fails
70
- pollStatus()
71
- }
72
-
73
- return () => {
74
- ws.close()
75
- clearInterval(timerRef.current!)
76
  }
 
 
77
  }, [jobId])
78
 
79
- const pollStatus = async () => {
80
- const interval = setInterval(async () => {
81
  try {
82
- const res = await fetch(`${API_BASE}/api/status/${jobId}`)
83
- const data = await res.json()
84
- setStatus(data.status)
85
- if (data.pct !== undefined) setPct(data.pct)
86
- if (data.detected) setDetected(data.detected)
87
- if (data.status === 'done') {
88
- clearInterval(interval)
89
- clearInterval(timerRef.current!)
90
  setTimeout(() => router.push(`/result/${jobId}`), 1200)
91
  }
92
- if (data.status === 'error') {
93
- setError(data.error)
94
- clearInterval(interval)
95
- }
96
- } catch (e) {
97
- // ignore transient errors
98
- }
99
- }, 1000)
100
  }
101
 
102
- const formatTime = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
 
103
 
104
  return (
105
- <div className="max-w-2xl mx-auto px-4 py-20">
106
- <div className="glass rounded-2xl p-10 shadow-2xl animate-fade-in">
107
-
108
- {/* Status header */}
109
- <div className="text-center mb-10">
 
 
 
 
110
  <div className={`w-20 h-20 rounded-2xl mx-auto mb-5 flex items-center justify-center
111
- ${status === 'done' ? 'bg-green-500/15' : status === 'error' ? 'bg-red-500/15' : 'bg-brand-500/15'}`}>
112
- {status === 'done' ? (
113
- <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5">
114
- <polyline points="20 6 9 17 4 12"/>
115
- </svg>
116
- ) : status === 'error' ? (
117
- <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5">
118
- <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
119
  </svg>
120
  ) : (
121
- <svg className="animate-spin" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2">
122
- <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
 
 
 
 
 
 
123
  </svg>
124
  )}
125
  </div>
126
 
127
- <h1 className="text-2xl font-bold text-white mb-1">
128
- {STATUS_LABELS[status] ?? status}
 
 
 
129
  </h1>
130
- <p className="text-gray-400 text-sm">
131
- Job ID: <code className="text-brand-400 font-mono">{jobId?.slice(0, 8)}…</code>
132
- {status === 'processing' && (
133
- <span className="ml-3 text-gray-500">⏱ {formatTime(elapsed)}</span>
134
- )}
135
  </p>
136
  </div>
137
 
138
- {/* Progress Bar */}
139
  {status !== 'error' && (
140
- <div className="mb-8">
141
- <div className="flex justify-between text-sm font-medium mb-2.5">
142
- <span className="text-gray-300">Progress</span>
143
- <span className={`${pct >= 100 ? 'text-green-400' : 'text-brand-400'}`}>{pct.toFixed(1)}%</span>
144
  </div>
145
- <div className="progress-track h-3">
146
- <div className="progress-fill h-full" style={{ width: `${pct}%` }} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  </div>
148
  </div>
149
  )}
150
 
151
  {/* Error */}
152
  {error && (
153
- <div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-sm mb-6">
154
  <strong>Error:</strong> {error}
155
  </div>
156
  )}
157
 
158
- {/* Detected classes */}
159
- {detected.length > 0 && (
160
- <div>
161
- <p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
162
- Detected Objects ({detected.length})
163
- </p>
164
- <div className="flex flex-wrap gap-2">
165
- {detected.map((cls) => (
166
- <span key={cls} className="class-pill">
167
- <span
168
- className="w-3 h-3 rounded-full flex-shrink-0"
169
- style={{ backgroundColor: VOC_COLORS[cls] ?? '#888' }}
170
- />
171
- {cls}
172
- </span>
173
- ))}
174
- </div>
175
  </div>
176
  )}
177
 
178
- {/* Queue state placeholder */}
179
  {status === 'queued' && (
180
- <div className="flex items-center gap-3 p-4 rounded-xl bg-white/5 border border-white/10">
181
- <div className="flex gap-1">
182
- {[0, 1, 2].map((i) => (
183
- <span
184
- key={i}
185
- className="w-2.5 h-2.5 bg-brand-400 rounded-full animate-bounce"
186
- style={{ animationDelay: `${i * 0.15}s` }}
187
- />
188
- ))}
189
  </div>
190
- <p className="text-gray-400 text-sm">Waiting for a worker to pick up this job …</p>
191
  </div>
192
  )}
193
 
194
- {/* Shimmer stats while processing */}
195
- {status === 'processing' && (
196
- <div className="mt-6 grid grid-cols-3 gap-3">
197
- {['Frames Processed', 'Objects Found', 'Time Elapsed'].map((label, i) => (
198
- <div key={label} className="stat-card text-center">
199
- <p className="text-xs text-gray-500 mb-1">{label}</p>
200
- <p className="font-bold text-white">
201
- {i === 0 ? `${pct.toFixed(0)}%` : i === 1 ? detected.length : formatTime(elapsed)}
202
- </p>
203
- </div>
204
- ))}
 
 
 
205
  </div>
206
  )}
207
 
208
- {/* Back button */}
209
- <a
210
- href="/"
211
- className="mt-8 flex items-center justify-center gap-2 text-sm text-gray-400 hover:text-white transition-colors"
212
- >
213
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
214
- <polyline points="15 18 9 12 15 6"/>
215
- </svg>
216
  Back to upload
217
  </a>
218
  </div>
 
6
  const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'
7
 
8
  const VOC_COLORS: Record<string, string> = {
9
+ aeroplane:'#87CEEB', bicycle:'#FFA500', bird:'#FFD700', boat:'#00BFFF',
10
+ bottle:'#9400D3', bus:'#FF1493', car:'#DC143C', cat:'#FF8C00',
11
+ chair:'#8B4513', cow:'#D4A017', diningtable:'#D2691E', dog:'#BA55D3',
12
+ horse:'#FF69B4', motorbike:'#22c55e', person:'#FF4500',
13
+ 'potted plant':'#228B22', sheep:'#B8A40A', sofa:'#00CED1',
14
+ train:'#3b82f6', 'tv/monitor':'#0D9488',
 
15
  }
16
 
17
+ const STEPS = ['Queued', 'Inferring Frames', 'Encoding H.264', 'Complete']
 
 
 
 
 
18
 
19
  export default function ProcessingPage() {
20
+ const router = useRouter()
21
+ const params = useParams()
22
+ const jobId = params?.id as string
23
+ const cardRef = useRef<HTMLDivElement>(null)
24
 
25
+ const [pct, setPct] = useState(0)
26
+ const [status, setStatus] = useState('queued')
27
  const [detected, setDetected] = useState<string[]>([])
28
+ const [error, setError] = useState<string | null>(null)
29
+ const [elapsed, setElapsed] = useState(0)
 
30
  const timerRef = useRef<NodeJS.Timeout | null>(null)
31
 
32
  useEffect(() => {
33
+ // Animate card in
34
+ setTimeout(() => cardRef.current?.classList.add('scroll-visible'), 50)
35
+ }, [])
36
 
37
+ useEffect(() => {
38
+ if (!jobId) return
39
+ const start = Date.now()
40
+ timerRef.current = setInterval(() => setElapsed(Math.floor((Date.now()-start)/1000)), 1000)
 
41
 
42
+ const wsUrl = `${API_BASE.replace('https','wss').replace('http','ws')}/ws/${jobId}`
 
43
  const ws = new WebSocket(wsUrl)
 
44
 
45
  ws.onmessage = (evt) => {
46
  const data = JSON.parse(evt.data)
 
48
  if (data.pct !== undefined) setPct(data.pct)
49
  if (data.detected) setDetected(data.detected)
50
  if (data.status === 'done') {
51
+ setPct(100); clearInterval(timerRef.current!)
 
52
  setTimeout(() => router.push(`/result/${jobId}`), 1200)
53
  }
54
+ if (data.status === 'error') { setError(data.error ?? 'Failed'); clearInterval(timerRef.current!) }
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
+ ws.onerror = () => pollFallback()
57
+ return () => { ws.close(); clearInterval(timerRef.current!) }
58
  }, [jobId])
59
 
60
+ const pollFallback = () => {
61
+ const iv = setInterval(async () => {
62
  try {
63
+ const d = await fetch(`${API_BASE}/api/status/${jobId}`).then(r=>r.json())
64
+ setStatus(d.status)
65
+ if (d.pct !== undefined) setPct(d.pct)
66
+ if (d.detected) setDetected(d.detected)
67
+ if (d.status === 'done') {
68
+ clearInterval(iv); clearInterval(timerRef.current!)
 
 
69
  setTimeout(() => router.push(`/result/${jobId}`), 1200)
70
  }
71
+ if (d.status === 'error') { setError(d.error); clearInterval(iv) }
72
+ } catch {}
73
+ }, 1200)
 
 
 
 
 
74
  }
75
 
76
+ const fmtTime = (s: number) => `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`
77
+ const currentStep = status==='queued' ? 0 : status==='processing' ? 1 : status==='done' ? 3 : 2
78
 
79
  return (
80
+ <div className="max-w-xl mx-auto px-5 py-20">
81
+ <div
82
+ ref={cardRef}
83
+ className="scroll-hidden card p-8 border border-slate-200 shadow-sm"
84
+ style={{ borderRadius: '20px' }}
85
+ >
86
+ {/* Head */}
87
+ <div className="text-center mb-8">
88
+ {/* Icon */}
89
  <div className={`w-20 h-20 rounded-2xl mx-auto mb-5 flex items-center justify-center
90
+ ${status==='done' ? 'bg-green-50 border border-green-200'
91
+ : status==='error' ? 'bg-red-50 border border-red-200'
92
+ : 'bg-orange-50 border border-orange-200'}`}>
93
+ {status==='done' ? (
94
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
95
+ ) : status==='error' ? (
96
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
97
+ <circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
98
  </svg>
99
  ) : (
100
+ <svg className="animate-spin" width="30" height="30" viewBox="0 0 24 24" fill="none" strokeWidth="2">
101
+ <defs>
102
+ <linearGradient id="spin-g" x1="0%" y1="0%" x2="100%" y2="100%">
103
+ <stop offset="0%" stopColor="#f97316"/>
104
+ <stop offset="100%" stopColor="#fbbf24"/>
105
+ </linearGradient>
106
+ </defs>
107
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" stroke="url(#spin-g)"/>
108
  </svg>
109
  )}
110
  </div>
111
 
112
+ <h1 className="text-xl font-bold text-slate-900 mb-1">
113
+ {status==='queued' ? 'In Queue'
114
+ : status==='processing' ? 'Segmenting…'
115
+ : status==='done' ? 'Complete!'
116
+ : status==='error' ? 'Failed' : status}
117
  </h1>
118
+ <p className="text-sm text-slate-400">
119
+ Job <code className="text-orange-500 font-mono text-xs">{jobId?.slice(0,8)}…</code>
120
+ {status==='processing' && <span className="ml-2 text-slate-400">Β· {fmtTime(elapsed)}</span>}
 
 
121
  </p>
122
  </div>
123
 
124
+ {/* Progress */}
125
  {status !== 'error' && (
126
+ <div className="mb-7">
127
+ <div className="flex justify-between text-xs font-medium text-slate-500 mb-2">
128
+ <span>Progress</span>
129
+ <span className={pct>=100 ? 'text-green-600' : 'text-orange-500'}>{pct.toFixed(1)}%</span>
130
  </div>
131
+ <div className="progress-track h-2">
132
+ <div className="progress-fill h-full" style={{ width:`${pct}%` }} />
133
+ </div>
134
+ </div>
135
+ )}
136
+
137
+ {/* Steps */}
138
+ {status !== 'error' && (
139
+ <div className="mb-7">
140
+ <div className="flex items-center gap-0">
141
+ {STEPS.map((s, i) => (
142
+ <div key={i} className="flex items-center flex-1">
143
+ <div className="flex flex-col items-center gap-1">
144
+ <div className={`step-dot ${i < currentStep ? 'done' : i === currentStep ? 'active' : 'pending'}`}>
145
+ {i < currentStep
146
+ ? <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#15803d" strokeWidth="3"><polyline points="20 6 9 17 4 12"/></svg>
147
+ : i+1}
148
+ </div>
149
+ <p className={`text-[9px] font-semibold uppercase tracking-wider whitespace-nowrap
150
+ ${i===currentStep ? 'text-orange-500' : i<currentStep ? 'text-green-600' : 'text-slate-300'}`}>
151
+ {s}
152
+ </p>
153
+ </div>
154
+ {i < STEPS.length-1 && (
155
+ <div className={`h-px flex-1 mx-1 mb-4 ${i < currentStep ? 'bg-green-300' : 'bg-slate-200'}`} />
156
+ )}
157
+ </div>
158
+ ))}
159
  </div>
160
  </div>
161
  )}
162
 
163
  {/* Error */}
164
  {error && (
165
+ <div className="p-4 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm mb-6">
166
  <strong>Error:</strong> {error}
167
  </div>
168
  )}
169
 
170
+ {/* Stats */}
171
+ {status === 'processing' && (
172
+ <div className="grid grid-cols-3 gap-3 mb-7">
173
+ {[
174
+ { label:'Progress', val:`${pct.toFixed(0)}%`, color:'text-orange-500' },
175
+ { label:'Objects', val:`${detected.length}`, color:'text-slate-800' },
176
+ { label:'Elapsed', val:fmtTime(elapsed), color:'text-slate-800' },
177
+ ].map(s => (
178
+ <div key={s.label} className="text-center p-4 rounded-xl bg-slate-50 border border-slate-100">
179
+ <p className="text-[10px] text-slate-400 uppercase tracking-widest mb-1">{s.label}</p>
180
+ <p className={`text-lg font-bold ${s.color}`} style={{fontVariantNumeric:'tabular-nums'}}>{s.val}</p>
181
+ </div>
182
+ ))}
 
 
 
 
183
  </div>
184
  )}
185
 
186
+ {/* Queue dots */}
187
  {status === 'queued' && (
188
+ <div className="flex items-center gap-3 p-4 rounded-xl bg-orange-50 border border-orange-100 mb-6">
189
+ <div className="flex gap-1.5">
190
+ <span className="bounce-dot bg-orange-400" />
191
+ <span className="bounce-dot bg-amber-400" />
192
+ <span className="bounce-dot bg-yellow-400" />
 
 
 
 
193
  </div>
194
+ <p className="text-sm text-orange-700">Waiting for a worker to pick up this job…</p>
195
  </div>
196
  )}
197
 
198
+ {/* Detected classes */}
199
+ {detected.length > 0 && (
200
+ <div className="pt-4 border-t border-slate-100">
201
+ <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-3">
202
+ Detected Objects Β· {detected.length}
203
+ </p>
204
+ <div className="flex flex-wrap gap-1.5">
205
+ {detected.map(cls => (
206
+ <span key={cls} className="class-pill">
207
+ <span className="w-2 h-2 rounded-full" style={{ backgroundColor: VOC_COLORS[cls]??'#888' }} />
208
+ {cls}
209
+ </span>
210
+ ))}
211
+ </div>
212
  </div>
213
  )}
214
 
215
+ {/* Back link */}
216
+ <a href="/" className="mt-8 flex items-center justify-center gap-1.5 text-xs text-slate-400 hover:text-slate-600 transition-colors">
217
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
 
 
 
 
 
218
  Back to upload
219
  </a>
220
  </div>
frontend/src/app/result/[id]/page.tsx CHANGED
@@ -6,259 +6,268 @@ import { useParams } from 'next/navigation'
6
  const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'
7
 
8
  const VOC_COLORS: Record<string, string> = {
9
- aeroplane: '#87CEEB', bicycle: '#FFA500', bird: '#FFD700',
10
- boat: '#00BFFF', bottle: '#9400D3', bus: '#FF1493',
11
- car: '#DC143C', cat: '#FF8C00', chair: '#8B4513',
12
- cow: '#FFFF00', diningtable: '#D2691E', dog: '#BA55D3',
13
- horse: '#FF69B4', motorbike: '#00FF7F', person: '#FF4500',
14
- 'potted plant': '#228B22', sheep: '#F0E68C', sofa: '#00CED1',
15
- train: '#0000FF', 'tv/monitor': '#7FFFD4',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  }
17
 
18
  export default function ResultPage() {
19
- const params = useParams()
20
- const jobId = params?.id as string
21
  const videoRef = useRef<HTMLVideoElement>(null)
22
- const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
23
- const [detected, setDetected] = useState<string[]>([])
24
- const [isPlaying, setIsPlaying] = useState(false)
 
 
 
25
  const [currentTime, setCurrentTime] = useState(0)
26
- const [duration, setDuration] = useState(0)
27
- const [volume, setVolume] = useState(1)
 
 
28
 
29
  const videoUrl = `${API_BASE}/api/video/${jobId}`
30
- const downloadUrl = videoUrl
 
31
 
32
  useEffect(() => {
33
  if (!jobId) return
34
- fetch(`${API_BASE}/api/status/${jobId}`)
35
- .then(r => r.json())
36
- .then(data => {
 
 
 
 
37
  if (data.status === 'done') {
38
- setDetected(data.detected || [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  setStatus('ready')
40
- } else if (data.status === 'error') {
 
 
41
  setStatus('error')
42
  }
43
- })
44
- .catch(() => setStatus('error'))
45
- }, [jobId])
 
 
 
 
46
 
47
  const togglePlay = () => {
48
- const v = videoRef.current
49
- if (!v) return
50
  if (v.paused) { v.play(); setIsPlaying(true) }
51
- else { v.pause(); setIsPlaying(false) }
52
- }
53
-
54
- const onTimeUpdate = () => {
55
- if (videoRef.current) setCurrentTime(videoRef.current.currentTime)
56
- }
57
-
58
- const onLoadedMetadata = () => {
59
- if (videoRef.current) setDuration(videoRef.current.duration)
60
  }
61
 
62
- const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
63
- const t = parseFloat(e.target.value)
64
- if (videoRef.current) { videoRef.current.currentTime = t; setCurrentTime(t) }
65
- }
66
-
67
- const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
68
- const v = parseFloat(e.target.value)
69
- if (videoRef.current) videoRef.current.volume = v
70
- setVolume(v)
71
- }
72
 
73
- const formatTime = (s: number) => {
74
- const m = Math.floor(s / 60)
75
- const sec = Math.floor(s % 60)
76
- return `${m}:${String(sec).padStart(2, '0')}`
77
  }
78
 
79
- if (status === 'loading') {
80
- return (
81
- <div className="max-w-4xl mx-auto px-4 py-20 text-center animate-fade-in">
82
- <div className="glass rounded-2xl p-16 shadow-2xl">
83
- <div className="w-16 h-16 rounded-2xl bg-brand-500/15 flex items-center justify-center mx-auto mb-5">
84
- <svg className="animate-spin" width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2">
85
- <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
86
- </svg>
87
- </div>
88
- <p className="text-gray-300 text-lg">Loading your result …</p>
89
- </div>
 
90
  </div>
91
- )
92
- }
 
 
 
 
93
 
94
- if (status === 'error') {
95
- return (
96
- <div className="max-w-4xl mx-auto px-4 py-20 text-center animate-fade-in">
97
- <div className="glass rounded-2xl p-16 shadow-2xl">
98
- <div className="w-16 h-16 rounded-2xl bg-red-500/15 flex items-center justify-center mx-auto mb-5">
99
- <svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
100
- <circle cx="12" cy="12" r="10"/>
101
- <line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
102
- </svg>
103
- </div>
104
- <p className="text-gray-300 text-lg mb-2">Result not available</p>
105
- <p className="text-gray-500 text-sm mb-6">The job may have failed or the result has expired.</p>
106
- <a href="/" className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 text-white font-medium text-sm transition-colors">
107
- Try again
108
- </a>
109
- </div>
110
  </div>
111
- )
112
- }
 
 
 
 
 
 
113
 
114
  return (
115
- <div className="max-w-5xl mx-auto px-4 py-12 animate-fade-in">
116
-
117
- {/* Success Banner */}
118
- <div className="flex items-center justify-between mb-8 flex-wrap gap-4">
119
  <div>
120
- <div className="flex items-center gap-2 mb-1">
121
- <span className="w-2 h-2 rounded-full bg-green-400"></span>
122
- <span className="text-xs font-semibold text-green-400 uppercase tracking-wider">Segmentation Complete</span>
123
  </div>
124
- <h1 className="text-3xl font-bold text-white">Your Segmented Video</h1>
125
- <p className="text-gray-400 text-sm mt-1">
126
- Job: <code className="font-mono text-brand-400">{jobId?.slice(0, 8)}…</code>
127
- {detected.length > 0 && ` Β· ${detected.length} object class${detected.length > 1 ? 'es' : ''} detected`}
128
  </p>
129
  </div>
130
- <a
131
- href={downloadUrl}
132
- download={`segmented_${jobId?.slice(0, 8)}.mp4`}
133
- className="flex items-center gap-2 px-5 py-2.5 rounded-xl
134
- bg-gradient-to-r from-brand-600 to-purple-600
135
- hover:from-brand-500 hover:to-purple-500
136
- text-white font-semibold text-sm transition-all
137
- hover:shadow-lg hover:shadow-brand-500/25 hover:-translate-y-0.5"
138
- >
139
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2">
140
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
141
- <polyline points="7 10 12 15 17 10"/>
142
- <line x1="12" y1="15" x2="12" y2="3"/>
143
- </svg>
144
- Download MP4
145
- </a>
146
  </div>
147
 
148
- {/* Video Player */}
149
- <div className="glass rounded-2xl overflow-hidden shadow-2xl mb-6">
150
- {/* Labels */}
151
- <div className="flex text-xs font-semibold text-gray-400 uppercase tracking-wider px-5 pt-4 pb-2 border-b border-white/5">
152
- <span className="w-1/2 text-center">Original</span>
153
- <span className="w-1/2 text-center text-brand-400">Segmented Overlay</span>
154
  </div>
155
 
156
- {/* Video */}
157
- <div className="bg-black relative">
158
  <video
159
  ref={videoRef}
160
  src={videoUrl}
161
- className="w-full max-h-[480px] object-contain"
162
- onTimeUpdate={onTimeUpdate}
163
- onLoadedMetadata={onLoadedMetadata}
 
 
 
 
 
 
 
164
  onEnded={() => setIsPlaying(false)}
 
165
  />
 
 
 
 
 
166
  </div>
167
 
168
- {/* Custom Controls */}
169
- <div className="px-5 py-4 bg-surface-card/60 backdrop-blur-sm space-y-3">
170
- {/* Seek bar */}
171
- <div className="flex items-center gap-3">
172
- <span className="text-xs text-gray-400 font-mono w-10">{formatTime(currentTime)}</span>
173
- <input
174
- type="range" min={0} max={duration || 1} step={0.1} value={currentTime}
175
- onChange={seek}
176
- className="flex-1 h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer accent-brand-500"
177
- />
178
- <span className="text-xs text-gray-400 font-mono w-10 text-right">{formatTime(duration)}</span>
179
- </div>
180
-
181
- {/* Buttons row */}
182
- <div className="flex items-center gap-4">
183
- <button
184
- onClick={togglePlay}
185
- className="w-10 h-10 rounded-xl bg-brand-500/15 hover:bg-brand-500/25 flex items-center justify-center transition-colors"
186
- >
187
- {isPlaying ? (
188
- <svg width="16" height="16" viewBox="0 0 24 24" fill="#6366f1">
189
- <rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>
190
- </svg>
191
- ) : (
192
- <svg width="16" height="16" viewBox="0 0 24 24" fill="#6366f1">
193
- <polygon points="5 3 19 12 5 21 5 3"/>
194
- </svg>
195
- )}
196
- </button>
197
-
198
- {/* Volume */}
199
- <div className="flex items-center gap-2 flex-1">
200
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="2">
201
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
202
- {volume > 0 && <path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>}
203
- {volume > 0.5 && <path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>}
204
- </svg>
205
  <input
206
- type="range" min={0} max={1} step={0.05} value={volume}
207
- onChange={changeVolume}
208
- className="w-20 h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer accent-brand-500"
 
 
 
209
  />
210
  </div>
 
 
211
 
212
- <span className="text-xs text-gray-500">
213
- Side-by-side: Original | Segmented
214
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  </div>
216
  </div>
217
  </div>
218
 
219
- {/* Detected Objects */}
220
- {detected.length > 0 && (
221
- <div className="glass rounded-2xl p-6 mb-6 shadow-xl">
222
- <h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4">
223
- 🎯 Detected Object Classes ({detected.length})
224
- </h2>
225
  <div className="flex flex-wrap gap-2">
226
- {detected.map((cls) => (
227
- <span key={cls} className="class-pill text-sm px-3 py-1">
228
- <span
229
- className="w-3.5 h-3.5 rounded-full flex-shrink-0"
230
- style={{ backgroundColor: VOC_COLORS[cls] ?? '#888' }}
231
- />
232
  {cls}
233
  </span>
234
- ))}
235
  </div>
236
  </div>
237
- )}
238
 
239
- {/* Action Buttons */}
240
- <div className="flex flex-wrap gap-3">
241
- <a
242
- href="/"
243
- className="flex items-center gap-2 px-5 py-2.5 rounded-xl border border-white/10 hover:border-white/20 hover:bg-white/5 text-gray-300 hover:text-white font-medium text-sm transition-all"
244
- >
245
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
246
- <polyline points="15 18 9 12 15 6"/>
247
- </svg>
248
- Segment Another Video
249
- </a>
250
- <a
251
- href={downloadUrl}
252
- download
253
- className="flex items-center gap-2 px-5 py-2.5 rounded-xl border border-brand-500/30 hover:border-brand-500/60 hover:bg-brand-500/5 text-brand-400 font-medium text-sm transition-all"
254
- >
255
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
256
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
257
- <polyline points="7 10 12 15 17 10"/>
258
- <line x1="12" y1="15" x2="12" y2="3"/>
259
- </svg>
260
- Download Result
261
- </a>
262
  </div>
263
  </div>
264
  )
 
6
  const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'
7
 
8
  const VOC_COLORS: Record<string, string> = {
9
+ aeroplane:'#87CEEB', bicycle:'#FFA500', bird:'#FFD700', boat:'#00BFFF',
10
+ bottle:'#9400D3', bus:'#FF1493', car:'#DC143C', cat:'#FF8C00',
11
+ chair:'#8B4513', cow:'#D4A017', diningtable:'#D2691E', dog:'#BA55D3',
12
+ horse:'#FF69B4', motorbike:'#22c55e', person:'#FF4500',
13
+ 'potted plant':'#228B22', sheep:'#B8A40A', sofa:'#00CED1',
14
+ train:'#3b82f6', 'tv/monitor':'#0D9488',
15
+ }
16
+
17
+ function useScrollReveal(status: string) {
18
+ useEffect(() => {
19
+ if (status !== 'ready') return
20
+ // Small delay to ensure the DOM has fully updated
21
+ const timer = setTimeout(() => {
22
+ const targets = document.querySelectorAll('.scroll-hidden, .scroll-left, .scroll-right, .scroll-scale')
23
+ const obs = new IntersectionObserver(
24
+ entries => entries.forEach(e => {
25
+ if (e.isIntersecting) {
26
+ e.target.classList.add('scroll-visible')
27
+ obs.unobserve(e.target)
28
+ }
29
+ }),
30
+ { threshold: 0.05 }
31
+ )
32
+ targets.forEach(t => obs.observe(t))
33
+ }, 100)
34
+ return () => clearTimeout(timer)
35
+ }, [status])
36
  }
37
 
38
  export default function ResultPage() {
39
+ const params = useParams()
40
+ const jobId = params?.id as string
41
  const videoRef = useRef<HTMLVideoElement>(null)
42
+
43
+ // 'loading' β†’ 'ready' or 'error'
44
+ const [status, setStatus] = useState<'loading'|'ready'|'error'>('loading')
45
+ const [detected, setDetected] = useState<string[]>([])
46
+ const [videoReady, setVideoReady] = useState(false) // video URL responded 200
47
+ const [isPlaying, setIsPlaying] = useState(false)
48
  const [currentTime, setCurrentTime] = useState(0)
49
+ const [duration, setDuration] = useState(0)
50
+ const [volume, setVolume] = useState(1)
51
+ const [copied, setCopied] = useState(false)
52
+ const [retries, setRetries] = useState(0)
53
 
54
  const videoUrl = `${API_BASE}/api/video/${jobId}`
55
+
56
+ useScrollReveal(status)
57
 
58
  useEffect(() => {
59
  if (!jobId) return
60
+
61
+ const fetchStatus = async () => {
62
+ try {
63
+ const res = await fetch(`${API_BASE}/api/status/${jobId}`)
64
+ if (!res.ok) throw new Error()
65
+ const data = await res.json()
66
+
67
  if (data.status === 'done') {
68
+ setDetected(data.detected ?? [])
69
+ setStatus('ready')
70
+ return
71
+ }
72
+ await probeVideo()
73
+ } catch {
74
+ await probeVideo()
75
+ }
76
+ }
77
+
78
+ const probeVideo = async () => {
79
+ try {
80
+ // Explicitly use HEAD; backend now supports this
81
+ const res = await fetch(videoUrl, { method: 'HEAD' })
82
+ if (res.ok) {
83
  setStatus('ready')
84
+ } else if (retries < 6) {
85
+ setTimeout(() => setRetries(r => r + 1), 1500)
86
+ } else {
87
  setStatus('error')
88
  }
89
+ } catch {
90
+ setStatus('error')
91
+ }
92
+ }
93
+
94
+ fetchStatus()
95
+ }, [jobId, retries, videoUrl])
96
 
97
  const togglePlay = () => {
98
+ const v = videoRef.current; if (!v) return
 
99
  if (v.paused) { v.play(); setIsPlaying(true) }
100
+ else { v.pause(); setIsPlaying(false) }
 
 
 
 
 
 
 
 
101
  }
102
 
103
+ const fmtTime = (s: number) =>
104
+ `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
 
 
 
 
 
 
 
 
105
 
106
+ const copyLink = async () => {
107
+ await navigator.clipboard.writeText(window.location.href)
108
+ setCopied(true)
109
+ setTimeout(() => setCopied(false), 2000)
110
  }
111
 
112
+ if (status === 'loading') return (
113
+ <div className="max-w-4xl mx-auto px-5 py-32 text-center">
114
+ <div className="w-16 h-16 rounded-2xl bg-orange-50 border border-orange-200 flex items-center justify-center mx-auto mb-5">
115
+ <svg className="animate-spin" width="24" height="24" viewBox="0 0 24 24" fill="none" strokeWidth="2">
116
+ <defs>
117
+ <linearGradient id="sg-spin" x1="0%" y1="0%" x2="100%" y2="100%">
118
+ <stop offset="0%" stopColor="#f97316"/>
119
+ <stop offset="100%" stopColor="#fbbf24"/>
120
+ </linearGradient>
121
+ </defs>
122
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" stroke="url(#sg-spin)"/>
123
+ </svg>
124
  </div>
125
+ <p className="text-sm font-medium text-slate-600">Loading your result…</p>
126
+ {retries > 0 && (
127
+ <p className="text-xs text-slate-400 mt-2">Connecting… (Attempt {retries})</p>
128
+ )}
129
+ </div>
130
+ )
131
 
132
+ if (status === 'error') return (
133
+ <div className="max-w-4xl mx-auto px-5 py-32 text-center">
134
+ <div className="w-16 h-16 rounded-2xl bg-red-50 border border-red-200 flex items-center justify-center mx-auto mb-5">
135
+ <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
136
+ <circle cx="12" cy="12" r="10"/>
137
+ <line x1="15" y1="9" x2="9" y2="15"/>
138
+ <line x1="9" y1="9" x1="15" y2="15"/>
139
+ </svg>
 
 
 
 
 
 
 
 
140
  </div>
141
+ <p className="font-semibold text-slate-800 mb-1">Result not available</p>
142
+ <p className="text-sm text-slate-500 mb-6">The job might still be processing or the file has expired.</p>
143
+ <div className="flex items-center justify-center gap-3">
144
+ <button onClick={() => { setStatus('loading'); setRetries(0) }} className="btn-outline px-5 py-2.5 text-sm">Retry</button>
145
+ <a href="/" className="btn-primary px-5 py-2.5 text-sm">New Upload</a>
146
+ </div>
147
+ </div>
148
+ )
149
 
150
  return (
151
+ <div className="bg-white max-w-5xl mx-auto px-5 py-12">
152
+ {/* Header β€” No animation for immediate layout stability */}
153
+ <div className="flex items-start justify-between mb-10 flex-wrap gap-4">
 
154
  <div>
155
+ <div className="flex items-center gap-2 mb-2">
156
+ <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
157
+ <span className="text-xs font-bold text-green-600 uppercase tracking-widest">Segmentation Finished</span>
158
  </div>
159
+ <h1 className="text-3xl font-bold text-slate-900 tracking-tight">Your AI Result</h1>
160
+ <p className="text-sm text-slate-500 mt-1">
161
+ Job ID: <code className="text-orange-500 font-mono">{jobId?.slice(0, 12)}</code>
 
162
  </p>
163
  </div>
164
+
165
+ <div className="flex gap-2">
166
+ <button onClick={copyLink} className="btn-outline px-4 py-2.5 text-sm flex items-center gap-2">
167
+ {copied ? 'Link Copied!' : 'Copy Result Link'}
168
+ </button>
169
+ <a href={videoUrl} download className="btn-primary px-5 py-2.5 text-sm flex items-center gap-2">
170
+ Download MP4
171
+ </a>
172
+ </div>
 
 
 
 
 
 
 
173
  </div>
174
 
175
+ {/* Video Player Card */}
176
+ <div className="card border border-slate-200 overflow-hidden mb-8 scroll-scale">
177
+ <div className="flex border-b border-slate-100 bg-slate-50/50">
178
+ <div className="flex-1 py-3 text-center text-[10px] font-bold text-slate-400 uppercase tracking-widest border-r border-slate-100">Original</div>
179
+ <div className="flex-1 py-3 text-center text-[10px] font-bold text-orange-500 uppercase tracking-widest">Segmented Overlay</div>
 
180
  </div>
181
 
182
+ <div className="bg-black relative aspect-video">
 
183
  <video
184
  ref={videoRef}
185
  src={videoUrl}
186
+ className="w-full h-full"
187
+ playsInline
188
+ preload="auto"
189
+ onTimeUpdate={() => videoRef.current && setCurrentTime(videoRef.current.currentTime)}
190
+ onLoadedMetadata={() => {
191
+ if (videoRef.current) {
192
+ setDuration(videoRef.current.duration)
193
+ setVideoReady(true)
194
+ }
195
+ }}
196
  onEnded={() => setIsPlaying(false)}
197
+ onError={() => setTimeout(() => setRetries(r => r + 1), 2000)}
198
  />
199
+ {!videoReady && (
200
+ <div className="absolute inset-0 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm">
201
+ <div className="w-8 h-8 border-2 border-white/20 border-t-white rounded-full animate-spin" />
202
+ </div>
203
+ )}
204
  </div>
205
 
206
+ <div className="px-6 py-5 bg-slate-50 border-t border-slate-100">
207
+ <div className="flex items-center gap-4 mb-4">
208
+ <span className="text-xs text-slate-400 font-mono w-10">{fmtTime(currentTime)}</span>
209
+ <div className="flex-1 h-1.5 rounded-full bg-slate-200 relative group cursor-pointer">
210
+ <div className="h-full rounded-full bg-gradient-to-r from-orange-500 to-amber-400" style={{ width: `${(currentTime/duration)*100}%` }} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  <input
212
+ type="range" min={0} max={duration || 1} step={0.1} value={currentTime}
213
+ onChange={e => {
214
+ const t = +e.target.value
215
+ if (videoRef.current) { videoRef.current.currentTime = t; setCurrentTime(t) }
216
+ }}
217
+ className="absolute inset-0 w-full opacity-0 cursor-pointer"
218
  />
219
  </div>
220
+ <span className="text-xs text-slate-400 font-mono w-10 text-right">{fmtTime(duration)}</span>
221
+ </div>
222
 
223
+ <div className="flex items-center justify-between">
224
+ <div className="flex items-center gap-4">
225
+ <button onClick={togglePlay} className="w-12 h-12 rounded-2xl bg-slate-900 flex items-center justify-center hover:bg-slate-800 transition-all active:scale-95 shadow-lg">
226
+ {isPlaying ? <svg width="18" height="18" viewBox="0 0 24 24" fill="white"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> : <svg width="18" height="18" viewBox="0 0 24 24" fill="white"><polygon points="5 3 19 12 5 21 5 3"/></svg>}
227
+ </button>
228
+ <div className="flex items-center gap-2 group">
229
+ <div className="w-8 h-8 rounded-lg bg-orange-100 flex items-center justify-center">
230
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f97316" strokeWidth="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
231
+ </div>
232
+ <input
233
+ type="range" min={0} max={1} step={0.05} value={volume}
234
+ onChange={e => {
235
+ const v = +e.target.value
236
+ if (videoRef.current) videoRef.current.volume = v
237
+ setVolume(v)
238
+ }}
239
+ className="w-24 h-1.5"
240
+ />
241
+ </div>
242
+ </div>
243
+ <div className="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">H.264 High Profile Β· 30 FPS</div>
244
  </div>
245
  </div>
246
  </div>
247
 
248
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
249
+ <div className="md:col-span-2 card p-8 scroll-hidden">
250
+ <div className="flex items-center justify-between mb-6">
251
+ <h3 className="text-sm font-bold text-slate-800 uppercase tracking-widest">AI Detections</h3>
252
+ <span className="badge">{detected.length} Objects</span>
253
+ </div>
254
  <div className="flex flex-wrap gap-2">
255
+ {detected.length > 0 ? detected.map(cls => (
256
+ <span key={cls} className="class-pill">
257
+ <span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: VOC_COLORS[cls] ?? '#888' }} />
 
 
 
258
  {cls}
259
  </span>
260
+ )) : <span className="text-sm text-slate-400">Processing detailed labels...</span>}
261
  </div>
262
  </div>
 
263
 
264
+ <div className="card p-8 bg-slate-900 border-slate-800 text-white scroll-hidden">
265
+ <h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Quick Actions</h3>
266
+ <div className="space-y-3">
267
+ <a href="/" className="flex items-center justify-center gap-2 w-full py-3 rounded-xl bg-white/10 hover:bg-white/20 text-sm font-medium transition-all">New Segmentation</a>
268
+ <a href={videoUrl} download className="flex items-center justify-center gap-2 w-full py-3 rounded-xl bg-orange-500 hover:bg-orange-600 text-sm font-bold transition-all shadow-lg shadow-orange-500/20">Save Result</a>
269
+ </div>
270
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  </div>
272
  </div>
273
  )
nginx.conf ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ events { worker_processes 1; }
2
+
3
+ http {
4
+ include /etc/nginx/mime.types;
5
+ default_type application/octet-stream;
6
+ sendfile on;
7
+
8
+ # Increase timeouts for large video uploads
9
+ client_max_body_size 250m;
10
+ proxy_read_timeout 300s;
11
+ proxy_send_timeout 300s;
12
+ proxy_connect_timeout 30s;
13
+
14
+ server {
15
+ listen 7860;
16
+
17
+ # ── API & WebSocket β†’ FastAPI :8000 ──────────────────────────────
18
+ location /api/ {
19
+ proxy_pass http://127.0.0.1:8000;
20
+ proxy_set_header Host $host;
21
+ proxy_set_header X-Real-IP $remote_addr;
22
+ proxy_read_timeout 300s;
23
+ }
24
+
25
+ location /ws/ {
26
+ proxy_pass http://127.0.0.1:8000;
27
+ proxy_http_version 1.1;
28
+ proxy_set_header Upgrade $http_upgrade;
29
+ proxy_set_header Connection "Upgrade";
30
+ proxy_set_header Host $host;
31
+ proxy_read_timeout 120s;
32
+ }
33
+
34
+ # ── Everything else β†’ Next.js :3000 ─────────────────────────────
35
+ location / {
36
+ proxy_pass http://127.0.0.1:3000;
37
+ proxy_set_header Host $host;
38
+ proxy_set_header X-Real-IP $remote_addr;
39
+ }
40
+ }
41
+ }
requirements_hf.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.110.0
2
+ uvicorn[standard]>=0.29.0
3
+ python-multipart>=0.0.9
4
+ aiofiles>=23.0.0
5
+ torch>=2.1.0
6
+ torchvision>=0.16.0
7
+ opencv-python-headless>=4.9.0
8
+ Pillow>=10.0.0
9
+ numpy>=1.24.0
10
+ imageio>=2.33.0
11
+ imageio-ffmpeg>=0.4.9
supervisord.conf ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [unix_http_server]
2
+ file=/tmp/supervisor.sock
3
+
4
+ [supervisord]
5
+ nodaemon=true
6
+ logfile=/var/log/supervisor/supervisord.log
7
+ pidfile=/tmp/supervisord.pid
8
+
9
+ [rpcinterface:supervisor]
10
+ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
11
+
12
+ [supervisorctl]
13
+ serverurl=unix:///tmp/supervisor.sock
14
+
15
+ # ── FastAPI backend ──────────────────────────────────────────────────────────
16
+ [program:fastapi]
17
+ command=uvicorn backend.app_hf:app --host 127.0.0.1 --port 8000 --workers 1 --timeout-keep-alive 120
18
+ directory=/app
19
+ autostart=true
20
+ autorestart=true
21
+ stdout_logfile=/var/log/supervisor/fastapi.log
22
+ stderr_logfile=/var/log/supervisor/fastapi.log
23
+
24
+ # ── Next.js standalone server ────────────────────────────────────────────────
25
+ [program:nextjs]
26
+ command=node /app/frontend/server.js
27
+ directory=/app/frontend
28
+ environment=PORT="3000",NODE_ENV="production",NEXT_PUBLIC_API_URL=""
29
+ autostart=true
30
+ autorestart=true
31
+ stdout_logfile=/var/log/supervisor/nextjs.log
32
+ stderr_logfile=/var/log/supervisor/nextjs.log
33
+
34
+ # ── nginx reverse proxy ──────────────────────────────────────────────────────
35
+ [program:nginx]
36
+ command=nginx -g "daemon off;"
37
+ autostart=true
38
+ autorestart=true
39
+ stdout_logfile=/var/log/supervisor/nginx.log
40
+ stderr_logfile=/var/log/supervisor/nginx.log