feat: Implement a FastAPI-based FFmpeg service for video processing, including API key authentication, video/audio input handling, text overlay, and audio mixing.

This commit is contained in:
Michael committed 2026-01-22 13:11:24 +01:00
1 parent e29048d374
commit 430187cbad
2 files changed
+25 -19

No files matched your search

+24 -18
View File
@@ -1,4 +1,4 @@
from fastapi import FastAPI, HTTPException, Header, Body from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import uvicorn import uvicorn
@@ -7,7 +7,6 @@ import os
import uuid import uuid
import base64 import base64
import requests import requests
import json
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -17,6 +16,7 @@ API_KEY = os.environ.get("API_KEY", "default-key")
TEMP_DIR = Path("/tmp/ffmpeg_processing") TEMP_DIR = Path("/tmp/ffmpeg_processing")
TEMP_DIR.mkdir(parents=True, exist_ok=True) TEMP_DIR.mkdir(parents=True, exist_ok=True)
class ReelRequest(BaseModel): class ReelRequest(BaseModel):
video_base64: Optional[str] = None video_base64: Optional[str] = None
video_url: Optional[str] = None video_url: Optional[str] = None
@@ -27,18 +27,21 @@ class ReelRequest(BaseModel):
font_size: int = 60 font_size: int = 60
music_volume: float = 0.25 music_volume: float = 0.25
class ReelResponse(BaseModel): class ReelResponse(BaseModel):
success: boolean success: bool
output_base64: Optional[str] = None output_base64: Optional[str] = None
duration: Optional[float] = None duration: Optional[float] = None
detail: Optional[str] = None detail: Optional[str] = None
@app.get("/health") @app.get("/health")
def health_check(x_api_key: str = Header(None)): def health_check(x_api_key: str = Header(None)):
if x_api_key != API_KEY: if x_api_key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API Key") raise HTTPException(status_code=401, detail="Invalid API Key")
return {"status": "healthy"} return {"status": "healthy"}
@app.post("/process-reel") @app.post("/process-reel")
async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
if x_api_key != API_KEY: if x_api_key != API_KEY:
@@ -70,7 +73,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
if request.music_url: if request.music_url:
try: try:
# Add User-Agent to avoid 403 on some CDNs # Add User-Agent to avoid 403 on some CDNs
headers = {'User-Agent': 'Mozilla/5.0'} headers = {"User-Agent": "Mozilla/5.0"}
response = requests.get(request.music_url, headers=headers, stream=True) response = requests.get(request.music_url, headers=headers, stream=True)
response.raise_for_status() response.raise_for_status()
with open(input_audio_path, "wb") as f: with open(input_audio_path, "wb") as f:
@@ -134,13 +137,13 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
filter_str = ",".join(filters) filter_str = ",".join(filters)
if filters: if filters:
cmd.extend(["-vf", filter_str]) cmd.extend(["-vf", filter_str])
if has_music: if has_music:
# Map video from 0, audio from 1 (music), shortest (cut music to video len) # Map video from 0, audio from 1 (music), shortest (cut music to video len)
cmd.extend(["-map", "0:v", "-map", "1:a", "-shortest"]) cmd.extend(["-map", "0:v", "-map", "1:a", "-shortest"])
else: else:
cmd.extend(["-map", "0:v", "-map", "0:a?"]) # Keep original audio if exists cmd.extend(["-map", "0:v", "-map", "0:a?"]) # Keep original audio if exists
cmd.append(str(output_video_path)) cmd.append(str(output_video_path))
@@ -152,31 +155,34 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
raise Exception(f"FFmpeg encoding failed: {process.stderr.decode()}") raise Exception(f"FFmpeg encoding failed: {process.stderr.decode()}")
# 4. Get Duration (ffprobe) # 4. Get Duration (ffprobe)
duration_cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(output_video_path)] duration_cmd = [
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(output_video_path),
]
dur_proc = subprocess.run(duration_cmd, stdout=subprocess.PIPE) dur_proc = subprocess.run(duration_cmd, stdout=subprocess.PIPE)
duration = float(dur_proc.stdout.decode().strip() or 0) duration = float(dur_proc.stdout.decode().strip() or 0)
# 5. Read Output # 5. Read Output
with open(output_video_path, "rb") as f: with open(output_video_path, "rb") as f:
out_bytes = f.read() out_bytes = f.read()
out_b64 = base64.b64encode(out_bytes).decode('utf-8') out_b64 = base64.b64encode(out_bytes).decode("utf-8")
# Cleanup # Cleanup
shutil.rmtree(job_dir) shutil.rmtree(job_dir)
return { return {"success": True, "output_base64": out_b64, "duration": duration}
"success": True,
"output_base64": out_b64,
"duration": duration
}
except Exception as e: except Exception as e:
if 'job_dir' in locals(): if "job_dir" in locals():
shutil.rmtree(job_dir, ignore_errors=True) shutil.rmtree(job_dir, ignore_errors=True)
return { return {"success": False, "detail": str(e)}
"success": False,
"detail": str(e)
}
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)
+1 -1
View File
@@ -3,4 +3,4 @@ uvicorn==0.27.0
python-multipart==0.0.6 python-multipart==0.0.6
requests==2.31.0 requests==2.31.0
pydantic==2.6.0 pydantic==2.6.0
shutil pydantic==2.6.0