Initial commit: image+audio to YouTube MP4 queue
This commit is contained in:
commit
f2d53a573f
15 changed files with 826 additions and 0 deletions
8
.env.example
Normal file
8
.env.example
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
APP_SECRET_KEY=
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
PUBLIC_BASE_URL=http://localhost:5000
|
||||||
|
INTERNAL_PORT=5000
|
||||||
|
HOST_PORT=127.0.0.1:5000
|
||||||
|
DATABASE=/app/data/app.db
|
||||||
|
UPLOAD_FOLDER=/app/uploads
|
||||||
|
OUTPUT_FOLDER=/app/outputs
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
.env.local
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
CMD ["gunicorn", "-b", "0.0.0.0:5000", "-w", "2", "--timeout", "300", "app:app"]
|
||||||
68
README.md
Normal file
68
README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Dionysis Media Video
|
||||||
|
|
||||||
|
**Version:** `v0.1.0`
|
||||||
|
|
||||||
|
A small Flask web app that combines a still image with an audio file into a YouTube-ready MP4 video.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Upload an image (PNG/JPG/WebP/etc.) and an audio file (MP3/WAV/etc.).
|
||||||
|
- Background worker queue processes jobs one at a time.
|
||||||
|
- Queue shows status: Waiting, Processing, Complete, or Error.
|
||||||
|
- Download completed videos from the queue.
|
||||||
|
- Click an Error status to see the traceback and retry (move to end of queue).
|
||||||
|
- Delete queued jobs unless they are currently processing.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Python 3.12 + Flask + Gunicorn
|
||||||
|
- SQLite for the queue
|
||||||
|
- ffmpeg + Pillow for video encoding
|
||||||
|
- Docker / Portainer deployment
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `APP_SECRET_KEY` | Flask secret key | generated at startup |
|
||||||
|
| `ADMIN_PASSWORD` | Plain password for `/admin` | none (when unset, login is disabled and admin is open) |
|
||||||
|
| `PUBLIC_BASE_URL` | External URL for download links | `http://localhost:5000` |
|
||||||
|
| `INTERNAL_PORT` | Port Gunicorn listens on | `5000` |
|
||||||
|
| `DATABASE` | SQLite path | `/app/data/app.db` |
|
||||||
|
| `UPLOAD_FOLDER` | Raw uploads directory | `/app/uploads` |
|
||||||
|
| `OUTPUT_FOLDER` | Completed videos directory | `/app/outputs` |
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/workspace/dionysis-media-video
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python init_db.py
|
||||||
|
flask --app app run
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:5000.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Build and run with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
For Portainer, use the repository stack URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://gitlab.hallsworth.ca/yrtria/dionysis-media-video.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch: `main`, Compose path: `docker-compose.yml`.
|
||||||
|
|
||||||
|
Then run once inside the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python init_db.py
|
||||||
|
```
|
||||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
0.1.0
|
||||||
208
app.py
Normal file
208
app.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Flask, render_template, request, redirect, url_for, flash,
|
||||||
|
send_from_directory, jsonify, abort
|
||||||
|
)
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from models import JobStore
|
||||||
|
from worker import process_job
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(Config)
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = Config.MAX_UPLOAD_SIZE
|
||||||
|
|
||||||
|
_cfg = Config()
|
||||||
|
store = JobStore(_cfg.DATABASE)
|
||||||
|
|
||||||
|
_worker_thread = None
|
||||||
|
_worker_stop = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_file(filename, extensions):
|
||||||
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in extensions
|
||||||
|
|
||||||
|
|
||||||
|
_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff'}
|
||||||
|
_AUDIO_EXTENSIONS = {'mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg', 'wma'}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dirs():
|
||||||
|
_cfg.UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
|
||||||
|
_cfg.OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
_ensure_dirs()
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_loop():
|
||||||
|
while not _worker_stop.is_set():
|
||||||
|
job = store.pop_next_waiting()
|
||||||
|
if job is None:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
process_job(job['id'], store, _cfg)
|
||||||
|
except Exception:
|
||||||
|
# error_message already stored; keep worker alive
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def start_worker():
|
||||||
|
global _worker_thread
|
||||||
|
if _worker_thread is None or not _worker_thread.is_alive():
|
||||||
|
_worker_stop.clear()
|
||||||
|
_worker_thread = threading.Thread(target=_worker_loop, daemon=True)
|
||||||
|
_worker_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
start_worker()
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_required():
|
||||||
|
if _cfg.ADMIN_PASSWORD:
|
||||||
|
if request.path.startswith('/admin') and request.path != url_for('admin_login'):
|
||||||
|
if request.form.get('password') == _cfg.ADMIN_PASSWORD:
|
||||||
|
return None
|
||||||
|
if request.args.get('password') == _cfg.ADMIN_PASSWORD:
|
||||||
|
return None
|
||||||
|
return abort(401)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _before_request():
|
||||||
|
# If admin password is set, protect the whole app except health check.
|
||||||
|
if _cfg.ADMIN_PASSWORD and request.path != '/health':
|
||||||
|
if request.path == url_for('admin_login'):
|
||||||
|
return None
|
||||||
|
if request.form.get('password') == _cfg.ADMIN_PASSWORD:
|
||||||
|
return None
|
||||||
|
if request.args.get('password') == _cfg.ADMIN_PASSWORD:
|
||||||
|
return None
|
||||||
|
return abort(401)
|
||||||
|
_admin_required()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
jobs = store.list_jobs()
|
||||||
|
return render_template('index.html', jobs=jobs, cfg=_cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/upload', methods=['POST'])
|
||||||
|
def upload():
|
||||||
|
title = request.form.get('title', '').strip() or 'Untitled'
|
||||||
|
image = request.files.get('image')
|
||||||
|
audio = request.files.get('audio')
|
||||||
|
|
||||||
|
if not image or not image.filename:
|
||||||
|
flash('Please select an image file.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
if not audio or not audio.filename:
|
||||||
|
flash('Please select an audio file.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
image_ext = image.filename.rsplit('.', 1)[1].lower()
|
||||||
|
audio_ext = audio.filename.rsplit('.', 1)[1].lower()
|
||||||
|
if not _allowed_file(image.filename, _IMAGE_EXTENSIONS):
|
||||||
|
flash(f'Unsupported image format: {image_ext}', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
if not _allowed_file(audio.filename, _AUDIO_EXTENSIONS):
|
||||||
|
flash(f'Unsupported audio format: {audio_ext}', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
_ensure_dirs()
|
||||||
|
safe_title = secure_filename(title).replace('.', '_') or 'untitled'
|
||||||
|
job_prefix = f"{int(time.time())}-{secrets.token_hex(4)}"
|
||||||
|
image_filename = f"{job_prefix}-img-{safe_title}.{image_ext}"
|
||||||
|
audio_filename = f"{job_prefix}-aud-{safe_title}.{audio_ext}"
|
||||||
|
|
||||||
|
image.save(_cfg.UPLOAD_FOLDER / image_filename)
|
||||||
|
audio.save(_cfg.UPLOAD_FOLDER / audio_filename)
|
||||||
|
|
||||||
|
job_id = store.create_job(title, image_filename, audio_filename)
|
||||||
|
flash(f'Job #{job_id} added to the queue.', 'success')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/delete/<int:job_id>', methods=['POST'])
|
||||||
|
def delete_job(job_id):
|
||||||
|
job = store.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
flash('Job not found.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
if job['status'] == 'processing':
|
||||||
|
flash('Cannot delete a job that is currently processing.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
deleted = store.delete_job(job_id)
|
||||||
|
if deleted:
|
||||||
|
# Best-effort file cleanup
|
||||||
|
try:
|
||||||
|
(_cfg.UPLOAD_FOLDER / job['image_file']).unlink(missing_ok=True)
|
||||||
|
(_cfg.UPLOAD_FOLDER / job['audio_file']).unlink(missing_ok=True)
|
||||||
|
if job['output_file']:
|
||||||
|
(_cfg.OUTPUT_FOLDER / job['output_file']).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
flash(f'Job #{job_id} deleted.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Job could not be deleted.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/retry/<int:job_id>', methods=['POST'])
|
||||||
|
def retry_job(job_id):
|
||||||
|
job = store.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
flash('Job not found.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
if job['status'] != 'error':
|
||||||
|
flash('Only error jobs can be retried.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
store.move_to_end(job_id)
|
||||||
|
flash(f'Job #{job_id} moved to the end of the queue for retry.', 'success')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/download/<int:job_id>')
|
||||||
|
def download(job_id):
|
||||||
|
job = store.get_job(job_id)
|
||||||
|
if job is None or job['status'] != 'complete' or not job['output_file']:
|
||||||
|
abort(404)
|
||||||
|
return send_from_directory(_cfg.OUTPUT_FOLDER, job['output_file'], as_attachment=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/login', methods=['GET', 'POST'])
|
||||||
|
def admin_login():
|
||||||
|
if not _cfg.ADMIN_PASSWORD:
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
if request.method == 'POST':
|
||||||
|
if request.form.get('password') == _cfg.ADMIN_PASSWORD:
|
||||||
|
return redirect(url_for('index', password=request.form.get('password')))
|
||||||
|
flash('Incorrect password.', 'error')
|
||||||
|
return render_template('admin/login.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health():
|
||||||
|
return jsonify({
|
||||||
|
'ok': True,
|
||||||
|
'version': _cfg.VERSION,
|
||||||
|
'queue': {
|
||||||
|
row['status']: sum(1 for r in store.list_jobs() if r['status'] == row['status'])
|
||||||
|
for row in store.list_jobs()
|
||||||
|
} or {}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=_cfg.INTERNAL_PORT, debug=True)
|
||||||
36
config.py
Normal file
36
config.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def env_default(name, default=None):
|
||||||
|
return os.environ.get(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
VERSION = (BASE_DIR / 'VERSION').read_text().strip()
|
||||||
|
SECRET_KEY = env_default('APP_SECRET_KEY', secrets.token_hex(32))
|
||||||
|
|
||||||
|
ADMIN_PASSWORD = env_default('ADMIN_PASSWORD')
|
||||||
|
|
||||||
|
PUBLIC_BASE_URL = env_default('PUBLIC_BASE_URL', 'http://localhost:5000').rstrip('/')
|
||||||
|
INTERNAL_PORT = int(env_default('INTERNAL_PORT', '5000'))
|
||||||
|
|
||||||
|
DATABASE = Path(env_default('DATABASE', str(BASE_DIR / 'data' / 'app.db')))
|
||||||
|
UPLOAD_FOLDER = Path(env_default('UPLOAD_FOLDER', str(BASE_DIR / 'uploads')))
|
||||||
|
OUTPUT_FOLDER = Path(env_default('OUTPUT_FOLDER', str(BASE_DIR / 'outputs')))
|
||||||
|
|
||||||
|
MAX_UPLOAD_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||||
|
|
||||||
|
VIDEO_WIDTH = 1920
|
||||||
|
VIDEO_HEIGHT = 1080
|
||||||
|
VIDEO_FPS = 30
|
||||||
|
VIDEO_BITRATE = '5000k'
|
||||||
|
AUDIO_BITRATE = '192k'
|
||||||
|
VIDEO_CODEC = 'libx264'
|
||||||
|
AUDIO_CODEC = 'aac'
|
||||||
|
OUTPUT_FORMAT = 'mp4'
|
||||||
|
|
||||||
|
YOUTUBE_PRESET = True # use faststart + yuv420p
|
||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: https://gitlab.hallsworth.ca/yrtria/dionysis-media-video.git#main
|
||||||
|
container_name: dionysis-media-video
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- APP_SECRET_KEY=${APP_SECRET_KEY}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
|
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:5000}
|
||||||
|
- INTERNAL_PORT=${INTERNAL_PORT:-5000}
|
||||||
|
- DATABASE=${DATABASE:-/app/data/app.db}
|
||||||
|
- UPLOAD_FOLDER=${UPLOAD_FOLDER:-/app/uploads}
|
||||||
|
- OUTPUT_FOLDER=${OUTPUT_FOLDER:-/app/outputs}
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-127.0.0.1:5000}:${INTERNAL_PORT:-5000}"
|
||||||
|
volumes:
|
||||||
|
- dmv-data:/app/data
|
||||||
|
- dmv-uploads:/app/uploads
|
||||||
|
- dmv-outputs:/app/outputs
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dmv-data:
|
||||||
|
dmv-uploads:
|
||||||
|
dmv-outputs:
|
||||||
7
init_db.py
Normal file
7
init_db.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from config import Config
|
||||||
|
from models import JobStore
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cfg = Config()
|
||||||
|
store = JobStore(cfg.DATABASE)
|
||||||
|
print(f"Database ready: {cfg.DATABASE}")
|
||||||
134
models.py
Normal file
134
models.py
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT,
|
||||||
|
image_file TEXT NOT NULL,
|
||||||
|
audio_file TEXT NOT NULL,
|
||||||
|
output_file TEXT,
|
||||||
|
status TEXT DEFAULT 'waiting' NOT NULL,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
position INTEGER DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
COLUMNS = {
|
||||||
|
'id': 'INTEGER',
|
||||||
|
'title': 'TEXT',
|
||||||
|
'image_file': 'TEXT',
|
||||||
|
'audio_file': 'TEXT',
|
||||||
|
'output_file': 'TEXT',
|
||||||
|
'status': 'TEXT',
|
||||||
|
'error_message': 'TEXT',
|
||||||
|
'created_at': 'TEXT',
|
||||||
|
'updated_at': 'TEXT',
|
||||||
|
'position': 'INTEGER',
|
||||||
|
}
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(db_path: Path) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
class JobStore:
|
||||||
|
def __init__(self, db_path: Path):
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.local = threading.local()
|
||||||
|
self.init_db()
|
||||||
|
|
||||||
|
def _conn(self) -> sqlite3.Connection:
|
||||||
|
if not hasattr(self.local, 'conn') or self.local.conn is None:
|
||||||
|
self.local.conn = _connect(self.db_path)
|
||||||
|
return self.local.conn
|
||||||
|
|
||||||
|
def init_db(self):
|
||||||
|
with _lock:
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = self._conn()
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
self._migrate_columns()
|
||||||
|
|
||||||
|
def _migrate_columns(self):
|
||||||
|
conn = self._conn()
|
||||||
|
existing = {
|
||||||
|
row['name']: row['type']
|
||||||
|
for row in conn.execute("PRAGMA table_info(jobs)")
|
||||||
|
}
|
||||||
|
for col, col_type in COLUMNS.items():
|
||||||
|
if col not in existing:
|
||||||
|
conn.execute(f"ALTER TABLE jobs ADD COLUMN {col} {col_type}")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def create_job(self, title: str, image_file: str, audio_file: str) -> int:
|
||||||
|
conn = self._conn()
|
||||||
|
max_pos = conn.execute("SELECT COALESCE(MAX(position), 0) FROM jobs").fetchone()[0]
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO jobs (title, image_file, audio_file, status, position)
|
||||||
|
VALUES (?, ?, ?, 'waiting', ?)""",
|
||||||
|
(title, image_file, audio_file, int(max_pos) + 1)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
def list_jobs(self) -> list[sqlite3.Row]:
|
||||||
|
conn = self._conn()
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT * FROM jobs ORDER BY position ASC, id ASC"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def get_job(self, job_id: int) -> sqlite3.Row | None:
|
||||||
|
conn = self._conn()
|
||||||
|
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
def update_status(self, job_id: int, status: str, error_message: str | None = None, output_file: str | None = None):
|
||||||
|
conn = self._conn()
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE jobs
|
||||||
|
SET status = ?, error_message = ?, output_file = ?, updated_at = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(status, error_message, output_file, datetime.utcnow().isoformat(), job_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def delete_job(self, job_id: int) -> bool:
|
||||||
|
conn = self._conn()
|
||||||
|
cur = conn.execute("DELETE FROM jobs WHERE id = ? AND status != 'processing'", (job_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def move_to_end(self, job_id: int) -> bool:
|
||||||
|
conn = self._conn()
|
||||||
|
max_pos = conn.execute("SELECT COALESCE(MAX(position), 0) FROM jobs").fetchone()[0]
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE jobs SET status = 'waiting', error_message = NULL, position = ? WHERE id = ?",
|
||||||
|
(int(max_pos) + 1, job_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def pop_next_waiting(self) -> sqlite3.Row | None:
|
||||||
|
conn = self._conn()
|
||||||
|
with _lock:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM jobs WHERE status = 'waiting' ORDER BY position ASC, id ASC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE jobs SET status = 'processing', updated_at = ? WHERE id = ?",
|
||||||
|
(datetime.utcnow().isoformat(), row['id'])
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return row
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
flask>=3.0
|
||||||
|
gunicorn>=22.0
|
||||||
|
Pillow>=10.0
|
||||||
|
python-dotenv>=1.0
|
||||||
|
Werkzeug>=3.0
|
||||||
16
templates/admin/login.html
Normal file
16
templates/admin/login.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Admin Login — Dionysis Media Video{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card" style="max-width: 400px; margin: 60px auto;">
|
||||||
|
<h2>Admin Login</h2>
|
||||||
|
<form method="post">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required autofocus>
|
||||||
|
<div style="margin-top: 16px;">
|
||||||
|
<button type="submit">Log In</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
115
templates/base.html
Normal file
115
templates/base.html
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Dionysis Media Video{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d0d0f;
|
||||||
|
--card: #16161a;
|
||||||
|
--text: #e8e8ec;
|
||||||
|
--muted: #9a9aa4;
|
||||||
|
--accent: #6c5ce7;
|
||||||
|
--accent-2: #00cec9;
|
||||||
|
--good: #00b894;
|
||||||
|
--warn: #fdcb6e;
|
||||||
|
--bad: #d63031;
|
||||||
|
--border: #2a2a30;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||||
|
h1, h2, h3 { margin-top: 0; }
|
||||||
|
a { color: var(--accent-2); }
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.flash {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.flash.success { background: rgba(0,184,148,0.15); color: var(--good); border: 1px solid rgba(0,184,148,0.3); }
|
||||||
|
.flash.error { background: rgba(214,48,49,0.15); color: var(--bad); border: 1px solid rgba(214,48,49,0.3); }
|
||||||
|
label { display: block; margin: 12px 0 4px; font-weight: 600; }
|
||||||
|
input[type="text"], input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
button, .button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
button:hover, .button:hover { opacity: 0.9; }
|
||||||
|
button.secondary { background: #2d3436; }
|
||||||
|
button.danger { background: var(--bad); }
|
||||||
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
@media (max-width: 700px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.status.waiting { background: rgba(253,203,110,0.15); color: var(--warn); }
|
||||||
|
.status.processing { background: rgba(108,92,231,0.2); color: var(--accent); }
|
||||||
|
.status.complete { background: rgba(0,184,148,0.15); color: var(--good); }
|
||||||
|
.status.error { background: rgba(214,48,49,0.15); color: var(--bad); cursor: pointer; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
th { color: var(--muted); font-weight: 600; }
|
||||||
|
td { vertical-align: middle; }
|
||||||
|
.actions form { display: inline; }
|
||||||
|
.error-detail {
|
||||||
|
background: rgba(214,48,49,0.1);
|
||||||
|
border-left: 3px solid var(--bad);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.empty { color: var(--muted); text-align: center; padding: 40px; }
|
||||||
|
.small { font-size: 0.85rem; color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
99
templates/index.html
Normal file
99
templates/index.html
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Queue — Dionysis Media Video{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<h1>Dionysis Media Video</h1>
|
||||||
|
<p class="small">Combine an image and an audio file into a YouTube-ready 1080p MP4 video. Uploads are queued and processed in order.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>New Job</h2>
|
||||||
|
<form action="{{ url_for('upload') }}" method="post" enctype="multipart/form-data">
|
||||||
|
<label for="title">Title (optional)</label>
|
||||||
|
<input type="text" id="title" name="title" placeholder="Untitled">
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div>
|
||||||
|
<label for="image">Image file</label>
|
||||||
|
<input type="file" id="image" name="image" accept="image/*" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="audio">Audio file</label>
|
||||||
|
<input type="file" id="audio" name="audio" accept="audio/*" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 16px;">
|
||||||
|
<button type="submit">Add to Queue</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if cfg.ADMIN_PASSWORD %}
|
||||||
|
<p class="small">This app is password protected. Add ?password=... to URLs or use /admin/login.</p>
|
||||||
|
{% endif %}</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Queue</h2>
|
||||||
|
{% if jobs %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for job in jobs %}
|
||||||
|
<tr>
|
||||||
|
<td>#{{ job.id }}</td>
|
||||||
|
<td>{{ job.title }}</td>
|
||||||
|
<td>
|
||||||
|
{% if job.status == 'error' %}
|
||||||
|
<span class="status error" title="Click to view error" onclick="toggleError({{ job.id }})">{{ job.status }}</span>
|
||||||
|
<div id="error-{{ job.id }}" class="error-detail" style="display:none;">{{ job.error_message or 'No error message.' }}</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="status {{ job.status }}">{{ job.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ job.created_at }}</td>
|
||||||
|
<td>{{ job.updated_at }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
{% if job.status == 'complete' %}
|
||||||
|
<a class="button" href="{{ url_for('download', job_id=job.id) }}">Download MP4</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if job.status == 'error' %}
|
||||||
|
<form action="{{ url_for('retry_job', job_id=job.id) }}" method="post">
|
||||||
|
<button type="submit" class="secondary">Retry</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if job.status != 'processing' %}
|
||||||
|
<form action="{{ url_for('delete_job', job_id=job.id) }}" method="post" onsubmit="return confirm('Delete job #{{ job.id }}?')">
|
||||||
|
<button type="submit" class="danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">No jobs yet. Upload an image and audio file above.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleError(id) {
|
||||||
|
var el = document.getElementById('error-' + id);
|
||||||
|
if (el) {
|
||||||
|
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<meta http-equiv="refresh" content="5">
|
||||||
|
{% endblock %}
|
||||||
78
worker.py
Normal file
78
worker.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from models import JobStore
|
||||||
|
|
||||||
|
|
||||||
|
def _ffmpeg_available() -> bool:
|
||||||
|
return shutil.which('ffmpeg') is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_image(image_path: Path, output_path: Path, width: int, height: int) -> None:
|
||||||
|
img = Image.open(image_path)
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
# Fit inside target box, then letterbox/pillarbox with black to exact 16:9
|
||||||
|
img.thumbnail((width, height), Image.LANCZOS)
|
||||||
|
|
||||||
|
canvas = Image.new('RGB', (width, height), (0, 0, 0))
|
||||||
|
x = (width - img.width) // 2
|
||||||
|
y = (height - img.height) // 2
|
||||||
|
canvas.paste(img, (x, y))
|
||||||
|
canvas.save(output_path, 'JPEG', quality=95)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_video(image_path: Path, audio_path: Path, output_path: Path, cfg: Config) -> None:
|
||||||
|
if not _ffmpeg_available():
|
||||||
|
raise RuntimeError('ffmpeg is not installed or not on PATH')
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tmp = Path(tmpdir)
|
||||||
|
prepared_image = tmp / 'prepared.jpg'
|
||||||
|
_prepare_image(image_path, prepared_image, cfg.VIDEO_WIDTH, cfg.VIDEO_HEIGHT)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg',
|
||||||
|
'-y',
|
||||||
|
'-loop', '1',
|
||||||
|
'-framerate', str(cfg.VIDEO_FPS),
|
||||||
|
'-i', str(prepared_image),
|
||||||
|
'-i', str(audio_path),
|
||||||
|
'-c:v', cfg.VIDEO_CODEC,
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
'-preset', 'medium',
|
||||||
|
'-b:v', cfg.VIDEO_BITRATE,
|
||||||
|
'-c:a', cfg.AUDIO_CODEC,
|
||||||
|
'-b:a', cfg.AUDIO_BITRATE,
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
'-shortest',
|
||||||
|
str(output_path),
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f'ffmpeg failed: {result.stderr}')
|
||||||
|
|
||||||
|
|
||||||
|
def process_job(job_id: int, store: JobStore, cfg: Config) -> None:
|
||||||
|
job = store.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
image_path = cfg.UPLOAD_FOLDER / job['image_file']
|
||||||
|
audio_path = cfg.UPLOAD_FOLDER / job['audio_file']
|
||||||
|
output_filename = f"job-{job_id}.{cfg.OUTPUT_FORMAT}"
|
||||||
|
output_path = cfg.OUTPUT_FOLDER / output_filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg.OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True)
|
||||||
|
_build_video(image_path, audio_path, output_path, cfg)
|
||||||
|
store.update_status(job_id, 'complete', output_file=output_filename)
|
||||||
|
except Exception as exc:
|
||||||
|
store.update_status(job_id, 'error', error_message=str(exc))
|
||||||
|
raise
|
||||||
Loading…
Add table
Add a link
Reference in a new issue