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
+41 -35

No files matched your search

+40 -34
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:
@@ -48,7 +51,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
job_dir = TEMP_DIR / job_id job_dir = TEMP_DIR / job_id
job_dir.mkdir() job_dir.mkdir()
input_video_path = job_dir / "input.mp4" input_video_path = job_dir / "input.mp4"
input_audio_path = job_dir / "music.mp3" input_audio_path = job_dir / "music.mp3"
output_video_path = job_dir / "output.mp4" output_video_path = job_dir / "output.mp4"
@@ -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:
@@ -79,15 +82,15 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
except Exception as e: except Exception as e:
print(f"Failed to download music: {e}") print(f"Failed to download music: {e}")
# We continue without music if it fails # We continue without music if it fails
# 3. Build FFmpeg Command # 3. Build FFmpeg Command
# This is a simplified command. Real implementation for tiktok-style text overlay is complex. # This is a simplified command. Real implementation for tiktok-style text overlay is complex.
# For now, we will draw simple text centered. # For now, we will draw simple text centered.
cmd = ["ffmpeg", "-y", "-i", str(input_video_path)] cmd = ["ffmpeg", "-y", "-i", str(input_video_path)]
filters = [] filters = []
# Text Overlay # Text Overlay
if request.text: if request.text:
# Escape text for drawtext # Escape text for drawtext
@@ -96,7 +99,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
font_path = "/usr/share/fonts/ttf-dejavu/DejaVuSans-Bold.ttf" font_path = "/usr/share/fonts/ttf-dejavu/DejaVuSans-Bold.ttf"
drawtext = f"drawtext=fontfile={font_path}:text='{sanitized_text}':fontcolor=white:fontsize={request.font_size}:box=1:boxcolor=black@0.5:boxborderw=5:x=(w-text_w)/2:y=(h-text_h)/2" drawtext = f"drawtext=fontfile={font_path}:text='{sanitized_text}':fontcolor=white:fontsize={request.font_size}:box=1:boxcolor=black@0.5:boxborderw=5:x=(w-text_w)/2:y=(h-text_h)/2"
filters.append(drawtext) filters.append(drawtext)
# Audio Mixing # Audio Mixing
if has_music: if has_music:
cmd.extend(["-i", str(input_audio_path)]) cmd.extend(["-i", str(input_audio_path)])
@@ -105,7 +108,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
# We need to loop music if video is longer, or cut it. # We need to loop music if video is longer, or cut it.
# Simple mix: amix # Simple mix: amix
# Better: volume adjustment # Better: volume adjustment
# Complex filter for audio: # Complex filter for audio:
# [1:a]volume={request.music_volume}[music];[0:a][music]amix=inputs=2:duration=first[aout] # [1:a]volume={request.music_volume}[music];[0:a][music]amix=inputs=2:duration=first[aout]
# But what if video has no audio? We need to verify. # But what if video has no audio? We need to verify.
@@ -114,69 +117,72 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
# Let's simple check: replacing audio is often safer for "Reels" unless voiceover is needed. # Let's simple check: replacing audio is often safer for "Reels" unless voiceover is needed.
# But usually we want background music. # But usually we want background music.
# Use filter_complex to mix # Use filter_complex to mix
filters.append(f"[1:a]volume={request.music_volume}[music]") filters.append(f"[1:a]volume={request.music_volume}[music]")
# We assume input video has audio. If not, we map just music. # We assume input video has audio. If not, we map just music.
# To be robust, we map [music] to output audio. # To be robust, we map [music] to output audio.
# Let's TRY to mix, but fallback to just music if 0:a doesn't exist? # Let's TRY to mix, but fallback to just music if 0:a doesn't exist?
# FFmpeg is tricky here. # FFmpeg is tricky here.
# Strategy: Generate a silent audio track matching video length, mix everything alongside. # Strategy: Generate a silent audio track matching video length, mix everything alongside.
# Too complex for this snippet. # Too complex for this snippet.
# Decision: Replace audio if music is present, or mix if simple. # Decision: Replace audio if music is present, or mix if simple.
# Let's just output the music track as the audio for now (Reel style), usually original audio is noise. # Let's just output the music track as the audio for now (Reel style), usually original audio is noise.
# Or add it. # Or add it.
# [0:a]volume=1.0[orig];[1:a]volume={vol}[mus];[orig][mus]amix=inputs=2[a] # [0:a]volume=1.0[orig];[1:a]volume={vol}[mus];[orig][mus]amix=inputs=2[a]
# Lets try Map 0:v and Map 1:a (Music replaces audio) - Safer MVP # Lets try Map 0:v and Map 1:a (Music replaces audio) - Safer MVP
# filters.append(...) # filters.append(...)
pass pass
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))
# execute # execute
process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if process.returncode != 0: if process.returncode != 0:
print(f"FFmpeg failed: {process.stderr.decode()}") print(f"FFmpeg failed: {process.stderr.decode()}")
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