Skip to content

Commit

Permalink
More refactoring, improved summary + log formatting
Browse files Browse the repository at this point in the history
  • Loading branch information
magnolialogic committed May 23, 2020
1 parent 8c5aacd commit 57dbab0
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 89 deletions.
108 changes: 71 additions & 37 deletions src/TranscodeSession.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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"])
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.")
119 changes: 67 additions & 52 deletions transcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -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
Expand Down

0 comments on commit 57dbab0

Please sign in to comment.