DoVi + HDR10 Fallback Konverter

Begonnen von Bambi, 11. Januar 2026, 16:09

Vorheriges Thema - Nächstes Thema

Bambi

Grüße euch,

ich habe mithilfe von OpenAI ein Tool entwickelt, mit dem sich 4K oder 1080p Dolby-Vision-Inhalte einfach in 4K bzw. 1080p Dolby Vision mit HDR10-Fallback konvertieren lassen.

Das Tool verfügt über eine übersichtliche grafische Oberfläche (UI) und unterstützt:
automatischen Passthrough von Audio
automatischen Passthrough von Untertiteln

Kurz gesagt: Datei auswählen, starten, fertig.


ANLEITUNG

VORAUSSETZUNGEN

Python wird benötigt.
Falls Python noch nicht installiert ist, kann es hier heruntergeladen werden:
www.python.org/downloads/windows/

(Beim Installieren darauf achten, dass ,,Add Python to PATH" aktiviert ist.)

SCHRITT 1: PYTHON-SKRIPT ERSTELLEN
Öffne den Windows-Editor (Notepad).
Erstelle eine neue Textdatei.

Benenne die Datei z.B.:
DoVi_Converter.txt

Kopiere den unten angefügten Code vollständig in diese Datei.

Speichere die Datei.

Benenne die Datei anschließend um in:
DoVi_Converter.py
(Die Windows-Sicherheitsabfrage bitte bestätigen.)


SCHRITT 2: ORDNERSTRUKTUR ANLEGEN
Erstelle einen Ordner mit einem beliebigen Namen, z.B.:
DoVi Converter

Lege die Datei DoVi_Converter.py in diesen Ordner.

SCHRITT 3: BENÖTIGTE TOOLS HINZUFÜGEN
Im Ordner ,,DoVi Converter" muss sich zusätzlich ein Ordner befinden mit dem Namen:
"
bin
"
In diesen ,,bin"-Ordner müssen folgende Dateien kopiert werden:
ffmpeg.exe
ffprobe.exe
dovi_tool.exe
mkvmerge.exe

Diese Tools lassen sich leicht über Google finden und herunterladen.
Nach dem Download einfach alle vier Dateien in den ,,bin"-Ordner kopieren.

SCHRITT 4: ORDNERSTRUKTUR ÜBERPRÜFEN
Am Ende sollte die Ordnerstruktur so aussehen:

DoVi Converter/
DoVi_Converter.py
bin/
ffmpeg.exe
ffprobe.exe
dovi_tool.exe
mkvmerge.exe

Öffne nun die .py Datei um dein Quellmaterial zu konvertieren.

Ich hoffe ihr habt Spaß mit dem Tool.
Gerne könnt ihr Bugs die auftreten sollten oder Verbesserungswünsche hier teilen.

import json
import os
import subprocess
import sys
import threading
import time
import tempfile
import shutil
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from collections import deque
from pathlib import Path


APP_TITLE = "Dolby Vision MKV Converter (HDR10/SDR/DV+HDR10)"


def resource_path(rel_path: str) -> str:
    base = getattr(sys, "_MEIPASS", os.path.abspath("."))
    return os.path.join(base, rel_path)


def is_windows() -> bool:
    return os.name == "nt"


def find_tool(basename: str) -> str:
    exe = f"{basename}.exe" if is_windows() else basename
    bundled = resource_path(os.path.join("bin", exe))
    if os.path.exists(bundled):
        return bundled
    return exe


FFMPEG = find_tool("ffmpeg")
FFPROBE = find_tool("ffprobe")
DOVI_TOOL = find_tool("dovi_tool")
MKVMERGE = find_tool("mkvmerge")  # NEW: MKVToolNix muxer for DV output


def ensure_tool_exists(tool_path: str, friendly: str):
    if os.path.sep in tool_path:
        if not os.path.exists(tool_path):
            raise RuntimeError(f"{friendly} nicht gefunden: {tool_path}")
        return

    resolved = shutil.which(tool_path)
    if not resolved:
        raise RuntimeError(
            f"{friendly} nicht gefunden in PATH (gesucht: {tool_path}). "
            f"Lege es in ./bin oder füge es zur PATH hinzu."
        )


def run_cmd_capture(cmd: list[str]) -> tuple[int, str, str]:
    p = subprocess.run(cmd, capture_output=True, text=True)
    return p.returncode, p.stdout, p.stderr


def run_ffprobe_json(input_path: str) -> dict:
    cmd = [
        FFPROBE,
        "-hide_banner",
        "-loglevel", "error",
        "-print_format", "json",
        "-show_format",
        "-show_streams",
        "-show_entries",
        "stream=index,codec_type,codec_name,width,height,avg_frame_rate,r_frame_rate,time_base,"
        "color_space,color_transfer,color_primaries,side_data_list:format=duration",
        input_path
    ]
    rc, out, err = run_cmd_capture(cmd)
    if rc != 0:
        raise RuntimeError(err.strip() or "ffprobe failed")
    return json.loads(out)


def _to_int(x):
    try:
        if x is None:
            return None
        if isinstance(x, (int, float)):
            return int(x)
        s = str(x).strip()
        if s.isdigit():
            return int(s)
    except Exception:
        pass
    return None


def _looks_like_1001_fps(s: str) -> bool:
    return isinstance(s, str) and ("/1001" in s)


def pick_fps_str(vstream: dict) -> str | None:
    r = (vstream.get("r_frame_rate") or "").strip()
    a = (vstream.get("avg_frame_rate") or "").strip()

    r = None if (not r or r == "0/0" or r.lower() == "n/a") else r
    a = None if (not a or a == "0/0" or a.lower() == "n/a") else a

    if r and _looks_like_1001_fps(r):
        return r
    if a and _looks_like_1001_fps(a):
        return a
    return r or a


def detect_stream_info(probe: dict) -> dict:
    streams = probe.get("streams", [])
    fmt = probe.get("format", {})
    duration = None
    try:
        if fmt.get("duration") is not None:
            duration = float(fmt["duration"])
    except Exception:
        duration = None

    v = next((s for s in streams if s.get("codec_type") == "video"), None)
    if not v:
        raise RuntimeError("Keine Video-Stream gefunden.")

    fps_str = pick_fps_str(v)

    side_data = v.get("side_data_list") or []
    is_dv = False
    dv_profile = dv_level = dv_bl_signal_compat_id = None

    for sd in side_data:
        sdt = (sd.get("side_data_type") or "").lower()
        if "dovi" in sdt or "dolby vision" in sdt:
            is_dv = True
            dv_profile = _to_int(sd.get("dv_profile", sd.get("profile")))
            dv_level = _to_int(sd.get("dv_level", sd.get("level")))
            dv_bl_signal_compat_id = _to_int(sd.get("dv_bl_signal_compat_id"))
            break

    width = _to_int(v.get("width")) or 0
    height = _to_int(v.get("height")) or 0

    color_transfer = v.get("color_transfer")
    color_primaries = v.get("color_primaries")
    color_space = v.get("color_space")

    has_hdr10_base = (
        (color_transfer == "smpte2084")
        and (color_primaries in ("bt2020", "bt2020nc"))
        and (color_space in ("bt2020nc", "bt2020c", "bt2020"))
    )

    return {
        "width": width,
        "height": height,
        "duration": duration,
        "fps_str": fps_str,

        "is_dv": is_dv,
        "dv_profile": dv_profile,
        "dv_level": dv_level,
        "dv_bl_signal_compat_id": dv_bl_signal_compat_id,

        "color_transfer": color_transfer,
        "color_primaries": color_primaries,
        "color_space": color_space,
        "has_hdr10_base": has_hdr10_base,
    }


def even(n: int) -> int:
    return n if n % 2 == 0 else n - 1


def compute_fit_and_pad(src_w: int, src_h: int, target_res: str):
    if src_w <= 0 or src_h <= 0:
        return None
    if target_res == "Original":
        return None

    canvas_w, canvas_h = (1920, 1080) if target_res == "FHD" else (3840, 2160)

    scale = min(canvas_w / src_w, canvas_h / src_h, 1.0)
    scaled_w = even(int(round(src_w * scale)))
    scaled_h = even(int(round(src_h * scale)))

    pad_left = (canvas_w - scaled_w) // 2
    pad_top = (canvas_h - scaled_h) // 2
    return canvas_w, canvas_h, scaled_w, scaled_h, pad_left, pad_top


def build_vf_fit_pad(src_w: int, src_h: int, target_res: str):
    fit = compute_fit_and_pad(src_w, src_h, target_res)
    if not fit:
        return None, {}

    canvas_w, canvas_h, scaled_w, scaled_h, pad_left, pad_top = fit
    pad_right = canvas_w - scaled_w - pad_left
    pad_bottom = canvas_h - scaled_h - pad_top

    # NEW: setsar=1 + setdar=16/9 for VLC sanity
    vf = (
        f"scale={scaled_w}:{scaled_h}:flags=lanczos,"
        f"pad={canvas_w}:{canvas_h}:{pad_left}:{pad_top},"
        f"setsar=1,setdar=16/9"
    )

    scale_factor = scaled_h / float(src_h) if src_h else 1.0

    pad_info = {
        "canvas_w": canvas_w,
        "canvas_h": canvas_h,
        "scaled_w": scaled_w,
        "scaled_h": scaled_h,
        "pad_left": pad_left,
        "pad_top": pad_top,
        "pad_right": pad_right,
        "pad_bottom": pad_bottom,
        "scale_factor": scale_factor,
    }
    return vf, pad_info


def build_hdr10_encode_to_hevc_cmd(input_path: str, out_hevc: str, vf: str | None, crf: int, preset: str,
                                  extra_x265_params: str = "") -> list[str]:
    # IMPORTANT: info=0 removes x265 "encoding settings" SEI (MediaInfo "Encoding settings")
    x265_params = "colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:hdr10=1:repeat-headers=1:info=0"
    if extra_x265_params:
        x265_params += f":{extra_x265_params}"

    cmd = [FFMPEG, "-hide_banner", "-y", "-i", input_path]
    cmd += ["-map", "0:v:0", "-an", "-sn"]
    if vf:
        cmd += ["-vf", vf]
    cmd += [
        "-c:v", "libx265",
        "-crf", str(crf),
        "-preset", preset,
        "-pix_fmt", "yuv420p10le",
        "-x265-params", x265_params,
        "-color_primaries", "bt2020",
        "-color_trc", "smpte2084",
        "-colorspace", "bt2020nc",
        "-f", "hevc",
        "-progress", "pipe:1",
        "-nostats",
        out_hevc
    ]
    return cmd


def build_hdr10_encode_to_mkv_cmd(input_path: str, out_mkv: str, vf: str | None, crf: int, preset: str) -> list[str]:
    x265_params = "colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:hdr10=1:repeat-headers=1:info=0"

    cmd = [FFMPEG, "-hide_banner", "-y", "-i", input_path]
    cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "0"]
    cmd += ["-c", "copy"]
    cmd += ["-c:v:0", "libx265", "-crf", str(crf), "-preset", preset]
    cmd += ["-pix_fmt", "yuv420p10le"]
    cmd += ["-x265-params", x265_params]
    cmd += ["-color_primaries", "bt2020", "-color_trc", "smpte2084", "-colorspace", "bt2020nc"]
    if vf:
        cmd += ["-vf", vf]
    # avoid encoder tags in container
    cmd += ["-metadata", "encoder="]
    cmd += ["-progress", "pipe:1", "-nostats"]
    cmd += [out_mkv]
    return cmd


