From 11fba088da4651d97152dcdb277b31613e9a8678 Mon Sep 17 00:00:00 2001 From: valenti Date: Mon, 7 Sep 2026 18:35:26 +0000 Subject: [PATCH] first --- README.md | 185 +++++++++++++++ __pycache__/main.cpython-313.pyc | Bin 0 -> 15681 bytes main.py | 391 +++++++++++++++++++++++++++++++ requirements.txt | 3 + 4 files changed, 579 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/main.cpython-313.pyc create mode 100644 main.py create mode 100644 requirements.txt 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 0000000000000000000000000000000000000000..c57f87218e6c24edba9f7e43a8ff407aa620b3a0 GIT binary patch literal 15681 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

sYv^1%FX_vQ<^p3!{Ic(!Le-*Z*Gd{yke zCLVYy+whbug%@%j7^~i_r#J5z+RS{Ru^QO;YR+*oZ$2rSPi~R1Ysfiz^X6XB+>7bM z8wYZZ!+G;z(R}!0$L<2#S@7&CH0~)h?JLwa7V7pA*bMiJFte`tL7mxY{S9TdSc?RB zD9@gk#~x}aC%Z8$4qSY9>fKTCD3^6iiRP*MHji>Ny(??mwdH69nxmntV@5R3d~9nh z)HD_9+6rzq@7|Mj@7b!`b9e8NH}$!?A<%levFM@dTD}wiR{XDP_pXe><+z-!ZOh%7 zckh4Sz5fRdAGmu99`=nxe{u*^*tK7D?T7oOwS8H4@2@pf-M(L+RZ*71lKJ^x*HV^2 z$(($^QkGNnJ#+6p^Wg^!e8Gb;nsR#y(_g!LfA`QynHxT(R2u7+zJ9I#uD%`4ezr_5 z%zBDe@=7;?&b{k=S0ncMv(^bQa81--`yB*Yf$Voh{XeAB*;1$T?q2vWx_cjyzW?u3 zJ6MR9nlA65-#FduKSI58oc3FpcLp7R|JX+RYnUI~Pq#zjCq1;^#r&if3x2Ah{dLSw zwP$J}@v|n{?_qw{>_oha_BSy<>qh+N4DD}Wey;H#-b(vf=I87XGW_C*3!3|-)q`-q z%fDOqOK7y+_{%{C@b_p7R-7VCVc8&`80_4swa+6zdGW++^!aF9)w=kWs5-^q%Tuu@q`BOtl9|qS&Q*< zlOUTzTv5#X{2@mO+QLt^~!Hv_QzlJ)+Nu=qm+TL6Hwl8dgu zBO{zcvtMSBMJ7Xr(%LPrwuG7FY6V;e9@PZOf7zjM-hh1hq@tR75ppwz7_7L{(8@Ey z(&JR5Lq^%bA?rF>zzFtuC#z~EJo+7Z&^_8tSuZbSq)y@u5sbJ*U!X-?ttE9do|b*3 z|Bek1hlyasYa-YiSU|9fpcw&|^zHr83h1`Tx!^BXmlFxA$QWd-k!YC6>=28B^Lm6| z0kLF~bL0>OS%#@;GpC`!pd0=QG?gAoVtBPne161F{oAdLX;^^wcp!n=s|BY?OLB|L?I2LP8q zl1a%LibR5yP6~gE#rF{mA-ISDB0-dVyca@j7Qr0^-$L*jg6|=~a}wgdN^0mW3I4-% zTKMA~;zeygD9|4VKXft1pAd?`2L_Z%eH{h_&dg}~BdY!HDC>t*&4-lXL(1|YW&4P# z|A_MZ4{G=WYWO3n=OgOCM^q=Az|}avWB6mkTIa^moV{<^QgAe`T^DN(EL#iqCP>z{ zh?KKX*Cv%Im`aM#h11Ca+a>V0>cTv;|2oDCvn zFIeiAjgs>)pm24(4B<~Bbk>5g6|V0Zo}rISP!U;H+KU zgNreFBPg0E+WCOe(kh^|xJ1{%tohKgpIvSLyv}TeQ3AP z+J~%~ZhUCg0p_yM#v%)UdqIPrSyUr(zs6nEAOamR+lo4Z>M4u8XdtMO(i$I{2s%c) z>2oqCB7_qW;I!5hbp+KTxq+Zv=Vj+wpy=Xx6;T084cRkdr=yo_h zRcXFa_hQ{jLymTS3i(Z+swtJLh!`y5P+0$B{mOwH?f%>PX1o~Ux&jy68nh4eRO_zw zzSp0-2d^`)XYTFZzv24c*gfzHF|)3zpm!HllzU7C7IX~jcV*DRzxa(Zn^>;=E> z>3cP`tLb(11|tse*_zpVj=D8w-MQfcFEZwoDlwBTz${V literal 0 HcmV?d00001 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