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:
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal 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
54
.gitignore
vendored
Normal 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
190
README.md
Normal 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
18
backend/.dockerignore
Normal 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
21
backend/Dockerfile
Normal 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
287
backend/compression.py
Normal 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
313
backend/main.py
Normal 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
5
backend/requirements.txt
Normal 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
32
docker-compose.yml
Normal 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
14
frontend/.dockerignore
Normal 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
30
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
29
frontend/nginx.conf
Normal 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
24
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
152
frontend/src/ActiveJobsMonitor.jsx
Normal file
152
frontend/src/ActiveJobsMonitor.jsx
Normal 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
489
frontend/src/App.jsx
Normal 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
|
||||
278
frontend/src/CompressionPanel.jsx
Normal file
278
frontend/src/CompressionPanel.jsx
Normal 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
12
frontend/src/index.css
Normal 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
10
frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal 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
16
frontend/vite.config.js
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user