def build_sdr_encode_to_mkv_cmd(input_path: str, out_mkv: str, vf_fitpad: str | None, crf: int, preset: str) -> list[str]:
    tonemap = (
        "zscale=t=linear:npl=100,format=gbrpf32le,"
        "tonemap=tonemap=hable:desat=0,"
        "zscale=t=bt709:m=bt709:r=tv"
    )
    if vf_fitpad:
        vf = f"{tonemap},{vf_fitpad},format=yuv420p"
    else:
        vf = f"{tonemap},format=yuv420p"

    cmd = [FFMPEG, "-hide_banner", "-y", "-i", input_path]
    cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "0"]
    cmd += ["-c", "copy"]
    cmd += ["-c:v:0", "libx265", "-crf", str(crf), "-preset", preset]
    cmd += ["-pix_fmt", "yuv420p"]
    cmd += ["-x265-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709:repeat-headers=1:info=0"]
    cmd += ["-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709"]
    cmd += ["-vf", vf]
    cmd += ["-metadata", "encoder="]
    cmd += ["-progress", "pipe:1", "-nostats"]
    cmd += [out_mkv]
    return cmd


def read_progress_percent(duration: float | None, out_time_ms: int) -> float | None:
    if not duration or duration <= 0:
        return None
    t = float(out_time_ms)
    seconds = (t / 1_000_000.0) if t > 1e9 else (t / 1000.0)
    return max(0.0, min(100.0, (seconds / duration) * 100.0))


# ---------- dovi_tool helpers ----------

def dovi_extract_rpu_mode2(input_path: str, out_rpu: str) -> list[str]:
    return [DOVI_TOOL, "-m", "2", "extract-rpu", input_path, "-o", out_rpu]


def dovi_export_level5(in_rpu: str, out_json: str) -> list[str]:
    return [DOVI_TOOL, "export", "-i", in_rpu, "-d", f"level5={out_json}"]


def dovi_editor_apply(in_rpu: str, editor_cfg_json: str, out_rpu: str) -> list[str]:
    return [DOVI_TOOL, "editor", "-i", in_rpu, "-j", editor_cfg_json, "-o", out_rpu]


def dovi_inject_rpu(in_hevc: str, rpu_in: str, out_hevc: str) -> list[str]:
    return [DOVI_TOOL, "inject-rpu", "-i", in_hevc, "--rpu-in", rpu_in, "-o", out_hevc]


def _load_json(p: Path) -> dict:
    with p.open("r", encoding="utf-8") as f:
        return json.load(f)


