diff --git a/.gitignore b/.gitignore index 82f9275..2d5b1cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.vscode +/temp +/bin/*.* + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/DV_HDR_merge.py b/DV_HDR_merge.py index 5eaf4a0..1c6fc5a 100644 --- a/DV_HDR_merge.py +++ b/DV_HDR_merge.py @@ -1,209 +1,658 @@ -import subprocess -import logging -import os -import json import argparse -import inquirer -import shutil - +import json +import logging +import re +import subprocess +import tkinter import tkinter.filedialog import tkinter.font -import tkinter -from PIL import ImageTk, Image, ImageChops -from pymediainfo import MediaInfo -from alive_progress import alive_bar +from pathlib import Path +from shutil import rmtree, which -parser = argparse.ArgumentParser(prog="Dolby Vision + HDR", description="A program to combine HDR videos with Dolby Vison videos for a Dolby Vison file with HDR fallback", epilog="Report bugs to Swedish-Wiking@GitHub") -parser.add_argument("input", metavar="I", nargs="*", help="List of file/folders to use as input") -parser.add_argument("-logL", dest="logL", choices=["debug", "info", "error"], help="Set verbose level") -parser.add_argument("-maxdif", dest="maxdif", type=int, help="Set maxed allowed frames to differ between videos") +import questionary +from PIL import Image, ImageChops, ImageTk +from pymediainfo import MediaInfo +from questionary import ValidationError, Validator +from rich.progress import (BarColumn, Progress, SpinnerColumn, + TaskProgressColumn, TextColumn, TimeElapsedColumn, + TimeRemainingColumn) +from winpty import PtyProcess + +parser = argparse.ArgumentParser( + prog="Dolby Vision + HDR", + description="A program to combine HDR videos with Dolby Vison videos for a Dolby Vison file with HDR fallback", + epilog="Report bugs to Swedish-Wiking@GitHub" +) +parser.add_argument( + "files", + nargs="*", + help="Paths of files and folders", + type=Path + ) +parser.add_argument( + "-logs", + dest="logs", + choices=["DEBUG", "INFO", "ERROR"], + help="Set verbosity level" +) +parser.add_argument( + "-maxdif", + dest="maxdif", + type=int, + help="Set maxed allowed frames to differ between videos" +) +parser.add_argument( + "--temp-folder", + dest="tempFolder", + type=Path, + help="A folder to which to save the converted ringtones", +) args = parser.parse_args() logging.basicConfig(format="%(levelname)s:\t%(message)s", level=logging.INFO) -if args.logL == "debug": logging.basicConfig(level=logging.DEBUG) -elif args.logL == "info": logging.basicConfig(level=logging.INFO) -elif args.logL == "error": logging.basicConfig(level=logging.ERROR) +logging.basicConfig(level=args.logs) -__location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) -_temp = os.path.join(__location__, "temp\\") -_bin = os.path.join(__location__, "bin\\") -mkvmerge = os.path.join(_bin, "mkvmerge.exe") -mkvextract = os.path.join(_bin, "mkvextract.exe") -dovi_Tool = os.path.join(_bin, "dovi_tool.exe") +def get_exe(name: str) -> Path: + _bins = Path(__file__).parent.resolve().joinpath("bin") + if which(name) is not None: return Path(which(name)) # type: ignore + elif _bins.joinpath(name).exists(): return _bins.joinpath(name) + raise FileExistsError(f"The executable {name} does not exist on PATH") -def createTempDir(): - os.makedirs(_temp, exist_ok=True) - logging.debug(f"Temp folder created at: {_temp}") +def collect_files(list_of_paths: list[Path], filter: list[str] = [".mp4", ".mkv"]) -> list[Path]: + files = list() + for f in list_of_paths: + if f.is_file() and f.suffix in filter: files.append(f) + elif f.is_dir(): [files.append(p) for p in f.rglob("*") if p.suffix in filter] + return files -class image_compare(): - def __init__(self, hdr, dv, hybrid): - self.dv_file = dv - self.hdr_file = hdr - self.hybrid = hybrid - self.base_refrence = 1000 - self.shifted_frames = 0 - createTempDir() - self.active_image_lb = "D" - self.active_image = self.create_thumbnails() - icon_file = os.path.join(__location__,"icon.png") +FFMPEG = get_exe("ffmpeg") +FFPROBE = get_exe("ffprobe") +MKVMERGE = get_exe("mkvmerge") +MKVEXTRACT = get_exe("mkvextract") +DOVI_TOOL = get_exe("dovi_tool.exe") + +temp_workdir = ( + args.tempFolder + if args.tempFolder + else Path(__file__).parent.resolve().joinpath("temp") +) + +class NumberValidator(Validator): + def validate(self, document): + ok = re.match( + r"^\d+$", + document.text, + ) + if not ok: + raise ValidationError( + message="Please enter a valid number", + cursor_position=len(document.text), + ) + +class ColorMerger(): + + def __init__(self, files:list[Path]) -> None: + self.files = self._tkAskForFiles() if not files else files + if len(self.files) <= 1: + logging.error("Not enough files were choosen!") + exit(1) + self.metadata = list(self._analyzeFiles()) + self.printMedia() + self._checkColors() + self.matchedFiles = self._matchFiles() + + def _tkAskForFiles(self) -> list[Path]: + filetypes = ( + ("Video files", ".mkv .mp4"), + ("Matroska files", ".mkv"), + ("MPEG-4 files", ".mp4"), + ("All files", "*.*") + ) + files = tkinter.filedialog.askopenfilenames( + title="Select files", + filetypes=filetypes + ) + return [Path(file) for file in files] + + def _analyzeFiles(self): + for file in sorted(self.files): + logging.info(f"Analyzing {file.name}...") + probe_cmd = [ + FFPROBE, + "-hide_banner", + "-loglevel", "fatal", + "-show_error", + "-show_streams", + "-select_streams", "v:0", + "-show_private_data", + "-print_format", "json", + file + ] + + data = subprocess.run(probe_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if data.returncode != 0: + logging.warning(f"File could not be analyzed: {data.stderr}") + break + + parsed_data = self.parseMetadata(data.stdout.decode("utf-8"), file) + logging.info("Sucessfully analyzed file!") + yield {"name": file.name, "path": file} | parsed_data + + @staticmethod + def parseMetadata(data, file): + json_data = json.loads(data) + logging.debug(json_data) + json_data = json_data["streams"][0] + width = int(json_data["width"]) + height = int(json_data["height"]) + fps = json_data["avg_frame_rate"].split("/") + fps = int(fps[0]) / int(fps[1]) + frameCount = 0 + colorProfile = "None" + + try: + frameCount = int(json_data["tags"]["NUMBER_OF_FRAMES"]) + except: + try: + frameCount = int(json_data["tags"]["NUMBER_OF_FRAMES-eng"]) + except: + try: + frameCount = int(json_data["nb_frames"]) + except: + logging.warning("Framerate was not found in ffprobe data, using MediaInfo") + logging.warning("Analysis may take longer") + media_info = MediaInfo.parse(file) + for track in media_info.tracks: + if track.track_type == "Video": + frameCount = int(track.frame_count) - #Initialization + try: + if json_data["side_data_list"][0]["rpu_present_flag"] == 1: + try: + if json_data["color_transfer"] == "smpte2084": + colorProfile = "HDR+DV" + except: + colorProfile = "DV" + except: + try: + if json_data["color_transfer"] == "smpte2084": + colorProfile = "HDR" + except: + colorProfile = "None" + + + + return {"frameCount": frameCount, "colorProfile": colorProfile, "pxWidth": width, "pxHeight": height, "frameRate": fps} + + def _checkColors(self): + cProfile = {d["colorProfile"] for d in self.metadata} + if ("DV" not in cProfile and "HDR+DV" not in cProfile) or not "HDR" in cProfile: + logging.error("Not enough files with HDR or DV layers") + exit(1) + + def printMedia(self) -> None: + + def printInfo(profile:str) -> None: + logging.info(f"{profile} Media:") + for media in filter(lambda d: d["colorProfile"] == profile, self.metadata): + logging.info(f"{media["name"]}:\n\tFrameCount: {media["frameCount"]}\n\tWidth: {media["pxWidth"]}\n\tHeight: {media["pxHeight"]}\n\tFrameRate: {media["frameRate"]}") + + for profile in {d["colorProfile"] for d in self.metadata}: + printInfo(profile) + + @staticmethod + def delayFrames(hdr, dv, hybrid:bool): + logging.warning("Dolby Vision layer probably needs to be delayed") + isManual = questionary.confirm("Do you want to input value to shift frames with?", default=False).ask() + if isManual: + delayed_frames = int( + questionary.text( + "Input frames to shift Dolby Vision layer with:", + qmark = "", + validate = NumberValidator + ).ask() + ) + else: + comparer = CompareWindow(hdr, dv, hybrid) + delayed_frames = comparer.shifted_frames + logging.info(f"Dolby Vision layer is shifted by {delayed_frames} frames") + return delayed_frames + + def _matchFiles(self): + matched_files = list() + HDRs = [media for media in self.metadata if media["colorProfile"] == "HDR"] + logging.debug(HDRs) + DVs = [media for media in self.metadata if media["colorProfile"] in ["DV", "HDR+DV"]] + logging.debug(DVs) + if args.maxdif != None: + maxDif = int(args.maxdif) + else: + maxDif = int( + questionary.text( + "Input max allowed differance in frames:", + qmark = "", + validate = NumberValidator + ).ask() + ) + + logging.info("Matching files...") + for hdr_file in HDRs: + miss = 0 + logging.info(f"Trying to match: {hdr_file["name"]}") + for dv_file in DVs: + hybrid = True if dv_file["colorProfile"] == "HDR+DV" else False + absDif = abs(hdr_file["frameCount"] - dv_file["frameCount"]) + if absDif == 0: + logging.info(f"Perfect match found with: {dv_file["name"]}") + isAutomatic = questionary.confirm("Want to frame match anyways", default=False).ask() + if isAutomatic: frames_to_delay = self.delayFrames(hdr_file, dv_file, hybrid) + else: frames_to_delay = 0 + match = {"HDR_FILE": hdr_file, "DV_FILE": dv_file, "framesToDelay": frames_to_delay} + matched_files.append(match) + break + elif(absDif <= maxDif): + logging.info(f"Match found but with a difference of: {absDif} frames, file matched with: {dv_file["name"]}") + isMatch = questionary.confirm("Is it a match", default=False).ask() + if isMatch: + frames_to_delay = self.delayFrames(hdr_file, dv_file, hybrid) + match = {"HDR_FILE": hdr_file, "DV_FILE": dv_file, "framesToDelay": frames_to_delay} + matched_files.append(match) + else: logging.info("Trying another...") + else: miss += 1 + if miss == len(DVs): + logging.warning(f"No match found for: {hdr_file["name"]}") + + logging.info("Matching process completeded") + logging.debug(matched_files) + return matched_files + + def _main(self, file_pair:dict): + self.mkTemp() + rpu_json = temp_workdir.joinpath("RPU.json") + rpu = temp_workdir.joinpath("RPU.bin") + rpu_edited = temp_workdir.joinpath("RPU_EDITED.bin") + hdr_hevc = temp_workdir.joinpath("HDR.hevc") + dv_hevc = temp_workdir.joinpath("DV.hevc") + hdr_dv_hevc = temp_workdir.joinpath("HDR_DV.hevc") + + isDVmp4 = (file_pair["DV_FILE"]["path"] == ".mp4") + + delay_frames = file_pair["framesToDelay"] + if delay_frames < 0: + remove_frames = "0-" + str(abs(delay_frames)-1) + delay_frames = 0 + else: remove_frames = "" + + crop = False + crop_amount = 0 + if (file_pair["HDR_FILE"]["pxHeight"] == file_pair["DV_FILE"]["pxHeight"]): + logging.debug("No crop needed for RPU-file") + elif (int(file_pair["HDR_FILE"]["pxHeight"]) > int(file_pair["DV_FILE"]["pxHeight"])): + logging.debug("Adding letterboxing to RPU-file to match with target file") + crop_amount = int((int(file_pair["HDR_FILE"]["pxHeight"]) - int(file_pair["DV_FILE"]["pxHeight"]))/2) + elif (int(file_pair["HDR_FILE"]["pxHeight"]) < int(file_pair["DV_FILE"]["pxHeight"])): + logging.debug("Croping needed for RPU-file") + crop = True + + json_data = { + "active_area": { + "crop": crop, + "presets": [{ + "id": 0, + "left": 0, + "right": 0, + "top": crop_amount, + "bottom": crop_amount + }]}, + "remove": [ + remove_frames + ], + "duplicate": [{ + "source": 0, + "offset": 0, + "length": delay_frames + }]} + + with open(rpu_json, "w") as outfile: outfile.write(json.dumps(json_data, indent=4)) + + cmdExtractHDRMKV = [ + MKVEXTRACT, + "tracks", + file_pair["HDR_FILE"]["path"], + "0:" + str(hdr_hevc), + "--gui-mode"] #1 + + cmdExtractDVMKV = [ + MKVEXTRACT, + "tracks", + file_pair["DV_FILE"]["path"], + "0:" + str(dv_hevc), + "--gui-mode"] #2 + + cmdExtractDV = [ + "ffmpeg", + "-loglevel", "error", + "-hide_banner", + "-progress", "-", + "-nostats", + "-analyzeduration", "6000M", + "-probesize", "2147M", + "-y", + "-i", file_pair["DV_FILE"]["path"], + "-an", "-c:v", + "copy", + "-f", "hevc", + dv_hevc] #2 + + cmdExtractRPU = [ + DOVI_TOOL, + "extract-rpu", + dv_hevc, + "-o", rpu] #3 + + cmdRPUEdit = [ + DOVI_TOOL, + "editor", + "-i", rpu, + "-j", rpu_json, + "-o", rpu_edited] #4 + + cmdRPUInject = [ + DOVI_TOOL, + "inject-rpu", + "-i", hdr_hevc, + "--rpu-in", rpu_edited, + "-o", hdr_dv_hevc] #5 + + logging.info("Injection process begins...") + logging.info(f"Files used: \n\t{file_pair["HDR_FILE"]["path"]}\n\t{file_pair["DV_FILE"]["path"]}") + + self.run(cmdExtractHDRMKV, title="Extracting HDR video:\t\t") + if isDVmp4: self.run(cmdExtractDV, title="Extracting DV video:\t\t", total=file_pair["DV_FILE"]["frameCount"]) + else: self.run(cmdExtractDVMKV, title="Extracting DV video:\t\t") + self.run(cmdExtractRPU, title="Extracting RPU from DV file:\t") + self.run(cmdRPUEdit, "Modifying RPU-file:\t\t") + self.run(cmdRPUInject, "Injecting RPU into HDR file:\t") + self._merge(file_pair["HDR_FILE"]["path"], hdr_dv_hevc) + + @staticmethod + def _run_dovi_tool(cmd, progress:Progress, title:str): + total = 100 + seconds = False + if "inject-rpu" in cmd: total = 200 + if "editor" in cmd: total = 1 + task = progress.add_task(title, total=total, status="") + ansi_clean = re.compile(r"\x1b\[[\?\d;]*[A-Za-z]|\r|\n") + proc = PtyProcess.spawn(cmd) + output = "ERROR" + try: + while proc.isalive(): + output = ansi_clean.sub("", proc.read(256)).strip() + m = re.search(r'(\d+)%', output) + if "Rewriting file with interleaved RPU NALs.." in output: seconds = True + if output and not m: + if output == "Done.": + progress.update(task_id=task, status="[green]Done") + else: + progress.update(task_id=task, status=output) + elif m: + percentage = int(m.group(1)) + if seconds: percentage += 100 + if percentage == total: + progress.update(task_id=task, status="[green]Done") + progress.update(task_id=task, completed=percentage) + except EOFError: pass + + if proc.exitstatus == 1: + logging.error(f"The \"{title.replace(":", "")}\" command failed: {output}") + progress.remove_task(task) + else: + if "editor" in cmd: + progress.update(task_id=task, completed=total, status="[green]Done") + + @staticmethod + def _run_cmd(cmd, progress:Progress, title:str, total:float): + task = progress.add_task(title, total=total, status="") + data = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True, + text=True + ) + completed = 0 + for line in data.stdout: # type: ignore + if "#GUI#progress" in line: + completed = int(line.replace("#GUI#progress ","").replace("%","")) + elif "frame=" in line: + completed = int(line.replace("frame=",""))/total + if completed: + progress.update(task, completed=completed) + if cmd[0] == FFMPEG and data.returncode == 0: + progress.update(task, completed=total) + + def run(self, cmd, title="", total=100): + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + TimeElapsedColumn(), + TimeRemainingColumn(compact=True), + TextColumn("{task.fields[status]}"), + refresh_per_second=10 + ) as progress: + try: + if cmd[0] in [FFMPEG, MKVEXTRACT, MKVMERGE]: + self._run_cmd(cmd, progress, title, total) + elif cmd[0] == DOVI_TOOL: + self._run_dovi_tool(cmd, progress, title) + except subprocess.CalledProcessError: + logging.error("Command failed, The process of this file will FAIL!") + raise RuntimeError + except KeyboardInterrupt: + logging.error("Command interupted, The process of this file will FAIL!") + raise InterruptedError + + def _merge(self, file:Path, DoVi:Path): + file_out = file.with_name(file.name.replace(".mkv", "_HDR_DV.mkv")) + cmdMerge = [ + MKVMERGE, + "--gui-mode", + "-o", file_out, + "--no-video", + file, + DoVi + ] + + self.run(cmdMerge, "Multiplexing hybrid file: \t") + logging.info("Files sucessfully combined") + + def inject(self): + for match in self.matchedFiles: + try: self._main(match) + except RuntimeError: + logging.error("Multiplexing of file FAILED") + except InterruptedError: + logging.error("Multiplexing of file FAILED because of Human interuption") + finally: + self.cleanUp() + + @staticmethod + def mkTemp(): + temp_workdir.mkdir(exist_ok=True) + logging.debug(f"Temp folder created at: {temp_workdir}") + + @staticmethod + def cleanUp(): + rmtree(temp_workdir, ignore_errors=True) + logging.debug(f"Temp folder was removed") + +class CompareWindow(): + + def __init__(self, hdr:dict, dv:dict, isHybrid:bool) -> None: self.window = tkinter.Tk() - icon = ImageTk.PhotoImage(file=str(icon_file)) - tk_font = tkinter.font.Font(weight="bold") + self.font = tkinter.font.Font(weight="bold") + self.color_mode = "D" + self.ref_int = tkinter.IntVar(value=1000) + self.shifted_int = tkinter.IntVar(value=0) + self.dv_file = dv["path"] + self.dv_fps = dv["frameRate"] + self.hdr_file = hdr["path"] + self.hdr_fps = hdr["frameRate"] + self.isHybrid = isHybrid + self.total_ref = hdr["frameCount"] + + # Variable to track scheduled resize + self._resize_after_id = None + + self.active_image, self.blend, self.difference = self._generate_image(self.ref_int.get(), self.shifted_int.get()) + self._window_init() + + @property + def shifted_frames(self) -> int: + return self.shifted_int.get() + + def _validate_ref_int(self, action:str, text:str) -> bool: + if action in ["1","0"]: + if text.isdigit(): + if int(text) > self.total_ref: + self.window.after_idle(lambda: self.ref_int.set(self.total_ref)) + if int(text) <= self.total_ref: + self.window.after_idle(lambda: self.ref_int.set(int(text))) + return True + elif text == "": + self.window.after_idle(lambda: self.ref_int.set(0)) + self.window.after_idle(lambda: self.ref_entry.icursor(1)) + return True + else: + return False + else: + return True + + def _validate_int(self, action:str, text:str, validate_state:str) -> bool: + if validate_state == "key": + valid_int = re.compile(r"^[+-]?[0-9]+$|^-{1}$") + return bool(valid_int.match(text)) if action == "1" else True + else: + valid_int = re.compile(r"^[+-]?[0-9]+$") + if not valid_int.match(text) or text == "": + self.window.after_idle(lambda: self.shifted_int.set(0)) + return bool(valid_int.match(text)) if action in ["1","0"] else True + + def _button_init(self) -> None: + # Open in photos + show_image_btn = tkinter.Button(self.window, text="Open picture", command=self._show_image) + show_image_btn.grid(column=0, row=0, sticky=tkinter.W, padx=5, pady=5) - #Window config + # Switch view + switch_btn = tkinter.Button(self.window, text="Switch view", command=self._switch_view) + switch_btn.grid(column=0, row=1, sticky=tkinter.W, padx=5, pady=5) + + # Submit value + done_btn = tkinter.Button(self.window, text="Done", command=self._done) + done_btn.grid(column=2, row=1, sticky=tkinter.E, padx=5, pady=5) + + def _entry_init(self) -> None: + # Validator + valid_ref_int = (self.window.register(self._validate_ref_int),"%d", "%P") + valid_int = (self.window.register(self._validate_int),"%d", "%P", "%V") + + # Reference frame + ref_label = tkinter.Label( + self.window, + text=f"Frame to refrence in HDR video (total: {self.total_ref}):", + bg="black", + fg="white", + font=self.font + ) + ref_label.grid(column=0, row=0, sticky=tkinter.E, padx=5, pady=5) + self.ref_entry = tkinter.Entry( + self.window, + validate = "all", + validatecommand = valid_ref_int, + textvariable=self.ref_int + ) + self.ref_entry.bind("", self._update_image) + self.ref_entry.grid(column=1, row=0, sticky=tkinter.W, padx=5, pady=5) + + # Other frame + shifted_label = tkinter.Label( + self.window, + text="Frames to shift Dolby Vision Layer with:", + bg="black", + fg="white", + font=self.font + ) + shifted_label.grid(column=0, row=1, sticky=tkinter.E, padx=5, pady=5) + self.shifted_entry = tkinter.Entry( + self.window, + validate = "all", + validatecommand = valid_int, + textvariable=self.shifted_int + ) + self.shifted_entry.bind("", self._update_image) + self.shifted_entry.grid(column=1, row=1, sticky=tkinter.W, padx=5, pady=5) + + def _canvas_init(self) -> None: + image = ImageTk.PhotoImage(self.active_image) + self.canvas = tkinter.Canvas( + self.window, + width=image.width(), + height=image.height(), + bg="black" + ) + self.canvas.grid(column=0, row=3, columnspan=3, padx=5, pady=5) + self.image_id = self.canvas.create_image(0, 0, image=image, anchor="nw") + + def _window_init(self): + # --- Main Window config --- self.window.title("Frame Compare") - self.window.iconphoto(True, icon) + self.window.iconbitmap(str(Path(__file__).parent.resolve().joinpath("icon.ico"))) self.window.geometry("1200x800") self.window.minsize(700,420) - self.window.configure(bg='black', padx=10, pady=10) + self.window.configure(bg="black", padx=10, pady=10) self.window.columnconfigure(0, weight=1) self.window.columnconfigure(1, weight=1) self.window.columnconfigure(2, weight=0) - - #Buttons - show_image_btn = tkinter.Button(self.window, text="Open picture", command=self.show_image) - show_image_btn.grid(column=0, row=0, sticky=tkinter.W, padx=5, pady=5) - switch_btn = tkinter.Button(self.window, text="Switch view", command=self.switch) - switch_btn.grid(column=0, row=1, sticky=tkinter.W, padx=5, pady=5) - done_btn = tkinter.Button(self.window, text="Done", command=self.done) - done_btn.grid(column=2, row=1, sticky=tkinter.E, padx=5, pady=5) - #Entries - v_base, v_shift = tkinter.IntVar(), tkinter.IntVar() - vcmd = (self.window.register(self.validate_int),'%d', '%i', '%P', '%s', '%S', '%v', '%V', '%W') - base_lb = tkinter.Label(self.window, text=f"Base frame to refrence (total: {hdr["frameCount"]}):", bg="black", fg="white", font=tk_font) - base_lb.grid(column=0, row=0, sticky=tkinter.E, padx=5, pady=5) - shift_lb = tkinter.Label(self.window, text="Frames to shift DV Layer:", bg="black", fg="white", font=tk_font) - shift_lb.grid(column=0, row=1, sticky=tkinter.E, padx=5, pady=5) - self.base_entry = tkinter.Entry(self.window, validate = 'key', validatecommand = vcmd, text=v_base) - self.base_entry.bind('', self.shift_Base_Frame) - self.base_entry.grid(column=1, row=0, sticky=tkinter.W, padx=5, pady=5) - self.shift_entry = tkinter.Entry(self.window, validate = 'key', validatecommand = vcmd, text=v_shift) - self.shift_entry.bind('', self.shift_DV_Layer) - self.shift_entry.grid(column=1, row=1, sticky=tkinter.W, padx=5, pady=5) - v_base.set(self.base_refrence) - v_shift.set(0) + self._button_init() + self._entry_init() - #Image Canvas - image = ImageTk.PhotoImage(self.active_image) - imageWidth = image.width() - imageHeight = image.height() - self.canvas = tkinter.Canvas(self.window, width=imageWidth, height=imageHeight, bg="black") - self.canvas.grid(column=0, row=3, columnspan=3, padx=5, pady=5) - self.image_id = self.canvas.create_image(0, 0, image=image, anchor='nw') - self.canvas.bind('', self.resize_image) - - #Open Window + self._canvas_init() + self.window.bind("", self._on_resize) + + # --- Focus the Window --- self.window.lift() self.window.attributes("-topmost",True) + self.window.protocol("WM_DELETE_WINDOW", self._done) self.window.after_idle(self.window.attributes,"-topmost",False) self.window.mainloop() - def validate_int(self, action, index, value_if_allowed, prior_value, text, validation_type, trigger_type, widget_name): - if(action=='1'): - if text in '0123456789-+': - try: - int(value_if_allowed) - return True - except ValueError: return False - else: return False - else: return True + def _done(self): + self.window.quit() + self.window.destroy() - def create_thumbnails(self): - logging.debug("Generating images to compare...") - vf_HDR = "zscale=t=linear,tonemap=hable,zscale=p=709:t=709:m=709" - vf_DV = "libplacebo=tonemapping=auto,zscale=t=linear,tonemap=hable,zscale=p=709:t=709:m=709" - ss_hdr = str(self.base_refrence / self.hdr_file["frameRate"]) - ss_dv = str((self.base_refrence - self.shifted_frames) / self.dv_file["frameRate"]) - hdr_out = os.path.join(_temp, "HDR.bmp") - dv_out = os.path.join(_temp, "DV.bmp") - - hdr_cmd = ["ffmpeg", - "-hide_banner", - "-v", "error", - "-y", - "-ss", ss_hdr, - "-i", self.hdr_file["path"], - "-qscale:v", "1", - "-vf", vf_HDR, - "-vframes", "1", - hdr_out - ] - - logging.debug("Generating HDR image...") - try: subprocess.run(hdr_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - except Exception as e: logging.error(f"Failed to generate HDR screencapture | ERROR:{e}") - - if self.hybrid: - dv_cmd = ["ffmpeg", - "-hide_banner", - "-v", "error", - "-y", - "-ss", ss_dv, - "-i", self.dv_file["path"], - "-qscale:v", "1", - "-vf", vf_HDR, - "-vframes", "1", - dv_out - ] - - logging.debug("Generating DV image...") - try: subprocess.run(dv_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - except Exception as e: logging.error(f"Failed to generate DV screencapture | ERROR:{e}") + def _update_image(self, event): + logging.debug(f"Refrence frame: {self.ref_int.get()}") + logging.debug(f"Shifted frames: {self.shifted_int.get()}") - else: - dv_cmd = ["ffmpeg", - "-hide_banner", - "-v", "error", - "-y", - "-ss", ss_dv, - "-i", self.dv_file["path"], - "-qscale:v", "1", - "-vf", vf_DV, - "-vframes", "1", - dv_out - ] - - logging.debug("Generating DV image...") - try: subprocess.run(dv_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - except Exception as e: logging.error(f"Failed to generate DV screencapture | ERROR:{e}") - - logging.debug("Processing Images...") - HDR_img = Image.open(hdr_out) - DV_img = Image.open(dv_out) - HDR_w, HDR_h = HDR_img.size - DV_w, DV_h = DV_img.size - crop = abs(HDR_h-DV_h)/2 - - if HDR_img.height < DV_img.height: DV_img = DV_img.crop((0, crop, DV_w, DV_h-crop)) - elif HDR_img.height > DV_img.height: HDR_img = HDR_img.crop((0, crop, HDR_w, HDR_h-crop)) - - self.difference_img = Image.blend(DV_img, HDR_img, 0.5) - self.blend_img = ImageChops.difference(DV_img.convert('L'), HDR_img.convert('L')) - logging.debug("Processing done!") - - if self.active_image_lb == "D": self.active_image = self.difference_img - elif self.active_image_lb == "B":self.active_image = self.blend_img - - return self.difference_img + self.active_image, self.blend, self.difference = self._generate_image(self.ref_int.get(), self.shifted_int.get()) + self._resize_image() - def shift_DV_Layer(self, event): - logging.debug(f"Dolby Vision Layer shifted {self.shift_entry.get()} frames") - self.shifted_frames = int(self.shift_entry.get()) - self.base_refrence = int(self.base_entry.get()) - self.create_thumbnails() - self.resize_image("") + def _show_image(self): + logging.info("Opening image in default photo viewer") + self.active_image.show() - def shift_Base_Frame(self, event): - logging.debug(f"Changed refrence frame to: {self.base_entry.get()}") - self.shifted_frames = int(self.shift_entry.get()) - self.base_refrence = int(self.base_entry.get()) - self.create_thumbnails() - self.resize_image("") - - def resize_image(self, e): + def _on_resize(self, event): + # Cancel any scheduled resize + if self._resize_after_id: + self.window.after_cancel(self._resize_after_id) + + # Schedule a new resize after 200 ms (debounce) + self._resize_after_id = self.window.after(150, self._resize_image) + + def _resize_image(self): global new_image, resized_image win_width = self.window.winfo_width() - 40 win_height = self.window.winfo_height() - 110 @@ -218,367 +667,88 @@ class image_compare(): new_image = ImageTk.PhotoImage(resized_image) self.canvas.itemconfigure(self.image_id, image=new_image) - def show_image(self): - logging.info("Opening image in default photo viewer") - self.active_image.show() + self._resize_after_id = None # Reset - def done(self): - self.window.quit() - self.window.destroy() - - def switch(self): - if self.active_image_lb == "D": + def _switch_view(self): + if self.color_mode == "D": logging.debug("Switching to Blended image") - self.active_image = self.blend_img - self.active_image_lb = "B" - self.resize_image("") - elif self.active_image_lb == "B": + self.active_image = self.blend + self.color_mode = "B" + self._resize_image() + else: logging.debug("Switching to Difference image") - self.active_image = self.difference_img - self.active_image_lb = "D" - self.resize_image("") - -def file_list(): - file_paths = list() - filetypes = (("Video files", ".mkv .mp4"), ("Matroska files", ".mkv"), ("MPEG-4 files", ".mp4"), ("All files", "*.*")) - - if args.input == []: - file_ask = tkinter.Tk() - file_ask.withdraw() - icon = ImageTk.PhotoImage(file=str(os.path.join(__location__,"icon.png"))) - file_ask.iconphoto(True, icon) - files = tkinter.filedialog.askopenfilenames(parent=file_ask, title="Select files", multiple=True, filetypes=filetypes) - file_ask.destroy() - else: files = args.input + self.active_image = self.difference + self.color_mode = "D" + self._resize_image() - for path in files: - if os.path.isfile(path): file_paths.append(path) - else: - for root, directories, files in os.walk(path): - for filename in files: - file_ext = os.path.splitext(filename)[1] - accepted_ext = {'.mkv', '.mp4'} - if file_ext in accepted_ext: - filepath = os.path.join(root, filename) - file_paths.append(filepath) - if len(file_paths) <= 1: - logging.error("No files chosen") - exit(code=1) - return file_paths + def _generate_image(self, ref:int, shift:int) -> tuple[Image.Image, Image.Image, Image.Image]: + logging.debug("Generating images to compare...") -def check_for_ffmpeg(): - try: - subprocess.check_call(["ffmpeg", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - except subprocess.CalledProcessError: - logging.error("FFmpeg installed but something whent wrong re-run script") - exit(code=1) - except OSError: - logging.error("FFmpeg not installed, please install and re-run script") - exit(code=1) + vf_HDR = "zscale=t=linear,tonemap=hable,zscale=p=709:t=709:m=709" + vf_DV = "libplacebo=tonemapping=auto,zscale=t=linear,tonemap=hable,zscale=p=709:t=709:m=709" + ss_hdr = str(ref / self.hdr_fps) + ss_dv = str((ref - shift) / self.dv_fps) -def is_integer(n): - try: float(n) - except ValueError: return False - else: return float(n).is_integer() + ColorMerger.mkTemp() + hdr_out = temp_workdir.joinpath("HDR.bmp") + dv_out = temp_workdir.joinpath("DV.bmp") -def parse_metadata(data, file): - json_data = json.loads(data) - logging.debug(json_data) - json_data = json_data["streams"][0] - width = int(json_data["width"]) - height = int(json_data["height"]) - fps = json_data["avg_frame_rate"].split("/") - fps = int(fps[0]) / int(fps[1]) - - try: framCount = int(json_data["tags"]["NUMBER_OF_FRAMES"]) - except: - try: framCount = int(json_data["tags"]["NUMBER_OF_FRAMES-eng"]) - except: - try: framCount = int(json_data["nb_frames"]) - except: - logging.warning("Framerate was not found in ffprobe data, using MediaInfo") - logging.warning("Analysis may take longer") - media_info = MediaInfo.parse(file) - for track in media_info.tracks: - if track.track_type == "Video": framCount = int(track.frame_count) - - try: - if json_data["side_data_list"][0]["rpu_present_flag"] == 1: - try: - if json_data["color_transfer"] == "smpte2084": colorProfile = "HDR+DV" - except: colorProfile = "DV" - except: - try: - if json_data["color_transfer"] == "smpte2084": colorProfile = "HDR" - except: colorProfile = "None" - - return {"frameCount": framCount, "colorProfile": colorProfile, "pxWidth": width, "pxHeight": height, "frameRate": fps} - -def analyze_files(files): - metadata_list = list() - for file in files: - name = os.path.basename(file) - logging.info(f"Analyzing {name}...") - probe_cmd = ["ffprobe", + hdr_cmd = [ + FFMPEG, "-hide_banner", - "-loglevel", "fatal", - "-show_error", - "-show_streams", - "-select_streams", "v:0", - "-show_private_data", - "-print_format", "json", - file] + "-v", "error", + "-y", + "-ss", ss_hdr, + "-i", self.hdr_file, + "-qscale:v", "1", + "-vf", vf_HDR, + "-vframes", "1", + hdr_out + ] - try: - data = subprocess.run(probe_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - if data.returncode != 0: - logging.warning(f"File could not be analyzed: {data.stderr}") - break - except: - logging.warning(f"File could not be analyzed: {data.stderr}") - break + dv_cmd = [ + FFMPEG, + "-hide_banner", + "-v", "error", + "-y", + "-ss", ss_dv, + "-i", self.dv_file, + "-qscale:v", "1", + "-vf", vf_HDR if self.isHybrid else vf_DV, + "-vframes", "1", + dv_out + ] - parsed_data = parse_metadata(data.stdout.decode('utf-8'), file) - logging.info("Sucessfully analyzed file!") - metadata_list.append({"name": name, "path": file} | parsed_data) - + logging.debug("Generating HDR image...") + try: + subprocess.run(hdr_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except Exception as e: + logging.error(f"Failed to generate HDR screencapture | ERROR:{e}") - cColorP = set() - for media in metadata_list: cColorP.add(media["colorProfile"]) - if "HDR" in cColorP: logging.info("HDR Media:") - for media in metadata_list: - if media["colorProfile"] == "HDR": - logging.info(f"{media["name"]}:\n\tFrameCount: {media["frameCount"]}\n\tWidth: {media["pxWidth"]}\n\tHeight: {media["pxHeight"]}\n\tFrameRate: {media["frameRate"]}") - if "DV" in cColorP: logging.info("Dolby Vision Media:") - for media in metadata_list: - if media["colorProfile"] == "DV": - logging.info(f"{media["name"]}:\n\tFrameCount: {media["frameCount"]}\n\tWidth: {media["pxWidth"]}\n\tHeight: {media["pxHeight"]}\n\tFrameRate: {media["frameRate"]}") - if "HDR+DV" in cColorP: logging.info("HDR + DV Media:") - for media in metadata_list: - if media["colorProfile"] == "HDR+DV": - logging.info(f"{media["name"]}:\n\tFrameCount: {media["frameCount"]}\n\tWidth: {media["pxWidth"]}\n\tHeight: {media["pxHeight"]}\n\tFrameRate: {media["frameRate"]}") - - return metadata_list, cColorP - -def frame_seeker(hdr, dv, hybrid): - logging.warning("Dolby Vision layer probably needs to be delayed") - isManual = inquirer.prompt([inquirer.Confirm("continue", message="Do you want to input pre-calculated frame-shift", default=False)])["continue"] - if isManual: - while True: - delayed_frames = input("Input frames to shift Dolby Vision layer with: ") - if(is_integer(delayed_frames) == False): logging.warning("Please input a valid number") - else: delayed_frames = int(delayed_frames); break - else: - comparer = image_compare(hdr, dv, hybrid) - delayed_frames = comparer.shifted_frames - logging.info(f"Dolby Vision layer is shifted by {delayed_frames} frames") - return delayed_frames - -def match_files(data_list): - matching_files = list() - HDRs = [(media) for media in data_list if media["colorProfile"] == "HDR"] - logging.debug(HDRs) - DVs = [(media) for media in data_list if media["colorProfile"] == "DV" or media["colorProfile"] == "HDR+DV"] - logging.debug(DVs) - if args.maxdif != None: maxDif = int(args.maxdif) - else: - while True: - maxDif = input(f"Input max allowed differance in frames: ") - if(is_integer(maxDif) == False): logging.warning("Please input a valid number") - else: maxDif = int(maxDif); break - - logging.info("Matching files...") - for hdr_file in HDRs: - miss = 0 - logging.info(f"Trying to match: {hdr_file["name"]}") - for dv_file in DVs: - hybrid = True if dv_file["colorProfile"] == "HDR+DV" else False - absDif = abs(hdr_file["frameCount"] - dv_file["frameCount"]) - if absDif == 0: - logging.info(f"Perfect match found with: {dv_file["name"]}") - isAutomatic = inquirer.prompt([inquirer.Confirm("auto", message="Want to frame match anyways?", default=False)])["auto"] - if isAutomatic: frames_to_delay = frame_seeker(hdr_file, dv_file, hybrid) - else: frames_to_delay = 0 - match = {"HDR_FILE": hdr_file, "DV_FILE": dv_file, "framesToDelay": frames_to_delay} - matching_files.append(match) - break - elif(absDif <= maxDif): - logging.info(f"Match found but with a difference of: {absDif} frames, file matched with: {dv_file["name"]}") - isMatch = inquirer.prompt([inquirer.Confirm("continue", message="Is it a match?", default=False)])["continue"] - if isMatch: - frames_to_delay = frame_seeker(hdr_file, dv_file, hybrid) - match = {"HDR_FILE": hdr_file, "DV_FILE": dv_file, "framesToDelay": frames_to_delay} - matching_files.append(match) - else: logging.info("Trying another ") - else: miss += 1 - if miss == len(DVs): - logging.warning(f"No match found for: {hdr_file["name"]}") - - logging.info("Matching process completeded") - logging.debug(matching_files) - return matching_files - -def run_cmd(cmd, title="", total=100): - try: - if cmd[0] == "ffmpeg" or cmd[0] == mkvextract or cmd[0] == mkvmerge: - with alive_bar(title=title, bar="filling", spinner="waves", manual=True, total=total, stats=False, monitor="{percent:,.1%}") as bar: - data = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, text=True) - for line in data.stdout: - if "#GUI#progress" in line: - progress = int(line.replace("#GUI#progress ","").replace("%",""))/100 - bar(progress) - elif "frame=" in line: - bar(int(line.replace("frame=",""))/total) - if cmd[0] == "ffmpeg": bar(1) - else: - with alive_bar(title=title, bar="filling", spinner="waves", manual=True, total=total, stats=False, monitor="{percent:,.1%}") as bar: - subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False) - bar(1) - - except subprocess.CalledProcessError: - logging.error("Command failed, The process of this file will FAIL!") - raise RuntimeError - except KeyboardInterrupt: - logging.error("Command interupted, The process of this file will FAIL!") - raise InterruptedError - -def remux_files(main_file, DV_injected): - file_out = os.path.join(os.path.dirname(main_file), os.path.basename(main_file).replace(".mkv", "_HDR_DV.mkv")) - cmdMerge = [mkvmerge, - "--gui-mode", - "-o", file_out, - "--no-video", - main_file, - DV_injected] - - run_cmd(cmdMerge, "Multiplexing hybrid file: \t") - logging.info("Files sucessfully combined") - -def injectDoVi(file_pair): - createTempDir() - rpu_json = os.path.join(_temp,"RPU.json") - rpu = os.path.join(_temp,"RPU.bin") - rpu_edited = os.path.join(_temp,"RPU_EDITED.bin") - hdr_hevc = os.path.join(_temp,"HDR.hevc") - dv_hevc = os.path.join(_temp,"DV.hevc") - hdr_dv_hevc = os.path.join(_temp,"HDR_DV.hevc") - - if str(os.path.splitext(file_pair["DV_FILE"]["path"])[1]) == ".mp4": isDVmp4 = True - else: isDVmp4 = False - - delay_frames = file_pair["framesToDelay"] - if delay_frames < 0: - remove_frames = "0-" + str(abs(delay_frames)-1) - delay_frames = 0 - else: remove_frames = "" - - crop = False - crop_amount = 0 - if (file_pair["HDR_FILE"]["pxHeight"] == file_pair["DV_FILE"]["pxHeight"]): - logging.debug("No crop needed for RPU-file") - elif (int(file_pair["HDR_FILE"]["pxHeight"]) > int(file_pair["DV_FILE"]["pxHeight"])): - logging.debug("Adding letterboxing to RPU-file to match with target file") - crop_amount = int((int(file_pair["HDR_FILE"]["pxHeight"]) - int(file_pair["DV_FILE"]["pxHeight"]))/2) - elif (int(file_pair["HDR_FILE"]["pxHeight"]) < int(file_pair["DV_FILE"]["pxHeight"])): - logging.debug("Croping needed for RPU-file") - crop = True + logging.debug("Generating DV image...") + try: + subprocess.run(dv_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except Exception as e: + logging.error(f"Failed to generate DV screencapture | ERROR:{e}") - json_data = { - "active_area": { - "crop": crop, - "presets": [{ - "id": 0, - "left": 0, - "right": 0, - "top": crop_amount, - "bottom": crop_amount - }]}, - "remove": [ - remove_frames - ], - "duplicate": [{ - "source": 0, - "offset": 0, - "length": delay_frames - }]} - - with open(rpu_json, "w") as outfile: outfile.write(json.dumps(json_data, indent=4)) + logging.debug("Processing Images...") + HDR_img = Image.open(hdr_out) + DV_img = Image.open(dv_out) + HDR_w, HDR_h = HDR_img.size + DV_w, DV_h = DV_img.size + crop = abs(HDR_h-DV_h)/2 - cmdExtractHDRMKV = [ - mkvextract, - "tracks", - file_pair["HDR_FILE"]["path"], - "0:" + str(hdr_hevc), - "--gui-mode"] #1 - cmdExtractDVMKV = [ - mkvextract, - "tracks", - file_pair["DV_FILE"]["path"], - "0:" + str(dv_hevc), - "--gui-mode"] #2 - cmdExtractDV = [ - "ffmpeg", - "-loglevel", "error", - "-hide_banner", - "-progress", "-", - "-nostats", - "-analyzeduration", "6000M", - "-probesize", "2147M", - "-y", - "-i", file_pair["DV_FILE"]["path"], - "-an", "-c:v", - "copy", - "-f", "hevc", - dv_hevc] #2 - cmdExtractRPU = [ - dovi_Tool, - "-m", "3", - "extract-rpu", - dv_hevc, - "-o", rpu] #3 - cmdRPUEdit = [ - dovi_Tool, - "editor", - "-i", rpu, - "-j", rpu_json, - "-o", rpu_edited] #4 - cmdRPUInject = [ - dovi_Tool, - "inject-rpu", - "-i", hdr_hevc, - "--rpu-in", rpu_edited, - "-o", hdr_dv_hevc] #5 + if HDR_img.height < DV_img.height: DV_img = DV_img.crop((0, crop, DV_w, DV_h-crop)) + elif HDR_img.height > DV_img.height: HDR_img = HDR_img.crop((0, crop, HDR_w, HDR_h-crop)) - logging.info("Injection process begins...") - logging.info(f"Files used: \n\t{file_pair["HDR_FILE"]["path"]}\n\t{file_pair["DV_FILE"]["path"]}") + difference = Image.blend(DV_img, HDR_img, 0.5) + blend = ImageChops.difference(DV_img.convert('L'), HDR_img.convert('L')) + logging.debug("Processing done!") - run_cmd(cmdExtractHDRMKV, title="Extracting HDR video:\t\t") - if isDVmp4: run_cmd(cmdExtractDV, title="Extracting DV video:\t\t", total=file_pair["DV_FILE"]["frameCount"]) - else: run_cmd(cmdExtractDVMKV, title="Extracting DV video:\t\t") - run_cmd(cmdExtractRPU, title="Extracting RPU from DV file:\t") - run_cmd(cmdRPUEdit, "Modifying RPU-file:\t\t") - run_cmd(cmdRPUInject, "Injecting RPU into HDR file:\t") - remux_files(file_pair["HDR_FILE"]["path"], hdr_dv_hevc) - try: shutil.rmtree(_temp) - except: logging.warning("Could not delete temp folder") + active_image = difference if self.color_mode == "D" else blend + + return active_image, blend, difference -def main(): - check_for_ffmpeg() - files = file_list() - file_data_list, cColorP = analyze_files(files) - if ("DV" not in cColorP and "HDR+DV" not in cColorP) or not "HDR" in cColorP: - logging.error("Not enough files with HDR or DV layers") - exit(code=1) - matched_files = match_files(file_data_list) - for match in matched_files: - try: injectDoVi(match) - except RuntimeError: - logging.error("Multiplexing of file FAILED") - try: shutil.rmtree(_temp) - except: logging.warning("Could not delete temp folder") - except InterruptedError: - logging.error("Multiplexing of file FAILED because of Human interuption") - try: shutil.rmtree(_temp) - except: logging.warning("Could not delete temp folder") - -if __name__ == "__main__": main() +if __name__ == "__main__": + injector = ColorMerger(collect_files(args.files)) + injector.inject() diff --git a/README.md b/README.md index 595f120..987890f 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,83 @@ # DV-HDR-merge -A script to batch combine HDR media with Dolby Vison media for a hybrid file that uses Dolby Vision but with fallback to HDR. -The script has a bulit in frame compare tool that makes syncing the different files very easy. + +A script to batch combine HDR media with Dolby Vision media for a hybrid file that uses Dolby Vision but with fallback to HDR. +The script has a built in frame compare tool that makes syncing the different files very easy. The script matches the files by comparing frame count because Dolby Vision RPU-files are based on frames, not on time. ## Dependencies -**[FFmpeg](https://github.com/FFmpeg/FFmpeg)** needs to be installed to path
-**[requirements.txt](https://github.com/Swedish-Wiking/DV_HDR_Merge/blob/main/requirements.txt)** needs to be installed with pip -### Accompanying dependencies: -**[quietvoid/dovi_tool](https://github.com/quietvoid/dovi_tool)** Thanks for making this possible!
-**[mkvmerge](https://mkvtoolnix.download/doc/mkvmerge.html)**
-**[mkvextract](https://mkvtoolnix.download/doc/mkvextract.html)**
+ +The following dependencies must either be installed to PATH or added to a folder called `bin` in the main directory: + +**[FFmpeg](https://github.com/FFmpeg/FFmpeg)**\ +**[quietvoid/dovi_tool](https://github.com/quietvoid/dovi_tool)**\ +**[mkvmerge](https://mkvtoolnix.download/doc/mkvmerge.html)**\ +**[mkvextract](https://mkvtoolnix.download/doc/mkvextract.html)** + +Python requirements that needs to be installed: +**[requirements.txt](https://github.com/Swedish-Wiking/DV_HDR_Merge/blob/main/requirements.txt)** ## Usage + ```properties DV_HDR_Merge.py [Input files/folders] ``` + Optional commands: `--help`, `-logL`, `-maxdif` -```console +```bash DV_HDR_Merge.py -logL debug -maxdif 100 HDR_movie.mkv DV_movie.mp4 HDR_movie2.mkv DV_movie2.mkv ./DV_movie_folder ./HDR_movie_folder ``` -**Valid input formats**:
-HDR media: `*.mkv`
-Dolby Vision media: `*.mkv`, `*.mp4`
-*Can be customized to use `*.mp4` as HDR input as well but Matroska is the superior container.*
+**Valid input formats**:\ +HDR media: `*.mkv`\ +Dolby Vision media: `*.mkv`, `*.mp4`\ + +*Can be customized to use `*.mp4` as HDR input as well but Matroska is the superior container.* **Output format**: `*.mkv` ### Explanations -**Shift frames**:
+**Shift frames**:\ A negative amount means that frames will be removed in the beginning and a positive means that the first frame will be duplicated to add enough frame. Any excess frame on the end will be cut off -**General**:
+**General**:\ If frame dimensions do not match the script will automagically correct for it. ### Example of code running -![Command Promt running script](/EXAMPLES/RUNNING.png) + +![Command Prompt running script](/EXAMPLES/RUNNING.png) ## Frame Compare Tool ![Application window](/EXAMPLES/APPLICATION.png) 1. Opens the active image in your default photo application for easier inspection -2. Switches between a 50/50 blend of the two compared images or a greyscale difference -3. Set the frame to refrence in the HDR media file, press `Enter` to apply. (Total amount of frames in media is shown in label) +2. Switches between a 50/50 blend of the two compared images or a grey-scale difference +3. Set the frame to reference in the HDR media file, press `Enter` to apply. (Total amount of frames in media is shown in label) 4. Set how may frames to shift Dolby Vision layer with, press `Enter` to apply. -5. Closes window and sends inputed frame-shift to be used when combining the two media.
+5. Closes window and sends inputted frame-shift to be used when combining the two media.\ (Closing the window will do the same as the `Done` button) -Always compare multiple refrence frames in case of missing or extra frames in some of the materials used. +Always compare multiple reference frames in case of missing or extra frames in some of the materials used. ### Example images -Both senarios are using frame 30000 as the HDR refrence frame. +Both scenarios are using frame 30000 as the HDR reference frame. #### Unsynced images -

- - -

-No frame shift have been added and the result is blurry edges and sometimes even different scenes. The difference images shows a lot of anomalies when pixels don't cancel each other out. + +Blended 50/50 | Difference +:----------------------------------------:|:----------------------------------------------: +![Synced Normal](/EXAMPLES/UNSYNCED.PNG) | ![Synced Difference](/EXAMPLES/UNSYNCED_DIF.PNG) + +No frame shift have been added and the result is blurry edges and sometimes even different scenes. The difference images shows a lot of anomalies when pixels don't cancel each other out. #### Synced images -

- - -

-When Dolby Vison layer is shifted with -3 frames no blurry edges can be seen on the blend iamge and on the difference image no anomalies can be found. (Sometimes you can get a faded silhouette as in this case because the luminance levels could not be correctly matched when trying to tonemap the thumbnails taken from the media) -## Known bugs... +Blended 50/50 | Difference +:--------------------------------------:|:----------------------------------------------: +![Synced Normal](/EXAMPLES/SYNCED.PNG) | ![Synced Difference](/EXAMPLES/SYNCED_DIF.PNG) -- When extracting, modifying and injecting the RPU file no live progress is shown because of a problem catching output from *dovi_tool* +When Dolby Vision layer is shifted with -3 frames in this cae, no blurry edges can be seen on the blend image and on the difference image no anomalies can be found. (Sometimes you can get a faded silhouette as in this case because the luminance levels could not be correctly matched when trying to tonemap the thumbnails taken from the media) diff --git a/bin/dovi_tool.exe b/bin/dovi_tool.exe deleted file mode 100644 index be80c02..0000000 Binary files a/bin/dovi_tool.exe and /dev/null differ diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..961c2a5 Binary files /dev/null and b/icon.ico differ diff --git a/icon.png b/icon.png deleted file mode 100644 index 60c2f80..0000000 Binary files a/icon.png and /dev/null differ diff --git a/requirements.txt b/requirements.txt index a77aafc..8a3f1ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -alive_progress==3.1.4 -inquirer==3.1.3 -Pillow==10.1.0 -pymediainfo==6.1.0 +Pillow==11.3.0 +pymediainfo==7.0.1 +questionary==2.1.1 +rich==14.1.0 +pywinpty==3.0.0