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()