def _save_json(p: Path, data: dict) -> None:
    with p.open("w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)


def build_editor_config_from_level5_export(level5_export_json: Path, editor_cfg_json: Path,
                                          scale_factor: float, pad_left: int, pad_top: int,
                                          pad_right: int, pad_bottom: int) -> None:
    l5 = _load_json(level5_export_json)

    if isinstance(l5, dict) and "active_area" in l5 and "mode" in l5:
        cfg = l5
    else:
        aa = l5 if isinstance(l5, dict) else {}
        cfg = {"mode": 0, "active_area": aa}

    if "crop" in cfg and isinstance(cfg.get("active_area"), dict):
        cfg["active_area"].setdefault("crop", cfg["crop"])
        del cfg["crop"]

    presets = []
    if isinstance(cfg.get("active_area"), dict):
        presets = cfg["active_area"].get("presets") or []
    if isinstance(presets, list):
        for p in presets:
            if not isinstance(p, dict):
                continue
            for k, pad in (("left", pad_left), ("right", pad_right), ("top", pad_top), ("bottom", pad_bottom)):
                if k in p and isinstance(p[k], (int, float)):
                    p[k] = int(round(float(p[k]) * float(scale_factor))) + int(pad)

    _save_json(editor_cfg_json, cfg)


# ---------- mkvmerge mux for DV----------

def mkvmerge_mux_dv_video_with_audio_subs(video_hevc: str, original_mkv: str, out_mkv: str, fps_str: str | None) -> list[str]:
    # mkvmerge expects: --default-duration TID:24000/1001fps
    fps = fps_str or "24/1"
    return [
        MKVMERGE,
        "-o", out_mkv,

        # video comes from raw hevc (track 0 of first input)
        "--language", "0:und",
        "--default-track", "0:yes",
        "--compression", "0:none",
        "--default-duration", f"0:{fps}fps",
        "--aspect-ratio", "0:16/9",

        video_hevc,

        # audio/subs/chapters/attachments from original
        "--no-video",
        original_mkv
    ]


# ---------- GUI App ----------

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title(APP_TITLE)
        self.geometry("980x680")
        self.minsize(980, 680)

        self.input_path = tk.StringVar()
        self.output_dir = tk.StringVar()

        self.target_mode = tk.StringVar(value="HDR10")
        self.target_res = tk.StringVar(value="Original")
        self.crf = tk.IntVar(value=18)
        self.preset_ui = tk.StringVar(value="Medium")

        self.detected = None
        self.worker = None
        self.proc = None
        self.stop_flag = False

        self._build_ui()
        self._log(f"ffmpeg:    {FFMPEG}")
        self._log(f"ffprobe:   {FFPROBE}")
        self._log(f"dovi_tool: {DOVI_TOOL}")
        self._log(f"mkvmerge:  {MKVMERGE}")

    def _build_ui(self):
        pad = {"padx": 10, "pady": 8}
        root = ttk.Frame(self)
        root.pack(fill="both", expand=True)

        lf_in = ttk.LabelFrame(root, text="Input")
        lf_in.pack(fill="x", **pad)
        ttk.Entry(lf_in, textvariable=self.input_path).pack(side="left", fill="x", expand=True, padx=8, pady=8)
        ttk.Button(lf_in, text="Datei wählen...", command=self.pick_input).pack(side="left", padx=8, pady=8)
        ttk.Button(lf_in, text="Analysieren", command=self.analyze).pack(side="left", padx=8, pady=8)

        lf_out = ttk.LabelFrame(root, text="Output")
        lf_out.pack(fill="x", **pad)
        ttk.Entry(lf_out, textvariable=self.output_dir).pack(side="left", fill="x", expand=True, padx=8, pady=8)
        ttk.Button(lf_out, text="Zielordner wählen...", command=self.pick_output_dir).pack(side="left", padx=8, pady=8)

        lf_opt = ttk.LabelFrame(root, text="Optionen")
        lf_opt.pack(fill="x", **pad)

        ttk.Label(lf_opt, text="Ziel:").grid(row=0, column=0, sticky="w", padx=8, pady=6)
        ttk.Combobox(
            lf_opt, textvariable=self.target_mode, state="readonly", width=34,
            values=["HDR10", "SDR", "DV (P8.1) + HDR10 Fallback behalten"]
        ).grid(row=0, column=1, sticky="w", padx=8, pady=6)

        ttk.Label(lf_opt, text="Auflösung:").grid(row=0, column=2, sticky="w", padx=8, pady=6)
        ttk.Combobox(
            lf_opt, textvariable=self.target_res, state="readonly", width=10,
            values=["Original", "FHD", "4K"]
        ).grid(row=0, column=3, sticky="w", padx=8, pady=6)

        ttk.Label(lf_opt, text="Preset:").grid(row=0, column=4, sticky="w", padx=8, pady=6)
        ttk.Combobox(
            lf_opt, textvariable=self.preset_ui, state="readonly", width=10,
            values=["Fast", "Medium", "Slow"]
        ).grid(row=0, column=5, sticky="w", padx=8, pady=6)

        ttk.Label(lf_opt, text="Qualität (CRF/RF):").grid(row=1, column=0, sticky="w", padx=8, pady=6)
        scale = ttk.Scale(lf_opt, from_=12, to=28, orient="horizontal", command=self._on_crf_scale)
        scale.set(self.crf.get())
        scale.grid(row=1, column=1, columnspan=5, sticky="we", padx=8, pady=6)
        self.crf_label = ttk.Label(lf_opt, text=str(self.crf.get()))
        self.crf_label.grid(row=1, column=6, sticky="w", padx=8, pady=6)
        lf_opt.columnconfigure(5, weight=1)

        lf_info = ttk.LabelFrame(root, text="Erkennung (ffprobe)")
        lf_info.pack(fill="x", **pad)
        self.info_text = tk.StringVar(value="Noch nicht analysiert.")
        ttk.Label(lf_info, textvariable=self.info_text, justify="left").pack(anchor="w", padx=8, pady=8)

        ctl = ttk.Frame(root)
        ctl.pack(fill="x", **pad)
        self.start_btn = ttk.Button(ctl, text="Start", command=self.start)
        self.start_btn.pack(side="left")
        self.stop_btn = ttk.Button(ctl, text="Stop", command=self.stop, state="disabled")
        self.stop_btn.pack(side="left", padx=8)

        self.progress = ttk.Progressbar(ctl, length=380, mode="determinate")
        self.progress.pack(side="left", padx=12)
        self.progress_label = ttk.Label(ctl, text="0%")
        self.progress_label.pack(side="left")
        self.step_label = ttk.Label(ctl, text="")
        self.step_label.pack(side="left", padx=12)

        lf_log = ttk.LabelFrame(root, text="Log")
        lf_log.pack(fill="both", expand=True, **pad)
        self.log = tk.Text(lf_log, height=18, wrap="word")
        self.log.pack(fill="both", expand=True, padx=8, pady=8)
        self.log.configure(state="disabled")

    def _on_crf_scale(self, val):
        v = int(float(val))
        self.crf.set(v)
        self.crf_label.configure(text=str(v))

    def _log(self, msg: str):
        self.log.configure(state="normal")
        self.log.insert("end", msg.rstrip() + "\n")
        self.log.see("end")
        self.log.configure(state="disabled")

    def _set_step(self, txt: str):
        self.after(0, lambda: self.step_label.configure(text=txt))

    def _set_progress(self, pct: float):
        self.after(0, lambda: (self.progress.configure(value=pct), self.progress_label.configure(text=f"{pct:.0f}%")))

    def pick_input(self):
        path = filedialog.askopenfilename(
            title="Quelldatei wählen",
            filetypes=[("Matroska", "*.mkv"), ("All files", "*.*")]
        )
        if path:
            self.input_path.set(path)

    def pick_output_dir(self):
        d = filedialog.askdirectory(title="Zielordner wählen")
        if d:
            self.output_dir.set(d)

    def _preset_value(self) -> str:
        m = {"Fast": "fast", "Medium": "medium", "Slow": "slow"}
        return m.get(self.preset_ui.get(), "medium")

    def analyze(self):
        ip = self.input_path.get().strip()
        if not ip or not os.path.isfile(ip):
            messagebox.showerror("Fehler", "Bitte eine gültige Input-Datei wählen.")
            return

        try:
            probe = run_ffprobe_json(ip)
            info = detect_stream_info(probe)
            self.detected = info

            dv_str = "Ja" if info["is_dv"] else "Nein"
            prof = info["dv_profile"] if info["dv_profile"] is not None else "-"
            lvl = info["dv_level"] if info["dv_level"] is not None else "-"
            compat = info["dv_bl_signal_compat_id"] if info["dv_bl_signal_compat_id"] is not None else "-"
            w, h = info["width"], info["height"]
            dur = info["duration"]
            dur_s = f"{dur:.2f}s" if isinstance(dur, (int, float)) else "-"
            hdr10_base = "Ja" if info["has_hdr10_base"] else "Unklar/Nein"
            fps = info.get("fps_str") or "-"

            _, pad_info = build_vf_fit_pad(w, h, self.target_res.get())
            if pad_info:
                target_line = (
                    f"Zielcanvas: {pad_info['canvas_w']}x{pad_info['canvas_h']} | "
                    f"Inhalt: {pad_info['scaled_w']}x{pad_info['scaled_h']} | "
                    f"Padding L/T/R/B: {pad_info['pad_left']}/{pad_info['pad_top']}/{pad_info['pad_right']}/{pad_info['pad_bottom']}"
                )
            else:
                target_line = f"Ziel: Original ({w}x{h})"

            self.info_text.set(
                f"Dolby Vision: {dv_str}\n"
                f"DV Profil: {prof} | Level: {lvl} | BL compat id: {compat}\n"
                f"HDR10 Base Layer (Heuristik): {hdr10_base}\n"
                f"Video: {w}x{h} | FPS: {fps} | Dauer: {dur_s}\n"
                f"Colors: transfer={info['color_transfer']} primaries={info['color_primaries']} space={info['color_space']}\n"
                f"{target_line}"
            )
            self._log("Analyse OK.")
        except Exception as e:
            self.detected = None
            self.info_text.set("Analyse fehlgeschlagen.")
            self._log(f"Analyse Fehler: {e}")
            messagebox.showerror("Analyse fehlgeschlagen", str(e))

    def start(self):
        if self.worker and self.worker.is_alive():
            return

        ip = self.input_path.get().strip()
        out_dir = self.output_dir.get().strip()
        if not ip or not os.path.isfile(ip):
            messagebox.showerror("Fehler", "Bitte eine gültige Input-Datei wählen.")
            return
        if not out_dir or not os.path.isdir(out_dir):
            messagebox.showerror("Fehler", "Bitte einen gültigen Zielordner wählen.")
            return

        if not self.detected:
            self.analyze()
            if not self.detected:
                return

        mode = self.target_mode.get()

        ensure_tool_exists(FFMPEG, "ffmpeg")
        ensure_tool_exists(FFPROBE, "ffprobe")
        if mode.startswith("DV"):
            ensure_tool_exists(DOVI_TOOL, "dovi_tool")
            # NEW: mkvmerge required for DV output correctness (DV retention + Plex detection)
            ensure_tool_exists(MKVMERGE, "mkvmerge")

        base_name = os.path.basename(ip)
        out_path = os.path.join(out_dir, base_name)

        info = self.detected
        if mode.startswith("DV"):
            if not info["is_dv"]:
                messagebox.showerror("Fehler", "DV behalten gewählt, aber die Quelle hat kein Dolby Vision (laut ffprobe).")
                return
            self._log("DV-Mode: mux via mkvmerge (DV bleibt erhalten).")
            self._log("DV-Mode: x265 info=0 (keine Encoding-Settings in Metadaten).")

        self.stop_flag = False
        self.progress.configure(value=0)
        self.progress_label.configure(text="0%")
        self._set_step("")
        self.start_btn.configure(state="disabled")
        self.stop_btn.configure(state="normal")

        preset = self._preset_value()
        args = (ip, out_path, info, mode, self.target_res.get(), int(self.crf.get()), preset)
        self.worker = threading.Thread(target=self._run_pipeline, args=args, daemon=True)
        self.worker.start()

    def stop(self):
        self.stop_flag = True
        if self.proc and self.proc.poll() is None:
            try:
                self.proc.terminate()
                self._log("Stop angefordert (terminate).")
            except Exception as e:
                self._log(f"Stop Fehler: {e}")

    def _finish(self, success: bool, msg: str):
        def ui():
            self.start_btn.configure(state="normal")
            self.stop_btn.configure(state="disabled")
            self._set_step("")
            if success:
                messagebox.showinfo("Fertig", msg)
            else:
                messagebox.showwarning("Nicht fertig", msg)
        self.after(0, ui)

    def _run_ffmpeg_with_progress(self, cmd: list[str], duration: float | None, base_pct: float, span_pct: float):
        self._log("ffmpeg:")
        self._log(" ".join(cmd))

        tail = deque(maxlen=200)

        self.proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1
        )

        out_time_ms = 0
        last = 0.0

        for line in self.proc.stdout:
            if self.stop_flag:
                break

            line = (line or "").strip()
            if not line:
                continue

            if "=" not in line:
                tail.append(line)
                continue

            k, v = line.split("=", 1)
            if k == "out_time_ms":
                try:
                    out_time_ms = int(v)
                except Exception:
                    pass
            elif k == "progress":
                now = time.time()
                if now - last > 0.15:
                    last = now
                    pct = read_progress_percent(duration, out_time_ms)
                    if pct is not None:
                        overall = base_pct + (pct * span_pct / 100.0)
                        self._set_progress(overall)
            else:
                tail.append(line)

        rc = self.proc.wait()

        if self.stop_flag:
            raise RuntimeError("Abgebrochen durch Benutzer.")
        if rc != 0:
            self._log("--- ffmpeg output (tail) ---")
            for l in list(tail)[-150:]:
                self._log(l)
            raise RuntimeError(f"ffmpeg Fehler (Returncode {rc}).")

    def _run_simple(self, cmd: list[str], label: str):
        self._set_step(label)
        self._log(f"{label}:")
        self._log(" ".join(cmd))
        self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        out, err = self.proc.communicate()

        if out.strip():
            self._log(out.strip())
        if err.strip():
            self._log(err.strip())

        if self.stop_flag:
            raise RuntimeError("Abgebrochen durch Benutzer.")
        if self.proc.returncode != 0:
            raise RuntimeError(f"{label} Fehler (Returncode {self.proc.returncode}).")

    def _run_pipeline(self, ip: str, out_path: str, info: dict, mode: str, target_res: str, crf: int, preset: str):
        tmpdir = None
        try:
            tmpdir = tempfile.mkdtemp(prefix="dvconv_")
            self._log(f"Temp: {tmpdir}")

            src_w, src_h = info["width"], info["height"]
            vf_fitpad, pad_info = build_vf_fit_pad(src_w, src_h, target_res)
            fps_str = info.get("fps_str")

            if mode == "SDR":
                self._set_step("SDR encode (Video) + Remux")
                cmd = build_sdr_encode_to_mkv_cmd(ip, out_path, vf_fitpad, crf, preset)
                self._run_ffmpeg_with_progress(cmd, info.get("duration"), base_pct=0.0, span_pct=100.0)
                self._set_progress(100.0)
                self._finish(True, "SDR-Konvertierung abgeschlossen.")
                return

            if mode == "HDR10":
                self._set_step("HDR10 encode (Video) + Remux")
                cmd = build_hdr10_encode_to_mkv_cmd(ip, out_path, vf_fitpad, crf, preset)
                self._run_ffmpeg_with_progress(cmd, info.get("duration"), base_pct=0.0, span_pct=100.0)
                self._set_progress(100.0)
                self._finish(True, "HDR10-Konvertierung abgeschlossen.")
                return

            if mode.startswith("DV"):
                bl_hevc = os.path.join(tmpdir, "bl_hdr10.hevc")
                bl_dv_hevc = os.path.join(tmpdir, "bl_hdr10_dv.hevc")
                rpu = os.path.join(tmpdir, "rpu_81.bin")
                rpu_edited = os.path.join(tmpdir, "rpu_81_edited.bin")
                level5_json = os.path.join(tmpdir, "level5.json")
                editor_cfg_json = os.path.join(tmpdir, "editor_cfg.json")

                # Encode HDR10 BL
                # NOTE: Keep bframes=0 so timestamps generated by container muxers are stable on raw HEVC
                self._set_step("Encode HDR10 Base Layer")
                cmd1 = build_hdr10_encode_to_hevc_cmd(
                    ip, bl_hevc, vf_fitpad, crf, preset,
                    extra_x265_params="bframes=0:b-adapt=0"
                )
                self._run_ffmpeg_with_progress(cmd1, info.get("duration"), base_pct=0.0, span_pct=80.0)

                # Extract RPU
                self._set_progress(82.0)
                self._run_simple(dovi_extract_rpu_mode2(ip, rpu), "RPU extrahieren (Mode 2)")

                # Apply active area scaling/padding if we changed resolution
                if pad_info:
                    self._set_progress(86.0)
                    self._run_simple(dovi_export_level5(rpu, level5_json), "Level5 exportieren")

                    build_editor_config_from_level5_export(
                        level5_export_json=Path(level5_json),
                        editor_cfg_json=Path(editor_cfg_json),
                        scale_factor=float(pad_info["scale_factor"]),
                        pad_left=int(pad_info["pad_left"]),
                        pad_top=int(pad_info["pad_top"]),
                        pad_right=int(pad_info["pad_right"]),
                        pad_bottom=int(pad_info["pad_bottom"]),
                    )

                    self._set_progress(88.0)
                    self._run_simple(dovi_editor_apply(rpu, editor_cfg_json, rpu_edited), "Level5 anwenden (Canvas/AA)")
                    rpu_for_inject = rpu_edited
                else:
                    rpu_for_inject = rpu

                # Inject RPU into BL
                self._set_progress(90.0)
                self._run_simple(dovi_inject_rpu(bl_hevc, rpu_for_inject, bl_dv_hevc), "RPU injizieren (P8.1)")

                # Mux via mkvmerge (keeps DV + better compatibility with Plex/VLC)
                self._set_step("Mux (mkvmerge) - DV + Audio/Sub passthrough")
                mux_cmd = mkvmerge_mux_dv_video_with_audio_subs(bl_dv_hevc, ip, out_path, fps_str)
                self._run_simple(mux_cmd, "mkvmerge mux")

                self._set_progress(100.0)
                self._finish(True, "DV (P8.1) + HDR10-Fallback abgeschlossen.")
                return

            raise RuntimeError("Unbekannter Modus.")

        except Exception as e:
            self._log(f"Fehler: {e}")
            self._finish(False, f"Konvertierung fehlgeschlagen: {e}")
        finally:
            try:
                if tmpdir and os.path.isdir(tmpdir):
                    shutil.rmtree(tmpdir, ignore_errors=True)
            except Exception:
                pass
            self.proc = None


