commit ae33befd856ebbf9753796d2c819b9604558eb6f Author: melbar Date: Mon Apr 13 17:01:31 2026 +0000 Upload files to "/" diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1c9ecb --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Amazon PVD Mezzanine Encoder + +**Automatisches Python-Script zur Erstellung von Amazon PVD-konformen Mezzanine-VOD-Dateien** +(Blu-ray • ProRes • DVD • PAL • NTSC • Interlaced) + +--- + +## Features + +- **Vollautomatische Erkennung** von SD/HD, PAL/NTSC und interlaced Material +- **Bester Deinterlacer** (`bwdif mode=0`) → saubere 25 fps bei PAL-DVDs (kein Double-Framerate) +- **Automatische Farbraum-Konvertierung** (Rec.601 PAL/NTSC ↔ Rec.709) inkl. `colorspace`-Filter +- **Forced Subtitles** werden automatisch eingebrannt (wenn `_forced.srt` vorhanden) +- **Intelligente Audio-Auswahl** (automatisch deutsche Spur oder manuelle Wahl) +- **Amazon PVD optimierte Parameter** (Bitrate, Level, GOP, x264-Settings) +- **Saubere Metadaten** + `faststart` für beste Streaming-Performance +- Funktioniert mit Drag & Drop (`.bat` oder direkt auf Script ziehen) + +## Voraussetzungen + +- Windows (getestet) +- FFmpeg + FFprobe (in `C:\Software\` oder Pfad in Script anpassen) +- Python 3.8+ + +## Installation & Nutzung + +1. Repo klonen oder herunterladen +2. `ffmpeg.exe` und `ffprobe.exe` in `C:\Software\` ablegen (oder Pfade im Script ändern) +3. Optional: `create_mezzanine.bat` anlegen: + ```bat + @echo off + python "%~dp0pvd_mezzanine.py" %* + pause \ No newline at end of file diff --git a/create_mezzanine.bat b/create_mezzanine.bat new file mode 100644 index 0000000..412408b --- /dev/null +++ b/create_mezzanine.bat @@ -0,0 +1,3 @@ +@echo off +python "%~dp0pvd_mezzanine.py" %* +pause \ No newline at end of file diff --git a/pvd_mezzanine.py b/pvd_mezzanine.py new file mode 100644 index 0000000..d913c9b --- /dev/null +++ b/pvd_mezzanine.py @@ -0,0 +1,269 @@ +import os +import sys +import json +import subprocess +import math +import re +from pathlib import Path + +# ============================================================================= +# 1. KONFIGURATION UND PFADE +# ============================================================================= +FFMPEG_EXE = r"C:\Software\ffmpeg.exe" +FFPROBE_EXE = r"C:\Software\ffprobe.exe" +OUTPUT_BASE_DIR = r"H:\VOD" + + +def get_pvd_filename(input_path: str) -> str: + """TITEL-EXTRAKTION (Blu-ray / ProRes / DVD)""" + path = Path(input_path) + path_parts = [p.upper() for p in path.parts] + + if "BDMV" in path_parts: + bdmv_index = path_parts.index("BDMV") + try: + project_folder = path_parts[bdmv_index - 4] + except IndexError: + project_folder = path_parts[bdmv_index - 1] if bdmv_index > 0 else "UNKNOWN" + + clean_name = re.sub(r'^BEST_', '', project_folder, flags=re.IGNORECASE) + words = [w.capitalize() for w in clean_name.split('_') if w.strip()] + extracted_title = "".join(words) + else: + extracted_title = path.stem + + extracted_title = re.sub(r'[^A-Za-z0-9]', '', extracted_title) + if not extracted_title: + extracted_title = "UNKNOWN_TITLE" + + return f"{extracted_title}_DEU20_PVD.mp4" + + +def probe_metadata(filepath: str) -> dict: + cmd = [ + FFPROBE_EXE, "-v", "quiet", "-print_format", "json", + "-show_streams", "-show_format", filepath + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + print(f"CRITICAL ERROR: ffprobe konnte die Datei nicht lesen.\n{result.stderr}") + sys.exit(1) + return json.loads(result.stdout) + + +def main(): + if len(sys.argv) < 2: + print("ERROR: Ziehe eine Datei auf dieses Script oder die verknüpfte .bat") + os.system("pause") + sys.exit(1) + + input_file = sys.argv[1] + if not os.path.isfile(input_file): + print(f"ERROR: Eingabedatei nicht gefunden: {input_file}") + os.system("pause") + sys.exit(1) + + os.makedirs(OUTPUT_BASE_DIR, exist_ok=True) + + # --- SCHRITT 1: ANALYSE --- + print(f"\n[1/5] Analysiere Quelle: {os.path.basename(input_file)}") + metadata = probe_metadata(input_file) + + v_stream = next((s for s in metadata['streams'] if s['codec_type'] == 'video'), None) + a_streams = [s for s in metadata['streams'] if s['codec_type'] == 'audio'] + + if not v_stream: + print("Fehler: Kein Videostream gefunden!") + sys.exit(1) + if not a_streams: + print("Fehler: Keine Audio-Tracks gefunden!") + sys.exit(1) + + # === AUFLÖSUNG + PAL/NTSC + INTERLACED-ERKENNUNG + FARBRAUM === + width = int(v_stream.get('width', 0)) + height = int(v_stream.get('height', 0)) + fps_raw = v_stream.get('r_frame_rate', '24/1') + num, den = map(int, fps_raw.split('/')) + fps = num / den + keyint = math.ceil(fps) + + # SD / PAL / NTSC Erkennung + if height <= 576: + is_ntsc = (height <= 480 and fps >= 29.0) + print(f" -> Auflösung: {width}x{height} @ {fps:.3f} fps → {'NTSC' if is_ntsc else 'PAL'} SD-Material") + is_sd = True + else: + is_ntsc = False + is_sd = False + print(f" -> Auflösung: {width}x{height} @ {fps:.3f} fps → HD-Material") + + print(f" -> Keyint (1 Sek. GOP): {keyint}") + + # === INTERLACED-ERKENNUNG FÜR DVD === + field_order = v_stream.get('field_order', 'unknown') + scan_type = v_stream.get('scan_type', 'unknown') + is_interlaced = (field_order != 'progressive' and field_order != 'unknown') or scan_type == 'interlaced' + + if is_interlaced: + print(f" → **Interlaced erkannt** (field_order={field_order}, scan_type={scan_type}) → Deinterlacing mit bwdif (beste Qualität, 25 fps) aktiviert") + else: + print(" → Progressive Quelle → kein Deinterlacing nötig") + + # Ziel-Farbraum (Rec.601 PAL/NTSC vs Rec.709) + if is_sd: + if is_ntsc: + target_prim = "smpte170m" + target_trc = "smpte170m" + target_space = "smpte170m" + target_name = "Rec.601 NTSC (smpte170m)" + else: + target_prim = "bt470bg" + target_trc = "bt470bg" + target_space = "bt470bg" + target_name = "Rec.601 PAL (bt470bg)" + else: + target_prim = "bt709" + target_trc = "bt709" + target_space = "bt709" + target_name = "Rec.709 HD" + + # Quell-Farbraum prüfen + echte Konvertierung + source_prim = v_stream.get('color_primaries', 'unknown') + source_trc = v_stream.get('color_transfer', 'unknown') + source_space = v_stream.get('color_space', 'unknown') + + needs_conversion = ( + source_prim == 'unknown' or + source_prim != target_prim or + source_trc != target_trc or + source_space != target_space + ) + + if needs_conversion: + print(f" → Farbkonvertierung AKTIVIERT → Ziel: {target_name}") + else: + print(f" → Quell-Farbraum passt perfekt → keine Konvertierung") + + # --- SCHRITT 2: AUDIO-WAHL --- + print("\n[2/5] Vorhandene Audio-Tracks:") + for i, s in enumerate(a_streams): + lang = s.get('tags', {}).get('language', 'und').upper() + ch = s.get('channels', 2) + codec = s.get('codec_name', 'pcm') + print(f" [{i}] Index {s['index']}: {lang} ({ch} Kanäle, {codec})") + + selected_audio_list_idx = next( + (i for i, s in enumerate(a_streams) if s.get('tags', {}).get('language', 'und').lower() in ('ger', 'deu')), + None + ) + + if selected_audio_list_idx is None: + choice = input("\nKeine deutsche Spur automatisch erkannt.\nWelche Spur ist die deutsche Master-Spur? [Standard: 0]: ") or "0" + try: + selected_audio_list_idx = int(choice) + if not 0 <= selected_audio_list_idx < len(a_streams): + raise ValueError + except Exception: + print("Ungültige Eingabe → verwende Spur 0.") + selected_audio_list_idx = 0 + + selected_audio_idx = a_streams[selected_audio_list_idx]['index'] + print(f" → Gewählte Audio-Spur: Index {selected_audio_idx}") + + # --- SCHRITT 3: VF-FILTER (Deinterlace + Farbe + Subs) --- + output_name = get_pvd_filename(input_file) + output_path = os.path.join(OUTPUT_BASE_DIR, output_name) + + forced_srt = os.path.splitext(input_file)[0] + "_forced.srt" + vf_filters = [] + + # 1. Deinterlacing mit bwdif (beste Qualität, behält originale Framerate = 25 fps bei PAL) + if is_interlaced: + vf_filters.append("bwdif=mode=0:parity=auto") + + # 2. Farbkonvertierung (falls nötig) + if needs_conversion: + conv = (f"colorspace=primaries={target_prim}:trc={target_trc}:matrix={target_space}:range=tv") + vf_filters.append(conv) + + # 3. Forced SRT Burn-In + if os.path.exists(forced_srt): + print(f"\n[3/5] Forced SRT gefunden → wird eingebrannt: {os.path.basename(forced_srt)}") + clean_srt_path = forced_srt.replace("\\", "/").replace(":", "\\:") + vf_filters.append(f"subtitles='{clean_srt_path}':force_style='Fontsize=14,MarginV=38'") + else: + print("\n[3/5] Keine Forced SRT gefunden → Burn-In übersprungen.") + + # --- SCHRITT 4: ENCODING --- + print(f"\n[4/5] Starte Encoding → {output_name}") + print(f" → Ziel-Farbraum: {target_name}") + + if is_sd: + bv = "8M" + maxr = "10M" + bufs = "15M" + lvl = "3.1" + print(" → SD-Parameter (Bitrate + Level)") + else: + bv = "28M" + maxr = "35M" + bufs = "50M" + lvl = "4.1" + print(" → HD-Parameter (Bitrate + Level)") + + ffmpeg_cmd = [ + FFMPEG_EXE, "-hide_banner", "-y", "-i", input_file, + "-map", f"0:{v_stream['index']}", + "-map", f"0:{selected_audio_idx}", + ] + + if vf_filters: + vf_str = ",".join(vf_filters) + ffmpeg_cmd.extend(["-vf", vf_str]) + + ffmpeg_cmd.extend([ + "-c:v", "libx264", + "-profile:v", "high", + "-level", lvl, + "-pix_fmt", "yuv420p", + "-b:v", bv, + "-maxrate", maxr, + "-bufsize", bufs, + "-preset", "slow", + "-tune", "film", + "-x264-params", f"keyint={keyint}:min-keyint=2:scenecut=40:bframes=3:aq-mode=2", + "-color_primaries", target_prim, + "-color_trc", target_trc, + "-colorspace", target_space, + ]) + + ffmpeg_cmd.extend([ + "-c:a", "aac", + "-b:a", "256k", + "-ac", "2", + "-ar", "48000", + "-f", "mp4", + "-movflags", "+faststart", + "-metadata:s:a:0", "language=ger", + "-metadata:s:v:0", "language=ger", + "-map_chapters", "-1", + "-map_metadata", "-1", + "-avoid_negative_ts", "make_zero", + output_path + ]) + + print("\n--- FFmpeg Kommando ---") + print(" ".join(ffmpeg_cmd)) + print("------------------------\n") + + result = subprocess.run(ffmpeg_cmd, check=False) + if result.returncode == 0: + print(f"\n[5/5] ✅ ERFOLGREICH abgeschlossen!\nDatei: {output_path}") + else: + print(f"\n[5/5] ❌ FFmpeg-Fehler (Code {result.returncode})") + + os.system("pause") + + +if __name__ == "__main__": + main() \ No newline at end of file