This commit is contained in:
valenti
2026-09-07 18:35:26 +00:00
commit 11fba088da
4 changed files with 579 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
# FFmpeg API
FastAPI service that exposes FFmpeg over HTTP for three explicit conversion workflows through a single endpoint:
- `video_to_mp4`
- `audio_to_mp3`
- `audio_image_to_mp4`
The API is designed to run with Uvicorn and prefer CUDA/NVIDIA acceleration by default through `h264_nvenc`.
Available H.264 video codecs:
- `libx264`
- `h264_nvenc`
## Requirements
- Python virtual environment available at `/home/azurian/.venv`
- FFmpeg installed on the host and available in `PATH`
- FFmpeg build with support for `libx264`
- Optional NVIDIA-enabled FFmpeg build with support for `h264_nvenc`
## Install
```bash
cd /home/azurian/ffmppppeg-api
/home/azurian/.venv/bin/pip install -r requirements.txt
```
## Start
Using the launcher:
```bash
/home/azurian/start-convert.sh
```
Or directly with Uvicorn:
```bash
cd /home/azurian/ffmppppeg-api
/home/azurian/.venv/bin/uvicorn main:app --host 0.0.0.0 --port 8022
```
Start in CUDA-only mode:
```bash
cd /home/azurian/ffmppppeg-api
FORCE_CUDA=1 /home/azurian/.venv/bin/uvicorn main:app --host 0.0.0.0 --port 8022
```
## Health Check
```bash
curl http://IP_DEL_SERVER:8022/health
```
## API Contract
### Endpoint
```text
POST /convert
```
### Query Parameters
- `mode`: `video_to_mp4`, `audio_to_mp3`, or `audio_image_to_mp4`
- `codec`: `libx264` or `h264_nvenc` for video modes, default `h264_nvenc`
- `preset`: optional video preset, default `medium` for `libx264` and `p4` for `h264_nvenc`
- `audio_bitrate`: audio bitrate for `audio_to_mp3`, default `192k`
- `width`: output width for `audio_image_to_mp4`, default `1920`
- `height`: output height for `audio_image_to_mp4`, default `1080`
- `fps`: output frame rate for `audio_image_to_mp4`, default `30`
- `logo_width`: logo width in pixels for `audio_image_to_mp4`, default `280`
- `margin`: top/right logo margin for `audio_image_to_mp4`, default `24`
Recommended output parameters:
- Video codec: H.264 via `h264_nvenc`
- Audio codec: `aac`
- Pixel format: `yuv420p`
- Container: `mp4`
- Flags: `+faststart`
Fallback:
- `libx264` remains available if you need a CPU-only fallback path.
CUDA enforcement:
- If you start the server with `FORCE_CUDA=1`, every video output must use `h264_nvenc`.
- With `FORCE_CUDA=1`, requests that specify `libx264` return HTTP 400.
- `audio_to_mp3` is unaffected because it does not produce video output.
## Modes
### 1. `video_to_mp4`
Multipart fields:
- `file`: input video file
Example:
```bash
curl -X POST "http://IP_DEL_SERVER:8022/convert?mode=video_to_mp4&codec=h264_nvenc&preset=p4" \
-F "file=@/path/input.mov" \
--output output.mp4
```
### 2. `audio_to_mp3`
Multipart fields:
- `audio`: input audio file
You can also send the audio file as `file`, but `audio` is the recommended field name.
Example:
```bash
curl -X POST "http://IP_DEL_SERVER:8022/convert?mode=audio_to_mp3&audio_bitrate=192k" \
-F "audio=@/path/input.wav" \
--output output.mp3
```
### 3. `audio_image_to_mp4`
Multipart fields:
- `audio`: input audio file
- `background`: background image or background video
- `logo`: overlay logo image
Supported background image formats:
- `.jpg`
- `.jpeg`
- `.png`
- `.webp`
Supported background video formats:
- `.mp4`
- `.mov`
- `.mkv`
- `.avi`
- `.webm`
- `.m4v`
Supported audio formats:
- `.wav`
- `.mp3`
- `.m4a`
- `.aac`
- `.flac`
- `.ogg`
Example with image background:
```bash
curl -X POST "http://IP_DEL_SERVER:8022/convert?mode=audio_image_to_mp4&codec=h264_nvenc&preset=p4&width=1920&height=1080&fps=30&logo_width=280&margin=24" \
-F "audio=@/path/voice.wav" \
-F "background=@/path/background.jpg" \
-F "logo=@/path/logo.png" \
--output output.mp4
```
Example with video background:
```bash
curl -X POST "http://IP_DEL_SERVER:8022/convert?mode=audio_image_to_mp4&codec=h264_nvenc&preset=p4" \
-F "audio=@/path/voice.wav" \
-F "background=@/path/background.mp4" \
-F "logo=@/path/logo.png" \
--output output.mp4
```
## Notes
- Temporary files are cleaned up automatically on success and error paths.
- FFmpeg failures are returned as HTTP 500 responses with the process error message.
- CORS is enabled for network access from other machines on the local network.
Binary file not shown.
+391
View File
@@ -0,0 +1,391 @@
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()
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.116.0,<1.0.0
uvicorn[standard]>=0.35.0,<1.0.0
python-multipart>=0.0.20,<1.0.0