From 37310d9335c3bfbc3c13c69aa648132662ff2861 Mon Sep 17 00:00:00 2001 From: Melbar Date: Thu, 7 May 2026 18:27:02 +0200 Subject: [PATCH] Add JSON configuration and path discovery --- README.md | 58 +++++++++++++++++---- config.json | 34 ++++++++++++ pvd_mezzanine.py | 131 +++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 198 insertions(+), 25 deletions(-) create mode 100644 config.json diff --git a/README.md b/README.md index 744f21b..921adda 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,12 @@ - **Saubere Metadaten** + `faststart` für beste Streaming-Performance - Funktioniert mit Drag & Drop (`.bat` oder direkt auf Script ziehen) -## Voraussetzungen - +## Voraussetzungen + - Windows (getestet) -- FFmpeg + FFprobe (in `C:\Software\` oder Pfad in Script anpassen) +- FFmpeg + FFprobe im `PATH`, in `C:\Tools\FFMPEG` oder in `C:\Software` - Python 3.10+ -- Schreibzugriff auf den Ausgabeordner `H:\VOD` +- Schreibzugriff auf den Ausgabeordner `F:\VOD` oder `H:\VOD` ## Installation & Nutzung @@ -129,14 +129,54 @@ Film_forced.srt ## Konfiguration -Die wichtigsten Pfade stehen oben in `pvd_mezzanine.py`: +Die wichtigsten Parameter stehen in `config.json`. Wenn die Datei fehlt, wird sie beim Start mit Default-Werten erzeugt. -```python -FFMPEG_EXE = r"C:\Software\ffmpeg.exe" -FFPROBE_EXE = r"C:\Software\ffprobe.exe" -OUTPUT_BASE_DIR = r"H:\VOD" +```json +{ + "ffmpeg": { + "ffmpeg_exe": "", + "ffprobe_exe": "", + "search_dirs": [ + "C:\\Tools\\FFMPEG", + "C:\\Software" + ] + }, + "output": { + "base_dir": "", + "preferred_dirs": [ + "F:\\VOD", + "H:\\VOD" + ] + }, + "video": { + "hd_bitrate": "30M", + "hd_maxrate": "35M", + "hd_bufsize": "50M", + "sd_bitrate": "8M", + "sd_maxrate": "10M", + "sd_bufsize": "15M" + }, + "audio": { + "mp4_bitrate": "256k", + "sample_rate": "48000", + "pcm_codec": "pcm_s24le" + } +} ``` +FFmpeg wird in dieser Reihenfolge gesucht: + +1. Explizite Pfade aus `ffmpeg_exe` und `ffprobe_exe` +2. `PATH` +3. `C:\Tools\FFMPEG` +4. `C:\Software` + +Das Zielverzeichnis wird so bestimmt: + +1. `output.base_dir`, wenn gesetzt +2. erstes vorhandenes Verzeichnis aus `output.preferred_dirs`, standardmaessig `F:\VOD`, dann `H:\VOD` +3. falls keines existiert, der erste Preferred-Dir-Wert + ## Git-Hinweis Die lokale `.env` enthält Zugangsdaten und wird absichtlich nicht versioniert. Neue Änderungen sollten zusammen mit einer passenden README-Aktualisierung committed und nach `main` gepusht werden. diff --git a/config.json b/config.json new file mode 100644 index 0000000..5d2af03 --- /dev/null +++ b/config.json @@ -0,0 +1,34 @@ +{ + "ffmpeg": { + "ffmpeg_exe": "", + "ffprobe_exe": "", + "search_dirs": [ + "C:\\Tools\\FFMPEG", + "C:\\Software" + ] + }, + "output": { + "base_dir": "", + "preferred_dirs": [ + "F:\\VOD", + "H:\\VOD" + ] + }, + "video": { + "hd_bitrate": "30M", + "hd_maxrate": "35M", + "hd_bufsize": "50M", + "hd_level": "4.1", + "sd_bitrate": "8M", + "sd_maxrate": "10M", + "sd_bufsize": "15M", + "sd_level": "3.1", + "preset": "slow", + "tune": "film" + }, + "audio": { + "mp4_bitrate": "256k", + "sample_rate": "48000", + "pcm_codec": "pcm_s24le" + } +} diff --git a/pvd_mezzanine.py b/pvd_mezzanine.py index 610ba9d..1b8ead4 100644 --- a/pvd_mezzanine.py +++ b/pvd_mezzanine.py @@ -5,6 +5,7 @@ import math import os import queue import re +import shutil import subprocess import sys import threading @@ -25,9 +26,102 @@ except Exception: # ============================================================================= # 1. KONFIGURATION UND PFADE # ============================================================================= -FFMPEG_EXE = r"C:\Software\ffmpeg.exe" -FFPROBE_EXE = r"C:\Software\ffprobe.exe" -OUTPUT_BASE_DIR = r"H:\VOD" +APP_DIR = Path(__file__).resolve().parent +CONFIG_PATH = APP_DIR / "config.json" +DEFAULT_CONFIG = { + "ffmpeg": { + "ffmpeg_exe": "", + "ffprobe_exe": "", + "search_dirs": [ + r"C:\Tools\FFMPEG", + r"C:\Software", + ], + }, + "output": { + "base_dir": "", + "preferred_dirs": [ + r"F:\VOD", + r"H:\VOD", + ], + }, + "video": { + "hd_bitrate": "30M", + "hd_maxrate": "35M", + "hd_bufsize": "50M", + "hd_level": "4.1", + "sd_bitrate": "8M", + "sd_maxrate": "10M", + "sd_bufsize": "15M", + "sd_level": "3.1", + "preset": "slow", + "tune": "film", + }, + "audio": { + "mp4_bitrate": "256k", + "sample_rate": "48000", + "pcm_codec": "pcm_s24le", + }, +} + + +def deep_merge_config(default: dict, override: dict) -> dict: + merged = dict(default) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = deep_merge_config(merged[key], value) + else: + merged[key] = value + return merged + + +def write_default_config(path: Path) -> None: + path.write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n", encoding="utf-8") + + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + write_default_config(CONFIG_PATH) + return DEFAULT_CONFIG + try: + user_config = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise RuntimeError(f"config.json ist ungueltig: {exc}") from exc + return deep_merge_config(DEFAULT_CONFIG, user_config) + + +def find_executable(name: str, configured_path: str, search_dirs: list[str]) -> str: + if configured_path: + path = Path(configured_path) + if path.is_file(): + return str(path) + raise FileNotFoundError(f"Konfigurierter Pfad fuer {name} existiert nicht: {configured_path}") + + from_path = shutil.which(name) + if from_path: + return from_path + + for directory in search_dirs: + candidate = Path(directory) / name + if candidate.is_file(): + return str(candidate) + + searched = ", ".join(["PATH", *search_dirs]) + raise FileNotFoundError(f"{name} nicht gefunden. Gesucht in: {searched}") + + +def choose_output_base_dir(configured_path: str, preferred_dirs: list[str]) -> str: + if configured_path: + return configured_path + for directory in preferred_dirs: + if Path(directory).is_dir(): + return directory + return preferred_dirs[0] if preferred_dirs else str(APP_DIR) + + +CONFIG = load_config() +FFMPEG_EXE = find_executable("ffmpeg.exe", CONFIG["ffmpeg"]["ffmpeg_exe"], CONFIG["ffmpeg"]["search_dirs"]) +FFPROBE_EXE = find_executable("ffprobe.exe", CONFIG["ffmpeg"]["ffprobe_exe"], CONFIG["ffmpeg"]["search_dirs"]) +OUTPUT_BASE_DIR = choose_output_base_dir(CONFIG["output"]["base_dir"], CONFIG["output"]["preferred_dirs"]) GERMAN_LANGS = {"DEU", "GER"} LANGUAGE_NAMES = { @@ -372,16 +466,18 @@ def build_commands(plan: JobPlan) -> list[tuple[str, list[str]]]: def build_pvd_mp4_command(plan: JobPlan) -> list[str]: profile = plan.video_profile selected_audio = plan.selected_mp4_audio + video_config = CONFIG["video"] + audio_config = CONFIG["audio"] if profile.is_sd: - bv = "8M" - maxr = "10M" - bufs = "15M" - level = "3.1" + bv = video_config["sd_bitrate"] + maxr = video_config["sd_maxrate"] + bufs = video_config["sd_bufsize"] + level = video_config["sd_level"] else: - bv = "30M" - maxr = "35M" - bufs = "50M" - level = "4.1" + bv = video_config["hd_bitrate"] + maxr = video_config["hd_maxrate"] + bufs = video_config["hd_bufsize"] + level = video_config["hd_level"] cmd = [ FFMPEG_EXE, @@ -415,9 +511,9 @@ def build_pvd_mp4_command(plan: JobPlan) -> list[str]: "-bufsize", bufs, "-preset", - "slow", + video_config["preset"], "-tune", - "film", + video_config["tune"], "-x264-params", f"keyint={profile.keyint}:min-keyint=2:scenecut=40:bframes=3:aq-mode=2", "-color_primaries", @@ -429,11 +525,11 @@ def build_pvd_mp4_command(plan: JobPlan) -> list[str]: "-c:a", "aac", "-b:a", - "256k", + audio_config["mp4_bitrate"], "-ac:a:0", "2", "-ar:a:0", - "48000", + audio_config["sample_rate"], "-f", "mp4", "-movflags", @@ -456,12 +552,13 @@ def build_pvd_mp4_command(plan: JobPlan) -> list[str]: def build_audio_mov_command(plan: JobPlan, roles: list[AudioRole], output_path: str) -> list[str]: cmd = [FFMPEG_EXE, "-hide_banner", "-y", "-i", plan.input_file] + audio_config = CONFIG["audio"] for role in roles: cmd.extend(["-map", f"0:{role.stream_index}"]) cmd.extend(["-vn", "-map_chapters", "-1", "-map_metadata", "-1"]) for idx, role in enumerate(roles): - cmd.extend([f"-c:a:{idx}", "pcm_s24le", f"-ar:a:{idx}", "48000"]) + cmd.extend([f"-c:a:{idx}", audio_config["pcm_codec"], f"-ar:a:{idx}", audio_config["sample_rate"]]) cmd.extend([f"-metadata:s:a:{idx}", f"language={role.language.lower()}"]) title = role.display_language if role.layout_from_name: @@ -502,6 +599,8 @@ def run_plan(plan: JobPlan, log: Callable[[str], None] = print) -> None: def log_plan(plan: JobPlan, log: Callable[[str], None] = print) -> None: profile = plan.video_profile log(f"Quelle: {plan.input_file}") + log(f"FFmpeg: {FFMPEG_EXE}") + log(f"FFprobe: {FFPROBE_EXE}") log(f"Video: {profile.width}x{profile.height} @ {profile.fps:.3f} fps, {profile.target_name}") if profile.needs_conversion and not profile.can_convert_colorspace: log("Farbraum: Quell-Metadaten unvollstaendig; setze Ziel-Metadaten ohne Colorspace-Filter.")