if __name__ == "__main__":
    try:
        App().mainloop()
    except Exception as e:
        try:
            messagebox.showerror("Fatal", str(e))
        except Exception:
            pass
        raise

Simon

Zitat von: Bambi am 11. Januar 2026, 16:094K oder 1080p Dolby-Vision-Inhalte einfach in 4K bzw. 1080p Dolby Vision mit HDR10-Fallback konvertieren
Dazu musst du das Video komplett neu enkodieren. Das ist Murks weil jeder Streaming-Anbieter schon HDR10-Videos anbietet, auf die man einfach die DV-Daten draufklatschen kann.

Bambi

Hallo Simon,
danke für deine Nachricht.

Dazu müsste ich aber auch beides haben, nehme ich an?
An die DV Daten komme ich ja nicht so einfach.

Es sind nun ein paar Wochen vergangen und konnte nicht nur einiges fixen sondern direkte vergleiche mit viel Referenzmaterial durchführen.

Dazu habe ich einen Philips OLED TV mit AppleTV-Plex (DV-Test) und meinen Rechner mit Eizo IPS Panel und einem LG OLED Screen (HDR+SDR-Test) mit wahlweise VLC oder mpv benutzt.
Als Quellmaterial habe ich div. Versionen einer Folge und eines Films getestet.

Am besten war in beiden Fällen das reine DV Material.
Danach Folgt reines HDR Material.
DV mit HDR Fallback schneidet meist am schlechtesten ab aufgrund Artefakte und Farbrauschen.

