Files
spark-ffmppppeg-api/main.py
T
2026-09-07 18:35:26 +00:00

392 lines
12 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
import os
import shutil
import tempfile
from pathlib import Path
from typing import Literal
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
FFMPEG_BINARY = os.getenv("FFMPEG_BINARY", "ffmpeg")
ALLOWED_CODECS = {"libx264", "h264_nvenc"}
DEFAULT_CODEC = "h264_nvenc"
DEFAULT_X264_PRESET = "medium"
DEFAULT_NVENC_PRESET = "p4"
FORCE_CUDA = os.getenv("FORCE_CUDA", "").strip().lower() in {"1", "true", "yes", "on"}
OUTPUT_EXTENSION = ".mp4"
CHUNK_SIZE = 1024 * 1024
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"}
AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg"}
app = FastAPI(
title="FFmpeg Hardware API",
description=(
"API FastAPI per conversione media in MP4/MP3 ottimizzata per CUDA con "
"`h264_nvenc` come default. Imposta `FORCE_CUDA=1` per accettare solo codec NVIDIA."
),
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
CodecType = Literal["libx264", "h264_nvenc"]
ConvertMode = Literal["video_to_mp4", "audio_to_mp3", "audio_image_to_mp4"]
async def save_upload_file(upload_file: UploadFile, destination: Path) -> None:
with destination.open("wb") as buffer:
while chunk := await upload_file.read(CHUNK_SIZE):
buffer.write(chunk)
await upload_file.close()
def validate_codec(codec: str) -> None:
if codec not in ALLOWED_CODECS:
raise HTTPException(
status_code=400,
detail=f"Unsupported codec. Allowed values: {', '.join(sorted(ALLOWED_CODECS))}",
)
if FORCE_CUDA and codec != "h264_nvenc":
raise HTTPException(
status_code=400,
detail="FORCE_CUDA is enabled. Only `h264_nvenc` is allowed for video outputs.",
)
def resolve_preset(codec: str, preset: str | None) -> str:
if preset:
return preset
if codec == "h264_nvenc":
return DEFAULT_NVENC_PRESET
return DEFAULT_X264_PRESET
def background_kind(path: Path) -> str:
suffix = path.suffix.lower()
if suffix in IMAGE_EXTENSIONS:
return "image"
if suffix in VIDEO_EXTENSIONS:
return "video"
raise HTTPException(
status_code=400,
detail=(
"Unsupported background format. Use an image "
f"({', '.join(sorted(IMAGE_EXTENSIONS))}) or a video "
f"({', '.join(sorted(VIDEO_EXTENSIONS))})."
),
)
def ensure_filename(upload: UploadFile | None, field_name: str) -> str:
if upload is None or not upload.filename:
raise HTTPException(status_code=400, detail=f"Missing uploaded file for field '{field_name}'.")
return upload.filename
def audio_kind(path: Path) -> str:
suffix = path.suffix.lower()
if suffix in AUDIO_EXTENSIONS:
return "audio"
raise HTTPException(
status_code=400,
detail=f"Unsupported audio format. Allowed values: {', '.join(sorted(AUDIO_EXTENSIONS))}.",
)
async def run_ffmpeg_command(command: list[str], error_prefix: str) -> None:
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_message = (stderr or stdout).decode("utf-8", errors="replace").strip()
raise HTTPException(
status_code=500,
detail=f"{error_prefix}: {error_message or 'unknown error'}",
)
async def run_ffmpeg(
input_path: Path,
output_path: Path,
codec: str,
preset: str,
) -> None:
command = [FFMPEG_BINARY, "-y", "-hide_banner", "-loglevel", "error"]
if codec == "h264_nvenc":
command.extend(["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"])
command.extend(
[
"-i",
str(input_path),
"-map",
"0:v:0",
"-map",
"0:a?",
"-c:v",
codec,
"-preset",
preset,
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-c:a",
"aac",
"-b:a",
"192k",
str(output_path),
]
)
await run_ffmpeg_command(command, "FFmpeg conversion failed")
async def run_ffmpeg_audio_to_mp3(
input_path: Path,
output_path: Path,
bitrate: str,
) -> None:
audio_kind(input_path)
command = [
FFMPEG_BINARY,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(input_path),
"-vn",
"-codec:a",
"libmp3lame",
"-b:a",
bitrate,
str(output_path),
]
await run_ffmpeg_command(command, "FFmpeg audio conversion failed")
async def run_ffmpeg_compose(
background_path: Path,
logo_path: Path,
audio_path: Path,
output_path: Path,
codec: str,
preset: str,
width: int,
height: int,
fps: int,
logo_width: int,
margin: int,
) -> None:
kind = background_kind(background_path)
scale_background = (
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black"
)
filter_complex = (
f"[0:v]{scale_background}[bg];"
f"[1:v]scale={logo_width}:-1[logo];"
f"[bg][logo]overlay=W-w-{margin}:{margin}:format=auto[v]"
)
command = [
FFMPEG_BINARY,
"-y",
"-hide_banner",
"-loglevel",
"error",
]
if kind == "image":
command.extend(["-loop", "1"])
elif codec == "h264_nvenc":
# Keep CPU filters, but still ask FFmpeg to prefer CUDA decoding for video backgrounds.
command.extend(["-hwaccel", "cuda"])
command.extend(
[
"-i",
str(background_path),
"-i",
str(logo_path),
"-i",
str(audio_path),
"-filter_complex",
filter_complex,
"-map",
"[v]",
"-map",
"2:a:0",
"-c:v",
codec,
"-preset",
preset,
"-r",
str(fps),
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
str(output_path),
]
)
await run_ffmpeg_command(command, "FFmpeg compose failed")
def cleanup_temp_dir(temp_dir: str) -> None:
shutil.rmtree(temp_dir, ignore_errors=True)
def output_filename(stem: str, suffix: str) -> str:
safe_stem = stem or "converted"
return f"{safe_stem}{suffix}"
@app.get("/health")
async def healthcheck() -> dict[str, str]:
return {"status": "ok"}
@app.post("/convert")
async def convert_media(
mode: ConvertMode = Query(
"video_to_mp4",
description="Conversion mode: video_to_mp4, audio_to_mp3, audio_image_to_mp4.",
),
file: UploadFile | None = File(None),
audio: UploadFile | None = File(None),
background: UploadFile | None = File(None),
logo: UploadFile | None = File(None),
codec: CodecType = Query(
DEFAULT_CODEC,
description="Codec video consentito. `h264_nvenc` e' il default ottimizzato per CUDA; `libx264` resta disponibile come fallback.",
),
preset: str | None = Query(
None,
description="Preset opzionale. Default automatico: `medium` per libx264, `p4` per h264_nvenc.",
),
audio_bitrate: str = Query("192k", description="Audio bitrate for audio_to_mp3 mode."),
width: int = Query(1920, ge=320, le=7680, description="Final video width for audio_image_to_mp4."),
height: int = Query(1080, ge=240, le=4320, description="Final video height for audio_image_to_mp4."),
fps: int = Query(30, ge=1, le=120, description="Frame rate for audio_image_to_mp4."),
logo_width: int = Query(280, ge=32, le=2048, description="Logo width in pixels."),
margin: int = Query(24, ge=0, le=500, description="Logo margin from top/right border."),
):
temp_dir = tempfile.mkdtemp(prefix="ffmpeg-api-")
uploads = {
"file": file,
"audio": audio,
"background": background,
"logo": logo,
}
try:
if mode == "video_to_mp4":
validate_codec(codec)
resolved_preset = resolve_preset(codec, preset)
filename = ensure_filename(file, "file")
input_suffix = Path(filename).suffix or ".bin"
input_path = Path(temp_dir) / f"input{input_suffix}"
output_stem = Path(filename).stem or "converted"
output_path = Path(temp_dir) / f"{output_stem}_{codec}{OUTPUT_EXTENSION}"
await save_upload_file(file, input_path)
await run_ffmpeg(input_path=input_path, output_path=output_path, codec=codec, preset=resolved_preset)
media_type = "video/mp4"
elif mode == "audio_to_mp3":
filename = ensure_filename(audio or file, "audio")
audio_upload = audio or file
input_suffix = Path(filename).suffix or ".bin"
input_path = Path(temp_dir) / f"input_audio{input_suffix}"
output_stem = Path(filename).stem or "converted"
output_path = Path(temp_dir) / output_filename(output_stem, ".mp3")
await save_upload_file(audio_upload, input_path)
await run_ffmpeg_audio_to_mp3(input_path=input_path, output_path=output_path, bitrate=audio_bitrate)
media_type = "audio/mpeg"
elif mode == "audio_image_to_mp4":
validate_codec(codec)
resolved_preset = resolve_preset(codec, preset)
audio_filename = ensure_filename(audio, "audio")
background_filename = ensure_filename(background, "background")
logo_filename = ensure_filename(logo, "logo")
background_path = Path(temp_dir) / f"background{Path(background_filename).suffix or '.bin'}"
logo_path = Path(temp_dir) / f"logo{Path(logo_filename).suffix or '.bin'}"
audio_path = Path(temp_dir) / f"audio{Path(audio_filename).suffix or '.bin'}"
output_stem = Path(audio_filename).stem or "composed"
output_path = Path(temp_dir) / f"{output_stem}_{codec}{OUTPUT_EXTENSION}"
await save_upload_file(background, background_path)
await save_upload_file(logo, logo_path)
await save_upload_file(audio, audio_path)
await run_ffmpeg_compose(
background_path=background_path,
logo_path=logo_path,
audio_path=audio_path,
output_path=output_path,
codec=codec,
preset=resolved_preset,
width=width,
height=height,
fps=fps,
logo_width=logo_width,
margin=margin,
)
media_type = "video/mp4"
else:
raise HTTPException(status_code=400, detail=f"Unsupported mode: {mode}")
if not output_path.exists():
raise HTTPException(status_code=500, detail="FFmpeg did not produce any output file.")
return FileResponse(
path=output_path,
media_type=media_type,
filename=output_path.name,
background=BackgroundTask(cleanup_temp_dir, temp_dir),
)
except HTTPException:
cleanup_temp_dir(temp_dir)
raise
except Exception as exc:
cleanup_temp_dir(temp_dir)
raise HTTPException(status_code=500, detail=f"Unexpected error: {exc}") from exc
finally:
for upload in uploads.values():
if upload is not None:
with contextlib.suppress(Exception):
await upload.close()