import base64 import binascii import os import shutil import sqlite3 import subprocess import textwrap import threading import time import uuid from dataclasses import dataclass from typing import Callable, Dict, List, Optional import gradio as gr import magic import uvicorn import yt_dlp from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.responses import FileResponse app = FastAPI(title="Basyx FFmpeg Automation Hub") TEMP_DIR = os.getenv("TEMP_DIR", "temp") FONT_PATH = os.getenv( "FONT_PATH", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" ) MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "500")) MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024 FILE_TTL_SECONDS = int(os.getenv("FILE_TTL_SECONDS", "3600")) HISTORY_LIMIT = int(os.getenv("HISTORY_LIMIT", "50")) FFMPEG_TIMEOUT_SECONDS = int(os.getenv("FFMPEG_TIMEOUT_SECONDS", "1800")) MAX_URL_DOWNLOAD_MB = int(os.getenv("MAX_URL_DOWNLOAD_MB", str(MAX_UPLOAD_MB))) MAX_URL_DOWNLOAD_BYTES = MAX_URL_DOWNLOAD_MB * 1024 * 1024 ALLOW_URL_INPUTS = os.getenv("ALLOW_URL_INPUTS", "true").lower() in {"1", "true", "yes"} STATE_DB_PATH = os.getenv("STATE_DB_PATH", os.path.join(TEMP_DIR, "state.sqlite3")) OPTION_FIELDS = [ "text", "start_time", "end_time", "duration", "aspect_ratio", "resolution", "crf", "preset", "audio_bitrate", "volume", "position", "opacity", "fps", "width", "speed", "timestamp", "image_duration", "frame_rate", "font_size", "wave_color", ] os.makedirs(TEMP_DIR, exist_ok=True) @dataclass(frozen=True) class TaskDefinition: label: str description: str category: str output_ext: str min_files: int max_files: Optional[int] accepted_types: List[str] builder: Optional[Callable[[List[str], Dict[str, str], str], List[str]]] = None runner: Optional[Callable[[List[str], Dict[str, str], str], str]] = None file_types: Optional[List[List[str]]] = None JOBS: Dict[str, Dict[str, object]] = {} HISTORY: List[Dict[str, object]] = [] STATE_LOCK = threading.Lock() def db_connect(): connection = sqlite3.connect(STATE_DB_PATH, timeout=30) connection.row_factory = sqlite3.Row return connection def init_state_store() -> None: with db_connect() as connection: connection.execute( """ CREATE TABLE IF NOT EXISTS jobs ( job_id TEXT PRIMARY KEY, task_id TEXT NOT NULL, status TEXT NOT NULL, message TEXT NOT NULL, output TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) """ ) connection.execute( """ CREATE TABLE IF NOT EXISTS history ( history_id TEXT PRIMARY KEY, job_id TEXT NOT NULL, task_id TEXT NOT NULL, task TEXT NOT NULL, output TEXT NOT NULL, filename TEXT NOT NULL, size INTEGER NOT NULL, created_at INTEGER NOT NULL ) """ ) connection.execute( "CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC)" ) def load_state_store() -> None: with STATE_LOCK, db_connect() as connection: JOBS.clear() for row in connection.execute("SELECT * FROM jobs ORDER BY created_at DESC"): JOBS[row["job_id"]] = dict(row) HISTORY.clear() for row in connection.execute( "SELECT * FROM history ORDER BY created_at DESC LIMIT ?", (HISTORY_LIMIT,) ): item = dict(row) item["download_url"] = f"/history/{item['history_id']}/download" HISTORY.append(item) def persist_job(job: Dict[str, object]) -> None: now = int(time.time()) with db_connect() as connection: connection.execute( """ INSERT INTO jobs (job_id, task_id, status, message, output, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(job_id) DO UPDATE SET status=excluded.status, message=excluded.message, output=excluded.output, updated_at=excluded.updated_at """, ( str(job["job_id"]), str(job["task_id"]), str(job["status"]), str(job["message"]), str(job.get("output") or ""), int(job.get("created_at") or now), now, ), ) def persist_history(item: Dict[str, object]) -> None: with db_connect() as connection: connection.execute( """ INSERT OR REPLACE INTO history (history_id, job_id, task_id, task, output, filename, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( str(item["history_id"]), str(item["job_id"]), str(item["task_id"]), str(item["task"]), str(item["output"]), str(item["filename"]), int(item["size"]), int(item["created_at"]), ), ) stale_ids = [ row["history_id"] for row in connection.execute( """ SELECT history_id FROM history ORDER BY created_at DESC LIMIT -1 OFFSET ? """, (HISTORY_LIMIT,), ) ] if stale_ids: connection.executemany( "DELETE FROM history WHERE history_id = ?", [(history_id,) for history_id in stale_ids], ) init_state_store() load_state_store() def ffmpeg_escape(value: str) -> str: return ( value.replace("\\", "\\\\") .replace(":", "\\:") .replace("'", "\\'") .replace("\n", "\\n") .replace("%", "\\%") ) def filter_path(path: str) -> str: return ffmpeg_escape(os.path.abspath(path)) def ffconcat_path(path: str) -> str: return os.path.abspath(path).replace("\\", "\\\\").replace("'", "\\'") def drawtext_file_path(path: str) -> str: return filter_path(path) def remove_file_quietly(path: str) -> None: try: os.remove(path) except FileNotFoundError: return except OSError: return def clamp_int(value: str, default: int, minimum: int, maximum: int) -> int: try: parsed = int(float(value)) except (TypeError, ValueError): parsed = default return max(minimum, min(maximum, parsed)) def clamp_float(value: str, default: float, minimum: float, maximum: float) -> float: try: parsed = float(value) except (TypeError, ValueError): parsed = default return max(minimum, min(maximum, parsed)) def dimensions(preset: str) -> tuple[int, int]: presets = { "9:16": (1080, 1920), "16:9": (1920, 1080), "1:1": (1080, 1080), "4:5": (1080, 1350), } return presets.get(preset, presets["9:16"]) def scaled_crop_filter(preset: str) -> str: width, height = dimensions(preset) return ( f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}" ) def output_scale_filter(size: str) -> str: sizes = {"480p": 480, "720p": 720, "1080p": 1080} height = sizes.get(size, 720) return f"scale=-2:{height}" def write_wrapped_text_file(text: str, width: int = 24, max_lines: Optional[int] = None) -> str: cleaned = " ".join((text or "").split()) if not cleaned: cleaned = "Your faceless video" lines = textwrap.wrap(cleaned, width=width, break_long_words=False) if max_lines: lines = lines[:max_lines] path = os.path.abspath(os.path.join(TEMP_DIR, f"text_{uuid.uuid4().hex[:8]}.txt")) with open(path, "w", encoding="utf-8") as handle: handle.write("\n".join(lines)) return path def split_story_pages(text: str, words_per_page: int = 22) -> List[str]: words = (text or "").split() if not words: words = ["Add", "story", "text", "to", "generate", "faceless", "video", "slides."] pages = [ " ".join(words[index : index + words_per_page]) for index in range(0, len(words), words_per_page) ] return pages[:20] def production_checks() -> Dict[str, Dict[str, object]]: checks = { "ffmpeg": {"ok": shutil.which("ffmpeg") is not None}, "ffprobe": {"ok": shutil.which("ffprobe") is not None}, "font": {"ok": os.path.exists(FONT_PATH), "path": FONT_PATH}, "temp_dir": { "ok": os.path.isdir(TEMP_DIR) and os.access(TEMP_DIR, os.W_OK), "path": TEMP_DIR, }, "state_db": { "ok": os.path.exists(STATE_DB_PATH) and os.access(STATE_DB_PATH, os.W_OK), "path": STATE_DB_PATH, }, } checks["url_inputs"] = {"ok": True, "enabled": ALLOW_URL_INPUTS} return checks def production_ready() -> bool: required = ["ffmpeg", "ffprobe", "font", "temp_dir", "state_db"] checks = production_checks() return all(bool(checks[name]["ok"]) for name in required) def add_video_quality(cmd: List[str], opts: Dict[str, str]) -> List[str]: crf = str(clamp_int(opts.get("crf", "23"), 23, 12, 35)) preset = opts.get("preset") or "fast" if preset not in {"ultrafast", "veryfast", "fast", "medium", "slow"}: preset = "fast" return cmd + ["-c:v", "libx264", "-preset", preset, "-crf", crf, "-c:a", "aac"] def run_command(cmd: List[str]) -> subprocess.CompletedProcess: try: return subprocess.run( cmd, check=True, capture_output=True, text=True, timeout=FFMPEG_TIMEOUT_SECONDS, ) except subprocess.TimeoutExpired as exc: raise HTTPException( status_code=504, detail=f"FFmpeg timed out after {FFMPEG_TIMEOUT_SECONDS} seconds.", ) from exc def media_duration_seconds(path: str) -> float: result = run_command( [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path, ] ) try: duration = float(result.stdout.strip()) except ValueError as exc: raise HTTPException(status_code=500, detail="Could not read media duration.") from exc if duration <= 0: raise HTTPException(status_code=500, detail="Media duration is not usable.") return duration def build_normalize(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: cmd = ["ffmpeg", "-i", paths[0]] return add_video_quality(cmd, opts) + ["-y", out] def build_extract_audio(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: bitrate = str(clamp_int(opts.get("audio_bitrate", "192"), 192, 64, 320)) return [ "ffmpeg", "-i", paths[0], "-vn", "-acodec", "libmp3lame", "-ar", "44100", "-ac", "2", "-b:a", f"{bitrate}k", "-y", out, ] def build_resize(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: vf = scaled_crop_filter(opts.get("aspect_ratio", "9:16")) return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out] def build_subtitles(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: subtitle_filter = f"subtitles='{filter_path(paths[1])}'" return ["ffmpeg", "-i", paths[0], "-vf", subtitle_filter, "-c:a", "copy", "-y", out] def build_burn_lyrics(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: style = ( "Fontname=DejaVu Sans,Fontsize=24,PrimaryColour=&H00FFFF," "OutlineColour=&H000000,BorderStyle=3,Outline=1,Shadow=1,Alignment=2" ) vf = f"subtitles='{filter_path(paths[1])}':force_style='{style}'" return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out] def build_text_overlay(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: text = ffmpeg_escape(opts.get("text", "")) font_size = clamp_int(opts.get("font_size", "60"), 60, 18, 160) vf = ( f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{text}':" f"fontcolor=white:fontsize={font_size}:box=1:boxcolor=black@0.45:" "boxborderw=16:x=(w-text_w)/2:y=h-200" ) return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out] def build_merge_music(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: volume = clamp_float(opts.get("volume", "0.3"), 0.3, 0.0, 2.0) return [ "ffmpeg", "-i", paths[0], "-i", paths[1], "-filter_complex", f"[1:a]volume={volume}[a1]", "-map", "0:v", "-map", "[a1]", "-c:v", "copy", "-shortest", "-y", out, ] def build_thumbnail(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: timestamp = opts.get("timestamp") or "00:00:02" return ["ffmpeg", "-i", paths[0], "-ss", timestamp, "-vframes", "1", "-y", out] def build_watermark(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: positions = { "bottom-right": "W-w-20:H-h-20", "bottom-left": "20:H-h-20", "top-right": "W-w-20:20", "top-left": "20:20", "center": "(W-w)/2:(H-h)/2", } position = positions.get(opts.get("position"), positions["bottom-right"]) opacity = clamp_float(opts.get("opacity", "1"), 1, 0.1, 1.0) overlay = f"[1:v]format=rgba,colorchannelmixer=aa={opacity}[wm];[0:v][wm]overlay={position}" return ["ffmpeg", "-i", paths[0], "-i", paths[1], "-filter_complex", overlay, "-y", out] def build_compress(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: vf = output_scale_filter(opts.get("resolution", "720p")) cmd = ["ffmpeg", "-i", paths[0], "-vf", vf] return add_video_quality(cmd, opts) + ["-y", out] def build_gif(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: duration = str(clamp_int(opts.get("duration", "5"), 5, 1, 60)) fps = str(clamp_int(opts.get("fps", "10"), 10, 5, 30)) width = str(clamp_int(opts.get("width", "480"), 480, 240, 1080)) vf = f"fps={fps},scale={width}:-1:flags=lanczos" return ["ffmpeg", "-i", paths[0], "-t", duration, "-vf", vf, "-y", out] def build_tiktok_lyrics(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: text = ffmpeg_escape(opts.get("text", "")) duration = str(clamp_int(opts.get("duration", "15"), 15, 1, 180)) vf = ( scaled_crop_filter("9:16") + "," + f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{text}':" "fontcolor=white:fontsize=50:box=1:boxcolor=black@0.5:" "boxborderw=20:line_spacing=15:x=(w-text_w)/2:y=(h-text_h)/2" ) return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-t", duration, "-y", out] def build_tiktok_pro(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: text = ffmpeg_escape(opts.get("text", "")) vf = ( scaled_crop_filter("9:16") + "," + f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{text}':" "fontcolor=white:fontsize=48:box=1:boxcolor=black@0.4:" "boxborderw=14:x=(w-text_w)/2:y=h-200," "eq=contrast=1.15:brightness=0.04" ) return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out] def build_reels_blur_fit(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: filter_complex = ( "[0:v]scale=1080:1920:force_original_aspect_ratio=increase," "crop=1080:1920,gblur=sigma=24,eq=brightness=-0.08[bg];" "[0:v]scale=1080:1920:force_original_aspect_ratio=decrease[fg];" "[bg][fg]overlay=(W-w)/2:(H-h)/2,setsar=1[v]" ) return [ "ffmpeg", "-i", paths[0], "-filter_complex", filter_complex, "-map", "[v]", "-map", "0:a?", "-c:v", "libx264", "-preset", opts.get("preset") or "fast", "-crf", str(clamp_int(opts.get("crf", "23"), 23, 12, 35)), "-c:a", "aac", "-y", out, ] def build_reels_safe_caption(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: text = ffmpeg_escape(opts.get("text", "")) font_size = clamp_int(opts.get("font_size", "58"), 58, 24, 140) vf = ( scaled_crop_filter("9:16") + "," + f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{text}':" f"fontcolor=white:fontsize={font_size}:line_spacing=10:" "box=1:boxcolor=black@0.55:boxborderw=22:" "x=(w-text_w)/2:y=h-430" ) return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out] def build_reels_hook_title(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: text = ffmpeg_escape(opts.get("text", "")) font_size = clamp_int(opts.get("font_size", "64"), 64, 28, 150) vf = ( scaled_crop_filter("9:16") + ",drawbox=x=0:y=95:w=iw:h=210:color=black@0.62:t=fill," + f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{text}':" f"fontcolor=white:fontsize={font_size}:line_spacing=8:" "x=(w-text_w)/2:y=145" ) return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out] def build_reels_progress_bar(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: duration = clamp_float(opts.get("duration", "30"), 30, 1, 180) vf = ( scaled_crop_filter("9:16") + ",drawbox=x=0:y=0:w=iw:h=10:color=black@0.35:t=fill," + f"drawbox=x=0:y=0:w='min(iw,iw*t/{duration:.3f})':h=10:" "color=white@0.95:t=fill" ) return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-t", f"{duration:.3f}", "-y", out] def build_reels_loop(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: duration = clamp_float(opts.get("duration", "15"), 15, 1, 180) return [ "ffmpeg", "-stream_loop", "-1", "-i", paths[0], "-t", f"{duration:.3f}", "-vf", scaled_crop_filter("9:16"), "-c:v", "libx264", "-preset", opts.get("preset") or "fast", "-crf", str(clamp_int(opts.get("crf", "23"), 23, 12, 35)), "-c:a", "aac", "-y", out, ] def build_reels_subtitle_safe(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: vf = ( scaled_crop_filter("9:16") + ",subtitles='" + filter_path(paths[1]) + "':force_style='Fontname=DejaVu Sans,Fontsize=28," + "PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000," + "BorderStyle=3,Outline=1,Shadow=0,Alignment=2,MarginV=250'" ) return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out] def build_reels_reaction_stack(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: filter_complex = ( "[0:v]scale=1080:960:force_original_aspect_ratio=increase," "crop=1080:960,setsar=1[top];" "[1:v]scale=1080:960:force_original_aspect_ratio=increase," "crop=1080:960,setsar=1[bottom];" "[top][bottom]vstack=inputs=2[v]" ) return [ "ffmpeg", "-i", paths[0], "-i", paths[1], "-filter_complex", filter_complex, "-map", "[v]", "-map", "0:a?", "-shortest", "-c:v", "libx264", "-preset", opts.get("preset") or "fast", "-crf", str(clamp_int(opts.get("crf", "23"), 23, 12, 35)), "-c:a", "aac", "-y", out, ] def build_reels_audio_duck(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: music_volume = clamp_float(opts.get("volume", "0.18"), 0.18, 0.0, 1.0) filter_complex = ( f"[1:a]volume={music_volume}[music];" "[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[a]" ) return [ "ffmpeg", "-i", paths[0], "-i", paths[1], "-filter_complex", filter_complex, "-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac", "-shortest", "-y", out, ] def build_faceless_quote_card(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: duration = clamp_float(opts.get("duration", "12"), 12, 3, 180) font_size = clamp_int(opts.get("font_size", "68"), 68, 28, 140) text_path = write_wrapped_text_file(opts.get("text", ""), width=22, max_lines=9) vf = ( "drawbox=x=0:y=0:w=iw:h=ih:color=0x111827@1:t=fill," "drawbox=x=70:y=190:w=940:h=1540:color=0x0f766e@0.22:t=fill," f"drawtext=fontfile='{filter_path(FONT_PATH)}':" f"textfile='{drawtext_file_path(text_path)}':fontcolor=white:" f"fontsize={font_size}:line_spacing=14:x=(w-text_w)/2:y=(h-text_h)/2" ) return [ "ffmpeg", "-f", "lavfi", "-i", f"color=c=0x111827:s=1080x1920:r=30:d={duration:.3f}", "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-vf", vf, "-map", "0:v", "-map", "1:a", "-t", f"{duration:.3f}", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", "-y", out, ] def build_faceless_image_narration(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: text_path = write_wrapped_text_file(opts.get("text", ""), width=24, max_lines=6) font_size = clamp_int(opts.get("font_size", "54"), 54, 24, 110) vf = ( "scale=1200:-1,zoompan=z='min(zoom+0.0009,1.12)':" "d=1800:s=1080x1920:fps=30," f"drawtext=fontfile='{filter_path(FONT_PATH)}':" f"textfile='{drawtext_file_path(text_path)}':fontcolor=white:" f"fontsize={font_size}:line_spacing=10:box=1:boxcolor=black@0.52:" "boxborderw=22:x=(w-text_w)/2:y=h-520" ) return [ "ffmpeg", "-loop", "1", "-i", paths[0], "-i", paths[1], "-vf", vf, "-map", "0:v", "-map", "1:a", "-c:v", "libx264", "-preset", opts.get("preset") or "fast", "-crf", str(clamp_int(opts.get("crf", "23"), 23, 12, 35)), "-c:a", "aac", "-shortest", "-pix_fmt", "yuv420p", "-y", out, ] def build_faceless_video_narration(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: text_path = write_wrapped_text_file(opts.get("text", ""), width=24, max_lines=6) font_size = clamp_int(opts.get("font_size", "52"), 52, 24, 110) filter_complex = ( "[0:v]scale=1080:1920:force_original_aspect_ratio=increase," "crop=1080:1920,setsar=1," f"drawtext=fontfile='{filter_path(FONT_PATH)}':" f"textfile='{drawtext_file_path(text_path)}':fontcolor=white:" f"fontsize={font_size}:line_spacing=10:box=1:boxcolor=black@0.5:" "boxborderw=20:x=(w-text_w)/2:y=h-500[v]" ) return [ "ffmpeg", "-i", paths[0], "-i", paths[1], "-filter_complex", filter_complex, "-map", "[v]", "-map", "1:a", "-c:v", "libx264", "-preset", opts.get("preset") or "fast", "-crf", str(clamp_int(opts.get("crf", "23"), 23, 12, 35)), "-c:a", "aac", "-shortest", "-y", out, ] def run_faceless_story_pages(paths: List[str], options: Dict[str, str], out: str) -> str: pages = split_story_pages(options.get("text", ""), words_per_page=24) seconds = clamp_float(options.get("image_duration", "3"), 3, 1, 12) work_dir = os.path.join(TEMP_DIR, f"story_{uuid.uuid4().hex[:8]}") os.makedirs(work_dir, exist_ok=True) list_path = os.path.join(work_dir, "concat.txt") try: with open(list_path, "w", encoding="utf-8") as concat_file: for index, page in enumerate(pages, start=1): clip_path = os.path.join(work_dir, f"page_{index:03d}.mp4") text_path = write_wrapped_text_file(page, width=24, max_lines=8) vf = ( "drawbox=x=0:y=0:w=iw:h=ih:color=0x0f172a@1:t=fill," f"drawtext=fontfile='{filter_path(FONT_PATH)}':" f"textfile='{drawtext_file_path(text_path)}':fontcolor=white:" "fontsize=62:line_spacing=12:x=(w-text_w)/2:y=(h-text_h)/2," f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{index}/{len(pages)}':" "fontcolor=white@0.62:fontsize=34:x=(w-text_w)/2:y=h-150" ) cmd = [ "ffmpeg", "-f", "lavfi", "-i", f"color=c=0x0f172a:s=1080x1920:r=30:d={seconds:.3f}", "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-vf", vf, "-map", "0:v", "-map", "1:a", "-t", f"{seconds:.3f}", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-y", clip_path, ] run_command(cmd) concat_file.write(f"file '{ffconcat_path(clip_path)}'\n") run_command(["ffmpeg", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", "-y", out]) return out finally: shutil.rmtree(work_dir, ignore_errors=True) def run_faceless_broll_montage(paths: List[str], options: Dict[str, str], out: str) -> str: clip_seconds = clamp_float(options.get("image_duration", "2.5"), 2.5, 1, 8) max_clips = clamp_int(options.get("duration", "12"), 12, 1, 60) work_dir = os.path.join(TEMP_DIR, f"broll_{uuid.uuid4().hex[:8]}") os.makedirs(work_dir, exist_ok=True) list_path = os.path.join(work_dir, "concat.txt") try: with open(list_path, "w", encoding="utf-8") as concat_file: for index, path in enumerate(paths[:max_clips], start=1): clip_path = os.path.join(work_dir, f"clip_{index:03d}.mp4") cmd = [ "ffmpeg", "-i", path, "-t", f"{clip_seconds:.3f}", "-vf", scaled_crop_filter("9:16") + ",setsar=1", "-an", "-c:v", "libx264", "-preset", options.get("preset") or "fast", "-crf", str(clamp_int(options.get("crf", "23"), 23, 12, 35)), "-pix_fmt", "yuv420p", "-y", clip_path, ] run_command(cmd) concat_file.write(f"file '{ffconcat_path(clip_path)}'\n") run_command(["ffmpeg", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", "-y", out]) return out finally: shutil.rmtree(work_dir, ignore_errors=True) def build_series_episode_badge(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: title = ffmpeg_escape(opts.get("text", "Mini Series")) font_size = clamp_int(opts.get("font_size", "48"), 48, 24, 110) vf = ( scaled_crop_filter("9:16") + ",drawbox=x=36:y=86:w=360:h=92:color=black@0.68:t=fill," + f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{title}':" f"fontcolor=white:fontsize={font_size}:x=60:y=106," + "drawbox=x=36:y=h-238:w=1008:h=116:color=black@0.44:t=fill" ) return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out] def build_series_recap_card(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: duration = clamp_float(opts.get("duration", "5"), 5, 2, 30) text_path = write_wrapped_text_file(opts.get("text", "Previously in this series"), width=22, max_lines=7) vf = ( "drawbox=x=0:y=0:w=iw:h=ih:color=0x111827@1:t=fill," "drawbox=x=70:y=260:w=940:h=1180:color=0x7c2d12@0.32:t=fill," f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='PREVIOUSLY':" "fontcolor=white@0.72:fontsize=46:x=(w-text_w)/2:y=350," f"drawtext=fontfile='{filter_path(FONT_PATH)}':textfile='{drawtext_file_path(text_path)}':" "fontcolor=white:fontsize=68:line_spacing=14:x=(w-text_w)/2:y=(h-text_h)/2" ) return [ "ffmpeg", "-f", "lavfi", "-i", f"color=c=0x111827:s=1080x1920:r=30:d={duration:.3f}", "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-vf", vf, "-map", "0:v", "-map", "1:a", "-t", f"{duration:.3f}", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", "-y", out, ] def run_series_split_pack(paths: List[str], options: Dict[str, str], out: str) -> str: segment_seconds = clamp_float(options.get("duration", "60"), 60, 15, 180) title = options.get("text", "Mini Series") duration = media_duration_seconds(paths[0]) episode_count = min(100, int((duration + segment_seconds - 0.001) // segment_seconds)) work_dir = os.path.join(TEMP_DIR, f"series_{uuid.uuid4().hex[:8]}") os.makedirs(work_dir, exist_ok=True) manifest_path = os.path.join(work_dir, "manifest.txt") try: with open(manifest_path, "w", encoding="utf-8") as manifest: manifest.write(f"series_title={title}\n") manifest.write(f"episode_count={episode_count}\n") manifest.write(f"episode_seconds={segment_seconds:.3f}\n\n") for episode in range(1, episode_count + 1): start = (episode - 1) * segment_seconds remaining = max(0.1, duration - start) clip_duration = min(segment_seconds, remaining) episode_out = os.path.join(work_dir, f"episode_{episode:03d}_of_{episode_count:03d}.mp4") label = ffmpeg_escape(f"PART {episode}/{episode_count}") caption = ffmpeg_escape(title) vf = ( scaled_crop_filter("9:16") + ",drawbox=x=36:y=86:w=390:h=92:color=black@0.68:t=fill," + f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{label}':" "fontcolor=white:fontsize=46:x=58:y=108," + "drawbox=x=36:y=h-238:w=1008:h=116:color=black@0.48:t=fill," + f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{caption}':" "fontcolor=white:fontsize=42:x=(w-text_w)/2:y=h-205" ) cmd = [ "ffmpeg", "-ss", f"{start:.3f}", "-i", paths[0], "-t", f"{clip_duration:.3f}", "-vf", vf, "-c:v", "libx264", "-preset", options.get("preset") or "fast", "-crf", str(clamp_int(options.get("crf", "23"), 23, 12, 35)), "-c:a", "aac", "-y", episode_out, ] run_command(cmd) manifest.write(f"{episode_out}\n") shutil.make_archive(out[:-4], "zip", work_dir) return out finally: shutil.rmtree(work_dir, ignore_errors=True) def run_series_batch_pack(paths: List[str], options: Dict[str, str], out: str) -> str: title = options.get("text", "Mini Series") work_dir = os.path.join(TEMP_DIR, f"series_batch_{uuid.uuid4().hex[:8]}") os.makedirs(work_dir, exist_ok=True) manifest_path = os.path.join(work_dir, "manifest.txt") try: with open(manifest_path, "w", encoding="utf-8") as manifest: manifest.write(f"series_title={title}\n") manifest.write(f"episode_count={len(paths)}\n\n") for episode, path in enumerate(paths, start=1): episode_out = os.path.join(work_dir, f"episode_{episode:03d}_of_{len(paths):03d}.mp4") label = ffmpeg_escape(f"PART {episode}/{len(paths)}") caption = ffmpeg_escape(title) vf = ( scaled_crop_filter("9:16") + ",drawbox=x=36:y=86:w=390:h=92:color=black@0.68:t=fill," + f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{label}':" "fontcolor=white:fontsize=46:x=58:y=108," + "drawbox=x=36:y=h-238:w=1008:h=116:color=black@0.48:t=fill," + f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{caption}':" "fontcolor=white:fontsize=42:x=(w-text_w)/2:y=h-205" ) cmd = [ "ffmpeg", "-i", path, "-vf", vf, "-c:v", "libx264", "-preset", options.get("preset") or "fast", "-crf", str(clamp_int(options.get("crf", "23"), 23, 12, 35)), "-c:a", "aac", "-y", episode_out, ] run_command(cmd) manifest.write(f"{episode_out}\n") shutil.make_archive(out[:-4], "zip", work_dir) return out finally: shutil.rmtree(work_dir, ignore_errors=True) def build_concat(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: job_id = uuid.uuid4().hex[:8] list_path = os.path.join(TEMP_DIR, f"list_{job_id}.txt") with open(list_path, "w", encoding="utf-8") as handle: for path in paths: quoted = ffconcat_path(path) handle.write(f"file '{quoted}'\n") return ["ffmpeg", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", "-y", out] def build_slideshow(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: duration = str(clamp_int(opts.get("image_duration", "3"), 3, 1, 15)) job_id = uuid.uuid4().hex[:8] list_path = os.path.join(TEMP_DIR, f"slides_{job_id}.txt") with open(list_path, "w", encoding="utf-8") as handle: for path in paths: quoted = ffconcat_path(path) handle.write(f"file '{quoted}'\nduration {duration}\n") quoted = ffconcat_path(paths[-1]) handle.write(f"file '{quoted}'\n") return [ "ffmpeg", "-f", "concat", "-safe", "0", "-i", list_path, "-vsync", "vfr", "-pix_fmt", "yuv420p", "-y", out, ] def build_trim(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: start = opts.get("start_time") or "00:00:00" end = opts.get("end_time") or "" cmd = ["ffmpeg", "-ss", start, "-i", paths[0]] if end: cmd += ["-to", end] return cmd + ["-c", "copy", "-y", out] def build_crop(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: vf = scaled_crop_filter(opts.get("aspect_ratio", "1:1")) return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out] def build_waveform(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: color = opts.get("wave_color") or "00e5ff" if not all(ch in "0123456789abcdefABCDEF" for ch in color) or len(color) != 6: color = "00e5ff" return [ "ffmpeg", "-i", paths[0], "-filter_complex", f"[0:a]showwaves=s=1280x720:mode=line:colors=#{color}[v]", "-map", "[v]", "-map", "0:a", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", "-y", out, ] def build_extract_frames(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: fps = str(clamp_float(opts.get("frame_rate", "1"), 1, 0.1, 30)) pattern = out.replace(".zip", "_%04d.jpg") return ["ffmpeg", "-i", paths[0], "-vf", f"fps={fps}", "-y", pattern] def build_add_intro_outro(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: clips = paths[:] return build_concat(clips, opts, out) def build_speed(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: speed = clamp_float(opts.get("speed", "1.25"), 1.25, 0.25, 4.0) setpts = 1 / speed atempo_parts = [] remaining = speed while remaining > 2.0: atempo_parts.append("atempo=2.0") remaining /= 2.0 while remaining < 0.5: atempo_parts.append("atempo=0.5") remaining /= 0.5 atempo_parts.append(f"atempo={remaining:.4f}") filter_complex = f"[0:v]setpts={setpts:.6f}*PTS[v];[0:a]{','.join(atempo_parts)}[a]" return [ "ffmpeg", "-i", paths[0], "-filter_complex", filter_complex, "-map", "[v]", "-map", "[a]", "-y", out, ] def build_remove_audio(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: return ["ffmpeg", "-i", paths[0], "-c:v", "copy", "-an", "-y", out] def build_replace_audio(paths: List[str], opts: Dict[str, str], out: str) -> List[str]: return [ "ffmpeg", "-i", paths[0], "-i", paths[1], "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", "-shortest", "-y", out, ] def run_batch_compress(paths: List[str], options: Dict[str, str], out: str) -> str: archive_dir = os.path.join(TEMP_DIR, f"batch_{uuid.uuid4().hex[:8]}") os.makedirs(archive_dir, exist_ok=True) try: for index, path in enumerate(paths, start=1): name, _ext = os.path.splitext(os.path.basename(path)) item_out = os.path.join(archive_dir, f"{index:02d}_{name}_compressed.mp4") cmd = build_compress([path], options, item_out) run_command(cmd) shutil.make_archive(out[:-4], "zip", archive_dir) return out finally: shutil.rmtree(archive_dir, ignore_errors=True) TASKS: Dict[str, TaskDefinition] = { "normalize": TaskDefinition("Normalize MP4", "Convert to H.264/AAC MP4.", "Video", "mp4", 1, 1, ["video"], build_normalize), "extract_audio": TaskDefinition("Extract Audio", "Export MP3 audio from a video.", "Audio", "mp3", 1, 1, ["video", "audio"], build_extract_audio), "resize_916": TaskDefinition("Resize 9:16", "Crop video for vertical platforms.", "Social", "mp4", 1, 1, ["video"], build_resize), "add_subtitles": TaskDefinition("Add Subtitles", "Burn an SRT/ASS subtitle file into video.", "Subtitles", "mp4", 2, 2, ["video", "text"], build_subtitles, file_types=[["video"], ["text"]]), "burn_lyrics": TaskDefinition("Burn Lyrics", "Burn styled lyric subtitles into video.", "Subtitles", "mp4", 2, 2, ["video", "text"], build_burn_lyrics, file_types=[["video"], ["text"]]), "text_overlay": TaskDefinition("Text Overlay", "Add caption text to video.", "Social", "mp4", 1, 1, ["video"], build_text_overlay), "merge_music": TaskDefinition("Merge Music", "Add background music to video.", "Audio", "mp4", 2, 2, ["video", "audio"], build_merge_music, file_types=[["video"], ["audio"]]), "thumbnail": TaskDefinition("Thumbnail", "Export one JPG frame.", "Image", "jpg", 1, 1, ["video"], build_thumbnail), "watermark": TaskDefinition("Watermark", "Overlay a logo/image on video.", "Branding", "mp4", 2, 2, ["video", "image"], build_watermark, file_types=[["video"], ["image"]]), "compress": TaskDefinition("Compress", "Reduce video size using CRF and scale.", "Video", "mp4", 1, 1, ["video"], build_compress), "batch_compress": TaskDefinition("Batch Compress", "Compress multiple videos and return a ZIP.", "Video", "zip", 1, None, ["video"], runner=run_batch_compress), "make_gif": TaskDefinition("Make GIF", "Create a short GIF clip.", "Image", "gif", 1, 1, ["video"], build_gif), "tiktok_lyrics": TaskDefinition("TikTok Lyrics", "Vertical lyric clip with boxed center text.", "Social", "mp4", 1, 1, ["video"], build_tiktok_lyrics), "tiktok_pro_reframer": TaskDefinition("TikTok Pro Reframer", "Vertical crop, text, and contrast pass.", "Social", "mp4", 1, 1, ["video"], build_tiktok_pro), "reels_blur_fit": TaskDefinition("Reels Blur Fit", "Fit any video into 9:16 with a blurred background.", "Social", "mp4", 1, 1, ["video"], build_reels_blur_fit), "reels_safe_caption": TaskDefinition("Reels Safe Caption", "Add lower-third caption text inside Reels and Shorts safe zones.", "Social", "mp4", 1, 1, ["video"], build_reels_safe_caption), "reels_hook_title": TaskDefinition("Reels Hook Title", "Add a bold top hook/title banner to a vertical clip.", "Social", "mp4", 1, 1, ["video"], build_reels_hook_title), "reels_progress_bar": TaskDefinition("Reels Progress Bar", "Add a top progress bar and optional duration trim.", "Social", "mp4", 1, 1, ["video"], build_reels_progress_bar), "reels_loop": TaskDefinition("Reels Loop", "Loop a clip to a target vertical Shorts/Reels duration.", "Social", "mp4", 1, 1, ["video"], build_reels_loop), "reels_subtitle_safe": TaskDefinition("Reels Subtitle Safe", "Burn subtitles with mobile-safe lower margins.", "Social", "mp4", 2, 2, ["video", "text"], build_reels_subtitle_safe, file_types=[["video"], ["text"]]), "reels_reaction_stack": TaskDefinition("Reels Reaction Stack", "Stack two videos vertically for reaction-style Shorts.", "Social", "mp4", 2, 2, ["video"], build_reels_reaction_stack), "reels_audio_duck": TaskDefinition("Reels Audio Duck", "Mix background music under the original video audio.", "Social", "mp4", 2, 2, ["video", "audio"], build_reels_audio_duck, file_types=[["video"], ["audio"]]), "faceless_quote_card": TaskDefinition("Faceless Quote Card", "Generate a vertical text-only quote short from caption text.", "Faceless", "mp4", 0, 0, [], build_faceless_quote_card), "faceless_story_pages": TaskDefinition("Faceless Story Pages", "Split long text into timed vertical story slides.", "Faceless", "mp4", 0, 0, [], runner=run_faceless_story_pages), "faceless_image_narration": TaskDefinition("Faceless Image Narration", "Create a Ken Burns image short with narration audio and caption text.", "Faceless", "mp4", 2, 2, ["image", "audio"], build_faceless_image_narration, file_types=[["image"], ["audio"]]), "faceless_video_narration": TaskDefinition("Faceless Video Narration", "Create a vertical b-roll short with narration audio and caption text.", "Faceless", "mp4", 2, 2, ["video", "audio"], build_faceless_video_narration, file_types=[["video"], ["audio"]]), "faceless_broll_montage": TaskDefinition("Faceless B-roll Montage", "Create a silent vertical montage from multiple b-roll videos.", "Faceless", "mp4", 2, None, ["video"], runner=run_faceless_broll_montage), "series_split_pack": TaskDefinition("Mini Series Split Pack", "Split one long video into numbered vertical TikTok/Reels episodes and return a ZIP.", "Series", "zip", 1, 1, ["video"], runner=run_series_split_pack), "series_episode_badge": TaskDefinition("Mini Series Episode Badge", "Add a series title and episode-safe badge area to one vertical clip.", "Series", "mp4", 1, 1, ["video"], build_series_episode_badge), "series_batch_pack": TaskDefinition("Mini Series Batch Pack", "Number multiple clips as a TikTok/Reels mini-series and return a ZIP.", "Series", "zip", 2, None, ["video"], runner=run_series_batch_pack), "series_recap_card": TaskDefinition("Mini Series Recap Card", "Generate a short text-only recap card for the next episode.", "Series", "mp4", 0, 0, [], build_series_recap_card), "concat": TaskDefinition("Concat Videos", "Join clips in upload order.", "Video", "mp4", 2, None, ["video"], build_concat), "slideshow": TaskDefinition("Slideshow", "Create a video from images.", "Image", "mp4", 2, None, ["image"], build_slideshow), "trim": TaskDefinition("Trim", "Cut a clip by start/end time.", "Video", "mp4", 1, 1, ["video", "audio"], build_trim), "crop_aspect": TaskDefinition("Crop Aspect", "Crop to 1:1, 4:5, 16:9, or 9:16.", "Video", "mp4", 1, 1, ["video"], build_crop), "waveform": TaskDefinition("Waveform Video", "Create a waveform video from audio.", "Audio", "mp4", 1, 1, ["audio", "video"], build_waveform), "extract_frames": TaskDefinition("Extract Frames", "Export JPG frames and return a ZIP.", "Image", "zip", 1, 1, ["video"], build_extract_frames), "add_intro_outro": TaskDefinition("Add Intro/Outro", "Join intro, main clip, and outro.", "Video", "mp4", 2, 3, ["video"], build_add_intro_outro), "speed": TaskDefinition("Speed Change", "Speed up or slow down video and audio.", "Video", "mp4", 1, 1, ["video"], build_speed), "remove_audio": TaskDefinition("Remove Audio", "Export video without audio.", "Audio", "mp4", 1, 1, ["video"], build_remove_audio), "replace_audio": TaskDefinition("Replace Audio", "Replace video audio with another file.", "Audio", "mp4", 2, 2, ["video", "audio"], build_replace_audio, file_types=[["video"], ["audio"]]), } def cleanup_worker(): while True: now = time.time() expired_outputs = [] for name in os.listdir(TEMP_DIR): path = os.path.join(TEMP_DIR, name) try: if os.path.abspath(path) == os.path.abspath(STATE_DB_PATH): continue if os.path.isfile(path) and now - os.path.getmtime(path) > FILE_TTL_SECONDS: expired_outputs.append(os.path.abspath(path)) remove_file_quietly(path) except OSError: continue if expired_outputs: with db_connect() as connection: connection.executemany( "DELETE FROM history WHERE output = ?", [(path,) for path in expired_outputs], ) load_state_store() time.sleep(600) threading.Thread(target=cleanup_worker, daemon=True).start() def file_category(path: str) -> str: try: mime_type = magic.from_file(path, mime=True) or "" except Exception: mime_type = "" if mime_type.startswith("video/"): return "video" if mime_type.startswith("audio/"): return "audio" if mime_type.startswith("image/"): return "image" if mime_type in {"text/plain", "application/x-subrip"} or path.lower().endswith( (".srt", ".ass", ".vtt") ): return "text" return "unknown" def validate_files(task_id: str, paths: List[str]) -> None: task = get_task(task_id) if len(paths) < task.min_files: raise HTTPException( status_code=400, detail=f"{task.label} requires at least {task.min_files} file(s).", ) if task.max_files is not None and len(paths) > task.max_files: raise HTTPException( status_code=400, detail=f"{task.label} accepts at most {task.max_files} file(s).", ) for index, path in enumerate(paths): if not os.path.exists(path): raise HTTPException(status_code=400, detail=f"Input not found: {path}") if os.path.getsize(path) > MAX_UPLOAD_BYTES: raise HTTPException( status_code=413, detail=f"{os.path.basename(path)} exceeds {MAX_UPLOAD_MB} MB.", ) category = file_category(path) expected_types = ( task.file_types[index] if task.file_types and index < len(task.file_types) else task.accepted_types ) if category not in expected_types: raise HTTPException( status_code=400, detail=( f"{os.path.basename(path)} is {category}; " f"{task.label} expects {', '.join(expected_types)}." ), ) def get_task(task_id: str) -> TaskDefinition: task = TASKS.get(task_id) if not task: raise HTTPException(status_code=404, detail=f"Unknown task: {task_id}") return task def output_path(task_id: str, ext: str, job_id: Optional[str] = None) -> str: safe_job_id = job_id or uuid.uuid4().hex[:8] return os.path.abspath(os.path.join(TEMP_DIR, f"{task_id}_{safe_job_id}.{ext}")) def zip_frames(pattern_prefix: str, zip_path: str) -> str: frame_dir = os.path.dirname(pattern_prefix) prefix = os.path.basename(pattern_prefix).split("_%04d")[0] archive_base = zip_path[:-4] frame_paths = [ os.path.join(frame_dir, name) for name in os.listdir(frame_dir) if name.startswith(prefix) and name.endswith(".jpg") ] if not frame_paths: raise HTTPException(status_code=500, detail="No frames were generated.") temp_archive_dir = os.path.join(TEMP_DIR, f"frames_{uuid.uuid4().hex[:8]}") os.makedirs(temp_archive_dir, exist_ok=True) try: for frame_path in frame_paths: shutil.copy2(frame_path, os.path.join(temp_archive_dir, os.path.basename(frame_path))) shutil.make_archive(archive_base, "zip", temp_archive_dir) finally: shutil.rmtree(temp_archive_dir, ignore_errors=True) return zip_path def run_ffmpeg(task_id: str, paths: List[str], options: Optional[Dict[str, str]] = None) -> str: task = get_task(task_id) opts = options or {} validate_files(task_id, paths) job_id = uuid.uuid4().hex[:8] out = output_path(task_id, task.output_ext, job_id) if task.runner: try: return task.runner(paths, opts, out) except subprocess.CalledProcessError as exc: stderr = exc.stderr.strip()[-2000:] or "FFmpeg failed without stderr." raise HTTPException(status_code=500, detail=stderr) if not task.builder: raise HTTPException(status_code=500, detail=f"{task_id} is not executable.") cmd = task.builder(paths, opts, out) try: run_command(cmd) if task_id == "extract_frames": out = zip_frames(out.replace(".zip", "_%04d.jpg"), out) return out except subprocess.CalledProcessError as exc: stderr = exc.stderr.strip()[-2000:] or "FFmpeg failed without stderr." raise HTTPException(status_code=500, detail=stderr) def record_history(job_id: str, task_id: str, output: str) -> None: task = get_task(task_id) history_id = uuid.uuid4().hex[:12] item = { "history_id": history_id, "job_id": job_id, "task_id": task_id, "task": task.label, "output": output, "filename": os.path.basename(output), "size": os.path.getsize(output) if os.path.exists(output) else 0, "created_at": int(time.time()), "download_url": f"/history/{history_id}/download", } with STATE_LOCK: HISTORY.insert(0, item) del HISTORY[HISTORY_LIMIT:] persist_history(item) def update_job(job_id: str, **updates: object) -> None: with STATE_LOCK: job = JOBS.get(job_id) if not job: return job.update(updates) snapshot = dict(job) persist_job(snapshot) def execute_job(job_id: str, task_id: str, paths: List[str], options: Dict[str, str]) -> None: update_job(job_id, status="running", message="Running FFmpeg") try: output = run_ffmpeg(task_id, paths, options) record_history(job_id, task_id, output) update_job(job_id, status="complete", message="Complete", output=output) except HTTPException as exc: update_job(job_id, status="failed", message=exc.detail) except Exception as exc: update_job(job_id, status="failed", message=str(exc)) async def save_uploads(files: Optional[List[UploadFile]]) -> List[str]: saved = [] if not files: return saved for upload in files: filename = os.path.basename(upload.filename or "upload.bin") path = os.path.join(TEMP_DIR, f"{uuid.uuid4().hex}_{filename}") size = 0 with open(path, "wb") as handle: while chunk := await upload.read(1024 * 1024): size += len(chunk) if size > MAX_UPLOAD_BYTES: handle.close() remove_file_quietly(path) raise HTTPException( status_code=413, detail=f"{filename} exceeds {MAX_UPLOAD_MB} MB.", ) handle.write(chunk) saved.append(path) return saved def safe_upload_name(filename: str, fallback: str = "upload.bin") -> str: cleaned = os.path.basename(filename or fallback).strip() return cleaned or fallback def save_bytes_file(content: bytes, filename: str) -> str: if len(content) > MAX_UPLOAD_BYTES: raise HTTPException( status_code=413, detail=f"{safe_upload_name(filename)} exceeds {MAX_UPLOAD_MB} MB.", ) path = os.path.abspath( os.path.join(TEMP_DIR, f"{uuid.uuid4().hex}_{safe_upload_name(filename)}") ) with open(path, "wb") as handle: handle.write(content) return path def decode_base64_content(value: str) -> bytes: if "," in value and value.lstrip().startswith("data:"): value = value.split(",", 1)[1] try: return base64.b64decode(value, validate=True) except (binascii.Error, ValueError) as exc: raise HTTPException(status_code=400, detail="Invalid base64 file content.") from exc def collect_base64_files(payload: object) -> List[str]: saved = [] def walk(value: object, inherited_name: str = "n8n-upload.bin") -> None: if isinstance(value, list): for item in value: walk(item, inherited_name) return if not isinstance(value, dict): return filename = str( value.get("fileName") or value.get("filename") or value.get("name") or inherited_name ) content = ( value.get("content_base64") or value.get("base64") or value.get("content") or value.get("data") ) if isinstance(content, str) and ( value.get("mimeType") or value.get("mime_type") or value.get("fileName") or value.get("filename") or value.get("base64") or value.get("content_base64") ): saved.append(save_bytes_file(decode_base64_content(content), filename)) return for key, nested in value.items(): walk(nested, str(key)) walk(payload) return saved def split_url_values(value: object) -> List[str]: if not value: return list() if isinstance(value, list): urls = [] for item in value: urls.extend(split_url_values(item)) return urls if not isinstance(value, str): return list() chunks = value.replace(",", "\n").splitlines() return [chunk.strip() for chunk in chunks if chunk.strip()] def options_from_mapping(values: Dict[str, object]) -> Dict[str, str]: nested_options = values.get("options") merged = dict(values) if isinstance(nested_options, dict): merged.update(nested_options) return { field: "" if merged.get(field) is None else str(merged.get(field, "")) for field in OPTION_FIELDS } async def collect_n8n_inputs(request: Request) -> tuple[List[str], Dict[str, str]]: content_type = request.headers.get("content-type", "").lower() saved: List[str] = [] values: Dict[str, object] = {} if "multipart/form-data" in content_type: form = await request.form() uploads = [] urls = [] for key, value in form.multi_items(): if hasattr(value, "filename") and hasattr(value, "read"): uploads.append(value) else: values[key] = value if key in {"url", "urls", "file_url", "source_url", "download_url"}: urls.extend(split_url_values(value)) saved.extend(await save_uploads(uploads)) for url in urls: saved.append(download_url(url)) return saved, options_from_mapping(values) if "application/json" in content_type: payload = await request.json() if not isinstance(payload, dict): raise HTTPException(status_code=400, detail="JSON body must be an object.") values.update(payload) for key in ("url", "urls", "file_url", "source_url", "download_url"): for url in split_url_values(payload.get(key)): saved.append(download_url(url)) saved.extend(collect_base64_files(payload.get("files", []))) saved.extend(collect_base64_files(payload.get("binary", {}))) saved.extend(collect_base64_files(payload.get("data", {}))) if not saved: saved.extend(collect_base64_files(payload)) return saved, options_from_mapping(values) body = await request.body() if body: filename = ( request.headers.get("x-filename") or request.headers.get("x-file-name") or "n8n-upload.bin" ) saved.append(save_bytes_file(body, filename)) return saved, options_from_mapping(dict(request.query_params)) def download_url(url: str) -> str: if not ALLOW_URL_INPUTS: raise HTTPException(status_code=403, detail="URL inputs are disabled.") outtmpl = os.path.join(TEMP_DIR, f"{uuid.uuid4().hex}.%(ext)s") ydl_opts = { "outtmpl": outtmpl, "format": "bestvideo+bestaudio/best", "max_filesize": MAX_URL_DOWNLOAD_BYTES, "noplaylist": True, "quiet": True, "no_warnings": True, } with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(url, download=True) path = ydl.prepare_filename(info) if not os.path.exists(path): raise HTTPException(status_code=502, detail="URL download did not produce a file.") if os.path.getsize(path) > MAX_URL_DOWNLOAD_BYTES: remove_file_quietly(path) raise HTTPException( status_code=413, detail=f"Downloaded media exceeds {MAX_URL_DOWNLOAD_MB} MB.", ) return path def options_from_form( text: str, start_time: str, end_time: str, duration: str, aspect_ratio: str, resolution: str, crf: str, preset: str, audio_bitrate: str, volume: str, position: str, opacity: str, fps: str, width: str, speed: str, timestamp: str, image_duration: str, frame_rate: str, font_size: str, wave_color: str, ) -> Dict[str, str]: return { "text": text or "", "start_time": start_time or "", "end_time": end_time or "", "duration": duration or "", "aspect_ratio": aspect_ratio or "", "resolution": resolution or "", "crf": crf or "", "preset": preset or "", "audio_bitrate": audio_bitrate or "", "volume": volume or "", "position": position or "", "opacity": opacity or "", "fps": fps or "", "width": width or "", "speed": speed or "", "timestamp": timestamp or "", "image_duration": image_duration or "", "frame_rate": frame_rate or "", "font_size": font_size or "", "wave_color": wave_color or "", } @app.get("/healthz") def healthz(): return {"status": "ok", "checks": production_checks()} @app.get("/readyz") def readyz(): checks = production_checks() if not production_ready(): raise HTTPException(status_code=503, detail={"status": "not_ready", "checks": checks}) return {"status": "ready", "checks": checks} @app.get("/tasks") def list_tasks(): return { task_id: { "label": task.label, "description": task.description, "category": task.category, "output_ext": task.output_ext, "min_files": task.min_files, "max_files": task.max_files, "accepted_types": task.accepted_types, "file_types": task.file_types, } for task_id, task in TASKS.items() } @app.post("/n8n/execute/{task_id}") async def n8n_execute(task_id: str, request: Request): task = get_task(task_id) saved, options = await collect_n8n_inputs(request) if not saved and task.min_files > 0: raise HTTPException(status_code=400, detail="No n8n binary, base64 file, raw body, or URL input provided.") output = run_ffmpeg(task_id, saved, options) record_history(uuid.uuid4().hex[:8], task_id, output) return FileResponse(output, filename=os.path.basename(output)) @app.post("/n8n/jobs/{task_id}") async def n8n_create_job(task_id: str, request: Request, background_tasks: BackgroundTasks): task = get_task(task_id) saved, options = await collect_n8n_inputs(request) if not saved and task.min_files > 0: raise HTTPException(status_code=400, detail="No n8n binary, base64 file, raw body, or URL input provided.") job_id = uuid.uuid4().hex[:12] job = { "job_id": job_id, "task_id": task_id, "status": "queued", "message": "Queued", "output": None, "created_at": int(time.time()), } with STATE_LOCK: JOBS[job_id] = job persist_job(job) background_tasks.add_task(execute_job, job_id, task_id, saved, options) return {"job_id": job_id, "status_url": f"/status/{job_id}"} @app.post("/execute/{task_id}") async def api_call( task_id: str, files: List[UploadFile] = File(None), url: Optional[str] = Form(None), text: str = Form(""), start_time: str = Form(""), end_time: str = Form(""), duration: str = Form(""), aspect_ratio: str = Form(""), resolution: str = Form(""), crf: str = Form(""), preset: str = Form(""), audio_bitrate: str = Form(""), volume: str = Form(""), position: str = Form(""), opacity: str = Form(""), fps: str = Form(""), width: str = Form(""), speed: str = Form(""), timestamp: str = Form(""), image_duration: str = Form(""), frame_rate: str = Form(""), font_size: str = Form(""), wave_color: str = Form(""), ): task = get_task(task_id) saved = [] if url: saved.append(download_url(url)) saved.extend(await save_uploads(files)) if not saved and task.min_files > 0: raise HTTPException(status_code=400, detail="No input provided") options = options_from_form( text, start_time, end_time, duration, aspect_ratio, resolution, crf, preset, audio_bitrate, volume, position, opacity, fps, width, speed, timestamp, image_duration, frame_rate, font_size, wave_color, ) output = run_ffmpeg(task_id, saved, options) record_history(uuid.uuid4().hex[:8], task_id, output) return FileResponse(output, filename=os.path.basename(output)) @app.post("/jobs/{task_id}") async def create_job( task_id: str, background_tasks: BackgroundTasks, files: List[UploadFile] = File(None), url: Optional[str] = Form(None), text: str = Form(""), start_time: str = Form(""), end_time: str = Form(""), duration: str = Form(""), aspect_ratio: str = Form(""), resolution: str = Form(""), crf: str = Form(""), preset: str = Form(""), audio_bitrate: str = Form(""), volume: str = Form(""), position: str = Form(""), opacity: str = Form(""), fps: str = Form(""), width: str = Form(""), speed: str = Form(""), timestamp: str = Form(""), image_duration: str = Form(""), frame_rate: str = Form(""), font_size: str = Form(""), wave_color: str = Form(""), ): task = get_task(task_id) saved = [] if url: saved.append(download_url(url)) saved.extend(await save_uploads(files)) if not saved and task.min_files > 0: raise HTTPException(status_code=400, detail="No input provided") options = options_from_form( text, start_time, end_time, duration, aspect_ratio, resolution, crf, preset, audio_bitrate, volume, position, opacity, fps, width, speed, timestamp, image_duration, frame_rate, font_size, wave_color, ) job_id = uuid.uuid4().hex[:12] job = { "job_id": job_id, "task_id": task_id, "status": "queued", "message": "Queued", "output": None, "created_at": int(time.time()), } with STATE_LOCK: JOBS[job_id] = job persist_job(job) background_tasks.add_task(execute_job, job_id, task_id, saved, options) return {"job_id": job_id, "status_url": f"/status/{job_id}"} @app.get("/status/{job_id}") def job_status(job_id: str): with STATE_LOCK: job = JOBS.get(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") data = dict(job) if data.get("output"): data["download_url"] = f"/download/{job_id}" return data @app.get("/download/{job_id}") def download_job(job_id: str): with STATE_LOCK: job = JOBS.get(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") output = job.get("output") if not output or not os.path.exists(str(output)): raise HTTPException(status_code=404, detail="Output not available") return FileResponse(str(output), filename=os.path.basename(str(output))) @app.get("/history") def history(): with STATE_LOCK: return [ {key: value for key, value in item.items() if key != "output"} for item in HISTORY ] @app.get("/history/{history_id}/download") def download_history_item(history_id: str): with STATE_LOCK: item = next( (entry for entry in HISTORY if entry.get("history_id") == history_id), None ) if not item: raise HTTPException(status_code=404, detail="History item not found") output = str(item["output"]) if not os.path.exists(output): raise HTTPException(status_code=404, detail="Output expired") return FileResponse(output, filename=os.path.basename(output)) def task_choices() -> List[str]: return [f"{task.label} ({task_id})" for task_id, task in TASKS.items()] def selected_task_id(choice: str) -> str: if not choice: return "normalize" if "(" in choice and choice.endswith(")"): return choice.rsplit("(", 1)[1][:-1] return choice if choice in TASKS else "normalize" def describe_task(choice: str) -> str: task_id = selected_task_id(choice) task = get_task(task_id) max_files = "unlimited" if task.max_files is None else str(task.max_files) return ( f"{task.description}\n\n" f"Files: {task.min_files}-{max_files}. " f"Accepted: {', '.join(task.accepted_types)}. " f"Output: .{task.output_ext}" ) def option_visibility(choice: str): task_id = selected_task_id(choice) text_tasks = { "text_overlay", "tiktok_lyrics", "tiktok_pro_reframer", "reels_safe_caption", "reels_hook_title", "faceless_quote_card", "faceless_story_pages", "faceless_image_narration", "faceless_video_narration", "series_split_pack", "series_episode_badge", "series_batch_pack", "series_recap_card", } duration_tasks = { "make_gif", "tiktok_lyrics", "reels_progress_bar", "reels_loop", "faceless_quote_card", "faceless_broll_montage", "series_split_pack", "series_recap_card", } quality_tasks = { "normalize", "compress", "batch_compress", "reels_blur_fit", "reels_loop", "reels_reaction_stack", "faceless_image_narration", "faceless_video_narration", "faceless_broll_montage", "series_split_pack", "series_batch_pack", } audio_tasks = {"extract_audio", "merge_music", "reels_audio_duck"} font_tasks = { "text_overlay", "tiktok_lyrics", "tiktok_pro_reframer", "reels_safe_caption", "reels_hook_title", "faceless_quote_card", "faceless_story_pages", "faceless_image_narration", "faceless_video_narration", "series_episode_badge", "series_recap_card", } visible = { "text": task_id in text_tasks, "trim": task_id == "trim", "duration": task_id in duration_tasks, "timestamp": task_id == "thumbnail", "aspect": task_id in {"resize_916", "crop_aspect"}, "resolution": task_id == "compress", "quality": task_id in quality_tasks, "audio": task_id in audio_tasks, "watermark": task_id == "watermark", "gif": task_id == "make_gif", "speed": task_id == "speed", "slideshow": task_id in {"slideshow", "faceless_story_pages", "faceless_broll_montage"}, "frames": task_id == "extract_frames", "font": task_id in font_tasks, "wave": task_id == "waveform", } return [ describe_task(choice), gr.update(visible=visible["text"]), gr.update(visible=visible["trim"]), gr.update(visible=visible["duration"]), gr.update(visible=visible["timestamp"]), gr.update(visible=visible["aspect"]), gr.update(visible=visible["resolution"]), gr.update(visible=visible["quality"]), gr.update(visible=visible["audio"]), gr.update(visible=visible["watermark"]), gr.update(visible=visible["gif"]), gr.update(visible=visible["speed"]), gr.update(visible=visible["slideshow"]), gr.update(visible=visible["frames"]), gr.update(visible=visible["font"]), gr.update(visible=visible["wave"]), ] def ui_handler( files, url, task_choice, text, start_time, end_time, duration, aspect_ratio, resolution, crf, preset, audio_bitrate, volume, position, opacity, fps, width, speed, timestamp, image_duration, frame_rate, font_size, wave_color, ): task_id = selected_task_id(task_choice) task = get_task(task_id) paths = [] if url: paths.append(download_url(url)) if files: paths.extend([file.name for file in files]) if not paths and task.min_files > 0: raise gr.Error("Upload files or provide a URL.") options = options_from_form( text, start_time, end_time, duration, aspect_ratio, resolution, crf, preset, audio_bitrate, volume, position, opacity, fps, width, speed, timestamp, image_duration, frame_rate, font_size, wave_color, ) try: output = run_ffmpeg(task_id, paths, options) record_history(uuid.uuid4().hex[:8], task_id, output) return output, history_table() except HTTPException as exc: raise gr.Error(str(exc.detail)) def history_table(): with STATE_LOCK: rows = [ [ item["task"], item["filename"], item["size"], time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(item["created_at"])), ] for item in HISTORY[:10] ] return rows with gr.Blocks(title="Basyx FFmpeg Automation Hub") as ui: gr.Markdown("# Basyx FFmpeg Automation Hub") gr.Markdown("Upload media, choose a task, tune the options, and download the output.") with gr.Row(): with gr.Column(scale=1): task_input = gr.Dropdown( choices=task_choices(), value=task_choices()[0], label="Task", ) task_info = gr.Textbox( label="Task requirements", value=describe_task(task_choices()[0]), lines=4 ) file_input = gr.File(label="Files", file_count="multiple") url_input = gr.Textbox(label="Media URL") with gr.Column(scale=1): text_input = gr.Textbox(label="Text / Caption / Lyrics", lines=3, visible=False) with gr.Row(visible=False) as trim_row: start_input = gr.Textbox(label="Start", value="00:00:00") end_input = gr.Textbox(label="End") with gr.Row(visible=False) as duration_row: duration_input = gr.Number(label="Duration seconds", value=5, precision=0) with gr.Row(visible=False) as timestamp_row: timestamp_input = gr.Textbox(label="Thumbnail timestamp", value="00:00:02") with gr.Row(visible=False) as aspect_row: aspect_input = gr.Dropdown(["9:16", "16:9", "1:1", "4:5"], label="Aspect", value="9:16") with gr.Row(visible=False) as resolution_row: resolution_input = gr.Dropdown(["480p", "720p", "1080p"], label="Resolution", value="720p") with gr.Row(visible=True) as quality_row: crf_input = gr.Slider(12, 35, value=23, step=1, label="CRF") preset_input = gr.Dropdown( ["ultrafast", "veryfast", "fast", "medium", "slow"], label="Preset", value="fast", ) with gr.Row(visible=False) as audio_row: audio_bitrate_input = gr.Slider(64, 320, value=192, step=16, label="Audio kbps") volume_input = gr.Slider(0, 2, value=0.3, step=0.05, label="Music volume") with gr.Row(visible=False) as watermark_row: position_input = gr.Dropdown( ["bottom-right", "bottom-left", "top-right", "top-left", "center"], label="Watermark position", value="bottom-right", ) opacity_input = gr.Slider(0.1, 1, value=1, step=0.05, label="Watermark opacity") with gr.Row(visible=False) as gif_row: fps_input = gr.Slider(5, 30, value=10, step=1, label="GIF FPS") width_input = gr.Slider(240, 1080, value=480, step=20, label="GIF width") with gr.Row(visible=False) as speed_row: speed_input = gr.Slider(0.25, 4, value=1.25, step=0.05, label="Speed") with gr.Row(visible=False) as slideshow_row: image_duration_input = gr.Slider(1, 15, value=3, step=1, label="Slide seconds") with gr.Row(visible=False) as frames_row: frame_rate_input = gr.Slider(0.1, 30, value=1, step=0.1, label="Frame FPS") with gr.Row(visible=False) as font_row: font_size_input = gr.Slider(18, 160, value=60, step=1, label="Font size") wave_color_input = gr.Textbox(label="Wave color hex", value="00e5ff", visible=False) run_button = gr.Button("Run") output_file = gr.File(label="Output") history_output = gr.Dataframe( headers=["Task", "Filename", "Bytes", "Created"], datatype=["str", "str", "number", "str"], label="Recent outputs", value=history_table, ) task_input.change( option_visibility, inputs=task_input, outputs=[ task_info, text_input, trim_row, duration_row, timestamp_row, aspect_row, resolution_row, quality_row, audio_row, watermark_row, gif_row, speed_row, slideshow_row, frames_row, font_row, wave_color_input, ], ) run_button.click( ui_handler, inputs=[ file_input, url_input, task_input, text_input, start_input, end_input, duration_input, aspect_input, resolution_input, crf_input, preset_input, audio_bitrate_input, volume_input, position_input, opacity_input, fps_input, width_input, speed_input, timestamp_input, image_duration_input, frame_rate_input, font_size_input, wave_color_input, ], outputs=[output_file, history_output], ) app = gr.mount_gradio_app(app, ui, path="/") if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=7860)