Initial commit: image+audio to YouTube MP4 queue

This commit is contained in:
Troll (Hermes Agent) 2026-08-05 22:29:01 +00:00
commit f2d53a573f
15 changed files with 826 additions and 0 deletions

8
.env.example Normal file
View 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
View file

@ -0,0 +1,9 @@
.env
venv/
__pycache__/
*.pyc
*.pyo
*.egg-info/
.env.local
*.db
*.sqlite3

17
Dockerfile Normal file
View 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
View 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
View file

@ -0,0 +1 @@
0.1.0

208
app.py Normal file
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
flask>=3.0
gunicorn>=22.0
Pillow>=10.0
python-dotenv>=1.0
Werkzeug>=3.0

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