Initial commit: Drone Footage Manager with Video Compression

- React frontend with video/image browser
- Python FastAPI backend with video compression
- Docker containerized setup
- Video compression with FFmpeg (two-pass encoding)
- Real-time job monitoring with SSE
- Global active jobs monitor
- Clickable header to reset navigation
- Toast notifications for user feedback
This commit is contained in:
Alihan
2025-10-12 02:22:12 +03:00
commit 0d71830cfb
22 changed files with 2016 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(docker compose:*)",
"mcp__playwright__browser_console_messages",
"mcp__playwright__browser_click",
"mcp__playwright__browser_evaluate",
"mcp__playwright__browser_navigate"
],
"deny": [],
"ask": []
}
}

54
.gitignore vendored Normal file
View File

@@ -0,0 +1,54 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dist/
build/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Docker logs
*.log
# Local sensitive data - drone footage
data/
footage/
videos/
media/
# Database files
*.db
*.sqlite
*.sqlite3
# Temporary files
*.tmp
*.temp

190
README.md Normal file
View File

@@ -0,0 +1,190 @@
# Drone Footage Manager
A web application for browsing and viewing drone footage organized by location and date.
## Features
- 📁 **Hierarchical Navigation**: Browse footage by location → date → files
- 🎥 **Video Streaming**: Built-in HTML5 video player with seeking support
- 🖼️ **Image Viewer**: View drone photos directly in the browser
- 🐳 **Dockerized**: Fully containerized application with docker-compose
- 🔒 **Read-only Access**: Footage directory mounted as read-only for safety
## Architecture
- **Backend**: Python FastAPI server for file browsing and media streaming
- **Frontend**: React application with Tailwind CSS
- **Deployment**: Docker containers with nginx for frontend static files
## Quick Start
### Prerequisites
- Docker
- Docker Compose
### Running the Application
1. **Start the containers:**
```bash
docker-compose up -d
```
2. **Access the application:**
- **From the host machine**: `http://localhost` or `http://127.0.0.1`
- **From local network**: `http://<SERVER_IP>` (e.g., `http://192.168.1.100`)
- The backend API is available at port 8000
To find your server's IP address:
```bash
# Linux/Mac
hostname -I | awk '{print $1}'
# or
ip addr show | grep "inet " | grep -v 127.0.0.1
```
3. **Stop the containers:**
```bash
docker-compose down
```
### Rebuilding After Changes
If you make changes to the code, rebuild the containers:
```bash
docker-compose up -d --build
```
## Project Structure
```
drone-footage-manager/
├── backend/
│ ├── main.py # FastAPI application
│ ├── requirements.txt # Python dependencies
│ ├── Dockerfile # Backend container definition
│ └── .dockerignore
├── frontend/
│ ├── src/
│ │ ├── App.jsx # Main React component
│ │ ├── main.jsx # React entry point
│ │ └── index.css # Tailwind styles
│ ├── index.html
│ ├── package.json # Node dependencies
│ ├── vite.config.js # Vite configuration
│ ├── tailwind.config.js # Tailwind configuration
│ ├── nginx.conf # Nginx configuration
│ ├── Dockerfile # Frontend container definition
│ └── .dockerignore
├── docker-compose.yml # Docker Compose configuration
└── README.md
```
## Configuration
### Volume Mount
The footage directory is mounted in `docker-compose.yml`:
```yaml
volumes:
- /home/uad/nextcloud/footages:/footages:ro
```
To use a different directory, edit the `docker-compose.yml` file and update the path before the colon.
### Ports
- **Frontend**: Port 80 (accessible from local network)
- **Backend**: Port 8000 (accessible from local network)
The application binds to `0.0.0.0`, making it accessible from:
- The host machine (localhost)
- Other devices on the local network (using server's IP)
To change ports, edit the `ports` section in `docker-compose.yml`.
### Network Access
By default, the application is accessible from any device on your local network. If you cannot access it from other devices, check your firewall:
```bash
# Allow ports 80 and 8000 through firewall (Ubuntu/Debian)
sudo ufw allow 80/tcp
sudo ufw allow 8000/tcp
```
## API Endpoints
- `GET /api/locations` - List all location folders
- `GET /api/locations/{location}/dates` - List dates for a location
- `GET /api/files/{location}/{date}` - List files with metadata
- `GET /api/stream/{location}/{date}/{filename}` - Stream video files
- `GET /api/image/{location}/{date}/{filename}` - Serve image files
## Development
### Backend Development
```bash
cd backend
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt
uvicorn main:app --reload
```
### Frontend Development
```bash
cd frontend
npm install
npm run dev
```
## Supported File Types
**Videos**: `.mp4`, `.MP4`, `.mov`, `.MOV`, `.avi`, `.AVI`
**Images**: `.jpg`, `.JPG`, `.jpeg`, `.JPEG`, `.png`, `.PNG`
## Logs
View container logs:
```bash
# All services
docker-compose logs -f
# Backend only
docker-compose logs -f backend
# Frontend only
docker-compose logs -f frontend
```
## Troubleshooting
### Videos not playing
- Ensure the video codec is supported by your browser (H.264 recommended)
- Check browser console for errors
- Verify file permissions on the footage directory
### Cannot access the application
- Verify containers are running: `docker-compose ps`
- Check logs: `docker-compose logs`
- Ensure ports 80 and 8000 are not in use by other applications
- Check firewall settings: `sudo ufw status`
- Verify you're using the correct IP address: `hostname -I`
### Changes not reflected
- Rebuild containers: `docker-compose up -d --build`
- Clear browser cache
## License
MIT

18
backend/.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
ENV/
.venv
*.egg-info/
dist/
build/
.pytest_cache/
.coverage
htmlcov/
.git
.gitignore
*.md

21
backend/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM python:3.11-slim
# Install ffmpeg
RUN apt-get update && \
apt-get install -y ffmpeg && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

287
backend/compression.py Normal file
View File

@@ -0,0 +1,287 @@
import asyncio
import subprocess
import uuid
import os
import re
from pathlib import Path
from datetime import datetime
from typing import Dict, Optional
class CompressionJob:
def __init__(self, file_path: str, reduce_percentage: int):
self.job_id = str(uuid.uuid4())
self.file_path = file_path
self.reduce_percentage = reduce_percentage
self.status = "pending" # pending, processing, validating, completed, failed, cancelled
self.progress = 0.0
self.eta_seconds = None
self.current_pass = 0 # 0=not started, 1=first pass, 2=second pass
self.created_at = datetime.now()
self.started_at = None
self.completed_at = None
self.error = None
self.output_file = None
self.current_size_mb = None
self.target_size_mb = None
self.video_bitrate = None
self.duration_seconds = None
class CompressionManager:
def __init__(self):
self.jobs: Dict[str, CompressionJob] = {}
self.active_jobs: Dict[str, asyncio.Task] = {}
async def get_video_info(self, file_path: str) -> Dict:
"""Extract video duration and file size using ffprobe"""
# Get file size in MB
file_size_mb = os.path.getsize(file_path) / (1024 * 1024)
# Get duration using ffprobe
cmd = [
'ffprobe', '-i', file_path,
'-show_entries', 'format=duration',
'-v', 'quiet',
'-of', 'csv=p=0'
]
result = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, _ = await result.communicate()
duration = int(float(stdout.decode().strip()))
return {
'size_mb': file_size_mb,
'duration_seconds': duration
}
def calculate_bitrates(self, current_size_mb: float,
target_size_mb: float,
duration_seconds: int,
audio_bitrate: int = 128) -> int:
"""Calculate video bitrate based on target size"""
# Total bitrate in kbps
total_bitrate = (target_size_mb * 8192) / duration_seconds
# Video bitrate = total - audio
video_bitrate = int(total_bitrate - audio_bitrate)
return max(video_bitrate, 100) # Minimum 100kbps
async def compress_video(self, job: CompressionJob):
"""Main compression function - two-pass encoding"""
try:
job.status = "processing"
job.started_at = datetime.now()
# Get video information
info = await self.get_video_info(job.file_path)
job.current_size_mb = info['size_mb']
job.duration_seconds = info['duration_seconds']
# Calculate target size and bitrate
job.target_size_mb = job.current_size_mb * (1 - job.reduce_percentage / 100)
job.video_bitrate = self.calculate_bitrates(
job.current_size_mb,
job.target_size_mb,
job.duration_seconds
)
print(f"Job {job.job_id}:")
print(f" Current Size: {job.current_size_mb:.2f} MB")
print(f" Target Size: {job.target_size_mb:.2f} MB")
print(f" Duration: {job.duration_seconds}s")
print(f" Video Bitrate: {job.video_bitrate} kbps")
# Generate output filename
file_path = Path(job.file_path)
temp_file = file_path.parent / f"temp_{file_path.name}"
output_file = file_path.parent / f"{file_path.stem}_compressed_{job.reduce_percentage}{file_path.suffix}"
# PASS 1: Analysis
job.current_pass = 1
await self.run_ffmpeg_pass1(job, temp_file)
if job.status == "cancelled":
self.cleanup_temp_files(job)
return
# PASS 2: Encoding
job.current_pass = 2
await self.run_ffmpeg_pass2(job, temp_file)
if job.status == "cancelled":
self.cleanup_temp_files(job)
return
# VALIDATION
job.status = "validating"
job.progress = 95.0
if await self.validate_video(temp_file):
# Move temp file to final output
os.rename(temp_file, output_file)
job.output_file = str(output_file)
job.status = "completed"
job.progress = 100.0
job.completed_at = datetime.now()
self.cleanup_temp_files(job)
print(f"Job {job.job_id} completed successfully")
else:
job.status = "failed"
job.error = "Validation failed: Compressed video is corrupted"
self.cleanup_temp_files(job)
print(f"Job {job.job_id} failed validation")
except asyncio.CancelledError:
job.status = "cancelled"
self.cleanup_temp_files(job)
print(f"Job {job.job_id} cancelled")
except Exception as e:
job.status = "failed"
job.error = str(e)
self.cleanup_temp_files(job)
print(f"Job {job.job_id} failed: {e}")
async def run_ffmpeg_pass1(self, job: CompressionJob, output_file: Path):
"""First pass: Analysis"""
cmd = [
'ffmpeg', '-y',
'-i', job.file_path,
'-c:v', 'libx264',
'-b:v', f'{job.video_bitrate}k',
'-pass', '1',
'-an', # No audio in first pass
'-f', 'null',
'/dev/null',
'-progress', 'pipe:1'
]
await self.run_ffmpeg_with_progress(job, cmd, pass_num=1)
async def run_ffmpeg_pass2(self, job: CompressionJob, output_file: Path):
"""Second pass: Encoding"""
cmd = [
'ffmpeg',
'-i', job.file_path,
'-c:v', 'libx264',
'-b:v', f'{job.video_bitrate}k',
'-pass', '2',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart', # Important for web streaming
str(output_file),
'-progress', 'pipe:1'
]
await self.run_ffmpeg_with_progress(job, cmd, pass_num=2)
async def run_ffmpeg_with_progress(self, job: CompressionJob, cmd: list, pass_num: int):
"""Run ffmpeg and track progress"""
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
# Progress tracking
time_pattern = re.compile(r'out_time_ms=(\d+)')
fps_pattern = re.compile(r'fps=([\d.]+)')
async for line in process.stdout:
if job.status == "cancelled":
process.kill()
raise asyncio.CancelledError()
line_str = line.decode('utf-8', errors='ignore')
# Extract time
time_match = time_pattern.search(line_str)
if time_match:
current_time_ms = int(time_match.group(1))
current_time_sec = current_time_ms / 1_000_000
# Calculate progress for this pass (0-50% or 50-100%)
pass_progress = (current_time_sec / job.duration_seconds) * 50
if pass_num == 1:
job.progress = min(pass_progress, 50)
else:
job.progress = min(50 + pass_progress, 95)
# Extract FPS for ETA calculation
fps_match = fps_pattern.search(line_str)
if fps_match:
fps = float(fps_match.group(1))
if fps > 0:
remaining_sec = (job.duration_seconds - current_time_sec)
if pass_num == 1:
remaining_sec = remaining_sec + job.duration_seconds # Account for both passes
job.eta_seconds = int(remaining_sec)
await process.wait()
if process.returncode != 0:
stderr = await process.stderr.read()
raise Exception(f"FFmpeg pass {pass_num} failed: {stderr.decode()}")
async def validate_video(self, file_path: Path) -> bool:
"""Validate compressed video is not corrupted"""
cmd = [
'ffmpeg',
'-v', 'error',
'-i', str(file_path),
'-f', 'null',
'-'
]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
_, stderr = await process.communicate()
# If there are errors in stderr, validation failed
return len(stderr) == 0
def cleanup_temp_files(self, job: CompressionJob):
"""Clean up temporary files"""
file_path = Path(job.file_path)
temp_file = file_path.parent / f"temp_{file_path.name}"
# Remove temp file
try:
if temp_file.exists():
temp_file.unlink()
except Exception as e:
print(f"Failed to remove temp file: {e}")
# Remove ffmpeg pass log files
try:
log_file = Path("ffmpeg2pass-0.log")
if log_file.exists():
log_file.unlink()
except Exception as e:
print(f"Failed to remove log file: {e}")
async def start_compression(self, file_path: str, reduce_percentage: int) -> str:
"""Start a new compression job"""
job = CompressionJob(file_path, reduce_percentage)
self.jobs[job.job_id] = job
# Start compression in background
task = asyncio.create_task(self.compress_video(job))
self.active_jobs[job.job_id] = task
# Clean up task when done
task.add_done_callback(lambda t: self.active_jobs.pop(job.job_id, None))
return job.job_id
async def cancel_job(self, job_id: str):
"""Cancel a running compression job"""
if job_id in self.jobs:
self.jobs[job_id].status = "cancelled"
if job_id in self.active_jobs:
self.active_jobs[job_id].cancel()

313
backend/main.py Normal file
View File

@@ -0,0 +1,313 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse, Response
from pathlib import Path
from typing import List, Dict
from pydantic import BaseModel
import os
from datetime import datetime
import aiofiles
import asyncio
import json
from sse_starlette.sse import EventSourceResponse
from compression import CompressionManager
app = FastAPI(title="Drone Footage Manager API")
# Initialize compression manager
compression_manager = CompressionManager()
# CORS middleware for frontend communication
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify your frontend domain
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Base path for footages
FOOTAGES_PATH = Path("/footages")
# Supported video and image extensions
VIDEO_EXTENSIONS = {".mp4", ".MP4", ".mov", ".MOV", ".avi", ".AVI"}
IMAGE_EXTENSIONS = {".jpg", ".JPG", ".jpeg", ".JPEG", ".png", ".PNG"}
def is_media_file(filename: str) -> bool:
"""Check if file is a video or image"""
ext = Path(filename).suffix
return ext in VIDEO_EXTENSIONS or ext in IMAGE_EXTENSIONS
def get_file_info(file_path: Path) -> Dict:
"""Get file metadata"""
stat = file_path.stat()
return {
"name": file_path.name,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
"is_video": file_path.suffix in VIDEO_EXTENSIONS,
"is_image": file_path.suffix in IMAGE_EXTENSIONS,
}
@app.get("/")
async def root():
return {"message": "Drone Footage Manager API", "status": "running"}
@app.get("/api/locations")
async def get_locations() -> List[Dict]:
"""Get list of all location folders with metadata"""
if not FOOTAGES_PATH.exists():
raise HTTPException(status_code=500, detail="Footages directory not found")
locations = []
for item in FOOTAGES_PATH.iterdir():
if item.is_dir():
stat = item.stat()
locations.append({
"name": item.name,
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
})
return locations
@app.get("/api/locations/{location}/dates")
async def get_dates(location: str) -> List[Dict]:
"""Get list of date folders for a location with metadata"""
location_path = FOOTAGES_PATH / location
if not location_path.exists() or not location_path.is_dir():
raise HTTPException(status_code=404, detail="Location not found")
dates = []
for item in location_path.iterdir():
if item.is_dir():
stat = item.stat()
dates.append({
"name": item.name,
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
})
return dates
@app.get("/api/files/{location}/{date}")
async def get_files(location: str, date: str) -> List[Dict]:
"""Get list of files for a location and date"""
files_path = FOOTAGES_PATH / location / date
if not files_path.exists() or not files_path.is_dir():
raise HTTPException(status_code=404, detail="Path not found")
files = []
for item in sorted(files_path.iterdir()):
if item.is_file() and is_media_file(item.name):
files.append(get_file_info(item))
return files
@app.get("/api/stream/{location}/{date}/{filename}")
async def stream_video(location: str, date: str, filename: str, request: Request):
"""Stream video file with HTTP range request support for fast seeking"""
file_path = FOOTAGES_PATH / location / date / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
# Check if it's a video file
if file_path.suffix not in VIDEO_EXTENSIONS:
raise HTTPException(status_code=400, detail="Not a video file")
# Get file size
file_size = file_path.stat().st_size
# Parse range header
range_header = request.headers.get("range")
if range_header:
# Parse range header (e.g., "bytes=0-1023")
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if range_match[1] else file_size - 1
end = min(end, file_size - 1)
# Calculate content length
content_length = end - start + 1
# Create streaming response
async def iterfile():
async with aiofiles.open(file_path, mode='rb') as f:
await f.seek(start)
remaining = content_length
chunk_size = 1024 * 1024 # 1MB chunks
while remaining > 0:
chunk = await f.read(min(chunk_size, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
headers = {
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(content_length),
"Content-Type": "video/mp4",
}
return StreamingResponse(
iterfile(),
status_code=206,
headers=headers,
media_type="video/mp4"
)
# No range header - return full file
return FileResponse(
file_path,
media_type="video/mp4",
headers={"Accept-Ranges": "bytes"}
)
@app.get("/api/image/{location}/{date}/{filename}")
async def get_image(location: str, date: str, filename: str):
"""Serve image file"""
file_path = FOOTAGES_PATH / location / date / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
# Check if it's an image file
if file_path.suffix not in IMAGE_EXTENSIONS:
raise HTTPException(status_code=400, detail="Not an image file")
# Determine media type
media_type = "image/jpeg" if file_path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
return FileResponse(file_path, media_type=media_type)
# ========== COMPRESSION API ENDPOINTS ==========
class CompressionRequest(BaseModel):
location: str
date: str
filename: str
reduce_percentage: int
@app.post("/api/compress/start")
async def start_compression(request: CompressionRequest):
"""Start a compression job"""
if not 1 <= request.reduce_percentage <= 90:
raise HTTPException(status_code=400, detail="Percentage must be between 1-90")
file_path = FOOTAGES_PATH / request.location / request.date / request.filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if file_path.suffix not in VIDEO_EXTENSIONS:
raise HTTPException(status_code=400, detail="File is not a video")
job_id = await compression_manager.start_compression(str(file_path), request.reduce_percentage)
return {"job_id": job_id, "status": "started"}
@app.get("/api/compress/jobs")
async def get_all_jobs():
"""Get all compression jobs"""
jobs = []
for job in compression_manager.jobs.values():
jobs.append({
"job_id": job.job_id,
"file_path": job.file_path,
"file_name": Path(job.file_path).name,
"reduce_percentage": job.reduce_percentage,
"status": job.status,
"progress": round(job.progress, 1),
"eta_seconds": job.eta_seconds,
"current_pass": job.current_pass,
"current_size_mb": round(job.current_size_mb, 2) if job.current_size_mb else None,
"target_size_mb": round(job.target_size_mb, 2) if job.target_size_mb else None,
"video_bitrate": job.video_bitrate,
"created_at": job.created_at.isoformat() if job.created_at else None,
"output_file": Path(job.output_file).name if job.output_file else None,
"error": job.error
})
return jobs
@app.get("/api/compress/jobs/{job_id}")
async def get_job_status(job_id: str):
"""Get status of specific compression job"""
if job_id not in compression_manager.jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = compression_manager.jobs[job_id]
return {
"job_id": job.job_id,
"status": job.status,
"progress": round(job.progress, 1),
"eta_seconds": job.eta_seconds,
"current_pass": job.current_pass,
"output_file": Path(job.output_file).name if job.output_file else None,
"error": job.error
}
@app.delete("/api/compress/jobs/{job_id}")
async def cancel_job(job_id: str):
"""Cancel a compression job"""
if job_id not in compression_manager.jobs:
raise HTTPException(status_code=404, detail="Job not found")
await compression_manager.cancel_job(job_id)
return {"status": "cancelled"}
@app.get("/api/compress/events")
async def compression_events(request: Request):
"""Server-Sent Events endpoint for real-time progress updates"""
async def event_generator():
try:
while True:
# Check if client is still connected
if await request.is_disconnected():
break
# Send status of all active jobs
active_jobs = []
for job in compression_manager.jobs.values():
if job.status in ["pending", "processing", "validating"]:
active_jobs.append({
"job_id": job.job_id,
"status": job.status,
"progress": round(job.progress, 1),
"eta_seconds": job.eta_seconds,
"current_pass": job.current_pass
})
if active_jobs:
yield {
"event": "progress",
"data": json.dumps(active_jobs)
}
await asyncio.sleep(0.5) # Update every 500ms
except asyncio.CancelledError:
pass
return EventSourceResponse(event_generator())
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

5
backend/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
aiofiles==23.2.1
sse-starlette==1.6.5

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: drone-footage-backend
volumes:
- /home/uad/nextcloud/footages:/footages:ro
ports:
- "8000:8000"
restart: unless-stopped
networks:
- drone-footage-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: drone-footage-frontend
ports:
- "9999:80"
depends_on:
- backend
restart: unless-stopped
networks:
- drone-footage-network
networks:
drone-footage-network:
driver: bridge

14
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
dist
build
.git
.gitignore
*.md

30
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Build stage
FROM node:20-alpine as builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Drone Footage Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

29
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Serve static files
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Important for video streaming
proxy_buffering off;
proxy_request_buffering off;
}
}

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "drone-footage-manager-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^5.0.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,152 @@
import { useState, useEffect, useRef } from 'react'
function ActiveJobsMonitor() {
const [jobs, setJobs] = useState([])
const eventSourceRef = useRef(null)
const API_URL = import.meta.env.VITE_API_URL || '/api'
useEffect(() => {
// Fetch initial jobs
fetchJobs()
// Connect to SSE for real-time updates
connectSSE()
// Refresh jobs every 5 seconds as backup
const interval = setInterval(fetchJobs, 5000)
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
clearInterval(interval)
}
}, [])
const connectSSE = () => {
const eventSource = new EventSource(`${API_URL}/compress/events`)
eventSource.addEventListener('progress', (event) => {
const updates = JSON.parse(event.data)
setJobs(prevJobs => {
const updatedJobs = [...prevJobs]
updates.forEach(update => {
const index = updatedJobs.findIndex(j => j.job_id === update.job_id)
if (index !== -1) {
updatedJobs[index] = { ...updatedJobs[index], ...update }
}
})
return updatedJobs
})
})
eventSource.onerror = () => {
console.error('SSE connection error')
eventSource.close()
setTimeout(connectSSE, 5000) // Reconnect after 5s
}
eventSourceRef.current = eventSource
}
const fetchJobs = async () => {
try {
const response = await fetch(`${API_URL}/compress/jobs`)
const data = await response.json()
setJobs(data)
} catch (error) {
console.error('Failed to fetch jobs:', error)
}
}
const cancelJob = async (jobId) => {
try {
await fetch(`${API_URL}/compress/jobs/${jobId}`, { method: 'DELETE' })
await fetchJobs()
} catch (error) {
console.error('Failed to cancel job:', error)
}
}
const formatETA = (seconds) => {
if (!seconds) return '--'
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}m ${secs}s`
}
const formatFileSize = (mb) => {
if (!mb) return '--'
return `${mb.toFixed(2)} MB`
}
const activeJobs = jobs.filter(j => ['pending', 'processing', 'validating'].includes(j.status))
// Don't show the component if there are no active jobs
if (activeJobs.length === 0) {
return null
}
return (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border-l-4 border-blue-600 rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-gray-800 flex items-center gap-2">
<span className="inline-block w-3 h-3 bg-blue-600 rounded-full animate-pulse"></span>
Active Compression Jobs ({activeJobs.length})
</h2>
</div>
<div className="space-y-4">
{activeJobs.map(job => (
<div key={job.job_id} className="bg-white rounded-lg p-4 shadow border border-blue-200">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<p className="font-semibold text-gray-800 truncate">{job.file_name}</p>
<p className="text-sm text-gray-600 mt-1">
{job.current_size_mb && `${formatFileSize(job.current_size_mb)}${formatFileSize(job.target_size_mb)}`}
{` (${job.reduce_percentage}% reduction)`}
</p>
<div className="flex gap-4 mt-2 text-xs text-gray-500">
<span className="font-medium">
{job.status === 'validating' ? '✓ Validating...' :
job.status === 'pending' ? '⏳ Pending' :
`🎬 Pass ${job.current_pass}/2`}
</span>
{job.video_bitrate && <span>Bitrate: {job.video_bitrate}k</span>}
</div>
</div>
<button
onClick={() => cancelJob(job.job_id)}
className="text-red-600 hover:text-red-800 text-sm font-medium px-3 py-1 rounded hover:bg-red-50 transition"
>
Cancel
</button>
</div>
{/* Progress Bar */}
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden shadow-inner">
<div
className="bg-gradient-to-r from-blue-500 to-blue-600 h-4 rounded-full transition-all duration-300 flex items-center justify-end pr-2"
style={{ width: `${job.progress}%` }}
>
{job.progress > 10 && (
<span className="text-xs text-white font-bold">{job.progress.toFixed(1)}%</span>
)}
</div>
</div>
<div className="flex justify-between text-sm mt-2">
<span className="text-gray-700 font-medium">{job.progress.toFixed(1)}% complete</span>
<span className="text-gray-600">
{job.status === 'validating' ? '✨ Almost done...' : `⏱ ETA: ${formatETA(job.eta_seconds)}`}
</span>
</div>
</div>
))}
</div>
</div>
)
}
export default ActiveJobsMonitor

489
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,489 @@
import { useState, useEffect, useRef } from 'react'
import ActiveJobsMonitor from './ActiveJobsMonitor'
function App() {
const [locations, setLocations] = useState([])
const [selectedLocation, setSelectedLocation] = useState(null)
const [dates, setDates] = useState([])
const [selectedDate, setSelectedDate] = useState(null)
const [files, setFiles] = useState([])
const [selectedFile, setSelectedFile] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [percentage, setPercentage] = useState(30)
const [compressing, setCompressing] = useState(false)
const [toast, setToast] = useState(null)
// Sorting states
const [locationSort, setLocationSort] = useState('modified') // 'name' or 'modified'
const [dateSort, setDateSort] = useState('modified') // 'name' or 'modified'
const [fileSort, setFileSort] = useState('modified') // 'name' or 'modified'
// Video ref for play/pause control
const videoRef = useRef(null)
const API_URL = import.meta.env.VITE_API_URL || '/api'
// Fetch locations on mount
useEffect(() => {
fetchLocations()
}, [])
// Fetch dates when location changes
useEffect(() => {
if (selectedLocation) {
fetchDates(selectedLocation)
}
}, [selectedLocation])
// Fetch files when date changes
useEffect(() => {
if (selectedLocation && selectedDate) {
fetchFiles(selectedLocation, selectedDate)
}
}, [selectedLocation, selectedDate])
// Keyboard event listener for spacebar play/pause
useEffect(() => {
const handleKeyPress = (e) => {
// Only handle spacebar if we're not focused on an interactive element
// and the video player is not focused
if (e.code === 'Space' && !e.repeat) {
const target = e.target
const tagName = target.tagName
// Ignore if focused on input elements, buttons, or the video element itself
if (tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'BUTTON' ||
tagName === 'VIDEO') {
return
}
// Only handle if we have a video loaded
if (videoRef.current && selectedFile?.is_video) {
e.preventDefault() // Prevent page scroll
e.stopPropagation() // Prevent event bubbling
if (videoRef.current.paused) {
videoRef.current.play()
} else {
videoRef.current.pause()
}
}
}
}
window.addEventListener('keydown', handleKeyPress)
return () => window.removeEventListener('keydown', handleKeyPress)
}, [selectedFile])
const fetchLocations = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(`${API_URL}/locations`)
if (!response.ok) throw new Error('Failed to fetch locations')
const data = await response.json()
setLocations(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const fetchDates = async (location) => {
setLoading(true)
setError(null)
setSelectedDate(null)
setFiles([])
setSelectedFile(null)
try {
const response = await fetch(`${API_URL}/locations/${encodeURIComponent(location)}/dates`)
if (!response.ok) throw new Error('Failed to fetch dates')
const data = await response.json()
setDates(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const fetchFiles = async (location, date) => {
setLoading(true)
setError(null)
setSelectedFile(null)
try {
const response = await fetch(`${API_URL}/files/${encodeURIComponent(location)}/${encodeURIComponent(date)}`)
if (!response.ok) throw new Error('Failed to fetch files')
const data = await response.json()
setFiles(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleLocationClick = (location) => {
setSelectedLocation(location)
}
const handleDateClick = (date) => {
setSelectedDate(date)
}
const handleFileClick = (file) => {
setSelectedFile(file)
}
// Sorting functions
const sortItems = (items, sortBy) => {
if (!items || items.length === 0) return items
const sorted = [...items]
if (sortBy === 'name') {
sorted.sort((a, b) => {
const nameA = (a.name || a).toLowerCase()
const nameB = (b.name || b).toLowerCase()
return nameA.localeCompare(nameB)
})
} else if (sortBy === 'modified') {
sorted.sort((a, b) => {
const dateA = new Date(a.modified || 0)
const dateB = new Date(b.modified || 0)
return dateB - dateA // Most recent first
})
}
return sorted
}
const getSortedLocations = () => sortItems(locations, locationSort)
const getSortedDates = () => sortItems(dates, dateSort)
const getSortedFiles = () => sortItems(files, fileSort)
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
const getMediaUrl = (file) => {
const endpoint = file.is_video ? 'stream' : 'image'
return `${API_URL}/${endpoint}/${encodeURIComponent(selectedLocation)}/${encodeURIComponent(selectedDate)}/${encodeURIComponent(file.name)}`
}
const handleResetToMain = () => {
setSelectedLocation(null)
setSelectedDate(null)
setSelectedFile(null)
setDates([])
setFiles([])
}
const showToast = (message, type = 'success') => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}
const startCompression = async () => {
if (!selectedFile || !selectedFile.is_video) return
setCompressing(true)
try {
const response = await fetch(`${API_URL}/compress/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
location: selectedLocation,
date: selectedDate,
filename: selectedFile.name,
reduce_percentage: percentage
})
})
if (response.ok) {
showToast('Compression job started!', 'success')
} else {
const error = await response.json()
showToast(`Failed: ${error.detail}`, 'error')
}
} catch (error) {
showToast(`Error: ${error.message}`, 'error')
} finally {
setCompressing(false)
}
}
return (
<div className="min-h-screen bg-gray-100">
{/* Header */}
<header className="bg-blue-600 text-white py-4 shadow-lg">
<div className="container mx-auto px-4">
<h1
className="text-2xl font-bold cursor-pointer hover:text-blue-200 transition"
onClick={handleResetToMain}
>
Drone Footage Manager
</h1>
</div>
</header>
{/* Main Content */}
<div className="container mx-auto px-4 py-6">
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Active Jobs Monitor - Always visible when there are active jobs */}
<ActiveJobsMonitor />
{/* Media Viewer */}
{selectedFile && (
<div className="mb-6 bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">{selectedFile.name}</h2>
<div className="bg-black rounded-lg overflow-hidden">
{selectedFile.is_video ? (
<video
ref={videoRef}
key={getMediaUrl(selectedFile)}
controls
preload="metadata"
className="w-full max-h-[70vh]"
>
<source src={getMediaUrl(selectedFile)} type="video/mp4" />
Your browser does not support the video tag.
</video>
) : (
<img
src={getMediaUrl(selectedFile)}
alt={selectedFile.name}
className="w-full max-h-[70vh] object-contain"
/>
)}
</div>
<div className="mt-4 text-sm text-gray-600">
<p><strong>Size:</strong> {formatFileSize(selectedFile.size)}</p>
<p><strong>Modified:</strong> {new Date(selectedFile.modified).toLocaleString()}</p>
</div>
</div>
)}
{/* Compression Controls - Only show for videos */}
{selectedFile && selectedFile.is_video && (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Video Compression</h2>
<div className="mb-6 pb-6">
<label className="block text-sm font-medium mb-2">
Reduce file size by: <span className="font-bold text-blue-600">{percentage}%</span>
</label>
<input
type="range"
min="10"
max="90"
value={percentage}
onChange={(e) => setPercentage(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(percentage - 10) / 0.8}%, #e5e7eb ${(percentage - 10) / 0.8}%, #e5e7eb 100%)`
}}
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>10%</span>
<span>50%</span>
<span>90%</span>
</div>
<div className="mt-3 text-sm text-gray-600">
<p>File: <span className="font-medium">{selectedFile.name}</span></p>
<p>Size: <span className="font-medium">{formatFileSize(selectedFile.size)}</span></p>
</div>
<button
onClick={startCompression}
disabled={compressing}
className="mt-4 bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition"
>
{compressing ? 'Starting...' : 'Start Compression'}
</button>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Locations Panel */}
<div className="bg-white rounded-lg shadow p-4">
<div className="mb-4 border-b pb-2">
<h2 className="text-lg font-semibold mb-2">Locations</h2>
<div className="flex gap-2">
<button
onClick={() => setLocationSort('name')}
className={`text-xs px-2 py-1 rounded ${
locationSort === 'name' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
}`}
>
A-Z
</button>
<button
onClick={() => setLocationSort('modified')}
className={`text-xs px-2 py-1 rounded ${
locationSort === 'modified' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
}`}
>
Recent
</button>
</div>
</div>
{loading && !locations.length ? (
<p className="text-gray-500">Loading...</p>
) : (
<ul className="space-y-1">
{getSortedLocations().map((location) => {
const locationName = location.name || location
return (
<li key={locationName}>
<button
onClick={() => handleLocationClick(locationName)}
className={`w-full text-left px-3 py-2 rounded hover:bg-blue-50 transition ${
selectedLocation === locationName ? 'bg-blue-100 font-semibold' : ''
}`}
>
{locationName}
</button>
</li>
)
})}
</ul>
)}
</div>
{/* Dates Panel */}
<div className="bg-white rounded-lg shadow p-4">
<div className="mb-4 border-b pb-2">
<h2 className="text-lg font-semibold mb-2">Dates</h2>
<div className="flex gap-2">
<button
onClick={() => setDateSort('name')}
className={`text-xs px-2 py-1 rounded ${
dateSort === 'name' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
}`}
>
A-Z
</button>
<button
onClick={() => setDateSort('modified')}
className={`text-xs px-2 py-1 rounded ${
dateSort === 'modified' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
}`}
>
Recent
</button>
</div>
</div>
{!selectedLocation ? (
<p className="text-gray-500">Select a location</p>
) : loading && !dates.length ? (
<p className="text-gray-500">Loading...</p>
) : (
<ul className="space-y-1">
{getSortedDates().map((date) => {
const dateName = date.name || date
return (
<li key={dateName}>
<button
onClick={() => handleDateClick(dateName)}
className={`w-full text-left px-3 py-2 rounded hover:bg-blue-50 transition ${
selectedDate === dateName ? 'bg-blue-100 font-semibold' : ''
}`}
>
{dateName}
</button>
</li>
)
})}
</ul>
)}
</div>
{/* Files Panel */}
<div className="bg-white rounded-lg shadow p-4">
<div className="mb-4 border-b pb-2">
<h2 className="text-lg font-semibold mb-2">Files</h2>
<div className="flex gap-2">
<button
onClick={() => setFileSort('name')}
className={`text-xs px-2 py-1 rounded ${
fileSort === 'name' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
}`}
>
A-Z
</button>
<button
onClick={() => setFileSort('modified')}
className={`text-xs px-2 py-1 rounded ${
fileSort === 'modified' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
}`}
>
Recent
</button>
</div>
</div>
{!selectedDate ? (
<p className="text-gray-500">Select a date</p>
) : loading && !files.length ? (
<p className="text-gray-500">Loading...</p>
) : files.length === 0 ? (
<p className="text-gray-500">No media files found</p>
) : (
<ul className="space-y-1">
{getSortedFiles().map((file, index) => (
<li key={index}>
<button
onClick={() => handleFileClick(file)}
className={`w-full text-left px-3 py-2 rounded hover:bg-blue-50 transition ${
selectedFile?.name === file.name ? 'bg-blue-100 font-semibold' : ''
}`}
>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-1 rounded ${
file.is_video ? 'bg-green-100 text-green-800' : 'bg-purple-100 text-purple-800'
}`}>
{file.is_video ? 'VIDEO' : 'IMAGE'}
</span>
<span className="truncate flex-1">{file.name}</span>
</div>
<div className="text-xs text-gray-500 mt-1">
{formatFileSize(file.size)}
</div>
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
{/* Toast Notification */}
{toast && (
<div className="fixed bottom-4 right-4 z-50 animate-fade-in">
<div className={`rounded-lg shadow-lg px-6 py-3 min-w-[250px] ${
toast.type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
}`}>
<p className="font-medium">{toast.message}</p>
</div>
</div>
)}
</div>
)
}
export default App

View File

@@ -0,0 +1,278 @@
import { useState, useEffect, useRef } from 'react'
function CompressionPanel({ selectedFile, location, date }) {
const [percentage, setPercentage] = useState(30)
const [jobs, setJobs] = useState([])
const [loading, setLoading] = useState(false)
const eventSourceRef = useRef(null)
const API_URL = import.meta.env.VITE_API_URL || '/api'
useEffect(() => {
// Fetch initial jobs
fetchJobs()
// Connect to SSE for real-time updates
connectSSE()
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
}
}, [])
const connectSSE = () => {
const eventSource = new EventSource(`${API_URL}/compress/events`)
eventSource.addEventListener('progress', (event) => {
const updates = JSON.parse(event.data)
setJobs(prevJobs => {
const updatedJobs = [...prevJobs]
updates.forEach(update => {
const index = updatedJobs.findIndex(j => j.job_id === update.job_id)
if (index !== -1) {
updatedJobs[index] = { ...updatedJobs[index], ...update }
}
})
return updatedJobs
})
})
eventSource.onerror = () => {
console.error('SSE connection error')
eventSource.close()
setTimeout(connectSSE, 5000) // Reconnect after 5s
}
eventSourceRef.current = eventSource
}
const fetchJobs = async () => {
try {
const response = await fetch(`${API_URL}/compress/jobs`)
const data = await response.json()
setJobs(data)
} catch (error) {
console.error('Failed to fetch jobs:', error)
}
}
const startCompression = async () => {
if (!selectedFile || !selectedFile.is_video) return
setLoading(true)
try {
const response = await fetch(`${API_URL}/compress/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
location,
date,
filename: selectedFile.name,
reduce_percentage: percentage
})
})
if (response.ok) {
await fetchJobs()
} else {
const error = await response.json()
alert(`Failed to start compression: ${error.detail}`)
}
} catch (error) {
alert(`Error: ${error.message}`)
} finally {
setLoading(false)
}
}
const cancelJob = async (jobId) => {
try {
await fetch(`${API_URL}/compress/jobs/${jobId}`, { method: 'DELETE' })
await fetchJobs()
} catch (error) {
console.error('Failed to cancel job:', error)
}
}
const formatETA = (seconds) => {
if (!seconds) return '--'
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}m ${secs}s`
}
const formatFileSize = (mb) => {
if (!mb) return '--'
return `${mb.toFixed(2)} MB`
}
const activeJobs = jobs.filter(j => ['pending', 'processing', 'validating'].includes(j.status))
const completedJobs = jobs.filter(j => j.status === 'completed').slice(0, 5)
const failedJobs = jobs.filter(j => j.status === 'failed').slice(0, 3)
return (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Video Compression</h2>
{/* Compression Controls */}
<div className="mb-6 pb-6 border-b">
<label className="block text-sm font-medium mb-2">
Reduce file size by: <span className="font-bold text-blue-600">{percentage}%</span>
</label>
<input
type="range"
min="10"
max="90"
value={percentage}
onChange={(e) => setPercentage(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(percentage - 10) / 0.8}%, #e5e7eb ${(percentage - 10) / 0.8}%, #e5e7eb 100%)`
}}
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>10%</span>
<span>50%</span>
<span>90%</span>
</div>
{selectedFile && selectedFile.is_video && (
<div className="mt-3 text-sm text-gray-600">
<p>File: <span className="font-medium">{selectedFile.name}</span></p>
<p>Size: <span className="font-medium">{formatFileSize(selectedFile.size / (1024 * 1024))}</span></p>
</div>
)}
<button
onClick={startCompression}
disabled={!selectedFile || !selectedFile.is_video || loading}
className="mt-4 bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition"
>
{loading ? 'Starting...' : 'Start Compression'}
</button>
{!selectedFile?.is_video && (
<p className="mt-2 text-sm text-gray-500">Select a video file to enable compression</p>
)}
</div>
{/* Active Jobs */}
{activeJobs.length > 0 && (
<div className="mb-6">
<h3 className="font-semibold text-lg mb-3">Active Jobs</h3>
<div className="space-y-4">
{activeJobs.map(job => (
<div key={job.job_id} className="border rounded-lg p-4 bg-blue-50">
<div className="flex justify-between items-start mb-2">
<div className="flex-1">
<p className="font-medium truncate">{job.file_name}</p>
<p className="text-sm text-gray-600">
{job.current_size_mb && `${formatFileSize(job.current_size_mb)}${formatFileSize(job.target_size_mb)}`}
{` (${job.reduce_percentage}% reduction)`}
</p>
<p className="text-xs text-gray-500">
{job.status === 'validating' ? 'Validating video...' : `Pass ${job.current_pass}/2`}
{job.video_bitrate && ` | Bitrate: ${job.video_bitrate}k`}
</p>
</div>
<button
onClick={() => cancelJob(job.job_id)}
className="text-red-600 hover:text-red-800 text-sm font-medium"
>
Cancel
</button>
</div>
{/* Progress Bar */}
<div className="w-full bg-gray-200 rounded-full h-3 mb-2 overflow-hidden">
<div
className="bg-blue-600 h-3 rounded-full transition-all duration-300 flex items-center justify-end pr-2"
style={{ width: `${job.progress}%` }}
>
{job.progress > 15 && (
<span className="text-xs text-white font-bold">{job.progress}%</span>
)}
</div>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-700">{job.progress.toFixed(1)}% complete</span>
<span className="text-gray-600">
{job.status === 'validating' ? 'Almost done...' : `ETA: ${formatETA(job.eta_seconds)}`}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Completed Jobs */}
{completedJobs.length > 0 && (
<div className="mb-6">
<h3 className="font-semibold text-lg mb-3">Completed</h3>
<div className="space-y-3">
{completedJobs.map(job => (
<div key={job.job_id} className="border rounded-lg p-4 bg-green-50">
<div className="flex justify-between items-center">
<div className="flex-1">
<p className="font-medium text-green-800">{job.file_name}</p>
<p className="text-sm text-gray-600">
{formatFileSize(job.current_size_mb)} {formatFileSize(job.target_size_mb)}
{` (saved ${formatFileSize(job.current_size_mb - job.target_size_mb)})`}
</p>
{job.output_file && (
<p className="text-xs text-gray-500 mt-1">Output: {job.output_file}</p>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => {
const url = `${API_URL}/stream/${location}/${date}/${job.output_file}`
window.open(url, '_blank')
}}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Play
</button>
<a
href={`${API_URL}/stream/${location}/${date}/${job.output_file}`}
download
className="text-green-600 hover:text-green-800 text-sm font-medium"
>
Download
</a>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Failed Jobs */}
{failedJobs.length > 0 && (
<div className="mb-4">
<h3 className="font-semibold text-lg mb-3 text-red-700">Failed</h3>
<div className="space-y-3">
{failedJobs.map(job => (
<div key={job.job_id} className="border border-red-200 rounded-lg p-4 bg-red-50">
<p className="font-medium text-red-700">{job.file_name}</p>
<p className="text-sm text-red-600 mt-1">{job.error}</p>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{activeJobs.length === 0 && completedJobs.length === 0 && failedJobs.length === 0 && (
<p className="text-gray-500 text-sm text-center py-4">No compression jobs yet</p>
)}
</div>
)
}
export default CompressionPanel

12
frontend/src/index.css Normal file
View File

@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

16
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 9999,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
}
}
}
})