Meine erstellte Variante liegt mMn zwischen reinem DV und dem reinen HDR10.
Technisch kann ich es leider nicht erklären aber meine Testcodierten Samples haben praktisch kein Farbrauschen und kaum bis garkeine Artefakte, zumindest nicht mehr als nicht schon im DV Quellmaterial zu sehen wäre.
Minimaler Downgrade zum DV Quellmaterial ist es dann aber doch, wenn auch für mich pers. nicht relevant und nur im direkten Vergleich erkennbar.

Bitte beachtet das für die neue Version zusätzlich Vulkan benötigt wird.
Dies ist erforderlich für libplacebo das wiederum für DV Profile 5 Quellmaterial erforderlich war.
(zumindest hat das bei mir einige Tonemapping und Timecode Probleme gelöst)

import json
import os
import re
import shutil
import subprocess
import tempfile
import threading
import queue
import time
from dataclasses import dataclass
from pathlib import Path
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

APP_NAME = "DV → DV+HDR10 Fallback Transcoder"
DEFAULT_CONTAINER = "mkv"  # passthrough-friendly for subs

# -----------------------------
# Tool discovery (./bin first)
# -----------------------------
def tool_path(name: str) -> str:
    exe = name + (".exe" if os.name == "nt" else "")
    local = Path(__file__).resolve().parent / "bin" / exe
    if local.exists():
        return str(local)
    found = shutil.which(exe) or shutil.which(name)
    if found:
        return found
    return ""

def require_tools():
    missing = []
    for t in ("ffmpeg", "ffprobe", "dovi_tool"):
        p = tool_path(t)
        if not p:
            missing.append(t)
    return missing

# -----------------------------
# ffprobe analysis
# -----------------------------
@dataclass
class DVInfo:
    has_dv: bool = False
    dv_profile: int | None = None
    dv_level: int | None = None
    dv_text: str = ""
    duration_s: float | None = None
    video_stream_index: int = 0

def run_capture(cmd: list[str]) -> tuple[int, str, str]:
    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
    out, err = p.communicate()
    return p.returncode, out, err

def ffprobe_info(input_file: str) -> DVInfo:
    ffprobe = tool_path("ffprobe")
    dv = DVInfo()
    cmd = [
        ffprobe, "-v", "error",
        "-print_format", "json",
        "-show_format",
        "-show_streams",
        input_file
    ]
    rc, out, err = run_capture(cmd)
    if rc != 0:
        dv.dv_text = f"ffprobe failed:\n{err}"
        return dv

    data = json.loads(out)
    # duration
    try:
        dv.duration_s = float(data.get("format", {}).get("duration"))
    except Exception:
        dv.duration_s = None

    # pick first video stream
    vstreams = [s for s in data.get("streams", []) if s.get("codec_type") == "video"]
    if vstreams:
        dv.video_stream_index = vstreams[0].get("index", 0)

    # Try to locate Dolby Vision side data (best effort; fields differ per ffmpeg build)
    # We also fallback to parsing ffmpeg -i output (contains "DOVI configuration record" often).
    def scan_side_data(stream):
        side = stream.get("side_data_list") or []
        for sd in side:
            t = sd.get("side_data_type", "")
            if "DOVI" in t or "Dolby Vision" in t:
                # ffprobe sometimes exposes dv_profile/dv_level
                prof = sd.get("dv_profile") or sd.get("profile")
                lvl = sd.get("dv_level") or sd.get("level")
                return prof, lvl, t, sd
        return None, None, "", None

    prof, lvl, t, sd = (None, None, "", None)
    if vstreams:
        prof, lvl, t, sd = scan_side_data(vstreams[0])

    if prof is not None:
        dv.has_dv = True
        try:
            dv.dv_profile = int(prof)
        except Exception:
            dv.dv_profile = None
        try:
            dv.dv_level = int(lvl) if lvl is not None else None
        except Exception:
            dv.dv_level = None
        dv.dv_text = f"{t} (profile={dv.dv_profile}, level={dv.dv_level})"
        return dv

    # Fallback: parse ffmpeg -i stderr for "DOVI configuration record: ... profile: X"
    ffmpeg = tool_path("ffmpeg")
    rc2, out2, err2 = run_capture([ffmpeg, "-hide_banner", "-i", input_file])
    m = re.search(r"DOVI configuration record:.*?profile:\s*(\d+)", err2, re.IGNORECASE | re.DOTALL)
    if m:
        dv.has_dv = True
        dv.dv_profile = int(m.group(1))
        dv.dv_text = f"DOVI configuration record (profile={dv.dv_profile})"
    else:
        dv.has_dv = False
        dv.dv_text = "No Dolby Vision detected (or ffmpeg build doesn't report it)."
    return dv

def ffprobe_fps(input_file: str) -> str | None:
    ffprobe = tool_path("ffprobe")
    cmd = [
        ffprobe, "-v", "error",
        "-select_streams", "v:0",
        "-show_entries", "stream=avg_frame_rate,r_frame_rate",
        "-of", "json",
        input_file
    ]
    rc, out, err = run_capture(cmd)
    if rc != 0:
        return None
    try:
        data = json.loads(out)
        s = (data.get("streams") or [{}])[0]
        fps = s.get("avg_frame_rate") or s.get("r_frame_rate")
        if not fps or fps == "0/0":
            return None
        return fps
    except Exception:
        return None

def ffprobe_audio_start_time(input_file: str) -> float | None:
    ffprobe = tool_path("ffprobe")
    cmd = [
        ffprobe, "-v", "error",
        "-select_streams", "a:0",
        "-show_entries", "stream=start_time",
        "-of", "json",
        input_file
    ]
    rc, out, err = run_capture(cmd)
    if rc != 0:
        return None
    try:
        data = json.loads(out)
        s = (data.get("streams") or [{}])[0]
        st = s.get("start_time", None)
        if st is None:
            return None
        return float(st)
    except Exception:
        return None


def ffprobe_audio_channels(input_file: str) -> int | None:
    ffprobe = tool_path("ffprobe")
    cmd = [
        ffprobe, "-v", "error",
        "-select_streams", "a:0",
        "-show_entries", "stream=channels",
        "-of", "json",
        input_file
    ]
    rc, out, err = run_capture(cmd)
    if rc != 0:
        return None
    try:
        data = json.loads(out)
        s = (data.get("streams") or [{}])[0]
        ch = s.get("channels", None)
        if ch is None:
            return None
        return int(ch)
    except Exception:
        return None

# -----------------------------
# Process runner with logging & progress
# -----------------------------
class ProcRunner:
    def __init__(self, log_fn, progress_fn=None):
        self.log = log_fn
        self.progress = progress_fn

    def run(self, cmd: list[str], stage: str, duration_s: float | None = None, use_progress: bool = False, cwd: str | None = None):
        self.log(f"\n[{stage}]\n$ " + " ".join(cmd) + "\n")
        if use_progress:
            # ffmpeg progress on stdout
            cmd = cmd + ["-progress", "pipe:1", "-nostats"]
            p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", cwd=cwd)
            out_time_ms = 0
            # read stderr in another thread so it doesn't block
            stderr_q = queue.Queue()

            def read_stderr():
                for line in p.stderr:
                    stderr_q.put(line)
                stderr_q.put(None)

            t = threading.Thread(target=read_stderr, daemon=True)
            t.start()

            for line in p.stdout:
                line = line.strip()
                if not line:
                    continue
                if "=" in line:
                    k, v = line.split("=", 1)
                    if k == "out_time_ms":
                        try:
                            out_time_ms = int(v)
                            if duration_s and self.progress:
                                pct = min(100.0, (out_time_ms / 1_000_000.0) / duration_s * 100.0)
                                self.progress(stage, pct)
                        except Exception:
                            pass
                    elif k == "progress" and v == "end":
                        if self.progress:
                            self.progress(stage, 100.0)
                else:
                    self.log(line + "\n")

                # drain some stderr
                while not stderr_q.empty():
                    s = stderr_q.get_nowait()
                    if s is None:
                        break
                    self.log(s)

            rc = p.wait()
            # drain remaining stderr
            while True:
                try:
                    s = stderr_q.get_nowait()
                except queue.Empty:
                    break
                if s is None:
                    break
                self.log(s)
            if rc != 0:
                raise RuntimeError(f"{stage} failed with exit code {rc}")
            return

        # no progress mode
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", cwd=cwd)
        for line in p.stdout:
            self.log(line)
        rc = p.wait()
        if rc != 0:
            raise RuntimeError(f"{stage} failed with exit code {rc}")

