initial commit
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 1m2s

This commit is contained in:
2026-02-09 16:52:27 -05:00
commit d2626aa691
9 changed files with 1183 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
Flask==3.0.0
Werkzeug==3.0.1

589
templates/index.html Normal file
View 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>