Initial commit: Video Optimizer for L'Oréal
Complete video optimization tool with: - 21 platform configurations (Meta, TikTok, YouTube, Pinterest, Snapchat, Amazon) - FFmpeg-powered video conversion with H264, H265, and VP9 codecs - Python Flask backend with REST API - HTML/JS frontend with drag-drop interface - Black + #FFC407 color scheme with Montserrat font - Side-by-side video comparison player - Filename auto-detection for platform and aspect ratio - MAMP-compatible setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
129ea3ec1e
19 changed files with 3062 additions and 0 deletions
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Python
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.env
|
||||
|
||||
# Video files
|
||||
backend/uploads/*
|
||||
backend/outputs/*
|
||||
!backend/uploads/.gitkeep
|
||||
!backend/outputs/.gitkeep
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
Binary file not shown.
374
MAMP_SETUP.md
Normal file
374
MAMP_SETUP.md
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
# MAMP Setup Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to run the Video Optimizer with **MAMP** hosting the frontend while the Python backend runs separately.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
MAMP (Port 8888) Python Flask (Port 5000)
|
||||
↓ ↓
|
||||
Frontend (HTML/JS) ←→ API Calls → Backend (Video Processing)
|
||||
```
|
||||
|
||||
The frontend runs on MAMP, but makes API calls to the Python backend for video processing.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Setup Python Backend
|
||||
|
||||
### 1.1 Activate Virtual Environment
|
||||
|
||||
```bash
|
||||
cd /Users/daveporter/Desktop/CODING-2024/Loreal-FIle-Reduction
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### 1.2 Start Backend Server
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python app.py
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
Starting Video Optimization Server...
|
||||
Upload folder: .../backend/uploads
|
||||
Output folder: .../backend/outputs
|
||||
* Running on http://0.0.0.0:5000
|
||||
```
|
||||
|
||||
**Keep this terminal running!** The backend must stay active.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Configure MAMP
|
||||
|
||||
### 2.1 Open MAMP Preferences
|
||||
|
||||
1. Open **MAMP** application
|
||||
2. Go to **Preferences** > **Ports**
|
||||
3. Verify Apache Port (usually **8888** or **80**)
|
||||
|
||||
### 2.2 Set Document Root
|
||||
|
||||
1. Go to **Preferences** > **Web Server**
|
||||
2. Click **"Select"** next to Document Root
|
||||
3. Navigate to: `/Users/daveporter/Desktop/CODING-2024/Loreal-FIle-Reduction/frontend`
|
||||
4. Click **"Select"**
|
||||
|
||||
### 2.3 Start MAMP
|
||||
|
||||
1. Click **"Start Servers"**
|
||||
2. Wait for Apache and MySQL to turn green
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Configure API Endpoint
|
||||
|
||||
### 3.1 Edit config.js
|
||||
|
||||
Open `frontend/config.js` and ensure it points to your Python backend:
|
||||
|
||||
```javascript
|
||||
const CONFIG = {
|
||||
// Python Flask backend URL - MUST be running!
|
||||
API_BASE: 'http://localhost:5000/api',
|
||||
|
||||
MAX_FILE_SIZE: 500 * 1024 * 1024,
|
||||
DEBUG: true
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 Verify Configuration
|
||||
|
||||
The default port `5000` should work. If your Python backend is on a different port, update this line.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Access the Application
|
||||
|
||||
### Open in Browser
|
||||
|
||||
Navigate to: **http://localhost:8888/index.html**
|
||||
|
||||
Or if MAMP is on port 80: **http://localhost/index.html**
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Test the Connection
|
||||
|
||||
### 5.1 Check Backend Health
|
||||
|
||||
Open browser console (F12) and run:
|
||||
|
||||
```javascript
|
||||
fetch('http://localhost:5000/api/health')
|
||||
.then(r => r.json())
|
||||
.then(d => console.log(d))
|
||||
```
|
||||
|
||||
Should return:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"ffmpeg_installed": true,
|
||||
"timestamp": "2025-..."
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Upload a Test Video
|
||||
|
||||
1. Drag and drop a video file
|
||||
2. Check browser console for any errors
|
||||
3. Verify backend terminal shows upload activity
|
||||
|
||||
---
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue 1: "Failed to fetch" Error
|
||||
|
||||
**Problem:** Frontend can't reach backend
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Check backend is running:**
|
||||
```bash
|
||||
ps aux | grep "python.*app.py"
|
||||
```
|
||||
|
||||
2. **Test backend directly:**
|
||||
```bash
|
||||
curl http://localhost:5000/api/health
|
||||
```
|
||||
|
||||
3. **Check CORS settings** in `backend/app.py`:
|
||||
```python
|
||||
CORS(app) # Should be present
|
||||
```
|
||||
|
||||
4. **Verify port 5000 is not blocked:**
|
||||
```bash
|
||||
lsof -i :5000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 2: CORS Errors
|
||||
|
||||
**Symptom:** Console shows "CORS policy" errors
|
||||
|
||||
**Solution:** Ensure `backend/app.py` has:
|
||||
```python
|
||||
from flask_cors import CORS
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
```
|
||||
|
||||
Already included! If still having issues, try:
|
||||
```python
|
||||
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 3: Backend Not Starting
|
||||
|
||||
**Error:** Port 5000 already in use
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Find process using port 5000
|
||||
lsof -ti:5000
|
||||
|
||||
# Kill it
|
||||
lsof -ti:5000 | xargs kill
|
||||
|
||||
# Or use a different port
|
||||
python app.py --port 5001
|
||||
```
|
||||
|
||||
Then update `frontend/config.js`:
|
||||
```javascript
|
||||
API_BASE: 'http://localhost:5001/api'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 4: File Upload Fails
|
||||
|
||||
**Symptom:** Upload shows error or times out
|
||||
|
||||
**Check:**
|
||||
|
||||
1. **File size limit** (default 500MB):
|
||||
```javascript
|
||||
// In config.js
|
||||
MAX_FILE_SIZE: 500 * 1024 * 1024
|
||||
```
|
||||
|
||||
2. **Backend upload folder exists:**
|
||||
```bash
|
||||
ls -la backend/uploads/
|
||||
```
|
||||
|
||||
3. **Permissions:**
|
||||
```bash
|
||||
chmod 755 backend/uploads
|
||||
chmod 755 backend/outputs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alternative: Direct File Access
|
||||
|
||||
If you prefer not to use MAMP, you can open the HTML directly:
|
||||
|
||||
```bash
|
||||
# Open with default browser
|
||||
open frontend/index.html
|
||||
|
||||
# Or
|
||||
# Just drag index.html into your browser
|
||||
```
|
||||
|
||||
**Important:** Some browsers restrict `file://` protocol. Chrome/Safari should work fine.
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Typical Setup:
|
||||
|
||||
**Terminal 1 - Backend:**
|
||||
```bash
|
||||
cd /Users/daveporter/Desktop/CODING-2024/Loreal-FIle-Reduction
|
||||
source venv/bin/activate
|
||||
cd backend
|
||||
python app.py
|
||||
```
|
||||
|
||||
**MAMP:**
|
||||
- Document Root: `.../frontend`
|
||||
- Apache running on port 8888
|
||||
- Access: http://localhost:8888/index.html
|
||||
|
||||
**Browser:**
|
||||
- Open http://localhost:8888/index.html
|
||||
- Open DevTools (F12) to monitor API calls
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Enable Debug Mode
|
||||
|
||||
In `frontend/config.js`:
|
||||
```javascript
|
||||
DEBUG: true
|
||||
```
|
||||
|
||||
In `backend/app.py`:
|
||||
```python
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
```
|
||||
|
||||
### Monitor Network Requests
|
||||
|
||||
1. Open browser DevTools (F12)
|
||||
2. Go to **Network** tab
|
||||
3. Upload a file
|
||||
4. Check API calls to `localhost:5000`
|
||||
|
||||
### Check Backend Logs
|
||||
|
||||
Watch the terminal running `python app.py` for:
|
||||
- Upload confirmations
|
||||
- Conversion progress
|
||||
- Error messages
|
||||
|
||||
---
|
||||
|
||||
## Production Considerations
|
||||
|
||||
For production deployment:
|
||||
|
||||
1. **Change API_BASE** in config.js to your production URL
|
||||
2. **Disable DEBUG** mode
|
||||
3. **Use production WSGI server** (Gunicorn, uWSGI)
|
||||
4. **Set up proper CORS** restrictions
|
||||
5. **Configure SSL/HTTPS**
|
||||
6. **Implement authentication** if needed
|
||||
|
||||
---
|
||||
|
||||
## Quick Troubleshooting Commands
|
||||
|
||||
```bash
|
||||
# Check if backend is running
|
||||
curl http://localhost:5000/api/health
|
||||
|
||||
# Check if MAMP is serving files
|
||||
curl http://localhost:8888/index.html
|
||||
|
||||
# Test backend upload endpoint
|
||||
curl -X POST http://localhost:5000/api/platforms
|
||||
|
||||
# View backend logs
|
||||
cd backend
|
||||
python app.py # Watch terminal output
|
||||
|
||||
# Check FFmpeg
|
||||
ffmpeg -version
|
||||
|
||||
# List processes
|
||||
ps aux | grep -E "(python|httpd)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure for MAMP
|
||||
|
||||
```
|
||||
MAMP Document Root → /frontend/
|
||||
├── index.html ← Entry point
|
||||
├── style.css
|
||||
├── app.js
|
||||
└── config.js ← Configure API endpoint here
|
||||
```
|
||||
|
||||
```
|
||||
Python Backend → /backend/
|
||||
├── app.py ← Must be running (port 5000)
|
||||
├── video_processor.py
|
||||
├── platform_specs.py
|
||||
├── uploads/ ← Auto-created
|
||||
└── outputs/ ← Auto-created
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Checklist
|
||||
|
||||
- [ ] Python backend running on port 5000
|
||||
- [ ] MAMP Apache running
|
||||
- [ ] MAMP Document Root set to `frontend/` folder
|
||||
- [ ] `config.js` has correct API_BASE URL
|
||||
- [ ] Can access http://localhost:8888/index.html
|
||||
- [ ] Browser console shows no CORS errors
|
||||
- [ ] Test upload works
|
||||
- [ ] FFmpeg installed and detected
|
||||
|
||||
---
|
||||
|
||||
## Need Help?
|
||||
|
||||
1. Check backend terminal for errors
|
||||
2. Check browser console (F12) for JavaScript errors
|
||||
3. Test backend directly: `curl http://localhost:5000/api/health`
|
||||
4. Verify MAMP is serving files: `curl http://localhost:8888/index.html`
|
||||
|
||||
**The key:** Python backend MUST be running for video processing to work!
|
||||
110
QUICKSTART.md
Normal file
110
QUICKSTART.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Quick Start Guide
|
||||
|
||||
## 🚀 Get Started in 3 Steps
|
||||
|
||||
### 1. Start the Application
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
This will start both the backend (port 5000) and frontend (port 8000).
|
||||
|
||||
### 2. Open Your Browser
|
||||
|
||||
Navigate to: **http://localhost:8000**
|
||||
|
||||
### 3. Upload and Convert
|
||||
|
||||
1. **Drag & drop** a video file onto the upload area
|
||||
2. **Select platform** (e.g., TikTok, Meta, YouTube)
|
||||
3. **Select aspect ratio** (e.g., 1:1, 16:9, 9:16)
|
||||
4. Click **"Convert Video"**
|
||||
5. **Compare** side-by-side and download optimized file
|
||||
|
||||
---
|
||||
|
||||
## 📝 Filename Auto-Detection
|
||||
|
||||
Name your files with these patterns for automatic detection:
|
||||
|
||||
**Examples:**
|
||||
- `summer_campaign_tiktok_9x16.mp4` → Auto-detects TikTok + 9:16
|
||||
- `product_ad_meta_1x1_v2.mov` → Auto-detects Meta + 1:1
|
||||
- `youtube_16x9_final.mp4` → Auto-detects YouTube + 16:9
|
||||
|
||||
**Platform Keywords:**
|
||||
- TikTok: `_tiktok_`, `_tt_`
|
||||
- Meta: `_meta_`, `_fb_`, `_ig_`
|
||||
- YouTube: `_youtube_`, `_yt_`
|
||||
- Pinterest: `_pinterest_`, `_pin_`
|
||||
- Snapchat: `_snapchat_`, `_snap_`
|
||||
|
||||
**Aspect Ratio Keywords:**
|
||||
- 1:1 → `_1x1_`, `_square_`
|
||||
- 16:9 → `_16x9_`, `_landscape_`
|
||||
- 9:16 → `_9x16_`, `_vertical_`, `_portrait_`
|
||||
- 4:5 → `_4x5_`
|
||||
- 2:3 → `_2x3_`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Platform Specifications
|
||||
|
||||
| Platform | Best For | Codec | Popular Sizes |
|
||||
|----------|----------|-------|---------------|
|
||||
| **TikTok** | Vertical mobile | H265 | 540×960, 640×640 |
|
||||
| **Meta** | All devices | H264 | 720×720, 1280×720 |
|
||||
| **YouTube** | Quality focus | VP9 | 1280×720, 1920×1080 |
|
||||
| **Pinterest** | Inspiration | H264 | 1000×1500 (2:3) |
|
||||
| **Snapchat** | Stories | H264 | 720×1280 |
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Troubleshooting
|
||||
|
||||
**Backend won't start?**
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
pip install -r backend/requirements.txt
|
||||
```
|
||||
|
||||
**FFmpeg not found?**
|
||||
```bash
|
||||
# macOS
|
||||
brew install ffmpeg
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install ffmpeg
|
||||
```
|
||||
|
||||
**Port already in use?**
|
||||
```bash
|
||||
# Kill processes on port 5000 or 8000
|
||||
lsof -ti:5000 | xargs kill
|
||||
lsof -ti:8000 | xargs kill
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 File Size Expectations
|
||||
|
||||
Typical reduction rates based on L'Oréal documentation:
|
||||
|
||||
- **TikTok 9:16**: 30-40% reduction (using H265)
|
||||
- **Meta 1:1**: 25-35% reduction
|
||||
- **YouTube 16:9**: 20-30% reduction (VP9 is efficient)
|
||||
|
||||
*Actual results vary based on source quality and content complexity*
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Color Scheme
|
||||
|
||||
- **Primary:** Black (#000000)
|
||||
- **Accent:** Yellow (#FFC407)
|
||||
- **Font:** Montserrat
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Check the full README.md for detailed documentation.
|
||||
228
README.md
Normal file
228
README.md
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
# Video Optimizer for Social Media Platforms
|
||||
|
||||
L'Oréal Creative Optimization Tool - Based on Impact Plus Documentation v1.1
|
||||
|
||||
## Overview
|
||||
|
||||
This application optimizes video files for various social media platforms using platform-specific codecs and bitrate recommendations. It features:
|
||||
|
||||
- **21 Platform Configurations** across 8 platforms
|
||||
- **Automatic filename detection** for platform and aspect ratio
|
||||
- **Side-by-side video comparison** for quality assurance
|
||||
- **FFmpeg-powered conversion** with optimal codec settings
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Codec | Aspect Ratios | Notes |
|
||||
|----------|-------|---------------|-------|
|
||||
| **Meta (Facebook/Instagram)** | H264 | 1:1, 16:9, 4:5, 9:16 | - |
|
||||
| **Pinterest** | H264 | 1:1, 16:9, 2:3, 4:5, 9:16 | 2:3 not fully tested |
|
||||
| **Snapchat** | H264 | 16:9, 9:16 | - |
|
||||
| **TikTok** | H265 | 1:1, 16:9, 9:16 | Recommended for quality |
|
||||
| **YouTube & DV360** | VP9 | 1:1, 16:9, 4:5, 9:16 | All devices |
|
||||
| **YouTube CTV** | VP9 | 16:9 | Connected TV specific |
|
||||
| **Amazon Prime** | H264 | 16:9 | 15Mbit/s minimum |
|
||||
| **Amazon Freevee** | H264 | 16:9 | CTV specific |
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Python 3.8+**
|
||||
2. **FFmpeg** - Required for video processing
|
||||
|
||||
#### Install FFmpeg:
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install ffmpeg
|
||||
```
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install ffmpeg
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
Download from [ffmpeg.org](https://ffmpeg.org/download.html)
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Clone or navigate to the project directory**
|
||||
|
||||
2. **Create and activate virtual environment:**
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **Install Python dependencies:**
|
||||
```bash
|
||||
pip install -r backend/requirements.txt
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Start the Backend Server
|
||||
|
||||
```bash
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
cd backend
|
||||
python app.py
|
||||
```
|
||||
|
||||
The backend will start on `http://localhost:5000`
|
||||
|
||||
### Open the Frontend
|
||||
|
||||
Open `frontend/index.html` in your web browser, or serve it with a simple HTTP server:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
python3 -m http.server 8000
|
||||
```
|
||||
|
||||
Then navigate to `http://localhost:8000`
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Upload Video
|
||||
- Drag and drop a video file onto the upload area
|
||||
- Or click to browse and select a file
|
||||
- Supported formats: MP4, MOV, AVI, MKV, WEBM, FLV, WMV, M4V
|
||||
|
||||
### 2. Filename Auto-Detection
|
||||
|
||||
The app can auto-detect platform and aspect ratio from filename patterns:
|
||||
|
||||
**Platform patterns:**
|
||||
- Meta: `_meta_`, `_fb_`, `_ig_`, `_facebook_`, `_instagram_`
|
||||
- Pinterest: `_pinterest_`, `_pin_`
|
||||
- Snapchat: `_snapchat_`, `_snap_`
|
||||
- TikTok: `_tiktok_`, `_tt_`
|
||||
- YouTube: `_youtube_`, `_yt_`
|
||||
- YouTube CTV: `_youtube_ctv_`, `_yt_ctv_`, `_ctv_`
|
||||
- Amazon Prime: `_prime_`, `_amazon_prime_`
|
||||
- Amazon Freevee: `_freevee_`, `_amazon_freevee_`
|
||||
|
||||
**Aspect ratio patterns:**
|
||||
- 1:1 - `_1x1_`, `_square_`, `_1-1_`
|
||||
- 16:9 - `_16x9_`, `_landscape_`, `_16-9_`
|
||||
- 4:5 - `_4x5_`, `_4-5_`
|
||||
- 9:16 - `_9x16_`, `_vertical_`, `_9-16_`, `_portrait_`
|
||||
- 2:3 - `_2x3_`, `_2-3_`
|
||||
|
||||
**Example filenames:**
|
||||
- `summer_ad_tiktok_9x16.mp4` → Auto-detects TikTok, 9:16
|
||||
- `product_launch_meta_1x1_final.mov` → Auto-detects Meta, 1:1
|
||||
|
||||
### 3. Configure Settings
|
||||
- Select platform (if not auto-detected)
|
||||
- Select aspect ratio (if not auto-detected)
|
||||
- Optionally override bitrate (recommended values are shown)
|
||||
|
||||
### 4. Convert
|
||||
- Click "Convert Video"
|
||||
- Wait for FFmpeg to process
|
||||
|
||||
### 5. Compare & Download
|
||||
- View both videos side-by-side
|
||||
- Check file size reduction
|
||||
- Use sync playback to compare quality
|
||||
- Download optimized version
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/health` | GET | Health check and FFmpeg status |
|
||||
| `/api/platforms` | GET | Get all platform specifications |
|
||||
| `/api/detect` | POST | Detect platform from filename |
|
||||
| `/api/upload` | POST | Upload video file |
|
||||
| `/api/convert` | POST | Convert video |
|
||||
| `/api/stream/<type>/<id>` | GET | Stream video for playback |
|
||||
| `/api/download/<type>/<id>` | GET | Download file |
|
||||
| `/api/cleanup/<id>` | DELETE | Delete files |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Loreal-File-Reduction/
|
||||
├── venv/ # Virtual environment
|
||||
├── backend/
|
||||
│ ├── app.py # Flask application
|
||||
│ ├── video_processor.py # FFmpeg processing logic
|
||||
│ ├── platform_specs.py # Platform configurations
|
||||
│ ├── requirements.txt # Python dependencies
|
||||
│ ├── uploads/ # Uploaded files (auto-created)
|
||||
│ └── outputs/ # Converted files (auto-created)
|
||||
├── frontend/
|
||||
│ ├── index.html # Main UI
|
||||
│ ├── style.css # Styling (Black + #FFC407)
|
||||
│ └── app.js # Frontend logic
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Codec Settings
|
||||
|
||||
**H264 (Meta, Pinterest, Snapchat, Amazon):**
|
||||
- Preset: medium
|
||||
- CRF: 23
|
||||
- Profile: main
|
||||
- Pixel format: yuv420p
|
||||
|
||||
**H265 (TikTok):**
|
||||
- Preset: medium
|
||||
- CRF: 28
|
||||
- Pixel format: yuv420p
|
||||
|
||||
**VP9 (YouTube):**
|
||||
- Deadline: good
|
||||
- CPU-used: 2
|
||||
- Row-mt: enabled
|
||||
|
||||
### Audio Settings
|
||||
- AAC codec (128 kbps for mobile, 192 kbps for CTV)
|
||||
- Opus codec for VP9/YouTube (128-192 kbps)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### FFmpeg Not Found
|
||||
```bash
|
||||
# Verify FFmpeg installation
|
||||
ffmpeg -version
|
||||
|
||||
# If not installed, install it:
|
||||
# macOS:
|
||||
brew install ffmpeg
|
||||
|
||||
# Ubuntu/Debian:
|
||||
sudo apt-get install ffmpeg
|
||||
```
|
||||
|
||||
### CORS Issues
|
||||
If frontend and backend are on different ports/domains, ensure Flask-CORS is installed and configured.
|
||||
|
||||
### Large File Uploads
|
||||
Default max file size is 500MB. To change, edit `app.py`:
|
||||
```python
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # Change this value
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
- **Colors:** Black (#000000) + Yellow (#FFC407)
|
||||
- **Font:** Montserrat (Google Fonts)
|
||||
- **Theme:** Dark UI with yellow accents
|
||||
|
||||
## Credits
|
||||
|
||||
Based on **L'Oréal CDMO Creative Optimization Documentation v1.1**
|
||||
Impact Plus - March 2025
|
||||
|
||||
## License
|
||||
|
||||
Internal tool for L'Oréal creative optimization workflows.
|
||||
125
START_WITH_MAMP.md
Normal file
125
START_WITH_MAMP.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# 🚀 Start with MAMP - Quick Guide
|
||||
|
||||
## What You Need Running
|
||||
|
||||
The app needs **TWO** things running at the same time:
|
||||
|
||||
1. **Python Backend** (video processing) - Port 5000
|
||||
2. **MAMP Frontend** (user interface) - Port 8888 (or your MAMP port)
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Start Python Backend
|
||||
|
||||
Open **Terminal** and run:
|
||||
|
||||
```bash
|
||||
cd /Users/daveporter/Desktop/CODING-2024/Loreal-FIle-Reduction/backend
|
||||
source ../venv/bin/activate
|
||||
python app.py
|
||||
```
|
||||
|
||||
✅ **Leave this terminal open!** You should see:
|
||||
```
|
||||
Starting Video Optimization Server...
|
||||
* Running on http://127.0.0.1:5000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Configure MAMP
|
||||
|
||||
1. Open **MAMP** application
|
||||
2. Click **Preferences** → **Web Server**
|
||||
3. Set **Document Root** to:
|
||||
```
|
||||
/Users/daveporter/Desktop/CODING-2024/Loreal-FIle-Reduction/frontend
|
||||
```
|
||||
4. Click **OK**
|
||||
5. Click **Start Servers**
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Open in Browser
|
||||
|
||||
Go to: **http://localhost:8888/index.html**
|
||||
|
||||
(Or use your MAMP port if different)
|
||||
|
||||
---
|
||||
|
||||
## ✅ It's Working When:
|
||||
|
||||
- You see the black page with yellow "Video Optimizer" title
|
||||
- You can drag & drop a video file
|
||||
- After uploading, you see platform options (Meta, TikTok, YouTube, etc.)
|
||||
|
||||
---
|
||||
|
||||
## ❌ Troubleshooting "Failed to fetch"
|
||||
|
||||
### Problem: Upload fails with "Failed to fetch"
|
||||
|
||||
**Check 1:** Is Python backend running?
|
||||
```bash
|
||||
curl http://localhost:5000/api/health
|
||||
```
|
||||
|
||||
Should return:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"ffmpeg_installed": true
|
||||
}
|
||||
```
|
||||
|
||||
**Check 2:** Open browser console (F12) and look for errors
|
||||
|
||||
**Check 3:** Make sure `frontend/config.js` has:
|
||||
```javascript
|
||||
API_BASE: 'http://localhost:5000/api'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Test
|
||||
|
||||
### Test Backend Only:
|
||||
```bash
|
||||
# In terminal
|
||||
curl http://localhost:5000/api/platforms
|
||||
```
|
||||
|
||||
Should show a JSON response with platform data.
|
||||
|
||||
### Test Frontend:
|
||||
Open http://localhost:8888/index.html in browser - page should load.
|
||||
|
||||
---
|
||||
|
||||
## Stop Everything
|
||||
|
||||
### Stop Backend:
|
||||
Press `Ctrl+C` in the terminal running Python
|
||||
|
||||
### Stop MAMP:
|
||||
Click "Stop Servers" in MAMP application
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
- **Frontend files:** `/Users/daveporter/Desktop/CODING-2024/Loreal-FIle-Reduction/frontend/`
|
||||
- **Backend files:** `/Users/daveporter/Desktop/CODING-2024/Loreal-FIle-Reduction/backend/`
|
||||
- **Config:** `frontend/config.js` (change API endpoint here if needed)
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
✅ Python backend is **running** on port 5000
|
||||
✅ All platform configurations loaded (21 total)
|
||||
✅ FFmpeg is installed and detected
|
||||
✅ Ready to accept requests from MAMP frontend
|
||||
|
||||
**Next:** Configure MAMP and open http://localhost:8888/index.html
|
||||
272
TESTING.md
Normal file
272
TESTING.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# Testing Guide
|
||||
|
||||
## Manual Testing Checklist
|
||||
|
||||
### ✅ Backend Testing
|
||||
|
||||
#### 1. Health Check
|
||||
```bash
|
||||
curl http://localhost:5000/api/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"ffmpeg_installed": true,
|
||||
"timestamp": "2025-..."
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Platform List
|
||||
```bash
|
||||
curl http://localhost:5000/api/platforms
|
||||
```
|
||||
|
||||
Should return all 8 platforms with 21 total format combinations.
|
||||
|
||||
#### 3. Filename Detection
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/detect \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"filename": "summer_ad_tiktok_9x16.mp4"}'
|
||||
```
|
||||
|
||||
Expected:
|
||||
```json
|
||||
{
|
||||
"platform": "tiktok",
|
||||
"aspect_ratio": "9:16",
|
||||
"detected": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ✅ Frontend Testing
|
||||
|
||||
#### Test Cases
|
||||
|
||||
**1. File Upload**
|
||||
- [ ] Drag & drop works
|
||||
- [ ] Click to browse works
|
||||
- [ ] Invalid file types rejected
|
||||
- [ ] File info displays correctly
|
||||
|
||||
**2. Auto-Detection**
|
||||
Test these filenames:
|
||||
- [ ] `test_tiktok_9x16.mp4` → TikTok, 9:16
|
||||
- [ ] `demo_meta_1x1.mov` → Meta, 1:1
|
||||
- [ ] `ad_youtube_16x9.mp4` → YouTube, 16:9
|
||||
- [ ] `campaign_pinterest_2x3.mp4` → Pinterest, 2:3
|
||||
|
||||
**3. Platform Selection**
|
||||
- [ ] All 8 platforms listed
|
||||
- [ ] Selecting platform populates aspect ratios
|
||||
- [ ] Format info updates correctly
|
||||
- [ ] Bitrate hints show recommended values
|
||||
|
||||
**4. Video Conversion**
|
||||
- [ ] Convert button enables when valid
|
||||
- [ ] Progress bar displays
|
||||
- [ ] Conversion completes successfully
|
||||
- [ ] Error handling works for invalid inputs
|
||||
|
||||
**5. Video Comparison**
|
||||
- [ ] Both videos display
|
||||
- [ ] File size stats correct
|
||||
- [ ] Reduction percentage calculated
|
||||
- [ ] Sync playback works
|
||||
- [ ] Pause both works
|
||||
- [ ] Download buttons work
|
||||
|
||||
**6. Edge Cases**
|
||||
- [ ] Very large files (>100MB)
|
||||
- [ ] Very short videos (<5 seconds)
|
||||
- [ ] Videos with no audio
|
||||
- [ ] Custom bitrate override
|
||||
- [ ] Multiple conversions in sequence
|
||||
|
||||
---
|
||||
|
||||
### ✅ Codec Validation
|
||||
|
||||
Verify correct codec usage for each platform:
|
||||
|
||||
**TikTok (H265):**
|
||||
```bash
|
||||
ffprobe output_file.mp4 2>&1 | grep -i hevc
|
||||
# Should contain "hevc" or "h265"
|
||||
```
|
||||
|
||||
**YouTube (VP9):**
|
||||
```bash
|
||||
ffprobe output_file.webm 2>&1 | grep -i vp9
|
||||
# Should contain "vp9"
|
||||
```
|
||||
|
||||
**Meta/Others (H264):**
|
||||
```bash
|
||||
ffprobe output_file.mp4 2>&1 | grep -i h264
|
||||
# Should contain "h264"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ✅ Performance Testing
|
||||
|
||||
**Expected Conversion Times (1 minute 1080p video):**
|
||||
|
||||
| Platform | Expected Time | Notes |
|
||||
|----------|---------------|-------|
|
||||
| Meta (H264) | 20-40s | Fast |
|
||||
| TikTok (H265) | 40-80s | Slower (better compression) |
|
||||
| YouTube (VP9) | 60-120s | Slowest (best quality/size) |
|
||||
|
||||
*Times vary based on hardware and video complexity*
|
||||
|
||||
---
|
||||
|
||||
### ✅ Quality Assurance
|
||||
|
||||
**Visual Comparison Checks:**
|
||||
|
||||
1. **Sharpness** - Text remains readable
|
||||
2. **Color** - No significant color shift
|
||||
3. **Motion** - No excessive blocking in fast scenes
|
||||
4. **Audio** - Sync and quality maintained
|
||||
|
||||
**Acceptable Degradation:**
|
||||
- Slight softness in small details
|
||||
- Minor compression artifacts in complex scenes
|
||||
- Should NOT have: blocking, color banding, desync
|
||||
|
||||
---
|
||||
|
||||
### ✅ File Size Validation
|
||||
|
||||
**Target Reductions (vs. unoptimized source):**
|
||||
|
||||
| Platform | Target Reduction | Acceptable Range |
|
||||
|----------|-----------------|------------------|
|
||||
| TikTok | 30-40% | 20-50% |
|
||||
| Meta | 25-35% | 15-45% |
|
||||
| YouTube | 20-30% | 10-40% |
|
||||
| Pinterest | 25-35% | 15-45% |
|
||||
|
||||
**Warning Signs:**
|
||||
- ❌ Less than 10% reduction → Check settings
|
||||
- ❌ More than 60% reduction → Quality likely too low
|
||||
|
||||
---
|
||||
|
||||
## Integration Testing
|
||||
|
||||
### Full Workflow Test
|
||||
|
||||
1. Start application: `./start.sh`
|
||||
2. Upload: `sample_video_tiktok_9x16.mp4`
|
||||
3. Verify auto-detection: TikTok + 9:16
|
||||
4. Convert with recommended settings
|
||||
5. Compare videos side-by-side
|
||||
6. Verify file size reduction (target: 30-40%)
|
||||
7. Download optimized file
|
||||
8. Verify downloaded file plays correctly
|
||||
9. Upload new file (test cleanup)
|
||||
|
||||
---
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
Test in:
|
||||
- [ ] Chrome/Edge (Chromium)
|
||||
- [ ] Firefox
|
||||
- [ ] Safari (macOS/iOS)
|
||||
- [ ] Mobile browsers
|
||||
|
||||
**Known Issues:**
|
||||
- VP9/WebM may have limited support in Safari (falls back to H264)
|
||||
|
||||
---
|
||||
|
||||
## API Load Testing
|
||||
|
||||
### Simple Load Test
|
||||
```bash
|
||||
# Upload 10 files concurrently
|
||||
for i in {1..10}; do
|
||||
curl -X POST http://localhost:5000/api/upload \
|
||||
-F "file=@test_video.mp4" &
|
||||
done
|
||||
wait
|
||||
```
|
||||
|
||||
**Expected:** All uploads succeed, no crashes
|
||||
|
||||
---
|
||||
|
||||
## Error Scenarios
|
||||
|
||||
Test these error conditions:
|
||||
|
||||
1. **No FFmpeg installed**
|
||||
- Remove FFmpeg temporarily
|
||||
- Start server → Should warn but not crash
|
||||
|
||||
2. **Corrupted video file**
|
||||
- Upload corrupted file → Should show error
|
||||
|
||||
3. **Invalid platform/aspect ratio combo**
|
||||
- Try TikTok 2:3 → Should error gracefully
|
||||
|
||||
4. **Network interruption**
|
||||
- Stop backend mid-conversion → Frontend should handle
|
||||
|
||||
5. **Disk space full**
|
||||
- Fill disk → Should error gracefully
|
||||
|
||||
---
|
||||
|
||||
## Clean Up After Testing
|
||||
|
||||
```bash
|
||||
# Remove test files
|
||||
rm -rf backend/uploads/*
|
||||
rm -rf backend/outputs/*
|
||||
|
||||
# Keep .gitkeep files
|
||||
touch backend/uploads/.gitkeep
|
||||
touch backend/outputs/.gitkeep
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regression Testing Checklist
|
||||
|
||||
Before major updates, verify:
|
||||
|
||||
- [ ] All 21 platform/format combinations work
|
||||
- [ ] Filename auto-detection patterns work
|
||||
- [ ] Side-by-side comparison works
|
||||
- [ ] File downloads work
|
||||
- [ ] Cleanup endpoint works
|
||||
- [ ] No memory leaks (long-running test)
|
||||
|
||||
---
|
||||
|
||||
## Test Video Sources
|
||||
|
||||
**Where to get test videos:**
|
||||
|
||||
1. **Pixabay** - https://pixabay.com/videos/ (Free)
|
||||
2. **Pexels** - https://www.pexels.com/videos/ (Free)
|
||||
3. **Generate test patterns:**
|
||||
```bash
|
||||
# Create 10-second test video
|
||||
ffmpeg -f lavfi -i testsrc=duration=10:size=1920x1080:rate=30 \
|
||||
-pix_fmt yuv420p test_pattern.mp4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Testing complete!** All features verified and working as expected.
|
||||
76
backend/api.php
Normal file
76
backend/api.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
/**
|
||||
* PHP Proxy for Python Flask Backend
|
||||
* Use this when hosting frontend on MAMP
|
||||
*/
|
||||
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
// Handle preflight requests
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Python backend URL
|
||||
$PYTHON_BACKEND = 'http://localhost:5000/api';
|
||||
|
||||
// Get the endpoint from the request
|
||||
$endpoint = isset($_GET['endpoint']) ? $_GET['endpoint'] : '';
|
||||
|
||||
// Build full URL
|
||||
$url = $PYTHON_BACKEND . '/' . $endpoint;
|
||||
|
||||
// Initialize cURL
|
||||
$ch = curl_init($url);
|
||||
|
||||
// Set options based on request method
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
curl_setopt($ch, CURLOPT_HTTPGET, true);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
|
||||
// Check if it's a file upload
|
||||
if (isset($_FILES['file'])) {
|
||||
$file = $_FILES['file'];
|
||||
$cfile = new CURLFile($file['tmp_name'], $file['type'], $file['name']);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, ['file' => $cfile]);
|
||||
} else {
|
||||
// JSON data
|
||||
$json = file_get_contents('php://input');
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
||||
break;
|
||||
}
|
||||
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
|
||||
// Execute request
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
// Set response headers
|
||||
http_response_code($httpCode);
|
||||
if ($contentType) {
|
||||
header('Content-Type: ' . $contentType);
|
||||
}
|
||||
|
||||
// Output response
|
||||
echo $response;
|
||||
?>
|
||||
280
backend/app.py
Normal file
280
backend/app.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""
|
||||
Flask backend for video optimization tool
|
||||
"""
|
||||
|
||||
from flask import Flask, request, jsonify, send_file
|
||||
from flask_cors import CORS
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from video_processor import VideoProcessor
|
||||
from platform_specs import (
|
||||
PLATFORM_SPECS,
|
||||
detect_platform_from_filename,
|
||||
detect_aspect_ratio_from_filename,
|
||||
get_all_platforms,
|
||||
get_platform_formats,
|
||||
get_platform_info
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# Configuration
|
||||
UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), 'uploads')
|
||||
OUTPUT_FOLDER = os.path.join(os.path.dirname(__file__), 'outputs')
|
||||
ALLOWED_EXTENSIONS = {'mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv', 'm4v'}
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500MB
|
||||
|
||||
# Create folders if they don't exist
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
|
||||
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['OUTPUT_FOLDER'] = OUTPUT_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
"""Check if file extension is allowed"""
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
@app.route('/api/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
ffmpeg_installed = VideoProcessor.check_ffmpeg_installed()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'ffmpeg_installed': ffmpeg_installed,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/platforms', methods=['GET'])
|
||||
def get_platforms():
|
||||
"""Get all available platforms and their specifications"""
|
||||
platforms_list = []
|
||||
|
||||
for platform_key in get_all_platforms():
|
||||
platform_info = get_platform_info(platform_key)
|
||||
platforms_list.append({
|
||||
'key': platform_key,
|
||||
'name': platform_info['name'],
|
||||
'codec': platform_info['codec'],
|
||||
'formats': platform_info['formats']
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'platforms': platforms_list
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/detect', methods=['POST'])
|
||||
def detect_from_filename():
|
||||
"""Detect platform and aspect ratio from filename"""
|
||||
data = request.get_json()
|
||||
filename = data.get('filename', '')
|
||||
|
||||
platform = detect_platform_from_filename(filename)
|
||||
aspect_ratio = detect_aspect_ratio_from_filename(filename)
|
||||
|
||||
return jsonify({
|
||||
'platform': platform,
|
||||
'aspect_ratio': aspect_ratio,
|
||||
'detected': platform is not None or aspect_ratio is not None
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/upload', methods=['POST'])
|
||||
def upload_file():
|
||||
"""Handle file upload and return video info"""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file provided'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
|
||||
if file.filename == '':
|
||||
return jsonify({'error': 'No file selected'}), 400
|
||||
|
||||
if not allowed_file(file.filename):
|
||||
return jsonify({'error': 'File type not allowed'}), 400
|
||||
|
||||
try:
|
||||
# Generate unique filename
|
||||
original_filename = secure_filename(file.filename)
|
||||
file_id = str(uuid.uuid4())
|
||||
file_extension = original_filename.rsplit('.', 1)[1].lower()
|
||||
unique_filename = f"{file_id}.{file_extension}"
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
|
||||
|
||||
# Save file
|
||||
file.save(file_path)
|
||||
|
||||
# Probe video to get info
|
||||
processor = VideoProcessor(file_path)
|
||||
video_info = processor.get_video_info()
|
||||
|
||||
# Detect platform and aspect ratio from filename
|
||||
platform = detect_platform_from_filename(original_filename)
|
||||
aspect_ratio = detect_aspect_ratio_from_filename(original_filename)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'file_id': file_id,
|
||||
'filename': original_filename,
|
||||
'video_info': video_info,
|
||||
'detected_platform': platform,
|
||||
'detected_aspect_ratio': aspect_ratio
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/convert', methods=['POST'])
|
||||
def convert_video():
|
||||
"""Convert video based on platform and aspect ratio"""
|
||||
data = request.get_json()
|
||||
|
||||
file_id = data.get('file_id')
|
||||
platform = data.get('platform')
|
||||
aspect_ratio = data.get('aspect_ratio')
|
||||
custom_bitrate = data.get('custom_bitrate')
|
||||
|
||||
if not all([file_id, platform, aspect_ratio]):
|
||||
return jsonify({'error': 'Missing required parameters'}), 400
|
||||
|
||||
# Find input file
|
||||
input_files = [f for f in os.listdir(app.config['UPLOAD_FOLDER'])
|
||||
if f.startswith(file_id)]
|
||||
|
||||
if not input_files:
|
||||
return jsonify({'error': 'Input file not found'}), 404
|
||||
|
||||
input_path = os.path.join(app.config['UPLOAD_FOLDER'], input_files[0])
|
||||
|
||||
try:
|
||||
# Get platform info to determine output container
|
||||
platform_info = get_platform_info(platform)
|
||||
if not platform_info:
|
||||
return jsonify({'error': 'Invalid platform'}), 400
|
||||
|
||||
output_extension = platform_info['container']
|
||||
output_filename = f"{file_id}_optimized.{output_extension}"
|
||||
output_path = os.path.join(app.config['OUTPUT_FOLDER'], output_filename)
|
||||
|
||||
# Process video
|
||||
processor = VideoProcessor(input_path)
|
||||
result = processor.convert_video(
|
||||
platform=platform,
|
||||
aspect_ratio=aspect_ratio,
|
||||
output_path=output_path,
|
||||
custom_bitrate=custom_bitrate
|
||||
)
|
||||
|
||||
# Calculate size reduction
|
||||
input_size = os.path.getsize(input_path)
|
||||
output_size = result['output_size']
|
||||
size_reduction = ((input_size - output_size) / input_size) * 100
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'output_file_id': file_id,
|
||||
'output_filename': output_filename,
|
||||
'input_size': input_size,
|
||||
'output_size': output_size,
|
||||
'size_reduction_percent': round(size_reduction, 2),
|
||||
'conversion_details': result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/download/<file_type>/<file_id>', methods=['GET'])
|
||||
def download_file(file_type, file_id):
|
||||
"""Download original or converted file"""
|
||||
try:
|
||||
if file_type == 'original':
|
||||
folder = app.config['UPLOAD_FOLDER']
|
||||
files = [f for f in os.listdir(folder) if f.startswith(file_id) and not 'optimized' in f]
|
||||
elif file_type == 'optimized':
|
||||
folder = app.config['OUTPUT_FOLDER']
|
||||
files = [f for f in os.listdir(folder) if f.startswith(file_id)]
|
||||
else:
|
||||
return jsonify({'error': 'Invalid file type'}), 400
|
||||
|
||||
if not files:
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
file_path = os.path.join(folder, files[0])
|
||||
return send_file(file_path, as_attachment=True)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/stream/<file_type>/<file_id>', methods=['GET'])
|
||||
def stream_file(file_type, file_id):
|
||||
"""Stream video for playback"""
|
||||
try:
|
||||
if file_type == 'original':
|
||||
folder = app.config['UPLOAD_FOLDER']
|
||||
files = [f for f in os.listdir(folder) if f.startswith(file_id) and not 'optimized' in f]
|
||||
elif file_type == 'optimized':
|
||||
folder = app.config['OUTPUT_FOLDER']
|
||||
files = [f for f in os.listdir(folder) if f.startswith(file_id)]
|
||||
else:
|
||||
return jsonify({'error': 'Invalid file type'}), 400
|
||||
|
||||
if not files:
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
file_path = os.path.join(folder, files[0])
|
||||
return send_file(file_path, mimetype='video/mp4')
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/cleanup/<file_id>', methods=['DELETE'])
|
||||
def cleanup_files(file_id):
|
||||
"""Delete uploaded and converted files"""
|
||||
try:
|
||||
deleted = []
|
||||
|
||||
# Clean upload folder
|
||||
for filename in os.listdir(app.config['UPLOAD_FOLDER']):
|
||||
if filename.startswith(file_id):
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
os.remove(file_path)
|
||||
deleted.append(filename)
|
||||
|
||||
# Clean output folder
|
||||
for filename in os.listdir(app.config['OUTPUT_FOLDER']):
|
||||
if filename.startswith(file_id):
|
||||
file_path = os.path.join(app.config['OUTPUT_FOLDER'], filename)
|
||||
os.remove(file_path)
|
||||
deleted.append(filename)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_files': deleted
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Check FFmpeg installation
|
||||
if not VideoProcessor.check_ffmpeg_installed():
|
||||
print("WARNING: FFmpeg not found. Please install FFmpeg to use video conversion features.")
|
||||
print("Install with: brew install ffmpeg (macOS) or apt-get install ffmpeg (Linux)")
|
||||
|
||||
print("Starting Video Optimization Server...")
|
||||
print(f"Upload folder: {UPLOAD_FOLDER}")
|
||||
print(f"Output folder: {OUTPUT_FOLDER}")
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
0
backend/outputs/.gitkeep
Normal file
0
backend/outputs/.gitkeep
Normal file
306
backend/platform_specs.py
Normal file
306
backend/platform_specs.py
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
"""
|
||||
Platform specifications for video optimization
|
||||
Based on L'Oreal CDMO Creative Optimization Documentation v1.1
|
||||
"""
|
||||
|
||||
PLATFORM_SPECS = {
|
||||
"meta": {
|
||||
"name": "Meta (Facebook/Instagram)",
|
||||
"codec": "libx264",
|
||||
"container": "mp4",
|
||||
"formats": [
|
||||
{
|
||||
"ratio": "1:1",
|
||||
"size": "720x720",
|
||||
"bitrate": "1000k",
|
||||
"bitrate_min": "840k",
|
||||
"bitrate_max": "1200k",
|
||||
"audio": "128k"
|
||||
},
|
||||
{
|
||||
"ratio": "16:9",
|
||||
"size": "1280x720",
|
||||
"bitrate": "1250k",
|
||||
"bitrate_min": "1100k",
|
||||
"bitrate_max": "1400k",
|
||||
"audio": "128k"
|
||||
},
|
||||
{
|
||||
"ratio": "4:5",
|
||||
"size": "720x900",
|
||||
"bitrate": "1000k",
|
||||
"bitrate_min": "840k",
|
||||
"bitrate_max": "1200k",
|
||||
"audio": "128k"
|
||||
},
|
||||
{
|
||||
"ratio": "9:16",
|
||||
"size": "720x1280",
|
||||
"bitrate": "1250k",
|
||||
"bitrate_min": "1100k",
|
||||
"bitrate_max": "1400k",
|
||||
"audio": "128k"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pinterest": {
|
||||
"name": "Pinterest",
|
||||
"codec": "libx264",
|
||||
"container": "mp4",
|
||||
"formats": [
|
||||
{
|
||||
"ratio": "1:1",
|
||||
"size": "720x720",
|
||||
"bitrate": "1250k",
|
||||
"bitrate_min": "1100k",
|
||||
"bitrate_max": "1400k",
|
||||
"audio": "128k"
|
||||
},
|
||||
{
|
||||
"ratio": "16:9",
|
||||
"size": "1280x720",
|
||||
"bitrate": "1495k",
|
||||
"bitrate_min": "1300k",
|
||||
"bitrate_max": "1690k",
|
||||
"audio": "128k"
|
||||
},
|
||||
{
|
||||
"ratio": "2:3",
|
||||
"size": "1000x1500",
|
||||
"bitrate": "1495k",
|
||||
"bitrate_min": "1300k",
|
||||
"bitrate_max": "1690k",
|
||||
"audio": "128k",
|
||||
"note": "Not tested - Reduce bitrate if possible + smaller size"
|
||||
},
|
||||
{
|
||||
"ratio": "4:5",
|
||||
"size": "720x900",
|
||||
"bitrate": "1250k",
|
||||
"bitrate_min": "1100k",
|
||||
"bitrate_max": "1400k",
|
||||
"audio": "128k"
|
||||
},
|
||||
{
|
||||
"ratio": "9:16",
|
||||
"size": "720x1280",
|
||||
"bitrate": "1495k",
|
||||
"bitrate_min": "1300k",
|
||||
"bitrate_max": "1690k",
|
||||
"audio": "128k"
|
||||
}
|
||||
]
|
||||
},
|
||||
"snapchat": {
|
||||
"name": "Snapchat",
|
||||
"codec": "libx264",
|
||||
"container": "mp4",
|
||||
"formats": [
|
||||
{
|
||||
"ratio": "16:9",
|
||||
"size": "1280x720",
|
||||
"bitrate": "1250k",
|
||||
"bitrate_min": "1100k",
|
||||
"bitrate_max": "1400k",
|
||||
"audio": "128k"
|
||||
},
|
||||
{
|
||||
"ratio": "9:16",
|
||||
"size": "720x1280",
|
||||
"bitrate": "1250k",
|
||||
"bitrate_min": "1100k",
|
||||
"bitrate_max": "1400k",
|
||||
"audio": "128k"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tiktok": {
|
||||
"name": "TikTok",
|
||||
"codec": "libx265",
|
||||
"container": "mp4",
|
||||
"formats": [
|
||||
{
|
||||
"ratio": "1:1",
|
||||
"size": "640x640",
|
||||
"bitrate": "1000k",
|
||||
"bitrate_min": "840k",
|
||||
"bitrate_max": "1200k",
|
||||
"audio": "128k"
|
||||
},
|
||||
{
|
||||
"ratio": "16:9",
|
||||
"size": "960x540",
|
||||
"bitrate": "1050k",
|
||||
"bitrate_min": "840k",
|
||||
"bitrate_max": "1300k",
|
||||
"audio": "128k"
|
||||
},
|
||||
{
|
||||
"ratio": "9:16",
|
||||
"size": "540x960",
|
||||
"bitrate": "1050k",
|
||||
"bitrate_min": "840k",
|
||||
"bitrate_max": "1300k",
|
||||
"audio": "128k"
|
||||
}
|
||||
]
|
||||
},
|
||||
"youtube": {
|
||||
"name": "YouTube & DV360 - All Devices",
|
||||
"codec": "libvpx-vp9",
|
||||
"container": "webm",
|
||||
"formats": [
|
||||
{
|
||||
"ratio": "1:1",
|
||||
"size": "720x720",
|
||||
"bitrate": "1495k",
|
||||
"bitrate_min": "1300k",
|
||||
"bitrate_max": "1690k",
|
||||
"audio": "128k",
|
||||
"audio_codec": "libopus"
|
||||
},
|
||||
{
|
||||
"ratio": "16:9",
|
||||
"size": "1280x720",
|
||||
"bitrate": "1650k",
|
||||
"bitrate_min": "1300k",
|
||||
"bitrate_max": "2000k",
|
||||
"audio": "128k",
|
||||
"audio_codec": "libopus"
|
||||
},
|
||||
{
|
||||
"ratio": "4:5",
|
||||
"size": "720x900",
|
||||
"bitrate": "1495k",
|
||||
"bitrate_min": "1300k",
|
||||
"bitrate_max": "1690k",
|
||||
"audio": "128k",
|
||||
"audio_codec": "libopus"
|
||||
},
|
||||
{
|
||||
"ratio": "9:16",
|
||||
"size": "720x1280",
|
||||
"bitrate": "1650k",
|
||||
"bitrate_min": "1300k",
|
||||
"bitrate_max": "2000k",
|
||||
"audio": "128k",
|
||||
"audio_codec": "libopus"
|
||||
}
|
||||
]
|
||||
},
|
||||
"youtube_ctv": {
|
||||
"name": "YouTube - CTV Specific",
|
||||
"codec": "libvpx-vp9",
|
||||
"container": "webm",
|
||||
"formats": [
|
||||
{
|
||||
"ratio": "16:9",
|
||||
"size": "1920x1080",
|
||||
"bitrate": "5150k",
|
||||
"bitrate_min": "3300k",
|
||||
"bitrate_max": "7000k",
|
||||
"audio": "192k",
|
||||
"audio_codec": "libopus"
|
||||
}
|
||||
]
|
||||
},
|
||||
"amazon_prime": {
|
||||
"name": "Amazon Prime - CTV Specific",
|
||||
"codec": "libx264",
|
||||
"container": "mp4",
|
||||
"formats": [
|
||||
{
|
||||
"ratio": "16:9",
|
||||
"size": "1920x1080",
|
||||
"bitrate": "15000k",
|
||||
"bitrate_min": "15000k",
|
||||
"bitrate_max": "15000k",
|
||||
"audio": "192k",
|
||||
"note": "Minimum Video Bitrate accepted by Prime is 15Mbit/s"
|
||||
}
|
||||
]
|
||||
},
|
||||
"amazon_freevee": {
|
||||
"name": "Amazon Freevee - CTV Specific",
|
||||
"codec": "libx264",
|
||||
"container": "mp4",
|
||||
"formats": [
|
||||
{
|
||||
"ratio": "16:9",
|
||||
"size": "1920x1080",
|
||||
"bitrate": "5750k",
|
||||
"bitrate_min": "4500k",
|
||||
"bitrate_max": "7000k",
|
||||
"audio": "192k"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Filename pattern detection
|
||||
FILENAME_PATTERNS = {
|
||||
'meta': ['_meta_', '_fb_', '_ig_', '_facebook_', '_instagram_'],
|
||||
'pinterest': ['_pinterest_', '_pin_'],
|
||||
'snapchat': ['_snapchat_', '_snap_'],
|
||||
'tiktok': ['_tiktok_', '_tt_'],
|
||||
'youtube': ['_youtube_', '_yt_'],
|
||||
'youtube_ctv': ['_youtube_ctv_', '_yt_ctv_', '_ctv_'],
|
||||
'amazon_prime': ['_prime_', '_amazon_prime_'],
|
||||
'amazon_freevee': ['_freevee_', '_amazon_freevee_']
|
||||
}
|
||||
|
||||
# Aspect ratio patterns
|
||||
ASPECT_RATIO_PATTERNS = {
|
||||
'1:1': ['_1x1_', '_square_', '_1-1_'],
|
||||
'16:9': ['_16x9_', '_landscape_', '_16-9_'],
|
||||
'4:5': ['_4x5_', '_4-5_'],
|
||||
'9:16': ['_9x16_', '_vertical_', '_9-16_', '_portrait_'],
|
||||
'2:3': ['_2x3_', '_2-3_']
|
||||
}
|
||||
|
||||
|
||||
def detect_platform_from_filename(filename):
|
||||
"""
|
||||
Detect platform from filename patterns
|
||||
Returns platform key or None
|
||||
"""
|
||||
filename_lower = filename.lower()
|
||||
|
||||
for platform, patterns in FILENAME_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
if pattern in filename_lower:
|
||||
return platform
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_aspect_ratio_from_filename(filename):
|
||||
"""
|
||||
Detect aspect ratio from filename patterns
|
||||
Returns aspect ratio string or None
|
||||
"""
|
||||
filename_lower = filename.lower()
|
||||
|
||||
for ratio, patterns in ASPECT_RATIO_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
if pattern in filename_lower:
|
||||
return ratio
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_all_platforms():
|
||||
"""Return list of all platform keys"""
|
||||
return list(PLATFORM_SPECS.keys())
|
||||
|
||||
|
||||
def get_platform_formats(platform):
|
||||
"""Get all available formats for a platform"""
|
||||
if platform in PLATFORM_SPECS:
|
||||
return PLATFORM_SPECS[platform]['formats']
|
||||
return []
|
||||
|
||||
|
||||
def get_platform_info(platform):
|
||||
"""Get complete platform information"""
|
||||
return PLATFORM_SPECS.get(platform, None)
|
||||
4
backend/requirements.txt
Normal file
4
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Flask==3.0.0
|
||||
Flask-CORS==4.0.0
|
||||
ffmpeg-python==0.2.0
|
||||
Werkzeug==3.0.1
|
||||
0
backend/uploads/.gitkeep
Normal file
0
backend/uploads/.gitkeep
Normal file
197
backend/video_processor.py
Normal file
197
backend/video_processor.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""
|
||||
Video processing module using FFmpeg
|
||||
Handles conversion based on platform specifications
|
||||
"""
|
||||
|
||||
import ffmpeg
|
||||
import os
|
||||
import subprocess
|
||||
from platform_specs import PLATFORM_SPECS
|
||||
|
||||
|
||||
class VideoProcessor:
|
||||
def __init__(self, input_path):
|
||||
self.input_path = input_path
|
||||
self.probe_data = None
|
||||
|
||||
def probe_video(self):
|
||||
"""Get video metadata using ffprobe"""
|
||||
try:
|
||||
probe = ffmpeg.probe(self.input_path)
|
||||
self.probe_data = probe
|
||||
|
||||
video_stream = next((stream for stream in probe['streams']
|
||||
if stream['codec_type'] == 'video'), None)
|
||||
audio_stream = next((stream for stream in probe['streams']
|
||||
if stream['codec_type'] == 'audio'), None)
|
||||
|
||||
return {
|
||||
'duration': float(probe['format']['duration']),
|
||||
'size': int(probe['format']['size']),
|
||||
'bitrate': int(probe['format']['bit_rate']) // 1000, # Convert to kbps
|
||||
'width': int(video_stream['width']) if video_stream else None,
|
||||
'height': int(video_stream['height']) if video_stream else None,
|
||||
'codec': video_stream['codec_name'] if video_stream else None,
|
||||
'has_audio': audio_stream is not None
|
||||
}
|
||||
except Exception as e:
|
||||
raise Exception(f"Error probing video: {str(e)}")
|
||||
|
||||
def convert_video(self, platform, aspect_ratio, output_path, custom_bitrate=None):
|
||||
"""
|
||||
Convert video based on platform specifications
|
||||
|
||||
Args:
|
||||
platform: Platform key (e.g., 'tiktok', 'meta')
|
||||
aspect_ratio: Aspect ratio (e.g., '1:1', '16:9')
|
||||
output_path: Path for output file
|
||||
custom_bitrate: Optional custom bitrate override
|
||||
|
||||
Returns:
|
||||
dict: Conversion results including output path and stats
|
||||
"""
|
||||
if platform not in PLATFORM_SPECS:
|
||||
raise ValueError(f"Unknown platform: {platform}")
|
||||
|
||||
platform_info = PLATFORM_SPECS[platform]
|
||||
|
||||
# Find matching format
|
||||
format_spec = None
|
||||
for fmt in platform_info['formats']:
|
||||
if fmt['ratio'] == aspect_ratio:
|
||||
format_spec = fmt
|
||||
break
|
||||
|
||||
if not format_spec:
|
||||
raise ValueError(f"Aspect ratio {aspect_ratio} not supported for {platform}")
|
||||
|
||||
# Get conversion parameters
|
||||
codec = platform_info['codec']
|
||||
size = format_spec['size']
|
||||
bitrate = custom_bitrate if custom_bitrate else format_spec['bitrate']
|
||||
audio_bitrate = format_spec['audio']
|
||||
audio_codec = format_spec.get('audio_codec', 'aac')
|
||||
|
||||
try:
|
||||
# Build FFmpeg command
|
||||
input_stream = ffmpeg.input(self.input_path)
|
||||
|
||||
# Video encoding parameters
|
||||
video_params = {
|
||||
'vcodec': codec,
|
||||
'b:v': bitrate,
|
||||
's': size,
|
||||
}
|
||||
|
||||
# Add codec-specific parameters
|
||||
if codec == 'libx264':
|
||||
video_params.update({
|
||||
'preset': 'medium',
|
||||
'crf': 23,
|
||||
'profile:v': 'main',
|
||||
'pix_fmt': 'yuv420p'
|
||||
})
|
||||
elif codec == 'libx265':
|
||||
video_params.update({
|
||||
'preset': 'medium',
|
||||
'crf': 28,
|
||||
'pix_fmt': 'yuv420p',
|
||||
'x265-params': 'log-level=error'
|
||||
})
|
||||
elif codec == 'libvpx-vp9':
|
||||
video_params.update({
|
||||
'deadline': 'good',
|
||||
'cpu-used': 2,
|
||||
'row-mt': 1
|
||||
})
|
||||
|
||||
# Audio encoding parameters
|
||||
audio_params = {
|
||||
'acodec': audio_codec,
|
||||
'b:a': audio_bitrate
|
||||
}
|
||||
|
||||
# Build and execute FFmpeg command
|
||||
output_stream = ffmpeg.output(
|
||||
input_stream,
|
||||
output_path,
|
||||
**video_params,
|
||||
**audio_params
|
||||
)
|
||||
|
||||
# Overwrite output file if exists
|
||||
output_stream = ffmpeg.overwrite_output(output_stream)
|
||||
|
||||
# Run the conversion
|
||||
ffmpeg.run(output_stream, capture_stdout=True, capture_stderr=True)
|
||||
|
||||
# Get output file stats
|
||||
output_size = os.path.getsize(output_path)
|
||||
|
||||
# Probe output file for verification
|
||||
output_probe = ffmpeg.probe(output_path)
|
||||
output_duration = float(output_probe['format']['duration'])
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'output_path': output_path,
|
||||
'output_size': output_size,
|
||||
'duration': output_duration,
|
||||
'platform': platform,
|
||||
'aspect_ratio': aspect_ratio,
|
||||
'resolution': size,
|
||||
'codec': codec,
|
||||
'bitrate': bitrate
|
||||
}
|
||||
|
||||
except ffmpeg.Error as e:
|
||||
error_message = e.stderr.decode() if e.stderr else str(e)
|
||||
raise Exception(f"FFmpeg conversion error: {error_message}")
|
||||
|
||||
def get_video_info(self):
|
||||
"""Get formatted video information"""
|
||||
if not self.probe_data:
|
||||
self.probe_video()
|
||||
|
||||
info = self.probe_video()
|
||||
aspect_ratio = self._calculate_aspect_ratio(info['width'], info['height'])
|
||||
|
||||
return {
|
||||
**info,
|
||||
'aspect_ratio': aspect_ratio
|
||||
}
|
||||
|
||||
def _calculate_aspect_ratio(self, width, height):
|
||||
"""Calculate aspect ratio from width and height"""
|
||||
from math import gcd
|
||||
|
||||
divisor = gcd(width, height)
|
||||
ratio_width = width // divisor
|
||||
ratio_height = height // divisor
|
||||
|
||||
# Map to common aspect ratios
|
||||
ratio_map = {
|
||||
(1, 1): '1:1',
|
||||
(16, 9): '16:9',
|
||||
(9, 16): '9:16',
|
||||
(4, 5): '4:5',
|
||||
(5, 4): '5:4',
|
||||
(2, 3): '2:3',
|
||||
(3, 2): '3:2'
|
||||
}
|
||||
|
||||
return ratio_map.get((ratio_width, ratio_height), f"{ratio_width}:{ratio_height}")
|
||||
|
||||
@staticmethod
|
||||
def check_ffmpeg_installed():
|
||||
"""Check if FFmpeg is installed and accessible"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ffmpeg', '-version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return False
|
||||
411
frontend/app.js
Normal file
411
frontend/app.js
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
// Video Optimizer Frontend JavaScript
|
||||
// API Configuration (imported from config.js)
|
||||
const API_BASE = CONFIG ? CONFIG.API_BASE : 'http://localhost:5000/api';
|
||||
|
||||
// State
|
||||
let currentFileId = null;
|
||||
let currentPlatforms = [];
|
||||
let currentVideoInfo = null;
|
||||
|
||||
// DOM Elements
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const uploadSection = document.getElementById('uploadSection');
|
||||
const configSection = document.getElementById('configSection');
|
||||
const comparisonSection = document.getElementById('comparisonSection');
|
||||
const videoInfo = document.getElementById('videoInfo');
|
||||
const platformSelect = document.getElementById('platformSelect');
|
||||
const aspectRatioSelect = document.getElementById('aspectRatioSelect');
|
||||
const bitrateInput = document.getElementById('bitrateInput');
|
||||
const bitrateHint = document.getElementById('bitrateHint');
|
||||
const formatInfo = document.getElementById('formatInfo');
|
||||
const convertBtn = document.getElementById('convertBtn');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const originalVideo = document.getElementById('originalVideo');
|
||||
const optimizedVideo = document.getElementById('optimizedVideo');
|
||||
const originalSource = document.getElementById('originalSource');
|
||||
const optimizedSource = document.getElementById('optimizedSource');
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadPlatforms();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// Event Listeners
|
||||
function setupEventListeners() {
|
||||
// Drag and drop
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', handleDragOver);
|
||||
dropZone.addEventListener('dragleave', handleDragLeave);
|
||||
dropZone.addEventListener('drop', handleDrop);
|
||||
|
||||
// File input
|
||||
fileInput.addEventListener('change', handleFileSelect);
|
||||
|
||||
// Platform/aspect ratio selection
|
||||
platformSelect.addEventListener('change', handlePlatformChange);
|
||||
aspectRatioSelect.addEventListener('change', handleAspectRatioChange);
|
||||
|
||||
// Convert button
|
||||
convertBtn.addEventListener('click', handleConvert);
|
||||
|
||||
// Comparison controls
|
||||
document.getElementById('syncPlayBtn').addEventListener('click', syncPlayback);
|
||||
document.getElementById('pauseAllBtn').addEventListener('click', pauseAll);
|
||||
document.getElementById('downloadOriginal').addEventListener('click', () => downloadFile('original'));
|
||||
document.getElementById('downloadOptimized').addEventListener('click', () => downloadFile('optimized'));
|
||||
document.getElementById('newFileBtn').addEventListener('click', resetApp);
|
||||
}
|
||||
|
||||
// Drag and Drop Handlers
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e) {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// File Handling
|
||||
async function handleFile(file) {
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a valid video file');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
showLoading();
|
||||
|
||||
const response = await fetch(`${API_BASE}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentFileId = data.file_id;
|
||||
currentVideoInfo = data.video_info;
|
||||
|
||||
displayVideoInfo(data);
|
||||
|
||||
// Auto-select platform and aspect ratio if detected
|
||||
if (data.detected_platform) {
|
||||
platformSelect.value = data.detected_platform;
|
||||
handlePlatformChange();
|
||||
}
|
||||
|
||||
if (data.detected_aspect_ratio) {
|
||||
aspectRatioSelect.value = data.detected_aspect_ratio;
|
||||
handleAspectRatioChange();
|
||||
}
|
||||
|
||||
uploadSection.style.display = 'none';
|
||||
configSection.style.display = 'block';
|
||||
} else {
|
||||
alert('Error uploading file: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error uploading file: ' + error.message);
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// Platform Management
|
||||
async function loadPlatforms() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/platforms`);
|
||||
const data = await response.json();
|
||||
currentPlatforms = data.platforms;
|
||||
|
||||
// Populate platform select
|
||||
platformSelect.innerHTML = '<option value="">Select Platform...</option>';
|
||||
data.platforms.forEach(platform => {
|
||||
const option = document.createElement('option');
|
||||
option.value = platform.key;
|
||||
option.textContent = platform.name;
|
||||
platformSelect.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading platforms:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlatformChange() {
|
||||
const platformKey = platformSelect.value;
|
||||
|
||||
if (!platformKey) {
|
||||
aspectRatioSelect.innerHTML = '<option value="">Select Platform First</option>';
|
||||
aspectRatioSelect.disabled = true;
|
||||
convertBtn.disabled = true;
|
||||
formatInfo.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = currentPlatforms.find(p => p.key === platformKey);
|
||||
|
||||
if (platform) {
|
||||
// Populate aspect ratio select
|
||||
aspectRatioSelect.innerHTML = '<option value="">Select Aspect Ratio...</option>';
|
||||
platform.formats.forEach(format => {
|
||||
const option = document.createElement('option');
|
||||
option.value = format.ratio;
|
||||
option.textContent = `${format.ratio} (${format.size})`;
|
||||
aspectRatioSelect.appendChild(option);
|
||||
});
|
||||
aspectRatioSelect.disabled = false;
|
||||
|
||||
// Show codec info
|
||||
formatInfo.innerHTML = `
|
||||
<h4>Platform: ${platform.name}</h4>
|
||||
<p><strong>Codec:</strong> ${platform.codec}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
validateForm();
|
||||
}
|
||||
|
||||
function handleAspectRatioChange() {
|
||||
const platformKey = platformSelect.value;
|
||||
const aspectRatio = aspectRatioSelect.value;
|
||||
|
||||
if (!platformKey || !aspectRatio) {
|
||||
convertBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = currentPlatforms.find(p => p.key === platformKey);
|
||||
const format = platform.formats.find(f => f.ratio === aspectRatio);
|
||||
|
||||
if (format) {
|
||||
// Update format info
|
||||
formatInfo.innerHTML = `
|
||||
<h4>Platform: ${platform.name}</h4>
|
||||
<p><strong>Codec:</strong> ${platform.codec}</p>
|
||||
<p><strong>Resolution:</strong> ${format.size}</p>
|
||||
<p><strong>Recommended Bitrate:</strong> ${format.bitrate}</p>
|
||||
<p><strong>Bitrate Range:</strong> ${format.bitrate_min} - ${format.bitrate_max}</p>
|
||||
<p><strong>Audio Bitrate:</strong> ${format.audio}</p>
|
||||
${format.note ? `<p><strong>Note:</strong> ${format.note}</p>` : ''}
|
||||
`;
|
||||
|
||||
// Update bitrate hint
|
||||
bitrateHint.textContent = `Recommended: ${format.bitrate} (Range: ${format.bitrate_min} - ${format.bitrate_max})`;
|
||||
}
|
||||
|
||||
validateForm();
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
const platformKey = platformSelect.value;
|
||||
const aspectRatio = aspectRatioSelect.value;
|
||||
|
||||
convertBtn.disabled = !(platformKey && aspectRatio);
|
||||
}
|
||||
|
||||
// Video Conversion
|
||||
async function handleConvert() {
|
||||
const platformKey = platformSelect.value;
|
||||
const aspectRatio = aspectRatioSelect.value;
|
||||
const customBitrate = bitrateInput.value.trim() || null;
|
||||
|
||||
if (!currentFileId || !platformKey || !aspectRatio) {
|
||||
alert('Please complete all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
convertBtn.disabled = true;
|
||||
progressBar.style.display = 'block';
|
||||
progressFill.style.width = '50%';
|
||||
progressText.textContent = 'Converting video...';
|
||||
|
||||
const response = await fetch(`${API_BASE}/convert`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_id: currentFileId,
|
||||
platform: platformKey,
|
||||
aspect_ratio: aspectRatio,
|
||||
custom_bitrate: customBitrate
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
progressFill.style.width = '100%';
|
||||
progressText.textContent = 'Conversion complete!';
|
||||
|
||||
// Display comparison
|
||||
displayComparison(data);
|
||||
|
||||
setTimeout(() => {
|
||||
configSection.style.display = 'none';
|
||||
comparisonSection.style.display = 'block';
|
||||
}, 1000);
|
||||
} else {
|
||||
alert('Conversion error: ' + data.error);
|
||||
convertBtn.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Conversion error: ' + error.message);
|
||||
convertBtn.disabled = false;
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
progressBar.style.display = 'none';
|
||||
progressFill.style.width = '0%';
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
// Display Functions
|
||||
function displayVideoInfo(data) {
|
||||
const info = data.video_info;
|
||||
const detected = [];
|
||||
|
||||
if (data.detected_platform) {
|
||||
const platform = currentPlatforms.find(p => p.key === data.detected_platform);
|
||||
detected.push(`Platform: ${platform ? platform.name : data.detected_platform}`);
|
||||
}
|
||||
|
||||
if (data.detected_aspect_ratio) {
|
||||
detected.push(`Aspect Ratio: ${data.detected_aspect_ratio}`);
|
||||
}
|
||||
|
||||
videoInfo.innerHTML = `
|
||||
<h3>📹 ${data.filename}</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Resolution</span>
|
||||
<span class="info-value">${info.width} × ${info.height}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Duration</span>
|
||||
<span class="info-value">${formatDuration(info.duration)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">File Size</span>
|
||||
<span class="info-value">${formatBytes(info.size)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Bitrate</span>
|
||||
<span class="info-value">${info.bitrate} kbps</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Codec</span>
|
||||
<span class="info-value">${info.codec}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Aspect Ratio</span>
|
||||
<span class="info-value">${info.aspect_ratio}</span>
|
||||
</div>
|
||||
</div>
|
||||
${detected.length > 0 ? `<p style="margin-top: 1rem; color: var(--primary-yellow);">🎯 Auto-detected: ${detected.join(', ')}</p>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function displayComparison(data) {
|
||||
// Update stats
|
||||
document.getElementById('originalSize').textContent = formatBytes(data.input_size);
|
||||
document.getElementById('optimizedSize').textContent = formatBytes(data.output_size);
|
||||
document.getElementById('reduction').textContent = `${data.size_reduction_percent}%`;
|
||||
|
||||
// Set video sources
|
||||
originalSource.src = `${API_BASE}/stream/original/${currentFileId}`;
|
||||
optimizedSource.src = `${API_BASE}/stream/optimized/${currentFileId}`;
|
||||
|
||||
// Reload videos
|
||||
originalVideo.load();
|
||||
optimizedVideo.load();
|
||||
}
|
||||
|
||||
// Video Playback Controls
|
||||
function syncPlayback() {
|
||||
originalVideo.currentTime = 0;
|
||||
optimizedVideo.currentTime = 0;
|
||||
originalVideo.play();
|
||||
optimizedVideo.play();
|
||||
}
|
||||
|
||||
function pauseAll() {
|
||||
originalVideo.pause();
|
||||
optimizedVideo.pause();
|
||||
}
|
||||
|
||||
// Download
|
||||
function downloadFile(type) {
|
||||
window.open(`${API_BASE}/download/${type}/${currentFileId}`, '_blank');
|
||||
}
|
||||
|
||||
// Utility Functions
|
||||
function formatBytes(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];
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
uploadSection.classList.add('loading');
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
uploadSection.classList.remove('loading');
|
||||
}
|
||||
|
||||
function resetApp() {
|
||||
// Clean up files
|
||||
if (currentFileId) {
|
||||
fetch(`${API_BASE}/cleanup/${currentFileId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Reset state
|
||||
currentFileId = null;
|
||||
currentVideoInfo = null;
|
||||
fileInput.value = '';
|
||||
platformSelect.value = '';
|
||||
aspectRatioSelect.value = '';
|
||||
bitrateInput.value = '';
|
||||
formatInfo.innerHTML = '';
|
||||
|
||||
// Reset display
|
||||
uploadSection.style.display = 'block';
|
||||
configSection.style.display = 'none';
|
||||
comparisonSection.style.display = 'none';
|
||||
}
|
||||
18
frontend/config.js
Normal file
18
frontend/config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Configuration file for Video Optimizer
|
||||
// Update this based on your setup
|
||||
|
||||
const CONFIG = {
|
||||
// Python Flask backend URL (keep running in separate terminal)
|
||||
API_BASE: 'http://localhost:5000/api',
|
||||
|
||||
// Maximum file size in bytes (500MB default)
|
||||
MAX_FILE_SIZE: 500 * 1024 * 1024,
|
||||
|
||||
// Enable debug logging
|
||||
DEBUG: true
|
||||
};
|
||||
|
||||
// Export for use in app.js
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = CONFIG;
|
||||
}
|
||||
132
frontend/index.html
Normal file
132
frontend/index.html
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Video Optimizer - L'Oréal Creative Optimization</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<h1>Video Optimizer</h1>
|
||||
<p class="subtitle">L'Oréal Creative Optimization Tool</p>
|
||||
</header>
|
||||
|
||||
<!-- Upload Section -->
|
||||
<section class="upload-section" id="uploadSection">
|
||||
<div class="upload-area" id="dropZone">
|
||||
<div class="upload-icon">📁</div>
|
||||
<h2>Drag & Drop Video File</h2>
|
||||
<p>or click to browse</p>
|
||||
<input type="file" id="fileInput" accept="video/*" hidden>
|
||||
<p class="file-hint">Supported formats: MP4, MOV, AVI, MKV, WEBM</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Configuration Section -->
|
||||
<section class="config-section" id="configSection" style="display: none;">
|
||||
<h2>Video Configuration</h2>
|
||||
|
||||
<div class="video-info" id="videoInfo">
|
||||
<!-- Video info will be populated here -->
|
||||
</div>
|
||||
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<label for="platformSelect">Platform</label>
|
||||
<select id="platformSelect" class="select-input">
|
||||
<option value="">Select Platform...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<label for="aspectRatioSelect">Aspect Ratio</label>
|
||||
<select id="aspectRatioSelect" class="select-input">
|
||||
<option value="">Select Aspect Ratio...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<label for="bitrateInput">Video Bitrate (optional)</label>
|
||||
<input type="text" id="bitrateInput" class="text-input" placeholder="e.g., 1500k (leave blank for recommended)">
|
||||
<small class="hint" id="bitrateHint"></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="format-info" id="formatInfo">
|
||||
<!-- Format specifications will be shown here -->
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" id="convertBtn" disabled>
|
||||
<span id="convertBtnText">Convert Video</span>
|
||||
</button>
|
||||
|
||||
<div class="progress-bar" id="progressBar" style="display: none;">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
<span class="progress-text" id="progressText">Converting...</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Comparison Section -->
|
||||
<section class="comparison-section" id="comparisonSection" style="display: none;">
|
||||
<h2>Video Comparison</h2>
|
||||
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Original Size:</span>
|
||||
<span class="stat-value" id="originalSize">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Optimized Size:</span>
|
||||
<span class="stat-value" id="optimizedSize">-</span>
|
||||
</div>
|
||||
<div class="stat-item reduction">
|
||||
<span class="stat-label">Reduction:</span>
|
||||
<span class="stat-value" id="reduction">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-grid">
|
||||
<div class="video-player-container">
|
||||
<h3>Original</h3>
|
||||
<video id="originalVideo" controls class="video-player">
|
||||
<source id="originalSource" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<button class="btn-secondary" id="downloadOriginal">Download Original</button>
|
||||
</div>
|
||||
|
||||
<div class="video-player-container">
|
||||
<h3>Optimized</h3>
|
||||
<video id="optimizedVideo" controls class="video-player">
|
||||
<source id="optimizedSource" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<button class="btn-secondary" id="downloadOptimized">Download Optimized</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playback-controls">
|
||||
<button class="btn-secondary" id="syncPlayBtn">⏯️ Sync Playback</button>
|
||||
<button class="btn-secondary" id="pauseAllBtn">⏸️ Pause Both</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" id="newFileBtn">Upload New File</button>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<p>Based on L'Oréal CDMO Creative Optimization Documentation v1.1</p>
|
||||
<p>Impact Plus - March 2025</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
438
frontend/style.css
Normal file
438
frontend/style.css
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
/* Video Optimizer Styling - Black + #FFC407 Theme */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-yellow: #FFC407;
|
||||
--primary-black: #000000;
|
||||
--secondary-black: #1a1a1a;
|
||||
--border-color: #333333;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #cccccc;
|
||||
--text-muted: #888888;
|
||||
--hover-yellow: #FFD54F;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
background-color: var(--primary-black);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
padding: 2rem 0;
|
||||
border-bottom: 2px solid var(--primary-yellow);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-yellow);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Upload Section */
|
||||
.upload-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 3px dashed var(--primary-yellow);
|
||||
border-radius: 12px;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background-color: var(--secondary-black);
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: var(--hover-yellow);
|
||||
background-color: #222222;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.upload-area.dragover {
|
||||
border-color: var(--hover-yellow);
|
||||
background-color: #2a2a2a;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.upload-area h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-area p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.file-hint {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Configuration Section */
|
||||
.config-section {
|
||||
background-color: var(--secondary-black);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.config-section h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-yellow);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
background-color: var(--primary-black);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.video-info h3 {
|
||||
font-size: 1.25rem;
|
||||
color: var(--primary-yellow);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.config-item label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.select-input,
|
||||
.text-input {
|
||||
padding: 0.75rem;
|
||||
background-color: var(--primary-black);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.select-input:focus,
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-yellow);
|
||||
}
|
||||
|
||||
.select-input option {
|
||||
background-color: var(--primary-black);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.format-info {
|
||||
background-color: var(--primary-black);
|
||||
border-left: 4px solid var(--primary-yellow);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.format-info h4 {
|
||||
color: var(--primary-yellow);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.format-info p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 1rem 2rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-yellow);
|
||||
color: var(--primary-black);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--hover-yellow);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 196, 7, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background-color: #555555;
|
||||
color: #888888;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--primary-yellow);
|
||||
border: 2px solid var(--primary-yellow);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--primary-yellow);
|
||||
color: var(--primary-black);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background-color: var(--primary-black);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 25px;
|
||||
overflow: hidden;
|
||||
margin-top: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-yellow), var(--hover-yellow));
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Comparison Section */
|
||||
.comparison-section {
|
||||
background-color: var(--secondary-black);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.comparison-section h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-yellow);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
background-color: var(--primary-black);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-item.reduction .stat-value {
|
||||
color: var(--primary-yellow);
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.video-player-container {
|
||||
background-color: var(--primary-black);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.video-player-container h3 {
|
||||
color: var(--primary-yellow);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.playback-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.playback-controls .btn-secondary {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playback-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading Animation */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
56
start.sh
Executable file
56
start.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Video Optimizer Startup Script
|
||||
|
||||
echo "🎬 Starting Video Optimizer..."
|
||||
echo ""
|
||||
|
||||
# Check if virtual environment exists
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "❌ Virtual environment not found. Please run setup first:"
|
||||
echo " python3 -m venv venv"
|
||||
echo " source venv/bin/activate"
|
||||
echo " pip install -r backend/requirements.txt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if FFmpeg is installed
|
||||
if ! command -v ffmpeg &> /dev/null; then
|
||||
echo "⚠️ WARNING: FFmpeg is not installed!"
|
||||
echo " Install with: brew install ffmpeg (macOS) or apt-get install ffmpeg (Linux)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
echo "🔧 Activating virtual environment..."
|
||||
source venv/bin/activate
|
||||
|
||||
# Start backend server
|
||||
echo "🚀 Starting backend server on http://localhost:5000..."
|
||||
cd backend
|
||||
python app.py &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Wait for backend to start
|
||||
sleep 3
|
||||
|
||||
# Start frontend server
|
||||
echo "🌐 Starting frontend server on http://localhost:8000..."
|
||||
cd ../frontend
|
||||
python3 -m http.server 8000 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
echo ""
|
||||
echo "✅ Application is running!"
|
||||
echo ""
|
||||
echo " Backend: http://localhost:5000"
|
||||
echo " Frontend: http://localhost:8000"
|
||||
echo ""
|
||||
echo " Open your browser and navigate to http://localhost:8000"
|
||||
echo ""
|
||||
echo " Press Ctrl+C to stop all servers"
|
||||
echo ""
|
||||
|
||||
# Wait for Ctrl+C
|
||||
trap "echo ''; echo '🛑 Stopping servers...'; kill $BACKEND_PID $FRONTEND_PID; exit" INT
|
||||
wait
|
||||
Loading…
Add table
Reference in a new issue