# -----------------------------
# Transcode pipeline
# -----------------------------
@dataclass
class JobOptions:
    input_file: str
    output_dir: str
    resolution: str  # "source", "2160", "1080", "720"
    crf: int
    preset: str      # "fast" | "medium" | "slow"
    container: str   # "mkv" | "mp4"
    subs_passthrough: bool
    audio_mode: str  # "passthrough" | "opus_2.0" | "opus_5.1"
    audio_bitrate_kbps: int  # used when audio_mode != passthrough
    use_libplacebo_for_p5: bool

def build_scale_filter(res: str) -> str | None:
    if res in ("source", "", None):
        return None
    # scale by height, keep aspect (even width)
    h = int(res)
    return f"scale=-2:{h}:flags=lanczos"

def encode_hdr10_base(r: ProcRunner, opt: JobOptions, dvinfo: DVInfo, tmpdir: str, base_hevc: str):
    ffmpeg = tool_path("ffmpeg")

    vf_parts = []
    scale = build_scale_filter(opt.resolution)
    if scale:
        vf_parts.append(scale)

    # If DV Profile 5 detected, prefer libplacebo path (Vulkan) to avoid the classic purple/green/magenta decode.
    # This follows common practice reported in the wild. (You can disable in GUI if it fails on your system.)
    use_lp = opt.use_libplacebo_for_p5 and dvinfo.dv_profile == 5

    if use_lp:
        # Keep everything 10-bit HDR PQ BT.2020
        # libplacebo does "reshaping" for DV P5 on supported builds/GPU drivers.
        # We'll do: hwupload -> libplacebo -> hwdownload -> format -> optional scale (if any, after download)
        lp = "hwupload,libplacebo=format=yuv420p10le:colorspace=bt2020nc:color_primaries=bt2020:color_trc=smpte2084,hwdownload,format=yuv420p10le"
        if scale:
            vf = lp + "," + scale
        else:
            vf = lp
        vf_parts = [vf]

        cmd = [
            ffmpeg,
            "-y",
            "-init_hw_device", "vulkan=vulkan",
            "-filter_hw_device", "vulkan",
            "-i", opt.input_file,
            "-map", f"0:v:{0}",
            "-vf", ",".join(vf_parts),
            "-c:v", "libx265",
            "-pix_fmt", "yuv420p10le",
            "-preset", opt.preset,
            "-crf", str(opt.crf),
            "-x265-params", "hdr-opt=1:repeat-headers=1:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc",
            "-color_primaries", "bt2020",
            "-color_trc", "smpte2084",
            "-colorspace", "bt2020nc",
            "-f", "hevc",
            base_hevc
        ]
    else:
        cmd = [
            ffmpeg,
            "-y",
            "-i", opt.input_file,
            "-map", f"0:v:{0}",
            "-c:v", "libx265",
            "-pix_fmt", "yuv420p10le",
            "-preset", opt.preset,
            "-crf", str(opt.crf),
            "-x265-params", "hdr-opt=1:repeat-headers=1:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc",
            "-color_primaries", "bt2020",
            "-color_trc", "smpte2084",
            "-colorspace", "bt2020nc",
        ]
        if vf_parts:
            cmd += ["-vf", ",".join(vf_parts)]
        cmd += ["-f", "hevc", base_hevc]

    r.run(cmd, stage="Encode HDR10 base (x265)", duration_s=dvinfo.duration_s, use_progress=True)

