initial commit
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 1m2s
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 1m2s
This commit is contained in:
89
.gitea/workflows/rebuild-prod.yaml
Normal file
89
.gitea/workflows/rebuild-prod.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
|
||||
name: Build Images and Deploy
|
||||
run-name: ${{ gitea.actor }} is building new PROD images and redeploying the existing stack 🚀
|
||||
on:
|
||||
push:
|
||||
# not working right now https://github.com/actions/runner/issues/2324
|
||||
# paths-ignore:
|
||||
# - **.yml
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
STACK_NAME: video-editor
|
||||
DOT_ENV: ${{ secrets.PROD_ENV }}
|
||||
PORTAINER_TOKEN: ${{ vars.PORTAINER_TOKEN }}
|
||||
PORTAINER_API_URL: https://portainer.dev.nervesocket.com/api
|
||||
ENDPOINT_NAME: "mini" #sometimes "primary"
|
||||
IMAGE_TAG: "reg.dev.nervesocket.com/video-editor:latest"
|
||||
|
||||
jobs:
|
||||
Update-PROD-Stack:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# if: contains(github.event.pull_request.head.ref, 'init-stack')
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Build and push PROD Docker image
|
||||
run: |
|
||||
echo $DOT_ENV | base64 -d > .env
|
||||
docker buildx build --push -f Dockerfile -t $IMAGE_TAG .
|
||||
|
||||
- name: Get the endpoint ID
|
||||
# Usually ID is 1, but you can get it from the API. Only skip this if you are VERY sure.
|
||||
run: |
|
||||
ENDPOINT_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/endpoints" | jq -r ".[] | select(.Name==\"$ENDPOINT_NAME\") | .Id")
|
||||
echo "ENDPOINT_ID=$ENDPOINT_ID" >> $GITHUB_ENV
|
||||
echo "Got stack Endpoint ID: $ENDPOINT_ID"
|
||||
|
||||
- name: Fetch stack ID from Portainer
|
||||
run: |
|
||||
STACK_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks" | jq -r ".[] | select(.Name==\"$STACK_NAME\" and .EndpointId==$ENDPOINT_ID) | .Id")
|
||||
|
||||
echo "STACK_ID=$STACK_ID" >> $GITHUB_ENV
|
||||
echo "Got stack ID: $STACK_ID matched with Endpoint ID: $ENDPOINT_ID"
|
||||
|
||||
- name: Fetch Stack
|
||||
run: |
|
||||
# Get the stack details (including env vars)
|
||||
STACK_DETAILS=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks/$STACK_ID")
|
||||
|
||||
# Extract environment variables from the stack
|
||||
echo "$STACK_DETAILS" | jq -r '.Env' > stack_env.json
|
||||
|
||||
echo "Existing stack environment variables:"
|
||||
cat stack_env.json
|
||||
|
||||
- name: Redeploy stack in Portainer
|
||||
run: |
|
||||
# Read stack file content
|
||||
STACK_FILE_CONTENT=$(echo "$(<prod-compose.yml )")
|
||||
|
||||
# Read existing environment variables from the fetched stack
|
||||
ENV_VARS=$(cat stack_env.json)
|
||||
|
||||
# Prepare JSON payload with environment variables
|
||||
JSON_PAYLOAD=$(jq -n --arg stackFileContent "$STACK_FILE_CONTENT" --argjson pullImage true --argjson env "$ENV_VARS" \
|
||||
'{stackFileContent: $stackFileContent, pullImage: $pullImage, env: $env}')
|
||||
|
||||
echo "About to push the following JSON payload:"
|
||||
echo $JSON_PAYLOAD
|
||||
|
||||
# Update stack in Portainer (this redeploys it)
|
||||
DEPLOY_RESPONSE=$(curl -X PUT "$PORTAINER_API_URL/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" \
|
||||
-H "X-API-Key: $PORTAINER_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$JSON_PAYLOAD")
|
||||
|
||||
echo "Redeployed stack in Portainer. Response:"
|
||||
echo $DEPLOY_RESPONSE
|
||||
|
||||
- name: Status check
|
||||
run: |
|
||||
echo "📋 This job's status is ${{ job.status }}. Make sure you delete the init file to avoid issues."
|
||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Video files
|
||||
uploads/
|
||||
outputs/
|
||||
*.mp4
|
||||
*.avi
|
||||
*.mov
|
||||
*.mkv
|
||||
*.wmv
|
||||
*.flv
|
||||
*.webm
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install ffmpeg
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ffmpeg && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements and install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application files
|
||||
COPY app.py .
|
||||
COPY templates/ ./templates/
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p uploads outputs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=app.py
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "app.py"]
|
||||
195
README.md
Normal file
195
README.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 🎬 Simple Video Editor
|
||||
|
||||
A lightweight, web-based video editor with an intuitive interface for basic video editing tasks. Built with Flask and FFmpeg, it allows you to trim, crop, resize, and convert videos to H.264 MP4 format.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **📹 Video Upload**: Drag and drop or browse to upload videos (supports MP4, AVI, MOV, MKV, WMV, FLV, WebM)
|
||||
- **⏱️ Trim**: Cut videos by specifying start and end times
|
||||
- **✂️ Crop**: Crop videos by defining X/Y position and dimensions
|
||||
- **📐 Resize**: Change output resolution with preset options (4K, 1080p, 720p, 480p) or custom dimensions
|
||||
- **🎯 H.264 Export**: All videos are exported as H.264 MP4 with optimized settings
|
||||
- **🖥️ Intuitive UI**: Clean, modern interface with real-time video preview
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Option 1: Run on Windows (Local)
|
||||
|
||||
#### Prerequisites
|
||||
- Python 3.11 or higher
|
||||
- FFmpeg installed and available in PATH
|
||||
|
||||
#### Installation Steps
|
||||
|
||||
1. **Install FFmpeg**:
|
||||
- Download from [ffmpeg.org](https://ffmpeg.org/download.html)
|
||||
- Extract and add to your system PATH
|
||||
- Verify installation: `ffmpeg -version`
|
||||
|
||||
2. **Install Python Dependencies**:
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Run the Application**:
|
||||
```powershell
|
||||
python app.py
|
||||
```
|
||||
|
||||
4. **Open in Browser**:
|
||||
Navigate to `http://localhost:5000`
|
||||
|
||||
### Option 2: Run with Docker
|
||||
|
||||
#### Prerequisites
|
||||
- Docker installed
|
||||
- Docker Compose installed
|
||||
|
||||
#### Steps
|
||||
|
||||
1. **Build and Run**:
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
2. **Open in Browser**:
|
||||
Navigate to `http://localhost:5000`
|
||||
|
||||
3. **Stop the Application**:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## 📖 Usage Guide
|
||||
|
||||
### Step 1: Upload Video
|
||||
- Click the upload area or drag and drop a video file
|
||||
- Supported formats: MP4, AVI, MOV, MKV, WMV, FLV, WebM
|
||||
- Maximum file size: 500MB
|
||||
|
||||
### Step 2: Edit Video
|
||||
|
||||
#### Trim Video
|
||||
- Set **Start Time** (in seconds) where the video should begin
|
||||
- Set **End Time** (in seconds) where the video should end
|
||||
- Leave at default values to keep the entire video
|
||||
|
||||
#### Change Resolution
|
||||
- Click a preset button for common resolutions:
|
||||
- 4K (3840x2160)
|
||||
- 1080p (1920x1080)
|
||||
- 720p (1280x720)
|
||||
- 480p (854x480)
|
||||
- Original (no resize)
|
||||
- Or enter custom width and height values
|
||||
|
||||
#### Crop Video (Optional)
|
||||
- **X Position**: Horizontal starting point for crop (0 = left edge)
|
||||
- **Y Position**: Vertical starting point for crop (0 = top edge)
|
||||
- **Crop Width**: Width of the cropped area
|
||||
- **Crop Height**: Height of the cropped area
|
||||
- Leave empty to skip cropping
|
||||
|
||||
### Step 3: Process and Download
|
||||
- Click **"🎬 Process Video"** to start encoding
|
||||
- Wait for processing to complete (time depends on video length and settings)
|
||||
- Click **"⬇️ Download Video"** to save the processed file
|
||||
|
||||
## 🛠️ Technical Details
|
||||
|
||||
### Backend
|
||||
- **Framework**: Flask 3.0
|
||||
- **Video Processing**: FFmpeg with libx264 encoder
|
||||
- **Encoding Settings**:
|
||||
- Video codec: H.264 (libx264)
|
||||
- Preset: medium (balance between speed and quality)
|
||||
- CRF: 23 (good quality)
|
||||
- Audio codec: AAC at 128kbps
|
||||
- Fast start enabled for web streaming
|
||||
|
||||
### Frontend
|
||||
- Pure HTML, CSS, and JavaScript (no frameworks)
|
||||
- Responsive design
|
||||
- Drag-and-drop file upload
|
||||
- Real-time video preview
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
editor/
|
||||
├── app.py # Flask backend application
|
||||
├── templates/
|
||||
│ └── index.html # Frontend UI
|
||||
├── requirements.txt # Python dependencies
|
||||
├── Dockerfile # Docker image configuration
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── .gitignore # Git ignore rules
|
||||
├── uploads/ # Temporary upload storage (auto-created)
|
||||
└── outputs/ # Processed video storage (auto-created)
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### File Size Limit
|
||||
Edit in `app.py`:
|
||||
```python
|
||||
app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB
|
||||
```
|
||||
|
||||
### FFmpeg Encoding Quality
|
||||
Edit in `app.py` (process_video function):
|
||||
```python
|
||||
'-crf', '23', # Lower = better quality (18-28 recommended)
|
||||
'-preset', 'medium', # ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow
|
||||
```
|
||||
|
||||
### Port Configuration
|
||||
- **Local**: Edit `app.py` - change `port=5000`
|
||||
- **Docker**: Edit `docker-compose.yml` - change `"5000:5000"` to `"YOUR_PORT:5000"`
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### "FFmpeg not found" error
|
||||
- **Windows**: Ensure FFmpeg is installed and in your PATH
|
||||
- **Docker**: Rebuild the container: `docker-compose up --build`
|
||||
|
||||
### Video processing fails
|
||||
- Check video format is supported
|
||||
- Ensure sufficient disk space
|
||||
- Check FFmpeg console output in the terminal
|
||||
|
||||
### Upload fails
|
||||
- Verify file size is under 500MB
|
||||
- Check file format is supported
|
||||
- Ensure `uploads/` folder has write permissions
|
||||
|
||||
## 🔒 Security Considerations
|
||||
|
||||
**Note**: This application is designed for local or self-hosted use. For production deployment:
|
||||
|
||||
1. Add authentication/authorization
|
||||
2. Implement rate limiting
|
||||
3. Validate and sanitize all inputs
|
||||
4. Add HTTPS support
|
||||
5. Configure proper CORS policies
|
||||
6. Implement file cleanup routines
|
||||
7. Add virus scanning for uploaded files
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is provided as-is for personal and educational use.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Feel free to fork, modify, and submit pull requests for improvements!
|
||||
|
||||
## 💡 Future Enhancements
|
||||
|
||||
- Add watermark support
|
||||
- Audio volume adjustment
|
||||
- Multiple video concatenation
|
||||
- Filters and effects (brightness, contrast, saturation)
|
||||
- Batch processing
|
||||
- Progress bar for encoding
|
||||
- Video rotation
|
||||
- Subtitle support
|
||||
201
app.py
Normal file
201
app.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from flask import Flask, render_template, request, send_file, jsonify
|
||||
from werkzeug.utils import secure_filename
|
||||
import json
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['UPLOAD_FOLDER'] = 'uploads'
|
||||
app.config['OUTPUT_FOLDER'] = 'outputs'
|
||||
app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB max file size
|
||||
|
||||
# Create necessary folders
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True)
|
||||
|
||||
ALLOWED_EXTENSIONS = {'mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm'}
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
def get_video_info(video_path):
|
||||
"""Get video information using ffprobe"""
|
||||
try:
|
||||
cmd = [
|
||||
'ffprobe',
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
video_path
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
info = json.loads(result.stdout)
|
||||
|
||||
# Extract video stream info
|
||||
video_stream = next((s for s in info['streams'] if s['codec_type'] == 'video'), None)
|
||||
if video_stream:
|
||||
return {
|
||||
'width': video_stream.get('width'),
|
||||
'height': video_stream.get('height'),
|
||||
'duration': float(info['format'].get('duration', 0)),
|
||||
'codec': video_stream.get('codec_name')
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error getting video info: {e}")
|
||||
return None
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/upload', methods=['POST'])
|
||||
def upload_video():
|
||||
if 'video' not in request.files:
|
||||
return jsonify({'error': 'No video file provided'}), 400
|
||||
|
||||
file = request.files['video']
|
||||
if file.filename == '':
|
||||
return jsonify({'error': 'No file selected'}), 400
|
||||
|
||||
if file and allowed_file(file.filename):
|
||||
# Generate unique filename
|
||||
file_id = str(uuid.uuid4())
|
||||
ext = file.filename.rsplit('.', 1)[1].lower()
|
||||
filename = f"{file_id}.{ext}"
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
file.save(filepath)
|
||||
|
||||
# Get video information
|
||||
video_info = get_video_info(filepath)
|
||||
|
||||
return jsonify({
|
||||
'file_id': file_id,
|
||||
'filename': filename,
|
||||
'original_name': file.filename,
|
||||
'info': video_info
|
||||
})
|
||||
|
||||
return jsonify({'error': 'Invalid file type'}), 400
|
||||
|
||||
@app.route('/process', methods=['POST'])
|
||||
def process_video():
|
||||
try:
|
||||
data = request.get_json()
|
||||
file_id = data.get('file_id')
|
||||
|
||||
if not file_id:
|
||||
return jsonify({'error': 'No file ID provided'}), 400
|
||||
|
||||
# Find the uploaded file
|
||||
uploaded_files = [f for f in os.listdir(app.config['UPLOAD_FOLDER']) if f.startswith(file_id)]
|
||||
if not uploaded_files:
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
input_path = os.path.join(app.config['UPLOAD_FOLDER'], uploaded_files[0])
|
||||
output_filename = f"{file_id}_processed.mp4"
|
||||
output_path = os.path.join(app.config['OUTPUT_FOLDER'], output_filename)
|
||||
|
||||
# Build ffmpeg command
|
||||
cmd = ['ffmpeg', '-i', input_path]
|
||||
|
||||
# Add trim parameters if specified
|
||||
start_time = data.get('start_time')
|
||||
end_time = data.get('end_time')
|
||||
if start_time is not None and start_time > 0:
|
||||
cmd.extend(['-ss', str(start_time)])
|
||||
if end_time is not None:
|
||||
duration = end_time - (start_time if start_time else 0)
|
||||
if duration > 0:
|
||||
cmd.extend(['-t', str(duration)])
|
||||
|
||||
# Build filter complex for crop and scale
|
||||
filters = []
|
||||
|
||||
# Add crop filter if specified
|
||||
crop = data.get('crop')
|
||||
if crop:
|
||||
x = crop.get('x', 0)
|
||||
y = crop.get('y', 0)
|
||||
width = crop.get('width')
|
||||
height = crop.get('height')
|
||||
if width and height:
|
||||
filters.append(f"crop={width}:{height}:{x}:{y}")
|
||||
|
||||
# Add scale filter if resolution specified
|
||||
resolution = data.get('resolution')
|
||||
if resolution:
|
||||
width = resolution.get('width')
|
||||
height = resolution.get('height')
|
||||
if width and height:
|
||||
filters.append(f"scale={width}:{height}")
|
||||
|
||||
# Apply filters if any
|
||||
if filters:
|
||||
cmd.extend(['-vf', ','.join(filters)])
|
||||
|
||||
# Output settings for H.264 MP4
|
||||
cmd.extend([
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'medium',
|
||||
'-crf', '23',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
'-y', # Overwrite output file
|
||||
output_path
|
||||
])
|
||||
|
||||
# Execute ffmpeg
|
||||
print(f"Executing: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"FFmpeg error: {result.stderr}")
|
||||
return jsonify({'error': 'Video processing failed', 'details': result.stderr}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'output_file': output_filename,
|
||||
'file_id': file_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Processing error: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/download/<file_id>')
|
||||
def download_video(file_id):
|
||||
try:
|
||||
output_filename = f"{file_id}_processed.mp4"
|
||||
output_path = os.path.join(app.config['OUTPUT_FOLDER'], output_filename)
|
||||
|
||||
if not os.path.exists(output_path):
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
return send_file(output_path, as_attachment=True, download_name='processed_video.mp4')
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/cleanup/<file_id>', methods=['DELETE'])
|
||||
def cleanup_files(file_id):
|
||||
"""Clean up uploaded and processed files"""
|
||||
try:
|
||||
# Clean up upload folder
|
||||
for f in os.listdir(app.config['UPLOAD_FOLDER']):
|
||||
if f.startswith(file_id):
|
||||
os.remove(os.path.join(app.config['UPLOAD_FOLDER'], f))
|
||||
|
||||
# Clean up output folder
|
||||
for f in os.listdir(app.config['OUTPUT_FOLDER']):
|
||||
if f.startswith(file_id):
|
||||
os.remove(os.path.join(app.config['OUTPUT_FOLDER'], f))
|
||||
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
video-editor:
|
||||
build: .
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- ./outputs:/app/outputs
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
restart: unless-stopped
|
||||
13
prod-compose.yml
Normal file
13
prod-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
video-editor:
|
||||
image: reg.dev.nervesocket.com/video-editor:latest
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- ./outputs:/app/outputs
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
restart: unless-stopped
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Flask==3.0.0
|
||||
Werkzeug==3.0.1
|
||||
589
templates/index.html
Normal file
589
templates/index.html
Normal file
@@ -0,0 +1,589 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Simple Video Editor</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
opacity: 0.9;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.section.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
color: #667eea;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 3px dashed #667eea;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #764ba2;
|
||||
}
|
||||
|
||||
.upload-area.dragover {
|
||||
background: #e7f3ff;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 3em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 6px;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
.video-preview {
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-preview video {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.video-info {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.video-info p {
|
||||
margin: 5px 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 1em;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin: 20px 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background: #e9ecef;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.range-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.range-slider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.crop-controls {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.preset-resolutions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border: 2px solid #667eea;
|
||||
color: #667eea;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preset-btn:hover,
|
||||
.preset-btn.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.loader {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 20px auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎬 Simple Video Editor</h1>
|
||||
<p>Trim, crop, and convert your videos with ease</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Step 1: Upload Video -->
|
||||
<div class="section" id="upload-section">
|
||||
<h2>1. Select Video</h2>
|
||||
<div class="upload-area" id="upload-area">
|
||||
<div class="upload-icon">📹</div>
|
||||
<h3>Drag and drop your video here</h3>
|
||||
<p>or click to browse</p>
|
||||
<p style="margin-top: 10px; color: #6c757d; font-size: 0.9em;">
|
||||
Supported formats: MP4, AVI, MOV, MKV, WMV, FLV, WebM
|
||||
</p>
|
||||
<input type="file" id="video-input" accept="video/*">
|
||||
</div>
|
||||
<div class="loader" id="upload-loader"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Edit Video -->
|
||||
<div class="section hidden" id="edit-section">
|
||||
<h2>2. Edit Video</h2>
|
||||
|
||||
<div class="video-preview">
|
||||
<video id="video-preview" controls></video>
|
||||
</div>
|
||||
|
||||
<div class="video-info" id="video-info"></div>
|
||||
|
||||
<!-- Trim Controls -->
|
||||
<div class="form-group">
|
||||
<label>⏱️ Trim Video</label>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label style="font-weight: normal;">Start Time (seconds)</label>
|
||||
<input type="number" id="start-time" value="0" min="0" step="0.1">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-weight: normal;">End Time (seconds)</label>
|
||||
<input type="number" id="end-time" min="0" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution Controls -->
|
||||
<div class="form-group">
|
||||
<label>📐 Output Resolution</label>
|
||||
<div class="preset-resolutions">
|
||||
<button class="preset-btn" data-width="3840" data-height="2160">4K (3840x2160)</button>
|
||||
<button class="preset-btn" data-width="1920" data-height="1080">1080p (1920x1080)</button>
|
||||
<button class="preset-btn" data-width="1280" data-height="720">720p (1280x720)</button>
|
||||
<button class="preset-btn" data-width="854" data-height="480">480p (854x480)</button>
|
||||
<button class="preset-btn active" data-width="0" data-height="0">Original</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label style="font-weight: normal;">Width</label>
|
||||
<input type="number" id="width" placeholder="Original" min="0">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-weight: normal;">Height</label>
|
||||
<input type="number" id="height" placeholder="Original" min="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Crop Controls -->
|
||||
<div class="form-group">
|
||||
<label>✂️ Crop Video (optional)</label>
|
||||
<div class="crop-controls">
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label style="font-weight: normal;">X Position</label>
|
||||
<input type="number" id="crop-x" value="0" min="0">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-weight: normal;">Y Position</label>
|
||||
<input type="number" id="crop-y" value="0" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top: 10px;">
|
||||
<div>
|
||||
<label style="font-weight: normal;">Crop Width</label>
|
||||
<input type="number" id="crop-width" placeholder="Full width" min="0">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-weight: normal;">Crop Height</label>
|
||||
<input type="number" id="crop-height" placeholder="Full height" min="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="btn" id="process-btn">🎬 Process Video</button>
|
||||
<button class="btn btn-secondary" id="reset-btn">🔄 Start Over</button>
|
||||
</div>
|
||||
|
||||
<div class="loader" id="process-loader"></div>
|
||||
<div id="alert-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Download -->
|
||||
<div class="section hidden" id="download-section">
|
||||
<h2>3. Download Processed Video</h2>
|
||||
<p style="margin-bottom: 20px;">Your video has been processed successfully!</p>
|
||||
<div class="button-group">
|
||||
<button class="btn" id="download-btn">⬇️ Download Video</button>
|
||||
<button class="btn btn-secondary" id="new-video-btn">🎬 Process Another Video</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentFileId = null;
|
||||
let videoInfo = null;
|
||||
|
||||
// Upload area handling
|
||||
const uploadArea = document.getElementById('upload-area');
|
||||
const videoInput = document.getElementById('video-input');
|
||||
const uploadLoader = document.getElementById('upload-loader');
|
||||
const processLoader = document.getElementById('process-loader');
|
||||
|
||||
uploadArea.addEventListener('click', () => videoInput.click());
|
||||
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('dragover');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
videoInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
handleFileUpload(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleFileUpload(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('video', file);
|
||||
|
||||
uploadLoader.style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await fetch('/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
currentFileId = data.file_id;
|
||||
videoInfo = data.info;
|
||||
|
||||
// Show video preview
|
||||
const videoPreview = document.getElementById('video-preview');
|
||||
videoPreview.src = URL.createObjectURL(file);
|
||||
|
||||
// Update video info
|
||||
const videoInfoDiv = document.getElementById('video-info');
|
||||
videoInfoDiv.innerHTML = `
|
||||
<p><strong>File:</strong> ${data.original_name}</p>
|
||||
<p><strong>Resolution:</strong> ${videoInfo.width}x${videoInfo.height}</p>
|
||||
<p><strong>Duration:</strong> ${videoInfo.duration.toFixed(2)} seconds</p>
|
||||
<p><strong>Codec:</strong> ${videoInfo.codec}</p>
|
||||
`;
|
||||
|
||||
// Set default values
|
||||
document.getElementById('end-time').value = videoInfo.duration.toFixed(2);
|
||||
|
||||
// Show edit section
|
||||
document.getElementById('upload-section').classList.add('hidden');
|
||||
document.getElementById('edit-section').classList.remove('hidden');
|
||||
|
||||
} catch (error) {
|
||||
alert('Error uploading video: ' + error.message);
|
||||
} finally {
|
||||
uploadLoader.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution presets
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
const width = btn.dataset.width;
|
||||
const height = btn.dataset.height;
|
||||
|
||||
document.getElementById('width').value = width === '0' ? '' : width;
|
||||
document.getElementById('height').value = height === '0' ? '' : height;
|
||||
});
|
||||
});
|
||||
|
||||
// Process video
|
||||
document.getElementById('process-btn').addEventListener('click', async () => {
|
||||
const startTime = parseFloat(document.getElementById('start-time').value) || 0;
|
||||
const endTime = parseFloat(document.getElementById('end-time').value) || videoInfo.duration;
|
||||
const width = parseInt(document.getElementById('width').value) || 0;
|
||||
const height = parseInt(document.getElementById('height').value) || 0;
|
||||
const cropX = parseInt(document.getElementById('crop-x').value) || 0;
|
||||
const cropY = parseInt(document.getElementById('crop-y').value) || 0;
|
||||
const cropWidth = parseInt(document.getElementById('crop-width').value) || 0;
|
||||
const cropHeight = parseInt(document.getElementById('crop-height').value) || 0;
|
||||
|
||||
const payload = {
|
||||
file_id: currentFileId,
|
||||
start_time: startTime,
|
||||
end_time: endTime
|
||||
};
|
||||
|
||||
if (width > 0 && height > 0) {
|
||||
payload.resolution = { width, height };
|
||||
}
|
||||
|
||||
if (cropWidth > 0 && cropHeight > 0) {
|
||||
payload.crop = {
|
||||
x: cropX,
|
||||
y: cropY,
|
||||
width: cropWidth,
|
||||
height: cropHeight
|
||||
};
|
||||
}
|
||||
|
||||
processLoader.style.display = 'block';
|
||||
document.getElementById('process-btn').disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/process', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Processing failed');
|
||||
}
|
||||
|
||||
// Show download section
|
||||
document.getElementById('edit-section').classList.add('hidden');
|
||||
document.getElementById('download-section').classList.remove('hidden');
|
||||
|
||||
} catch (error) {
|
||||
const alertContainer = document.getElementById('alert-container');
|
||||
alertContainer.innerHTML = `<div class="alert alert-error">Error: ${error.message}</div>`;
|
||||
} finally {
|
||||
processLoader.style.display = 'none';
|
||||
document.getElementById('process-btn').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Download video
|
||||
document.getElementById('download-btn').addEventListener('click', () => {
|
||||
window.location.href = `/download/${currentFileId}`;
|
||||
});
|
||||
|
||||
// Reset buttons
|
||||
document.getElementById('reset-btn').addEventListener('click', resetApp);
|
||||
document.getElementById('new-video-btn').addEventListener('click', resetApp);
|
||||
|
||||
async function resetApp() {
|
||||
if (currentFileId) {
|
||||
await fetch(`/cleanup/${currentFileId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
currentFileId = null;
|
||||
videoInfo = null;
|
||||
|
||||
document.getElementById('video-preview').src = '';
|
||||
videoInput.value = '';
|
||||
|
||||
document.getElementById('upload-section').classList.remove('hidden');
|
||||
document.getElementById('edit-section').classList.add('hidden');
|
||||
document.getElementById('download-section').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user