commit f2d53a573f5a49370a579f706a02fd7665ae53e6 Author: Troll (Hermes Agent) Date: Wed Aug 5 22:29:01 2026 +0000 Initial commit: image+audio to YouTube MP4 queue diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ca4b4c0 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87feb93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +venv/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.env.local +*.db +*.sqlite3 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2893b6a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..60265ca --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6c6aa7c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..37f6c5b --- /dev/null +++ b/app.py @@ -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/', 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/', 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/') +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) diff --git a/config.py b/config.py new file mode 100644 index 0000000..99a7bb2 --- /dev/null +++ b/config.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..447415a --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..2a42227 --- /dev/null +++ b/init_db.py @@ -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}") diff --git a/models.py b/models.py new file mode 100644 index 0000000..ed31e6d --- /dev/null +++ b/models.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..53a1c2e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0 +gunicorn>=22.0 +Pillow>=10.0 +python-dotenv>=1.0 +Werkzeug>=3.0 diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 0000000..9485f5b --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}Admin Login — Dionysis Media Video{% endblock %} + +{% block content %} +
+

Admin Login

+
+ + +
+ +
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..3d9d32a --- /dev/null +++ b/templates/base.html @@ -0,0 +1,115 @@ + + + + + + {% block title %}Dionysis Media Video{% endblock %} + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..df7045b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}Queue — Dionysis Media Video{% endblock %} + +{% block content %} +
+

Dionysis Media Video

+

Combine an image and an audio file into a YouTube-ready 1080p MP4 video. Uploads are queued and processed in order.

+
+ +
+

New Job

+
+ + + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ {% if cfg.ADMIN_PASSWORD %} +

This app is password protected. Add ?password=... to URLs or use /admin/login.

+ {% endif %}
+ +
+

Queue

+ {% if jobs %} + + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + {% endfor %} + +
#IDTitleStatusCreatedUpdatedActions
#{{ job.id }}{{ job.title }} + {% if job.status == 'error' %} + {{ job.status }} + + {% else %} + {{ job.status }} + {% endif %} + {{ job.created_at }}{{ job.updated_at }} + {% if job.status == 'complete' %} + Download MP4 + {% endif %} + {% if job.status == 'error' %} +
+ +
+ {% endif %} + {% if job.status != 'processing' %} +
+ +
+ {% endif %} +
+ {% else %} +
No jobs yet. Upload an image and audio file above.
+ {% endif %} +
+ + + + +{% endblock %} diff --git a/worker.py b/worker.py new file mode 100644 index 0000000..8d2e4ea --- /dev/null +++ b/worker.py @@ -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