def extract_rpu(r: ProcRunner, opt: JobOptions, dvinfo: DVInfo, rpu_out: str):
    ffmpeg = tool_path("ffmpeg")
    dovi = tool_path("dovi_tool")

    # Choose dovi_tool mode automatically
    # - mode 2: profile 7 -> profile 8.1 compatible (common)
    # - mode 3: profile 5 -> profile 8.1
    # - else: default (copy) or mode 2 as safe default if DV exists but unknown
    mode = None
    if dvinfo.dv_profile == 7:
        mode = "2"
    elif dvinfo.dv_profile == 5:
        mode = "3"
    elif dvinfo.has_dv:
        mode = "2"

    # Pipe HEVC elementary stream to dovi_tool extract-rpu
    # IMPORTANT FIX (Windows deadlock):
    # - ffmpeg writes a lot to stderr; if stderr is PIPE and not drained, ffmpeg can block.
    # - Use -v error -nostats and redirect stderr to DEVNULL.
    ffmpeg_cmd = [
        ffmpeg, "-hide_banner", "-v", "error", "-nostats", "-y",
        "-i", opt.input_file,
        "-map", "0:v:0",
        "-c:v", "copy",
        # "-bsf:v", "hevc_mp4toannexb",  # optional; for MKV usually not needed
        "-f", "hevc",
        "-"
    ]

    dovi_cmd = [dovi]
    if mode:
        dovi_cmd += ["-m", mode]
    dovi_cmd += ["extract-rpu", "-", "-o", rpu_out]

    r.log("\n[Extract RPU]\n$ " + " ".join(ffmpeg_cmd) + " | " + " ".join(dovi_cmd) + "\n")

    # stderr=DEVNULL is the key part of Fix #1
    p1 = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
    p2 = subprocess.Popen(dovi_cmd, stdin=p1.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

    # allow p1 to get SIGPIPE if p2 closes
    if p1.stdout:
        p1.stdout.close()

    # log dovi_tool output
    for line in p2.stdout:
        try:
            r.log(line.decode("utf-8", errors="replace"))
        except Exception:
            pass

    rc2 = p2.wait()
    rc1 = p1.wait()

    if rc1 != 0:
        raise RuntimeError(f"ffmpeg pipe failed ({rc1})")
    if rc2 != 0:
        raise RuntimeError(f"dovi_tool extract-rpu failed ({rc2})")


def inject_rpu(r: ProcRunner, base_hevc: str, rpu_in: str, out_hevc: str):
    dovi = tool_path("dovi_tool")
    # inject-rpu exists and is the standard way :contentReference[oaicite:4]{index=4}
    cmd = [dovi, "inject-rpu", "-i", base_hevc, "--rpu-in", rpu_in, "-o", out_hevc]
    r.run(cmd, stage="Inject RPU into new base", use_progress=False)

def invert_fps_to_duration(fps: str) -> str | None:
    """
    fps: e.g. "24000/1001", "25/1", "30"
    returns duration as rational string: e.g. "1001/24000", "1/25", "1/30"
    """
    if not fps:
        return None
    fps = fps.strip()
    if "/" in fps:
        num, den = fps.split("/", 1)
        try:
            n = int(num)
            d = int(den)
            if n == 0:
                return None
            return f"{d}/{n}"
        except Exception:
            return None
    # plain number
    return f"1/{fps}"


def mux_output(r: ProcRunner, opt: JobOptions, dv_hevc: str, out_file: str):
    # Fast path: mkvmerge can mux copied video + original audio/subs without re-encoding,
    # but it cannot apply audio conversion. So use it only for full passthrough.
    mkvmerge = tool_path_optional("mkvmerge")  # implementiere wie tool_path(), nur None wenn nicht da
    if mkvmerge and out_file.lower().endswith(".mkv") and opt.audio_mode == "passthrough":
        # mkvmerge: input0 = video, input1 = original ohne Video (nur Audio/Subs/etc.)
        cmd = [
            mkvmerge,
            "-o", out_file,
            dv_hevc,
            "--no-video", opt.input_file
        ]
        r.run(cmd, stage="Mux (mkvmerge: video + audio/subs)", use_progress=False)
        return

    # --- Fallback (ffmpeg) ---
    ffmpeg = tool_path("ffmpeg")
    fps = ffprobe_fps(opt.input_file) or "24000/1001"
    dur = invert_fps_to_duration(fps) or "1001/24000"
    setts = f"setts=pts=N/TB*{dur}:dts=N/TB*{dur}"

    cmd = [ffmpeg, "-y"]
    cmd += ["-fflags", "+genpts"]
    cmd += ["-f", "hevc", "-i", dv_hevc]
    cmd += ["-i", opt.input_file]

    cmd += ["-map", "0:v:0", "-map", "1:a?"]
    if opt.subs_passthrough:
        cmd += ["-map", "1:s?"]

    cmd += ["-c:v", "copy", "-bsf:v", setts]

    # Audio handling
    if opt.audio_mode == "passthrough":
        cmd += ["-c:a", "copy"]
    else:
        # Opus encode (2.0 or 5.1)
        cmd += ["-c:a", "libopus", "-b:a", f"{int(opt.audio_bitrate_kbps)}k"]
        if opt.audio_mode == "opus_2.0":
            cmd += ["-ac", "2"]
        elif opt.audio_mode == "opus_5.1":
            # Only force 5.1 if the source is already 5.1/7.1-ish; otherwise leave channel count unchanged.
            ch = ffprobe_audio_channels(opt.input_file)
            if ch is None or ch >= 6:
                cmd += ["-ac", "6"]

    if opt.subs_passthrough:
        cmd += ["-c:s", "copy"]

    cmd += ["-avoid_negative_ts", "make_zero", "-max_interleave_delta", "0", "-muxpreload", "0", "-muxdelay", "0"]
    cmd += [out_file]
    r.run(cmd, stage="Mux (ffmpeg: video+audio+subs)", use_progress=False)

def tool_path_optional(name: str) -> str | None:
    p = Path(__file__).resolve().parent / "bin" / (name + ".exe")
    return str(p) if p.exists() else None

def make_output_name(opt: JobOptions) -> str:
    src = Path(opt.input_file)
    res_tag = "SRC" if opt.resolution == "source" else f"{opt.resolution}p"
    return f"{src.stem}.DV8.1_HDR10_{res_tag}_crf{opt.crf}_{opt.preset}.{opt.container}"

def transcode_job(r: ProcRunner, opt: JobOptions):
    dv = ffprobe_info(opt.input_file)
    r.log(f"\n[Analysis]\n{dv.dv_text}\nDuration: {dv.duration_s if dv.duration_s else 'unknown'}s\n")

    if not dv.has_dv:
        r.log("⚠️  Warning: No DV detected. I will still encode HDR10 base, but RPU extraction will likely fail.\n")

    tmpdir = tempfile.mkdtemp(prefix="dvtrans_")
    try:
        base_hevc = str(Path(tmpdir) / "base_hdr10.hevc")
        rpu_bin = str(Path(tmpdir) / "rpu.bin")
        dv_hevc = str(Path(tmpdir) / "video_dv.hevc")

        encode_hdr10_base(r, opt, dv, tmpdir, base_hevc)
        extract_rpu(r, opt, dv, rpu_bin)
        inject_rpu(r, base_hevc, rpu_bin, dv_hevc)

        out_dir = Path(opt.output_dir)
        out_dir.mkdir(parents=True, exist_ok=True)
        out_file = str(out_dir / make_output_name(opt))
        mux_output(r, opt, dv_hevc, out_file)

        r.log(f"\n✅ Done.\nOutput: {out_file}\n")
    finally:
        try:
            shutil.rmtree(tmpdir, ignore_errors=True)
        except Exception:
            pass

# -----------------------------
# GUI
# -----------------------------
class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title(APP_NAME)
        self.geometry("980x680")

        self.log_q = queue.Queue()
        self.worker = None

        # Vars
        self.in_file = tk.StringVar()  # convenience: shows selected/last added file
        self.queue_files: list[str] = []  # processing queue
        self.out_dir = tk.StringVar(value=str(Path.cwd() / "output"))
        self.resolution = tk.StringVar(value="1080")
        self.crf = tk.IntVar(value=18)
        self.preset = tk.StringVar(value="slow")
        self.container = tk.StringVar(value=DEFAULT_CONTAINER)
        self.subs_pass = tk.BooleanVar(value=True)
        self.audio_mode = tk.StringVar(value="passthrough")
        self.audio_bitrate = tk.IntVar(value=192)  # kbps, used when audio is re-encoded
        self.use_libplacebo = tk.BooleanVar(value=True)

        self.progress_stage = tk.StringVar(value="Idle")
        self.progress_pct = tk.DoubleVar(value=0.0)

        self._build_ui()
        self.audio_mode.trace_add("write", lambda *_: self._on_audio_mode_change())
        self._on_audio_mode_change()
        self.after(50, self._drain_log)

        missing = require_tools()
        if missing:
            messagebox.showerror(
                "Missing tools",
                "Folgende Tools wurden nicht gefunden:\n"
                + "\n".join(missing)
                + "\n\nLege sie nach ./bin/ (neben das Script) oder installiere sie systemweit."
            )

    def _build_ui(self):
        pad = {"padx": 10, "pady": 6}

        frm = ttk.Frame(self)
        frm.pack(fill="x", **pad)

        # Input / Queue
        row1 = ttk.Frame(frm)
        row1.pack(fill="x")
        ttk.Label(row1, text="Input queue:").pack(side="left")
        ttk.Entry(row1, textvariable=self.in_file, state="readonly").pack(side="left", fill="x", expand=True, padx=8)

        ttk.Button(row1, text="Add files...", command=self._add_files).pack(side="left")
        ttk.Button(row1, text="Remove", command=self._remove_selected).pack(side="left", padx=6)
        ttk.Button(row1, text="Clear", command=self._clear_queue).pack(side="left")

        qfrm = ttk.Frame(frm)
        qfrm.pack(fill="x", pady=(6, 0))
        self.queue_list = tk.Listbox(qfrm, height=5, selectmode="extended")
        self.queue_list.pack(side="left", fill="x", expand=True)
        self.queue_list.bind("<<ListboxSelect>>", self._on_queue_select)

        sb = ttk.Scrollbar(qfrm, orient="vertical", command=self.queue_list.yview)
        sb.pack(side="right", fill="y")
        self.queue_list.config(yscrollcommand=sb.set)

        # Output
        row2 = ttk.Frame(frm)
        row2.pack(fill="x")
        ttk.Label(row2, text="Output folder:").pack(side="left")
        ttk.Entry(row2, textvariable=self.out_dir).pack(side="left", fill="x", expand=True, padx=8)
        ttk.Button(row2, text="Browse...", command=self._pick_output).pack(side="left")

        # Options
        opts = ttk.LabelFrame(self, text="Options")
        opts.pack(fill="x", **pad)

        grid = ttk.Frame(opts)
        grid.pack(fill="x", padx=10, pady=8)

        ttk.Label(grid, text="Resolution:").grid(row=0, column=0, sticky="w")
        ttk.Combobox(grid, textvariable=self.resolution, values=["source", "2160", "1080", "720"], width=10, state="readonly").grid(row=0, column=1, sticky="w", padx=8)

        ttk.Label(grid, text="CRF/RF:").grid(row=0, column=2, sticky="w")
        ttk.Spinbox(grid, from_=0, to=51, textvariable=self.crf, width=6).grid(row=0, column=3, sticky="w", padx=8)

        ttk.Label(grid, text="Preset:").grid(row=0, column=4, sticky="w")
        ttk.Combobox(grid, textvariable=self.preset, values=["fast", "medium", "slow"], width=10, state="readonly").grid(row=0, column=5, sticky="w", padx=8)

        ttk.Label(grid, text="Container:").grid(row=1, column=0, sticky="w", pady=6)
        ttk.Combobox(grid, textvariable=self.container, values=["mkv", "mp4"], width=10, state="readonly").grid(row=1, column=1, sticky="w", padx=8)

        ttk.Checkbutton(grid, text="Subtitle passthrough (MKV empfohlen)", variable=self.subs_pass).grid(row=1, column=2, columnspan=2, sticky="w")
        ttk.Checkbutton(grid, text="Use libplacebo (auto-hilft bei DV Profile 5, Vulkan nötig)", variable=self.use_libplacebo).grid(row=1, column=4, columnspan=2, sticky="w")

        ttk.Label(grid, text="Audio:").grid(row=2, column=0, sticky="w", pady=6)
        ttk.Combobox(
            grid,
            textvariable=self.audio_mode,
            values=["passthrough", "opus_2.0", "opus_5.1"],
            width=14,
            state="readonly"
        ).grid(row=2, column=1, sticky="w", padx=8)

        ttk.Label(grid, text="Bitrate (kbps):").grid(row=2, column=2, sticky="w")
        self.bitrate_cb = ttk.Combobox(
            grid,
            textvariable=self.audio_bitrate,
            values=[64, 96, 128, 160, 192, 224, 256, 320, 384, 448, 512],
            width=10,
            state="readonly"
        )
        self.bitrate_cb.grid(row=2, column=3, sticky="w", padx=8)

        # Controls
        controls = ttk.Frame(self)
        controls.pack(fill="x", **pad)

        ttk.Button(controls, text="Analyze", command=self._analyze).pack(side="left")
        ttk.Button(controls, text="Start", command=self._start).pack(side="left", padx=8)
        ttk.Button(controls, text="Clear log", command=lambda: self._set_log("")).pack(side="left", padx=8)

        # Progress
        pr = ttk.Frame(self)
        pr.pack(fill="x", **pad)
        ttk.Label(pr, textvariable=self.progress_stage).pack(side="left")
        self.pb = ttk.Progressbar(pr, variable=self.progress_pct, maximum=100)
        self.pb.pack(side="left", fill="x", expand=True, padx=10)
        ttk.Label(pr, text="").pack(side="left")

        # Log
        logfrm = ttk.LabelFrame(self, text="Log")
        logfrm.pack(fill="both", expand=True, **pad)
        self.logtxt = tk.Text(logfrm, wrap="word")
        self.logtxt.pack(fill="both", expand=True, padx=8, pady=8)

    def _set_log(self, s: str):
        self.logtxt.delete("1.0", "end")
        self.logtxt.insert("end", s)
        self.logtxt.see("end")

    def _append_log(self, s: str):
        self.logtxt.insert("end", s)
        self.logtxt.see("end")

    def _drain_log(self):
        try:
            while True:
                item = self.log_q.get_nowait()
                if item[0] == "log":
                    self._append_log(item[1])
                elif item[0] == "progress":
                    stage, pct = item[1], item[2]
                    self.progress_stage.set(stage)
                    self.progress_pct.set(pct)
        except queue.Empty:
            pass
        self.after(80, self._drain_log)

   
    def _on_audio_mode_change(self):
        # Enable bitrate selection only when re-encoding audio.
        mode = self.audio_mode.get()
        if hasattr(self, "bitrate_cb"):
            if mode == "passthrough":
                self.bitrate_cb.configure(state="disabled")
            else:
                self.bitrate_cb.configure(state="readonly")

        # Small convenience defaults
        try:
            br = int(self.audio_bitrate.get())
        except Exception:
            br = 192

        if mode == "opus_2.0" and br > 256:
            self.audio_bitrate.set(192)
        elif mode == "opus_5.1" and br < 256:
            self.audio_bitrate.set(384)

    def _refresh_queue_list(self):
        if not hasattr(self, "queue_list"):
            return
        self.queue_list.delete(0, "end")
        for f in self.queue_files:
            self.queue_list.insert("end", f)
        # Update display line
        if self.queue_files:
            self.in_file.set(self.queue_files[0])
        else:
            self.in_file.set("")

    def _add_files(self):
        files = filedialog.askopenfilenames(
            title="Select input video(s)",
            filetypes=[("Video files", "*.mkv *.mp4 *.mov *.m2ts *.ts *.m4v *.hevc *.h265"), ("All files", "*.*")]
        )
        if not files:
            return
        added = 0
        for f in files:
            if f and Path(f).exists() and f not in self.queue_files:
                self.queue_files.append(f)
                added += 1
        if added:
            self._refresh_queue_list()

    def _remove_selected(self):
        if not self.queue_files:
            return
        sel = list(self.queue_list.curselection()) if hasattr(self, "queue_list") else []
        if not sel:
            # if nothing selected, remove the last element as a small convenience
            self.queue_files.pop()
        else:
            for i in sorted(sel, reverse=True):
                if 0 <= i < len(self.queue_files):
                    self.queue_files.pop(i)
        self._refresh_queue_list()

    def _clear_queue(self):
        self.queue_files.clear()
        self._refresh_queue_list()

    def _on_queue_select(self, event=None):
        try:
            sel = self.queue_list.curselection()
            if not sel:
                return
            i = sel[0]
            if 0 <= i < len(self.queue_files):
                self.in_file.set(self.queue_files[i])
        except Exception:
            pass

    def _pick_input(self):
        # kept for backward compatibility; adds a single file to the queue
        f = filedialog.askopenfilename(
            title="Select input video",
            filetypes=[("Video files", "*.mkv *.mp4 *.mov *.m2ts *.ts *.m4v *.hevc *.h265"), ("All files", "*.*")]
        )
        if f and Path(f).exists():
            if f not in self.queue_files:
                self.queue_files.append(f)
            self._refresh_queue_list()

    def _pick_output(self):
        d = filedialog.askdirectory(title="Select output folder")
        if d:
            self.out_dir.set(d)

    def _analyze(self):
        f = ""
        # Prefer selected queue item, else first in queue, else the display field.
        try:
            if self.queue_files and hasattr(self, "queue_list"):
                sel = self.queue_list.curselection()
                if sel:
                    f = self.queue_files[sel[0]]
                else:
                    f = self.queue_files[0]
        except Exception:
            f = ""

        if not f:
            f = self.in_file.get().strip()

        if not f or not Path(f).exists():
            messagebox.showwarning("Input missing", "Please add at least one input file to the queue.")
            return

        dv = ffprobe_info(f)
        msg = f"File: {Path(f).name}\n\n{dv.dv_text}\nDuration: {dv.duration_s if dv.duration_s else 'unknown'}s\n"
        messagebox.showinfo("Analysis", msg)

    def _start(self):
        if self.worker and self.worker.is_alive():
            messagebox.showinfo("Busy", "A job is already running.")
            return

        # Build file list from queue; fallback to display field if queue empty
        files = list(self.queue_files)
        if not files:
            f = self.in_file.get().strip()
            if f and Path(f).exists():
                files = [f]

        if not files:
            messagebox.showwarning("Input missing", "Please add at least one input file to the queue.")
            return

        # Validate files exist (skip missing with a log entry)
        files_ok = []
        for f in files:
            if Path(f).exists():
                files_ok.append(f)
            else:
                self.log_q.put(("log", f"⚠️  Skipping missing file: {f}\n"))
        files = files_ok
        if not files:
            messagebox.showwarning("Input missing", "All queued files are missing.")
            return

        out = self.out_dir.get().strip()
        if not out:
            messagebox.showwarning("Output missing", "Please select an output folder.")
            return

        missing = require_tools()
        if missing:
            messagebox.showerror("Missing tools", "Missing: " + ", ".join(missing))
            return

        total = len(files)
        ctx = {"idx": 0, "total": total, "file": ""}

        def log_fn(s: str):
            self.log_q.put(("log", s))

        def prog_fn(stage: str, pct: float):
            fname = Path(ctx["file"]).name if ctx["file"] else ""
            label = f"{stage} [{ctx['idx']}/{ctx['total']}] {fname}".strip()
            self.log_q.put(("progress", label, pct))

        runner = ProcRunner(log_fn, prog_fn)

        def work():
            try:
                for i, f in enumerate(files, start=1):
                    ctx["idx"] = i
                    ctx["file"] = f

                    opt = JobOptions(
                        input_file=f,
                        output_dir=out,
                        resolution=self.resolution.get(),
                        crf=int(self.crf.get()),
                        preset=self.preset.get(),
                        container=self.container.get(),
                        subs_passthrough=bool(self.subs_pass.get()),
                        audio_mode=self.audio_mode.get(),
                        audio_bitrate_kbps=int(self.audio_bitrate.get()),
                        use_libplacebo_for_p5=bool(self.use_libplacebo.get()),
                    )

                    log_fn(f"\n=== Job {i}/{total}: {f} ===\n")
                    self.log_q.put(("progress", f"Starting [{i}/{total}] {Path(f).name}", 0.0))
                    transcode_job(runner, opt)

                self.log_q.put(("progress", "All done", 100.0))
            except Exception as e:
                log_fn(f"\n❌ ERROR: {e}\n")
                self.log_q.put(("progress", "Failed", 0.0))

        self.worker = threading.Thread(target=work, daemon=True)
        self.worker.start()
        self.progress_stage.set("Starting...")
        self.progress_pct.set(0.0)

if __name__ == "__main__":
    App().mainloop()

Simon

Zitat von: Bambi am 31. Januar 2026, 14:20Dazu müsste ich aber auch beides haben, nehme ich an?
Logisch.
Zitat von: Bambi am 31. Januar 2026, 14:20An die DV Daten komme ich ja nicht so einfach.
Du hast doch die DV-Version?!
Zitat von: Bambi am 31. Januar 2026, 14:20DV mit HDR Fallback schneidet meist am schlechtesten ab aufgrund Artefakte und Farbrauschen.
Das ist üblicherweise video-identisch zum Video mit reinem HDR10, da dort einfach nur die DV-Daten hinzugefügt werden. Insofern ergibt dein Testergebnis wenig Sinn. Oder meinst du hier dein eigens erstelltes Video?
Zitat von: Bambi am 31. Januar 2026, 14:20meine Testcodierten Samples haben praktisch kein Farbrauschen und kaum bis garkeine Artefakte
Schlechte Encodes bügeln alles glatt. Daher ist das keine Überraschung.

Auch wenn dein Engagement lobenswert bleibt, ist es in diesem Fall reine Zeitverschwendung und Unsinn. Sorry.

Bambi

Danke für dein Feedback.
Das stimmt schon.

Der Hauptgrund für das Tool ist um sich 1080p DoVi/HDR Files zu erstellen die so eher rar zu finden sind.
Und da kommt man um einen Encode nicht rum.

Ich denke eine DV 4K Quelle ist eine gute Basis für einen 1080p DoVi/HDR transcode.

Das Tool habe ich für mich und meine Bedürfnisse erstellt,
da ich Online nichts vergleichbares finden konnte.

Zeitverschwendung, möglich, wäre nicht mein erstes komisches Hobby  ::)