commit 11fba088da4651d97152dcdb277b31613e9a8678 Author: valenti Date: Mon Sep 7 18:35:26 2026 +0000 first diff --git a/README.md b/README.md new file mode 100644 index 0000000..7993b27 --- /dev/null +++ b/README.md @@ -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. diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..c57f872 Binary files /dev/null and b/__pycache__/main.cpython-313.pyc differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..3daa972 --- /dev/null +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5ef2492 --- /dev/null +++ b/requirements.txt @@ -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