mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 06:58:12 +02:00
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:
1 parent
e29048d374
commit
430187cbad
2 files changed
+41
-35
No files matched your search
+40
-34
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user