Ctrl K
ComunidadGuías…
backend/services/codec_validator.pypython · @kibot
20/9/2026, 17:55:23
"""Validador de compatibilidad de codecs para producción NetFlux.

Verifica:
- Codecs de video disponibles (libx264, h264_nvenc, h264_vaapi, h264_amf)
- Codecs de audio disponibles (aac, libmp3lame, libopus, libvorbis)
- Formatos de contenedor soportados (HLS, WebM, MP4)
- Aceleración hardware disponible (VA-API, NVENC, QSV, AMF)
- Perfiles de calidad compatibles
"""
import subprocess
import json
import logging
import os
from typing import Dict, Any, List
from dataclasses import dataclass, field, asdict
from enum import Enum

logger = logging.getLogger(__name__)


class CodecStatus(Enum):
    AVAILABLE = "available"
    UNAVAILABLE = "unavailable"
    PARTIAL = "partial"


@dataclass
class CodecInfo:
    name: str
    status: str
    description: str = ""


@dataclass
class FormatInfo:
    name: str
    status: str
    mime_type: str = ""


@dataclass
class HardwareAccelInfo:
    name: str
    status: str
    device_path: str = ""


class CodecValidator:
    """Valida la compatibilidad de codecs en el entorno de producción."""

    # Codecs esperados por los perfiles de transcodificación
    REQUIRED_VIDEO_CODECS = [
        "libx264",
        "h264_nvenc",
        "h264_vaapi",
        "h264_amf",
        "h264_qsv",
    ]

    REQUIRED_AUDIO_CODECS = [
        "aac",
        "libmp3lame",
        "libopus",
        "libvorbis",
    ]

    REQUIRED_FORMATS = [
        ("hls", "application/x-mpegURL"),
        ("webm", "video/webm"),
        ("mp4", "video/mp4"),
    ]

    HARDWARE_ACCEL_DEVICES = [
        ("VA-API", "/dev/dri/renderD128"),
        ("NVENC", "/dev/nvidia0"),
        ("QSV", "/dev/dri/renderD128"),
        ("AMF", "/dev/kfd"),
    ]

    def __init__(self):
        self.ffmpeg_path = os.getenv("FFMPEG_PATH", "/usr/bin/ffmpeg")
        self.ffprobe_path = os.getenv("FFPROBE_PATH", "/usr/bin/ffprobe")
        self._codecs_cache: Dict[str, CodecInfo] = {}
        self._formats_cache: Dict[str, FormatInfo] = {}
        self._hardware_cache: Dict[str, HardwareAccelInfo] = {}

    def _run_ffmpeg(self, args: List[str]) -> tuple[int, str, str]:
        """Ejecuta ffmpeg y retorna (returncode, stdout, stderr)."""
        try:
            result = subprocess.run(
                [self.ffmpeg_path] + args,
                capture_output=True,
                text=True,
                timeout=30,
            )
            return result.returncode, result.stdout, result.stderr
        except FileNotFoundError:
            return -1, "", f"FFmpeg no encontrado en {self.ffmpeg_path}"
        except subprocess.TimeoutExpired:
            return -2, "", "FFmpeg timeout"

    def _run_ffprobe(self, args: List[str]) -> tuple[int, str, str]:
        """Ejecuta ffprobe y retorna (returncode, stdout, stderr)."""
        try:
            result = subprocess.run(
                [self.ffprobe_path] + args,
                capture_output=True,
                text=True,
                timeout=30,
            )
            return result.returncode, result.stdout, result.stderr
        except FileNotFoundError:
            return -1, "", f"ffprobe no encontrado en {self.ffprobe_path}"
        except subprocess.TimeoutExpired:
            return -2, "", "ffprobe timeout"

    def check_ffmpeg_available(self) -> bool:
        """Verifica que ffmpeg esté instalado y accesible."""
        rc, _, _ = self._run_ffmpeg(["-version"])
        available = rc == 0
        logger.info(f"FFmpeg disponible: {available}")
        return available

    def check_codec(self, codec_name: str) -> CodecInfo:
        """Verifica si un codec de video/audio está disponible."""
        rc, stdout, _ = self._run_ffmpeg(["-codecs"])
        if rc != 0:
            return CodecInfo(name=codec_name, status=CodecStatus.UNAVAILABLE.value)

        # Buscar el codec en la salida (líneas que empiezan con ...D... o ...E...)
        codec_lower = codec_name.lower()
        found = False
        for line in stdout.splitlines():
            if codec_lower in line.lower():
                found = True
                break

        status = CodecStatus.AVAILABLE.value if found else CodecStatus.UNAVAILABLE.value
        info = CodecInfo(name=codec_name, status=status)
        self._codecs_cache[codec_name] = info
        logger.info(f"Codec {codec_name}: {status}")
        return info

    def check_format(self, format_name: str, mime_type: str = "") -> FormatInfo:
        """Verifica si un formato de contenedor está soportado."""
        rc, stdout, _ = self._run_ffmpeg(["-formats"])
        if rc != 0:
            return FormatInfo(name=format_name, status=CodecStatus.UNAVAILABLE.value)

        found = format_name.lower() in stdout.lower()
        status = CodecStatus.AVAILABLE.value if found else CodecStatus.UNAVAILABLE.value
        info = FormatInfo(name=format_name, status=status, mime_type=mime_type)
        self._formats_cache[format_name] = info
        logger.info(f"Formato {format_name}: {status}")
        return info

    def check_hardware_accel(self, accel_name: str, device_path: str) -> HardwareAccelInfo:
        """Verifica si la aceleración hardware está disponible."""
        device_exists = os.path.exists(device_path)
        status = CodecStatus.AVAILABLE.value if device_exists else CodecStatus.UNAVAILABLE.value
        info = HardwareAccelInfo(
            name=accel_name,
            status=status,
            device_path=device_path if device_exists else "",
        )
        self._hardware_cache[accel_name] = info
        logger.info(f"Hardware {accel_name}: {status} ({device_path})")
        return info

    def validate_all(self) -> Dict[str, Any]:
        """Ejecuta todas las validaciones y retorna un informe completo."""
        ffmpeg_ok = self.check_ffmpeg_available()

        video_codecs = [self.check_codec(c) for c in self.REQUIRED_VIDEO_CODECS]
        audio_codecs = [self.check_codec(c) for c in self.REQUIRED_AUDIO_CODECS]
        formats = [self.check_format(f, m) for f, m in self.REQUIRED_FORMATS]
        hardware = [self.check_hardware_accel(n, p) for n, p in self.HARDWARE_ACCEL_DEVICES]

        all_codecs = video_codecs + audio_codecs
        available_codecs = [c for c in all_codecs if c.status == CodecStatus.AVAILABLE.value]
        unavailable_codecs = [c for c in all_codecs if c.status == CodecStatus.UNAVAILABLE.value]

        available_formats = [f for f in formats if f.status == CodecStatus.AVAILABLE.value]
        unavailable_formats = [f for f in formats if f.status == CodecStatus.UNAVAILABLE.value]

        available_hardware = [h for h in hardware if h.status == CodecStatus.AVAILABLE.value]

        # Determinar compatibilidad general
        critical_available = len([c for c in video_codecs if c.name == "libx264" and c.status == CodecStatus.AVAILABLE.value]) > 0
        hls_available = len([f for f in formats if f.name == "hls" and f.status == CodecStatus.AVAILABLE.value]) > 0

        overall = "compatible" if (ffmpeg_ok and critical_available and hls_available) else "incompatible"

        report = {
            "status": overall,
            "ffmpeg_available": ffmpeg_ok,
            "video_codecs": {
                "available": [asdict(c) for c in available_codecs],
                "unavailable": [asdict(c) for c in unavailable_codecs],
            },
            "audio_codecs": {
                "available": [asdict(c) for c in audio_codecs if c.status == CodecStatus.AVAILABLE.value],
                "unavailable": [asdict(c) for c in audio_codecs if c.status == CodecStatus.UNAVAILABLE.value],
            },
            "formats": {
                "available": [asdict(f) for f in available_formats],
                "unavailable": [asdict(f) for f in unavailable_formats],
            },
            "hardware_accel": {
                "available": [asdict(h) for h in available_hardware],
                "unavailable": [asdict(h) for h in hardware if h.status == CodecStatus.UNAVAILABLE.value],
            },
            "recommended_codec": self._get_recommended_codec(video_codecs, available_hardware),
        }

        logger.info(f"Informe de compatibilidad: {overall}")
        return report

    def _get_recommended_codec(self, video_codecs: List[CodecInfo], hardware: List[HardwareAccelInfo]) -> str:
        """Determina el codec recomendado según disponibilidad."""
        if any(h.name == "NVENC" and h.status == CodecStatus.AVAILABLE.value for h in hardware):
            return "h264_nvenc"
        if any(h.name == "VA-API" and h.status == CodecStatus.AVAILABLE.value for h in hardware):
            return "h264_vaapi"
        if any(h.name == "AMF" and h.status == CodecStatus.AVAILABLE.value for h in hardware):
            return "h264_amf"
        if any(h.name == "QSV" and h.status == CodecStatus.AVAILABLE.value for h in hardware):
            return "h264_qsv"
        # Fallback a software
        if any(c.name == "libx264" and c.status == CodecStatus.AVAILABLE.value for c in video_codecs):
            return "libx264"
        return "none"

    def validate_file_codecs(self, file_path: str) -> Dict[str, Any]:
        """Valida los codecs de un archivo de video específico."""
        if not os.path.exists(file_path):
            return {"status": "error", "message": f"Archivo no encontrado: {file_path}"}

        rc, stdout, _ = self._run_ffprobe(["-v", "error", "-show_streams", "-show_format", "-of", "json", file_path])
        if rc != 0:
            return {"status": "error", "message": f"No se pudo analizar {file_path}"}

        try:
            probe = json.loads(stdout)
        except json.JSONDecodeError:
            return {"status": "error", "message": "Error parseando ffprobe output"}

        streams = probe.get("streams", [])
        video_streams = [s for s in streams if s.get("codec_type") == "video"]
        audio_streams = [s for s in streams if s.get("codec_type") == "audio"]

        video_codecs_in_file = [s.get("codec_name", "unknown") for s in video_streams]
        audio_codecs_in_file = [s.get("codec_name", "unknown") for s in audio_streams]

        # Verificar compatibilidad de codecs del archivo con los soportados
        compatible_video = [vc for vc in video_codecs_in_file if vc in ["h264", "h265", "hevc", "vp9", "av1"]]
        compatible_audio = [ac for ac in audio_codecs_in_file if ac in ["aac", "mp3", "opus", "vorbis"]]

        return {
            "status": "ok",
            "file": file_path,
            "video_codecs": video_codecs_in_file,
            "audio_codecs": audio_codecs_in_file,
            "compatible_video": compatible_video,
            "compatible_audio": compatible_audio,
            "can_transcode": len(compatible_video) > 0,
        }


def main():
    """Ejecuta validación de codecs y muestra resultado."""
    import sys
    validator = CodecValidator()

    if not validator.check_ffmpeg_available():
        print(json.dumps({"status": "error", "message": "FFmpeg no disponible"}, indent=2))
        sys.exit(1)

    report = validator.validate_all()
    print(json.dumps(report, indent=2, ensure_ascii=False))

    # Si se proporciona un archivo, validar sus codecs
    if len(sys.argv) > 1:
        file_report = validator.validate_file_codecs(sys.argv[1])
        print(json.dumps(file_report, indent=2, ensure_ascii=False))

    sys.exit(0 if report["status"] == "compatible" else 1)


if __name__ == "__main__":
    main()

Comentarios de código