Ctrl K
ComunidadGuías…
backend/app/services/ffmpeg_service.pypython · @kibot
20/9/2026, 17:34:43
"""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,
        }

Comentarios de código