backend/services/ffmpeg_service.pypython · @kibot
20/9/2026, 17:50:58"""Servicio de transcodificacion FFmpeg optimizado para NetFlux.
Caracteristicas:
- Deteccion automatica de aceleracion hardware (VA-API, NVENC, QSV)
- Perfiles de calidad predefinidos con balance calidad/recursos
- Limitacion de transcodificaciones concurrentes
- Timeout y manejo de errores robusto
- Limpieza de recursos temporales
"""
import os
import subprocess
import tempfile
import shutil
import logging
import threading
from typing import Optional, Dict, Any
from dataclasses import dataclass, field
from enum import Enum
from contextlib import contextmanager
logger = logging.getLogger(__name__)
class HardwareAccel(Enum):
NONE = "none"
VAAPI = "vaapi"
NVENC = "nvenc"
QSV = "qsv"
AMF = "amf"
class QualityPreset(Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
ULTRA = "ultra"
@dataclass
class TranscodeProfile:
quality: QualityPreset
video_codec: str
audio_codec: str
bitrate_video: str
bitrate_audio: str
resolution_scale: float
preset: str
hardware_accel: HardwareAccel = HardwareAccel.NONE
TRANSCODE_PROFILES: Dict[QualityPreset, TranscodeProfile] = {
QualityPreset.LOW: TranscodeProfile(
quality=QualityPreset.LOW,
video_codec="libx264",
audio_codec="aac",
bitrate_video="1000k",
bitrate_audio="64k",
resolution_scale=0.5,
preset="ultrafast",
hardware_accel=HardwareAccel.NONE,
),
QualityPreset.MEDIUM: TranscodeProfile(
quality=QualityPreset.MEDIUM,
video_codec="libx264",
audio_codec="aac",
bitrate_video="2500k",
bitrate_audio="128k",
resolution_scale=0.75,
preset="fast",
hardware_accel=HardwareAccel.NONE,
),
QualityPreset.HIGH: TranscodeProfile(
quality=QualityPreset.HIGH,
video_codec="libx264",
audio_codec="aac",
bitrate_video="5000k",
bitrate_audio="192k",
resolution_scale=1.0,
preset="medium",
hardware_accel=HardwareAccel.NONE,
),
QualityPreset.ULTRA: TranscodeProfile(
quality=QualityPreset.ULTRA,
video_codec="libx264",
audio_codec="aac",
bitrate_video="8000k",
bitrate_audio="256k",
resolution_scale=1.0,
preset="slow",
hardware_accel=HardwareAccel.NONE,
),
}
class TranscodeResourceManager:
"""Gestor de recursos para limitar transcodificaciones concurrentes."""
def __init__(self, max_concurrent: int = 2, max_memory_mb: int = 2048):
self.max_concurrent = max_concurrent
self.max_memory_mb = max_memory_mb
self._active_transcodes: int = 0
self._lock = threading.Lock()
self._semaphore = threading.Semaphore(max_concurrent)
def acquire(self) -> bool:
acquired = self._semaphore.acquire(blocking=False)
if acquired:
with self._lock:
self._active_transcodes += 1
logger.info(f"Transcode slot adquirido. Activos: {self._active_transcodes}")
return acquired
def release(self) -> None:
with self._lock:
self._active_transcodes = max(0, self._active_transcodes - 1)
self._semaphore.release()
logger.info(f"Transcode slot liberado. Activos: {self._active_transcodes}")
@property
def active_count(self) -> int:
with self._lock:
return self._active_transcodes
@contextmanager
def transcode_slot(self):
if not self.acquire():
raise RuntimeError(
f"Maximo de transcodificaciones concurrentes ({self.max_concurrent}) alcanzado."
)
try:
yield self
finally:
self.release()
class FFmpegService:
"""Servicio de transcodificacion FFmpeg optimizado."""
def __init__(
self,
max_concurrent_transcodes: int = 2,
max_memory_mb: int = 2048,
temp_dir: Optional[str] = None,
):
self.resource_manager = TranscodeResourceManager(
max_concurrent=max_concurrent_transcodes,
max_memory_mb=max_memory_mb,
)
self.temp_dir = temp_dir or tempfile.gettempdir()
self.hardware_accel = self._detect_hardware_accel()
self._apply_hardware_profiles()
def _detect_hardware_accel(self) -> HardwareAccel:
if os.path.exists("/dev/dri/renderD128") or os.path.exists("/dev/dri/card0"):
try:
result = subprocess.run(
["vainfo"], capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
logger.info("Aceleracion VA-API detectada")
return HardwareAccel.VAAPI
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
try:
result = subprocess.run(
["nvidia-smi"], capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
logger.info("Aceleracion NVENC (NVIDIA) detectada")
return HardwareAccel.NVENC
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
try:
result = subprocess.run(
["vainfo", "--display", "drm"], capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
logger.info("Aceleracion QSV (Intel) detectada")
return HardwareAccel.QSV
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
logger.info("Sin aceleracion hardware disponible, usando CPU")
return HardwareAccel.NONE
def _apply_hardware_profiles(self) -> None:
if self.hardware_accel == HardwareAccel.NVENC:
TRANSCODE_PROFILES[QualityPreset.MEDIUM].video_codec = "h264_nvenc"
TRANSCODE_PROFILES[QualityPreset.MEDIUM].preset = "p5"
TRANSCODE_PROFILES[QualityPreset.HIGH].video_codec = "h264_nvenc"
TRANSCODE_PROFILES[QualityPreset.HIGH].preset = "p4"
TRANSCODE_PROFILES[QualityPreset.ULTRA].video_codec = "h264_nvenc"
TRANSCODE_PROFILES[QualityPreset.ULTRA].preset = "p1"
logger.info("Perfiles NVENC aplicados")
elif self.hardware_accel == HardwareAccel.VAAPI:
TRANSCODE_PROFILES[QualityPreset.MEDIUM].video_codec = "h264_vaapi"
TRANSCODE_PROFILES[QualityPreset.MEDIUM].hardware_accel = HardwareAccel.VAAPI
TRANSCODE_PROFILES[QualityPreset.HIGH].video_codec = "h264_vaapi"
TRANSCODE_PROFILES[QualityPreset.HIGH].hardware_accel = HardwareAccel.VAAPI
logger.info("Perfiles VA-API aplicados")
def get_profile(self, quality: str = "medium") -> TranscodeProfile:
try:
return TRANSCODE_PROFILES[QualityPreset(quality.lower())]
except KeyError:
logger.warning(f"Calidad '{quality}' no encontrada, usando MEDIUM")
return TRANSCODE_PROFILES[QualityPreset.MEDIUM]
def transcode(
self,
input_path: str,
output_format: str = "hls",
quality: str = "medium",
output_dir: Optional[str] = None,
) -> Dict[str, Any]:
if not os.path.exists(input_path):
return {"status": "error", "message": f"Archivo no encontrado: {input_path}"}
profile = self.get_profile(quality)
output_dir = output_dir or os.getenv("STREAMS_ROOT", "/app/streams")
with self.resource_manager.transcode_slot():
try:
if output_format == "hls":
return self._transcode_hls(input_path, profile, output_dir)
elif output_format == "webm":
return self._transcode_webm(input_path, profile, output_dir)
elif output_format == "mp4":
return self._transcode_mp4(input_path, profile, output_dir)
else:
return {"status": "error", "message": f"Formato no soportado: {output_format}"}
except subprocess.TimeoutExpired:
logger.error(f"Timeout en transcodificacion de {input_path}")
return {"status": "error", "message": "Timeout en transcodificacion"}
except Exception as e:
logger.error(f"Error en transcodificacion: {e}")
return {"status": "error", "message": str(e)}
def _build_ffmpeg_cmd(
self,
input_path: str,
output_path: str,
profile: TranscodeProfile,
extra_args: Optional[list] = None,
) -> list:
cmd = ["ffmpeg", "-y", "-i", input_path]
if profile.hardware_accel == HardwareAccel.VAAPI:
cmd.extend(["-vaapi_device", "/dev/dri/renderD128"])
cmd.extend(["-vf", "format=nv12,vaapi_swapchain"])
elif profile.hardware_accel == HardwareAccel.NVENC:
cmd.extend(["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"])
cmd.extend(["-c:v", profile.video_codec])
cmd.extend(["-b:v", profile.bitrate_video])
cmd.extend(["-preset", profile.preset])
if profile.resolution_scale < 1.0:
cmd.extend(["-vf", f"scale=-2:{int(1080 * profile.resolution_scale)}"])
cmd.extend(["-c:a", profile.audio_codec])
cmd.extend(["-b:a", profile.bitrate_audio])
cmd.extend(["-movflags", "+faststart"])
cmd.extend(["-threads", str(os.cpu_count() or 2)])
if extra_args:
cmd.extend(extra_args)
cmd.append(output_path)
return cmd
def _transcode_hls(
self, input_path: str, profile: TranscodeProfile, output_dir: str
) -> Dict[str, Any]:
movie_id = os.path.splitext(os.path.basename(input_path))[0]
hls_dir = os.path.join(output_dir, "hls", movie_id)
os.makedirs(hls_dir, exist_ok=True)
output_path = os.path.join(hls_dir, "index.m3u8")
extra_args = [
"-f", "hls",
"-hls_time", "6",
"-hls_list_size", "0",
"-hls_segment_filename",
os.path.join(hls_dir, "segment_%03d.ts"),
]
cmd = self._build_ffmpeg_cmd(input_path, output_path, profile, extra_args)
logger.info(f"Ejecutando FFmpeg HLS")
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=300
)
if result.returncode != 0:
logger.error(f"FFmpeg HLS error: {result.stderr}")
return {"status": "error", "message": result.stderr}
return {
"status": "success",
"path": f"hls/{movie_id}/index.m3u8",
"format": "hls",
"quality": profile.quality.value,
}
def _transcode_webm(
self, input_path: str, profile: TranscodeProfile, output_dir: str
) -> Dict[str, Any]:
movie_id = os.path.splitext(os.path.basename(input_path))[0]
webm_dir = os.path.join(output_dir, "webm")
os.makedirs(webm_dir, exist_ok=True)
output_path = os.path.join(webm_dir, f"{movie_id}.webm")
cmd = self._build_ffmpeg_cmd(input_path, output_path, profile)
logger.info(f"Ejecutando FFmpeg WebM")
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=300
)
if result.returncode != 0:
logger.error(f"FFmpeg WebM error: {result.stderr}")
return {"status": "error", "message": result.stderr}
return {
"status": "success",
"path": f"webm/{movie_id}.webm",
"format": "webm",
"quality": profile.quality.value,
}
def _transcode_mp4(
self, input_path: str, profile: TranscodeProfile, output_dir: str
) -> Dict[str, Any]:
movie_id = os.path.splitext(os.path.basename(input_path))[0]
mp4_dir = os.path.join(output_dir, "mp4")
os.makedirs(mp4_dir, exist_ok=True)
output_path = os.path.join(mp4_dir, f"{movie_id}.mp4")
cmd = self._build_ffmpeg_cmd(input_path, output_path, profile)
logger.info(f"Ejecutando FFmpeg MP4")
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=300
)
if result.returncode != 0:
logger.error(f"FFmpeg MP4 error: {result.stderr}")
return {"status": "error", "message": result.stderr}
return {
"status": "success",
"path": f"mp4/{movie_id}.mp4",
"format": "mp4",
"quality": profile.quality.value,
}