diff --git a/src/TranscodeSession.py b/src/TranscodeSession.py index d1011f2..a707603 100644 --- a/src/TranscodeSession.py +++ b/src/TranscodeSession.py @@ -21,9 +21,10 @@ class ENCOPTS: FHD = "ctu=64:qg-size=64" UHD = "ctu=64:qg-size=64" + # Lifecycle methods + def __init__(self, file, args): signal.signal(signal.SIGINT, self.signal_handler) - self.args = args # Get source file metadata @@ -43,6 +44,7 @@ def __init__(self, file, args): resolution = "FHD" elif 2160 <= height: resolution = "UHD" + self.source["resolution"] = resolution # Create empty attributes for dynamic session options @@ -53,16 +55,10 @@ def __init__(self, file, args): # Construct session options and parameters self.map_options() - self.file_decorator = "_RF" + str(self.encoder_quality) - self.file_decorator += "_{preset}".format(preset=self.encoder_preset.capitalize()) - if self.args.baseline: - self.file_decorator += "_Baseline" - elif self.args.best: - self.file_decorator += "_Best" - if self.args.small: - self.file_decorator += "_Small" - self.path["output"] = "hevc/" + self.source["filename"] + self.file_decorator + ".mp4" - self.path["log"] = "performance/" + self.source["filename"] + self.file_decorator + ".log" + + self.output["filename"] = self.source["filename"] + self.output["file_decorator"] + self.path["output"] = "hevc/" + self.output["filename"] + ".mp4" + self.path["log"] = "performance/" + self.output["filename"] + ".log" # Verify no attributes are None self.validate() @@ -76,25 +72,10 @@ def signal_handler(self, sig, frame): if hasattr(self, "job"): self.job.terminate() self.cleanup() - sys.exit("\n\n{date}: Caught ctrl+c, aborting.\n\n".format(date=datetime.now())) - def cleanup(self): - """ Always deletes output file, deletes log if --delete is passed from command-line - """ - if os.path.exists(self.path["output"]): - os.remove(self.path["output"]) - if self.args.delete: - if os.path.exists(self.path["log"]): - os.remove(self.path["log"]) + sys.exit("\n\n{date}: Caught ctrl+c, aborting.\n\n".format(date=datetime.now())) - def log(self, elapsed_time, fps, compression_ratio): - """ Summarizes transcode session for screen and log - """ - with open(self.path["log"], "w") as logfile: - summary = "{elapsed_time}\n{fps} fps\n{compression_ratio}% reduction".format(elapsed_time=elapsed_time, fps=fps, compression_ratio=compression_ratio) - logfile.write(summary + "\n\n" + session.args + "\n\n") - pprint(vars(self), logfile) - print(summary) + # Object tasks def map_options(self): """ Start with settings based on source resolution and then override defaults based on command-line arguments @@ -107,29 +88,82 @@ def map_options(self): self.preset_name = "Baseline" else: self.preset_name = "Default" + if self.args.preset: self.encoder_preset = self.args.preset.lower() else: self.encoder_preset = "slow" + if self.args.quality: self.encoder_quality = self.args.quality + if self.args.small: self.encoder_options += ":tu-intra-depth=3:tu-inter-depth=3" + self.output = {"file_decorator": "_RF" + str(self.encoder_quality)} + self.output["file_decorator"] += "_{preset}".format(preset=self.encoder_preset.capitalize()) + if self.args.baseline: + self.output["file_decorator"] += "_Baseline" + elif self.args.best: + self.output["file_decorator"] += "_Best" + + if self.args.small: + self.output["file_decorator"] += "_Small" + + def validate(self): + """ Verifies that no session attributes are null + """ + if any(value is None for attribute, value in self.__dict__.items()): + sys.exit("FATAL: Session.validate(): found null attribute for " + self.path["source"]) + def start(self): """ Starts HandBrakeCLI session and creates job attribute """ + print("{date}: Starting transcode session for {source}:".format(date=str(datetime.now()), source=self.path["source"])) + pprint(vars(self), indent=4) + print("\n{command}\n".format(command=self.command)) + self.time = {"started": datetime.now()} self.job = subprocess.Popen(shlex.split(self.command, posix=False)) # Posix=False to escape double-quotes in arguments - def summarize(self): - """ Summarize transcode session before starting + def finish(self): + """ Compute attributes needed to generate summary and performance log """ - print("{date}: Starting transcode session for {source}:".format(date=str(datetime.now()), source=self.path["source"])) - pprint(vars(self)) - print() + self.time["finished"] = datetime.now() + print("\n{date}: Finished {output_file}".format(date=str(self.time["finished"]), output_file=self.path["output"])) + self.time["duration"] = self.time["finished"] - self.time["started"] + self.output["filesize"] = os.path.getsize(self.path["output"]) + self.output["compression_ratio"] = int(100 - (self.output["filesize"] / self.source["filesize"] * 100)) + self.fps = self.source["frames"] / self.time["duration"].seconds + self.log(self.time["duration"], self.fps, self.output["compression_ratio"]) + print("\n\n\n\n\n") + if self.args.delete: + self.cleanup() - def validate(self): - """ Verifies that no session attributes are null + def log(self, elapsed_time, fps, compression_ratio): + """ Summarizes transcode session for screen and log """ - if any(value is None for attribute, value in self.__dict__.items()): - sys.exit("FATAL: Session.validate(): found null attribute for " + self.path["source"]) \ No newline at end of file + summary = "{duration}\n{fps} fps\n{compression_ratio}% reduction ({source_size}mb to {output_size}mb)".format(duration=self.time["duration"], fps=self.fps, compression_ratio=self.output["compression_ratio"], source_size=int(self.source["filesize"] / 100000), output_size=int(self.output["filesize"] / 100000)) + with open(self.path["log"], "w") as logfile: + logfile.write(summary + "\n\n" + self.command + "\n\n") + pprint(vars(self), logfile) + + print(summary) + + def cleanup(self): + """ Always deletes output file, deletes log if --delete is passed from command-line + """ + if os.path.exists(self.path["output"]): + try: + os.remove(self.path["output"]) + except FileNotFoundError: + print("Session.cleanup():", self.path["output"], "does not exist.") + + if self.args.delete: + if os.path.exists(self.path["log"]): + try: + os.remove(self.path["log"]) + except FileNotFoundError: + print("Session.cleanup():", self.path["log"], "does not exist.") + +if __name__ == "__main__": + sys.exit("I am a module, not a script.") \ No newline at end of file diff --git a/transcode.py b/transcode.py index 2836ecd..a3b9495 100755 --- a/transcode.py +++ b/transcode.py @@ -4,37 +4,56 @@ from datetime import datetime import os import sys + +# Verify script is colocated with ./src/ and import TranscodeSession.py +if not os.path.isdir(os.path.join(sys.path[0], "src")): + sys.exit("FATAL: ./src/ not present in parent diectory.\n") sys.path.append(os.path.join(sys.path[0], "src")) -from TranscodeSession import Session +try: + from TranscodeSession import Session +except ImportError: + sys.exit("FATAL: failed to import TranscodeSession from src/TranscodeSession.py\n") """ TODO: - allow comma-separated string for --preset, e.g. medium,slow,slower, map to list -- ~~if presets.json does not exist, download from github~~ -- need to format source / output filenames: drop resolution suffixes - add check: if working directory == script location, exit with warning to symlink transcode.py onto $PATH, else if different directory but no symlink, prompt to run --install - add --install arg (with optional path to custom $PATH location) to create symlink at /usr/local/bin or custom $PATH location? +- once profiling is complete, only append file decorator if --test is specified """ -def main(): - # Define command-line arguments - parser = argparse.ArgumentParser() - files_group = parser.add_mutually_exclusive_group(required=True) - files_group.add_argument("-f", "--file", help="relative path to movie in source directory") - files_group.add_argument("--all", action="store_true", help="transcode all supported movies in source directory") - parser.add_argument("-q", "--quality", type=int, help="HandBrake quality slider value (-12,51)") - parser.add_argument("--preset", help="override video encoder preset") - preset_group = parser.add_mutually_exclusive_group(required=False) - preset_group.add_argument("--baseline", action="store_true", help="use baseline encoder options") - preset_group.add_argument("--best", action="store_true", help="use highest quality encoder options") - parser.add_argument("--small", action="store_true", help="use additional encoder options to minimize filesize at the expense of speed") - parser.add_argument("--delete", action="store_true", help="delete output files when complete/interrupted") - args = parser.parse_args() - valid_arguments = False +def build_source_list(args): + """ Constructs and returns list of source files + """ + extensions = [".mp4", ".m4v", ".mov", ".mkv", ".mpg", ".mpeg", ".avi", ".wmv", ".flv", ".webm", ".ts"] + print("\nBuilding source list...") + if args.all: + source_files = ["source/" + file for file in os.listdir("source") if os.path.splitext(file)[1].lower() in extensions] + else: + if os.path.splitext(args.file)[1].lower() in extensions: + source_files = [args.file] + else: + sys.exit("FATAL: " + args.file + " has invalid file extension!\n") + + for source_file in source_files: + session = Session(source_file, args) + if os.path.exists(session.path["output"]): + print(" Skipping", source_file) + source_files = [file for file in source_files if file is not source_file] + + if len(source_files) == 0: + sys.exit("All supported files in ./source/ have already been transcoded. Exiting.\n") + else: + print(str(source_files) + "\n") - # Validate command-line arguments + return source_files + +def validate_args(args): + """ Exits with error messages if command-line arguments are invalid + """ + valid_arguments = False if not "source" in os.listdir(): print("FATAL: invalid working directory!") elif args.file and not os.path.exists(args.file): @@ -45,6 +64,7 @@ def main(): print("FATAL: quality must be between -12 and 51 (lower is slower + higher quality)") else: valid_arguments = True + if not valid_arguments: sys.exit("Invalid command-line arguments.\n") elif args.all and args.quality: @@ -56,46 +76,41 @@ def main(): if reply[0] == "n": sys.exit("Aborting invocation with --all and --quality options.\n") - # Build list of source files and create directories if necessary - extensions = [".mp4", ".m4v", ".mov", ".mkv", ".mpg", ".mpeg", ".avi", ".wmv", ".flv", ".webm", ".ts"] - print("\nBuilding source list...") - if args.all: - source_files = ["source/" + file for file in os.listdir("source") if os.path.splitext(file)[1].lower() in extensions] - else: - source_files = [args.file] - for source_file in source_files: - session = Session(source_file, args) - if os.path.exists(session.path["output"]): - print(" Skipping", source_file) - source_files = [file for file in source_files if file is not source_file] - if len(source_files) == 0: - sys.exit("All source files have already been transcoded. Exiting.\n") - else: - print(str(source_files) + "\n") - if not os.path.exists("performance"): - os.mkdir("performance") - if not os.path.exists("hevc"): - os.mkdir("hevc") + if not os.path.isdir("performance"): + try: + os.mkdir("performance") + except FileExistsError: + sys.exit("FATAL: can't create directory \"performance\" because file with same name exists") + if not os.path.isdir("hevc"): + try: + os.mkdir("hevc") + except FileExistsError: + sys.exit("FATAL: can't create directory \"hevc\" because file with same name exists") + +def main(): + # Define command-line arguments + parser = argparse.ArgumentParser() + files_group = parser.add_mutually_exclusive_group(required=True) + files_group.add_argument("-f", "--file", help="relative path to movie in source directory") + files_group.add_argument("--all", action="store_true", help="transcode all supported movies in source directory") + parser.add_argument("-q", "--quality", type=int, help="HandBrake quality slider value (-12,51)") + parser.add_argument("--preset", help="override video encoder preset") + preset_group = parser.add_mutually_exclusive_group(required=False) + preset_group.add_argument("--baseline", action="store_true", help="use baseline encoder options") + preset_group.add_argument("--best", action="store_true", help="use highest quality encoder options") + parser.add_argument("--small", action="store_true", help="use additional encoder options to minimize filesize at the expense of speed") + parser.add_argument("--delete", action="store_true", help="delete output files when complete/interrupted") + args = parser.parse_args() + validate_args(args) # Do the thing + source_files = build_source_list(args) time_script_started = datetime.now() for file in source_files: session = Session(file, args) - session.summarize() - time_session_started = datetime.now() session.start() session.job.wait() - time_session_finished = datetime.now() - time_session_duration = time_session_finished - time_session_started - fps = session.source["frames"] / time_session_duration.seconds - source_file_size = session.source["filesize"] / 1000000 - output_file_size = os.path.getsize(session.path["output"]) / 1000000 - compression_ratio = int(100 - (output_file_size / source_file_size * 100)) - print("\n{date}: Finished {output_file}".format(date=str(time_session_finished), output_file=session.path["output"])) - session.log(time_session_duration, fps, compression_ratio) - print("\n\n\n\n\n") - if args.delete: - session.cleanup() + session.finish() time_script_finished = datetime.now() time_script_duration = time_script_finished - time_script_started