commit 7bba1479cb5c53f3584c20bad0878b095fc1e4f8 Author: William Söderberg Date: Mon Nov 20 23:14:10 2023 +0100 Initial commit Add files via upload Create bins.txt Add files via upload Delete bin/bins.txt Add files via upload Update README.md Add files via upload Add files via upload Update README.md Update README.md Update README.md Update README.md Update README.md Update README.md diff --git a/DV_HDR_merge.py b/DV_HDR_merge.py new file mode 100644 index 0000000..7c40f03 --- /dev/null +++ b/DV_HDR_merge.py @@ -0,0 +1,561 @@ +import subprocess +import logging +import os +import json +import argparse +import inquirer +import shutil +import time + +import tkinter.filedialog +import tkinter.font +import tkinter +from PIL import ImageTk, Image, ImageChops +from pymediainfo import MediaInfo +from alive_progress import alive_bar + +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") +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) + +__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 createTempDir(): + os.makedirs(_temp, exist_ok=True) + logging.debug(f"Temp folder created at: {_temp}") + +class image_compare(): + def __init__(self, hdr, dv): + self.dv_file = dv + self.hdr_file = hdr + 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") + + #Initialization + self.window = tkinter.Tk() + icon = ImageTk.PhotoImage(file=str(icon_file)) + tk_font = tkinter.font.Font(weight="bold") + + #Window config + self.window.title("Frame Compare") + self.window.iconphoto(True, icon) + self.window.geometry("1200x800") + self.window.minsize(700,420) + 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) + + #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.window.lift() + self.window.attributes("-topmost",True) + 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 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 + ] + + 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 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}") + 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 + + 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 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): + global new_image, resized_image + win_width = self.window.winfo_width() - 40 + win_height = self.window.winfo_height() - 110 + resized_height = int((self.active_image.height/self.active_image.width)*win_width) + if resized_height > win_height: + resized_width = int((self.active_image.width/self.active_image.height)*win_height) + resized_image = self.active_image.resize((resized_width, win_height), Image.Resampling.LANCZOS) + self.canvas.config(width=resized_width, height=win_height) + else: + resized_image = self.active_image.resize((win_width, resized_height), Image.Resampling.LANCZOS) + self.canvas.config(width=win_width, height=resized_height) + 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() + + def done(self): + self.window.quit() + self.window.destroy() + + def switch(self): + if self.active_image_lb == "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": + 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 + + 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 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) + +def is_integer(n): + try: float(n) + except ValueError: return False + else: return float(n).is_integer() + +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", + "-hide_banner", + "-loglevel", "fatal", + "-show_error", + "-show_streams", + "-select_streams", "v:0", + "-show_private_data", + "-print_format", "json", + file] + + 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 + + parsed_data = parse_metadata(data.stdout.decode('utf-8'), file) + logging.info("Sucessfully analyzed file!") + metadata_list.append({"name": name, "path": file} | parsed_data) + + + 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): + 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) + 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"] + [(media) for media in data_list if 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: + 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) + 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) + 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 + + 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, + "-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 + + logging.info("Injection process begins...") + logging.info(f"Files used: \n\t{file_pair["HDR_FILE"]["path"]}\n\t{file_pair["DV_FILE"]["path"]}") + + 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") + +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") + except InterruptedError: logging.error("Multiplexing of file FAILED because of Human interuption") + else: + try: shutil.rmtree(_temp) + except: logging.warning("Could not delete temp folder") + +if __name__ == "__main__": main() \ No newline at end of file diff --git a/Drop_files_here.bat b/Drop_files_here.bat new file mode 100644 index 0000000..996f7a1 --- /dev/null +++ b/Drop_files_here.bat @@ -0,0 +1,4 @@ +@echo off +pushd %~dp0 +python "DV_HDR_merge.py" %* +pause \ No newline at end of file diff --git a/EXAMPLES/APPLICATION.png b/EXAMPLES/APPLICATION.png new file mode 100644 index 0000000..de3a9c1 Binary files /dev/null and b/EXAMPLES/APPLICATION.png differ diff --git a/EXAMPLES/RUNNING.png b/EXAMPLES/RUNNING.png new file mode 100644 index 0000000..1996876 Binary files /dev/null and b/EXAMPLES/RUNNING.png differ diff --git a/EXAMPLES/SYNCED.PNG b/EXAMPLES/SYNCED.PNG new file mode 100644 index 0000000..5595b8f Binary files /dev/null and b/EXAMPLES/SYNCED.PNG differ diff --git a/EXAMPLES/SYNCED_DIF.PNG b/EXAMPLES/SYNCED_DIF.PNG new file mode 100644 index 0000000..0d420f8 Binary files /dev/null and b/EXAMPLES/SYNCED_DIF.PNG differ diff --git a/EXAMPLES/UNSYNCED.PNG b/EXAMPLES/UNSYNCED.PNG new file mode 100644 index 0000000..c4bc128 Binary files /dev/null and b/EXAMPLES/UNSYNCED.PNG differ diff --git a/EXAMPLES/UNSYNCED_DIF.PNG b/EXAMPLES/UNSYNCED_DIF.PNG new file mode 100644 index 0000000..d7dd1d8 Binary files /dev/null and b/EXAMPLES/UNSYNCED_DIF.PNG differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md new file mode 100644 index 0000000..5da8ae5 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# 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. +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)**
+ +## Usage +```properties +DV_HDR_Merge.py [Input files/folders] +``` +Optional commands: `--help`, `-logL`, `-maxdif` + +```console +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.*
+ +**Output format**: `*.mkv` + +### Explanations + +**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**:
+If frame dimensions do not match the script will automagically correct for it. + +### Example of code running +![Command Promt 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) +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.
+(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. + +### Example images + +Both senarios are using frame 30000 as the HDR refrence 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. + +#### 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... + +- When extracting, modifying and injecting the RPU file no live progress is shown because of a problem catching output from *dovi_tool* diff --git a/bin/dovi_tool.exe b/bin/dovi_tool.exe new file mode 100644 index 0000000..be80c02 Binary files /dev/null and b/bin/dovi_tool.exe differ diff --git a/bin/mkvextract.exe b/bin/mkvextract.exe new file mode 100644 index 0000000..a019853 Binary files /dev/null and b/bin/mkvextract.exe differ diff --git a/bin/mkvmerge.exe b/bin/mkvmerge.exe new file mode 100644 index 0000000..2189149 Binary files /dev/null and b/bin/mkvmerge.exe differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..60c2f80 Binary files /dev/null and b/icon.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a8086bd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +alive_progress==3.1.4 +inquirer==3.1.3 +Pillow==10.0.1 +Pillow==10.1.0 +pymediainfo==6.1.0