docker.videokonverter/video-konverter/app/services/encoder.py
data 37dff4de69 feat: VideoKonverter v2.9 - Projekt-Reset aus Docker-Image
Projekt aus Docker-Image videoconverter:2.9 extrahiert.
Enthält zweiphasigen Import-Workflow mit Serien-Zuordnung.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-27 11:41:48 +01:00

234 lines
7.7 KiB
Python

"""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)])
# Channel-Layout normalisieren fuer libopus
# EAC3/AC3 mit 5.1(side) Layout fuehrt zu Encoder-Fehler
if codec == "libopus" and channels == 6:
cmd.extend([
f"-filter:a:{audio_idx}",
"channelmap=channel_layout=5.1",
])
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