Add JSON configuration and path discovery

This commit is contained in:
Melbar
2026-05-07 18:27:02 +02:00
parent 184f56091b
commit 37310d9335
3 changed files with 198 additions and 25 deletions
+49 -9
View File
@@ -22,12 +22,12 @@
- **Saubere Metadaten** + `faststart` für beste Streaming-Performance - **Saubere Metadaten** + `faststart` für beste Streaming-Performance
- Funktioniert mit Drag & Drop (`.bat` oder direkt auf Script ziehen) - Funktioniert mit Drag & Drop (`.bat` oder direkt auf Script ziehen)
## Voraussetzungen ## Voraussetzungen
- Windows (getestet) - 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+ - Python 3.10+
- Schreibzugriff auf den Ausgabeordner `H:\VOD` - Schreibzugriff auf den Ausgabeordner `F:\VOD` oder `H:\VOD`
## Installation & Nutzung ## Installation & Nutzung
@@ -129,14 +129,54 @@ Film_forced.srt
## Konfiguration ## 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 ```json
FFMPEG_EXE = r"C:\Software\ffmpeg.exe" {
FFPROBE_EXE = r"C:\Software\ffprobe.exe" "ffmpeg": {
OUTPUT_BASE_DIR = r"H:\VOD" "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 ## 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. 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.
+34
View File
@@ -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"
}
}
+115 -16
View File
@@ -5,6 +5,7 @@ import math
import os import os
import queue import queue
import re import re
import shutil
import subprocess import subprocess
import sys import sys
import threading import threading
@@ -25,9 +26,102 @@ except Exception:
# ============================================================================= # =============================================================================
# 1. KONFIGURATION UND PFADE # 1. KONFIGURATION UND PFADE
# ============================================================================= # =============================================================================
FFMPEG_EXE = r"C:\Software\ffmpeg.exe" APP_DIR = Path(__file__).resolve().parent
FFPROBE_EXE = r"C:\Software\ffprobe.exe" CONFIG_PATH = APP_DIR / "config.json"
OUTPUT_BASE_DIR = r"H:\VOD" 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"} GERMAN_LANGS = {"DEU", "GER"}
LANGUAGE_NAMES = { 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]: def build_pvd_mp4_command(plan: JobPlan) -> list[str]:
profile = plan.video_profile profile = plan.video_profile
selected_audio = plan.selected_mp4_audio selected_audio = plan.selected_mp4_audio
video_config = CONFIG["video"]
audio_config = CONFIG["audio"]
if profile.is_sd: if profile.is_sd:
bv = "8M" bv = video_config["sd_bitrate"]
maxr = "10M" maxr = video_config["sd_maxrate"]
bufs = "15M" bufs = video_config["sd_bufsize"]
level = "3.1" level = video_config["sd_level"]
else: else:
bv = "30M" bv = video_config["hd_bitrate"]
maxr = "35M" maxr = video_config["hd_maxrate"]
bufs = "50M" bufs = video_config["hd_bufsize"]
level = "4.1" level = video_config["hd_level"]
cmd = [ cmd = [
FFMPEG_EXE, FFMPEG_EXE,
@@ -415,9 +511,9 @@ def build_pvd_mp4_command(plan: JobPlan) -> list[str]:
"-bufsize", "-bufsize",
bufs, bufs,
"-preset", "-preset",
"slow", video_config["preset"],
"-tune", "-tune",
"film", video_config["tune"],
"-x264-params", "-x264-params",
f"keyint={profile.keyint}:min-keyint=2:scenecut=40:bframes=3:aq-mode=2", f"keyint={profile.keyint}:min-keyint=2:scenecut=40:bframes=3:aq-mode=2",
"-color_primaries", "-color_primaries",
@@ -429,11 +525,11 @@ def build_pvd_mp4_command(plan: JobPlan) -> list[str]:
"-c:a", "-c:a",
"aac", "aac",
"-b:a", "-b:a",
"256k", audio_config["mp4_bitrate"],
"-ac:a:0", "-ac:a:0",
"2", "2",
"-ar:a:0", "-ar:a:0",
"48000", audio_config["sample_rate"],
"-f", "-f",
"mp4", "mp4",
"-movflags", "-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]: 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] cmd = [FFMPEG_EXE, "-hide_banner", "-y", "-i", plan.input_file]
audio_config = CONFIG["audio"]
for role in roles: for role in roles:
cmd.extend(["-map", f"0:{role.stream_index}"]) cmd.extend(["-map", f"0:{role.stream_index}"])
cmd.extend(["-vn", "-map_chapters", "-1", "-map_metadata", "-1"]) cmd.extend(["-vn", "-map_chapters", "-1", "-map_metadata", "-1"])
for idx, role in enumerate(roles): 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()}"]) cmd.extend([f"-metadata:s:a:{idx}", f"language={role.language.lower()}"])
title = role.display_language title = role.display_language
if role.layout_from_name: 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: def log_plan(plan: JobPlan, log: Callable[[str], None] = print) -> None:
profile = plan.video_profile profile = plan.video_profile
log(f"Quelle: {plan.input_file}") 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}") 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: if profile.needs_conversion and not profile.can_convert_colorspace:
log("Farbraum: Quell-Metadaten unvollstaendig; setze Ziel-Metadaten ohne Colorspace-Filter.") log("Farbraum: Quell-Metadaten unvollstaendig; setze Ziel-Metadaten ohne Colorspace-Filter.")