"""ffmpeg Command Builder - GPU und CPU Encoding""" import os import logging import asyncio from app.config import Config from app.models.job import ConversionJob class EncoderService: """Baut ffmpeg-Befehle basierend auf Preset und Media-Analyse""" def __init__(self, config: Config): self.config = config def build_command(self, job: ConversionJob) -> list[str]: """ Baut den vollstaendigen ffmpeg-Befehl. Beruecksichtigt GPU/CPU, Preset, Audio/Subtitle-Filter. """ preset = self.config.presets.get(job.preset_name, {}) if not preset: logging.error(f"Preset '{job.preset_name}' nicht gefunden") preset = self.config.default_preset cmd = ["ffmpeg", "-y"] # GPU-Initialisierung cmd.extend(self._build_hw_init(preset)) # Input cmd.extend(["-i", job.media.source_path]) # Video-Stream (erster Video-Stream) cmd.extend(self._build_video_params(job, preset)) # Audio-Streams (alle passenden) cmd.extend(self._build_audio_params(job)) # Subtitle-Streams (alle passenden) cmd.extend(self._build_subtitle_params(job)) # Output cmd.append(job.target_path) job.ffmpeg_cmd = cmd return cmd def _build_hw_init(self, preset: dict) -> list[str]: """GPU-Initialisierung fuer VAAPI""" if not preset.get("hw_init", False): return [] device = self.config.gpu_device return [ "-init_hw_device", f"vaapi=intel:{device}", "-hwaccel", "vaapi", "-hwaccel_device", "intel", ] def _build_video_params(self, job: ConversionJob, preset: dict) -> list[str]: """Video-Parameter: Codec, Quality, Filter""" cmd = ["-map", "0:v:0"] # Erster Video-Stream # Video-Codec codec = preset.get("video_codec", "libsvtav1") cmd.extend(["-c:v", codec]) # Quality (CRF oder QP je nach Encoder) quality_param = preset.get("quality_param", "crf") quality_value = preset.get("quality_value", 30) cmd.extend([f"-{quality_param}", str(quality_value)]) # GOP-Groesse gop = preset.get("gop_size") if gop: cmd.extend(["-g", str(gop)]) # Speed-Preset (nur CPU-Encoder) speed = preset.get("speed_preset") if speed is not None: cmd.extend(["-preset", str(speed)]) # Video-Filter vf = preset.get("video_filter", "") if not vf and preset.get("hw_init"): # Auto-Detect Pixel-Format fuer GPU vf = self._detect_gpu_filter(job) if vf: cmd.extend(["-vf", vf]) # Extra-Parameter for key, value in preset.get("extra_params", {}).items(): cmd.extend([f"-{key}", str(value)]) return cmd def _build_audio_params(self, job: ConversionJob) -> list[str]: """ Audio-Streams: Filtert nach Sprache, setzt Codec/Bitrate. WICHTIG: Kanalanzahl wird beibehalten (kein Downmix)! Surround (5.1/7.1) und Stereo (2.0/2.1) bleiben erhalten. """ audio_cfg = self.config.audio_config languages = audio_cfg.get("languages", ["ger", "eng", "und"]) codec = audio_cfg.get("default_codec", "libopus") bitrate_map = audio_cfg.get("bitrate_map", {2: "128k", 6: "320k", 8: "450k"}) default_bitrate = audio_cfg.get("default_bitrate", "192k") keep_channels = audio_cfg.get("keep_channels", True) cmd = [] audio_idx = 0 for stream in job.media.audio_streams: # Sprachfilter: Wenn Sprache gesetzt, muss sie in der Liste sein lang = stream.language if lang and lang not in languages: continue cmd.extend(["-map", f"0:{stream.index}"]) if codec == "copy": cmd.extend([f"-c:a:{audio_idx}", "copy"]) else: cmd.extend([f"-c:a:{audio_idx}", codec]) # Bitrate nach Kanalanzahl (Surround bekommt mehr) # Konvertiere bitrate_map Keys zu int (YAML laedt sie als int) channels = stream.channels bitrate = str(bitrate_map.get(channels, default_bitrate)) cmd.extend([f"-b:a:{audio_idx}", bitrate]) # Kanalanzahl beibehalten if keep_channels: cmd.extend([f"-ac:{audio_idx}", str(channels)]) audio_idx += 1 return cmd def _build_subtitle_params(self, job: ConversionJob) -> list[str]: """Subtitle-Streams: Filtert nach Sprache und Blacklist""" sub_cfg = self.config.subtitle_config languages = sub_cfg.get("languages", ["ger", "eng"]) blacklist = sub_cfg.get("codec_blacklist", []) cmd = [] for stream in job.media.subtitle_streams: # Codec-Blacklist (Bild-basierte Untertitel) if stream.codec_name in blacklist: continue # Sprachfilter lang = stream.language if lang and lang not in languages: continue cmd.extend(["-map", f"0:{stream.index}"]) # Subtitle-Codec: Bei WebM nur webvtt moeglich if job.target_container == "webm" and cmd: cmd.extend(["-c:s", "webvtt"]) elif cmd: cmd.extend(["-c:s", "copy"]) return cmd def _detect_gpu_filter(self, job: ConversionJob) -> str: """Erkennt Pixel-Format fuer GPU-Encoding""" if job.media.is_10bit: return "format=p010,hwupload" return "format=nv12,hwupload" @staticmethod def detect_gpu_available() -> bool: """Prueft ob GPU/VAAPI verfuegbar ist""" # Pruefe ob /dev/dri existiert if not os.path.exists("/dev/dri"): return False # Pruefe ob renderD* Devices vorhanden devices = EncoderService.get_available_render_devices() if not devices: return False return True @staticmethod def get_available_render_devices() -> list[str]: """Listet verfuegbare /dev/dri/renderD* Geraete""" dri_path = "/dev/dri" if not os.path.exists(dri_path): return [] devices = [] try: for entry in os.listdir(dri_path): if entry.startswith("renderD"): devices.append(f"{dri_path}/{entry}") except PermissionError: logging.warning("Kein Zugriff auf /dev/dri") return sorted(devices) @staticmethod async def test_gpu_encoding(device: str = "/dev/dri/renderD128") -> bool: """Testet ob GPU-Encoding tatsaechlich funktioniert""" cmd = [ "ffmpeg", "-y", "-init_hw_device", f"vaapi=test:{device}", "-f", "lavfi", "-i", "nullsrc=s=64x64:d=0.1", "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-frames:v", "1", "-f", "null", "-", ] try: process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) await asyncio.wait_for(process.communicate(), timeout=10) return process.returncode == 0 except Exception as e: logging.warning(f"GPU-Test fehlgeschlagen: {e}") return False