From 2af6051069468c626cd3b43ce7bd818036b406a9 Mon Sep 17 00:00:00 2001 From: Zach Fleeman Date: Wed, 24 Jan 2024 20:22:44 -0700 Subject: [PATCH] Two Pass Encoding Class (#10) * working move to class * class and script changes * simplify script. class updates. * Dockerfile update. MR comments. * scipt improvements and comments twopass class handles streams better and returns a file size * comments and timestamp work * changes for config file timestamps * readme update --- Dockerfile | 4 +- README.md | 54 +++++++--- conf.json | 11 ++ discord.py | 184 -------------------------------- encode.bat | 3 +- ffmpeg4discord.py | 21 ++++ twopass/__init__.py | 1 + twopass/twopass.py | 248 ++++++++++++++++++++++++++++++++++++++++++++ utils/arguments.py | 34 ++++++ 9 files changed, 361 insertions(+), 199 deletions(-) create mode 100644 conf.json delete mode 100644 discord.py create mode 100644 ffmpeg4discord.py create mode 100644 twopass/__init__.py create mode 100644 twopass/twopass.py create mode 100644 utils/arguments.py diff --git a/Dockerfile b/Dockerfile index 5e23fe7..2559b53 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,5 +3,5 @@ RUN apk add --no-cache ffmpeg RUN pip install ffmpeg-python RUN mkdir -p /usr/app/out/ WORKDIR /usr/app/ -COPY discord.py . -ENTRYPOINT ["python", "-u", "discord.py", "-o", "/usr/app/out/"] \ No newline at end of file +COPY . . +ENTRYPOINT ["python", "-u", "ffmpeg4discord.py", "-o", "/usr/app/out/"] \ No newline at end of file diff --git a/README.md b/README.md index b8ad98c..8caf40f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ -# ffmpeg video conversion for Discord -This is a Python script that takes a video file as its input and encodes it to be less than 8MB unless specified otherwise. You can change the file's name in a way that trims the file to a timestamped section. This script is useful when sharing large NVIDIA ShadowPlay clips quickly, without the need for a visual editor. +# Target File Size Video Compression for Discord using `ffmpeg` +This repository houses scripts and applications to assist with compressing a video file to a desired size. -This script supports a few ffmpeg video filters, like cropping and resolution scaling. It can be used in a variety of different audio/video workflows. It also showcases a 2-pass encoding methodology for the `ffmpeg-python` library, which is not well-documented on the web. +The `ffmpeg4discord.py` script takes a video file as its input and encodes it to be less than 8MB unless specified otherwise. Discord's free-tier file size sharing limit is 8MB. You can change the file's name in a way that trims the file to a timestamped section. I created this script to share large NVIDIA ShadowPlay clips on Discord quickly, without the need for a visual editor. + +The `TwoPass()` Class showcases a 2-pass encoding methodology for the `ffmpeg-python` library, which is not well-documented on the web. The Class also supports a few ffmpeg video filters, like cropping and resolution scaling. It can be used in a variety of different audio/video workflows. ## Usage You must first have `ffmpeg` installed on your system. `ffmpeg` needs to be registered in your PATH. @@ -12,7 +14,7 @@ Install the required Python package, `ffmpeg-python` with: Call the script with: -```python "C:/path/to/discord.py" cool_clip.mp4``` +```python "C:/path/to/ffmpeg4discord.py" cool_clip.mp4``` The included Batch file for Windows users, `encode.bat`, allows for drag and drop functionality. Be sure to edit the Batch file before dragging your video files on top of it. @@ -26,25 +28,51 @@ The included Batch file for Windows users, `encode.bat`, allows for drag and dro ## Optional Arguments - `-o`, `--output` - - default: `current working directory` + - default: the current working directory - If there is a folder that you want all of your smaller clips to land in, specify it with this argument. - `-s`, `--filesize` - default: `8.0` - - Increase or descrease this value if you want to compress your video to something other than the 8MB Discord limit. + - Increase or decrease this value if you want to compress your video to something other than the 8MB Discord limit. - `-a`, `--audio-br` - - default: `96` - You can change this value if you want to increase or decrease your audio bitrate. Lowering it will allow for a slight increase in the compressed file's video bitrate. - `-r`, `--resolution` - - Example: `"1280x720"` + - Example: `1280x720` - Modify this value to change the output resolution of your video file. I'd recommend lowering your output resolution by 1.5x or 2x. Example: `1920x1080` video should get an output resolution of `1280x720` -- `-c`, `--crop` +- `-x`, `--crop` - Example: `255x0x1410x1080` - From the top-left of your video, this example goes 255 pixels to the right, 0 pixels down, and it carves out a 1410x1080 section of the video. - [ffmpeg crop documentation](https://ffmpeg.org/ffmpeg-filters.html#Examples-61) +- `--config` + - Example: `custom_run_config.json` + - Path to a json file containing the configuration for the above parameters. This config file takes precedence over all of the other flags. + +### JSON Configuration +If your encoding job will always be the same, you can reference a JSON configuration file instead of passing a long list of arguments to the command line. + +``` +{ + "target_filesize": 8.0, + "audio_br": 96, + "crop": "", + "resolution": "", + "codec": "libx264", + "times": { + "from": "00:00:00", + "to": "00:00:40" + } +} +``` + +Notes: +- All of the keys except for `"from"` and `"to"` must always be present. Those entries can be deleted if you do not have a timestamp entry for the given field. Examples: + - `"times": {}` -> if you do not wish to trim the start and stop time of the file. This falls back to the [file name formatting](https://github.com/zfleeman/ffmpeg4discord#file-name-formatting). + - `"times": {"from": "00:00:10"}` -> trim the clip from `00:00:10` to the end of the file + - `"times": {"to": "00:00:20"}` -> trim the clip from the beginning of the file up to `00:00:20` +- You can set `audio_br` to `null` if you want to maintain the clips audio bitrate. ## Docker Usage -Using the docker image is very similar to the basic python example, above. You need to volume mount your input file and the output directory. The output directory is hard coded into the Dockerfile's `ENTRYPOINT` line as the `/usr/app/out/` directory in the container. After the `docker run` options and flags, you need to specify your filename and the optional flags specified in the Python example, above. +Using the docker image is very similar to the basic python example, above. You need to volume mount your input file and the output directory. The output directory is hard coded into the Dockerfile's `ENTRYPOINT` line as the `/usr/app/out/` directory in the container. After the `docker run` options and flags, you need to specify your file name and the optional flags specified in the Python example, above. ``` docker run \ @@ -54,10 +82,12 @@ docker run \ 000100.mp4 -s 20 -r 1280x720 ``` +If you want to use a JSON configuration file, be sure to mount it into your container. + ## Detailed Example ``` -python D:/ffmpeg4discord/discord.py 000050-000145.mp4 \ +python D:/ffmpeg4discord/ffmpeg4discord.py 000050-000145.mp4 \ -c 1280x0x2560x1440 \ -r 1920x1080 \ -s 50 \ @@ -65,6 +95,6 @@ python D:/ffmpeg4discord/discord.py 000050-000145.mp4 \ -o D:/shadowplay/ ``` -The example above takes a 5120x1440 resolution video as its input. The script trims the video from 00:00:50 to 00:01:45 (specified in the [filename](https://github.com/zfleeman/ffmpeg4discord#file-name-formatting)). It crops a 2560x1440 section starting at 1280 pixels from the top-left and 0 pixels down (`-c`). The output file will be located in `D:/shadowplay/` (`-o`) with a new resolution of 1920x1080 (`-r`), and it will be 50MB (`-s`). The audio bitrate will be reduced to 48k (`-a`) as well, but that's probably going to sound terrible. +The example above takes a 5120x1440 resolution video as its input. The script trims the video from 00:00:50 to 00:01:45 (specified in the [file name](https://github.com/zfleeman/ffmpeg4discord#file-name-formatting)). It crops a 2560x1440 section starting at 1280 pixels from the top-left and 0 pixels down (`-c`). The output file will be located in `D:/shadowplay/` (`-o`) with a new resolution of 1920x1080 (`-r`), and it will be 50MB (`-s`). The audio bitrate will be reduced to 48k (`-a`) as well, but that's probably going to sound terrible. ![](https://i.imgur.com/WJXA723.png) diff --git a/conf.json b/conf.json new file mode 100644 index 0000000..1f10fa3 --- /dev/null +++ b/conf.json @@ -0,0 +1,11 @@ +{ + "target_filesize": 8.0, + "audio_br": 96, + "crop": "", + "resolution": "", + "codec": "libx264", + "times": { + "from": "00:00:00", + "to": "00:00:40" + } +} \ No newline at end of file diff --git a/discord.py b/discord.py deleted file mode 100644 index 6531094..0000000 --- a/discord.py +++ /dev/null @@ -1,184 +0,0 @@ -import ffmpeg -import argparse -import math -import os -from datetime import datetime - - -def get_bitrate(length, filesize, audio_br): - br = math.floor(filesize / length - audio_br) * 1000 - return br, br * 0.50, br * 1.45 - - -def time_calculations(fname, duration): - startstring = fname[0:2] + ":" + fname[2:4] + ":" + fname[4:6] - endstring = fname[7:9] + ":" + fname[9:11] + ":" + fname[11:13] - times = {} - - try: - int(fname[0:6]) - startseconds = ( - int(fname[0:2]) * 60 * 60 + int(fname[2:4]) * 60 + int(fname[4:6]) - ) - times["ss"] = startstring - try: - int(fname[11:13]) - endseconds = ( - int(fname[7:9]) * 60 * 60 + int(fname[9:11]) * 60 + int(fname[11:13]) - ) - length = endseconds - startseconds - times["to"] = endstring - except: - length = duration - startseconds - except: - length = duration - - return length, times - - -def apply_video_filters(ffInput): - video = ffInput.video - - if args.crop: - crop = args.crop.split("x") - video = video.crop(x=crop[0], y=crop[1], width=crop[2], height=crop[3]) - args.inputratio = int(crop[2]) / int(crop[3]) - - if args.resolution: - video = video.filter("scale", args.resolution) - x = int(args.resolution.split("x")[0]) - y = int(args.resolution.split("x")[1]) - outputratio = x / y - - if args.inputratio != outputratio: - print("!!!!!!!!!\n!WARNING!\n!!!!!!!!!") - print( - "Your output resolution's aspect ratio does not match the\ninput resolution's or your croped resolution's aspect ratio." - ) - - return video - - -def first_pass(inputPath, params, times): - ffInput = ffmpeg.input(inputPath, **times) - video = apply_video_filters(ffInput) - ffOutput = ffmpeg.output(video, "pipe:", **params) - ffOutput = ffOutput.global_args("-loglevel", "quiet", "-stats") - std_out, std_err = ffOutput.run(capture_stdout=True) - - -def second_pass(inputPath, outputPath, params, times): - ffInput = ffmpeg.input(inputPath, **times) - audio = ffInput.audio - video = apply_video_filters(ffInput) - ffOutput = ffmpeg.output(video, audio, outputPath, **params) - ffOutput = ffOutput.global_args("-loglevel", "quiet", "-stats") - ffOutput.run(overwrite_output=True) - - -def get_new_fs(target_fs, output_filename): - return target_fs <= os.path.getsize(output_filename) * 0.00000095367432 - - -# args work -parser = argparse.ArgumentParser( - prog="ffmpeg4discord", - description="Video compression script.", - epilog="Compress those sick clips, boi.", -) -parser.add_argument( - "filename", help="The full file path of the file that you wish to compress." -) -parser.add_argument( - "-o", - "--output", - default="", - help="The desired output directory where the file will land.", -) -parser.add_argument( - "-s", - "--filesize", - default=8.0, - type=float, - help="The output file size in MB. Free Discord accepts a max of 8MB.", -) -parser.add_argument( - "-a", "--audio-br", default=96, type=float, help="Audio bitrate in kbps." -) -# video filters -parser.add_argument( - "-c", "--crop", help="Cropping dimensions. Example: 255x0x1410x1080" -) -parser.add_argument( - "-r", "--resolution", help="The output resolution of your final video." -) -args = parser.parse_args() - -# pre-run variables -fname = args.filename.replace("\\", "/").split("/")[-1] -target_fs = args.filesize -probe = ffmpeg.probe(args.filename) -args.inputratio = probe["streams"][0]["width"] / probe["streams"][0]["height"] -duration = math.floor(float(probe["format"]["duration"])) -length, times = time_calculations(fname, duration) - -if length <= 0: - raise Exception( - f"Your video is {duration / 60} minutes long, but you wanted to start clpping at {times['ss']}" - ) - -split_fname = fname.split(".") -output_filename = ( - args.output - + "small_" - + split_fname[0].replace(" ", "_") - + datetime.strftime(datetime.now(), "_%Y%m%d%H%M%S.mp4") -) -run = True - -while run: - end_fs = args.filesize * 8192 - br, minbr, maxbr = get_bitrate( - length=length, filesize=end_fs, audio_br=args.audio_br - ) - - pass_one_params = { - "pass": 1, - "f": "null", - "vsync": "cfr", - "c:v": "libx264", - "b:v": br, - "minrate": minbr, - "maxrate": maxbr, - "bufsize": br * 2, - } - - pass_two_params = { - "pass": 2, - "c:v": "libx264", - "c:a": "aac", - "b:a": args.audio_br * 1000, - "b:v": br, - "minrate": minbr, - "maxrate": maxbr, - "bufsize": br * 2, - } - - print("Performing first pass.") - first_pass(args.filename, pass_one_params, times) - print("First pass complete.\n") - - print("Performing second pass.") - second_pass(args.filename, output_filename, pass_two_params, times) - print("Second pass complete.\n") - - run = get_new_fs(target_fs, output_filename) - - if run: - print( - f"Resultant file size still above the target of {target_fs}MB.\nRestarting...\n" - ) - os.remove(output_filename) - args.filesize -= 0.2 - else: - print(f"Smaller file located at {output_filename}") diff --git a/encode.bat b/encode.bat index a77fea1..72acb0a 100644 --- a/encode.bat +++ b/encode.bat @@ -1,4 +1,5 @@ @echo off Set filename=%1 -python "C:/path/to/discord.py" %filename% -o "C:/output/folder/" +python "C:/path/to/ffmpeg4discord.py" %filename% -o "C:/output/folder/" +DEL "ffmpeg2*" PAUSE \ No newline at end of file diff --git a/ffmpeg4discord.py b/ffmpeg4discord.py new file mode 100644 index 0000000..3fc594f --- /dev/null +++ b/ffmpeg4discord.py @@ -0,0 +1,21 @@ +import os +from utils.arguments import get_args +from twopass import TwoPass + +# get args from the command line +args = get_args() + +# instantiate the TwoPass class and save our target file size for comparison in the loop +twopass = TwoPass(**args) +end_fs = args["target_filesize"] + +while twopass.run() >= end_fs: + print( + f"\nThe output file size ({round(twopass.output_filesize, 2)}MB) is still above the target of {end_fs}MB.\nRestarting...\n" + ) + os.remove(twopass.output_filename) + + # adjust the class's target file size to set a lower bitrate for the next run + twopass.target_filesize -= 0.2 + +print(f"\nSUCCESS!!\nThe smaller file ({round(twopass.output_filesize, 2)}MB) is located at {twopass.output_filename}") diff --git a/twopass/__init__.py b/twopass/__init__.py new file mode 100644 index 0000000..c038896 --- /dev/null +++ b/twopass/__init__.py @@ -0,0 +1 @@ +from .twopass import TwoPass diff --git a/twopass/twopass.py b/twopass/twopass.py new file mode 100644 index 0000000..3d26cf6 --- /dev/null +++ b/twopass/twopass.py @@ -0,0 +1,248 @@ +import ffmpeg +import math +import logging +import json +import os +from datetime import datetime + +logging.getLogger().setLevel(logging.INFO) + + +class TwoPass: + def __init__( + self, + filename: str, + target_filesize: float, + output_dir: str = "", + times: dict = {}, + audio_br: float = None, + codec: str = "libx264", + crop: str = "", + resolution: str = "", + config: str = "", + ) -> None: + """ + A Class to resize a video file to a specified MB target. + This class utilizes ffmpeg's two-pass encoding technique with the + ffmpeg-python wrapper package. + https://trac.ffmpeg.org/wiki/Encode/H.264#twopass + + :param filename: video file that needs to be compressed + :param output_dir: directory that the new, compressed output is delivered to + :param times: dict containing "from" and "to" timestamp keys in the format 00:00:00 + :param target_filesize: desired file size of the output file, in MB + :param audio_br: desired audio bitrate for the output file in kbps + :param codec: ffmpeg video codec to use when encoding the file + :param crop: coordinates for cropping the video to a different resolution + :param resolution: output file's final resolution e.g. 1280x720 + :param config: json containing values for the above params + """ + + if config: + self.init_from_config(config_file=config) + else: + self.target_filesize = target_filesize + self.crop = crop + self.resolution = resolution + self.times = times + self.audio_br = audio_br + self.codec = codec + + self.filename = filename + self.fname = self.filename.replace("\\", "/").split("/")[-1] + self.split_fname = self.fname.split(".") + self.output_dir = output_dir + + self.probe = ffmpeg.probe(filename=filename) + self.duration = math.floor(float(self.probe["format"]["duration"])) + + if len(self.probe["streams"]) > 2: + logging.warning( + "This media file has more than two streams, which could cause errors during the encoding job." + ) + + for stream in self.probe["streams"]: + ix = stream["index"] + if stream["codec_type"] == "video": + display_aspect_ratio = self.probe["streams"][ix]["display_aspect_ratio"].split(":") + self.ratio = int(display_aspect_ratio[0]) / int(display_aspect_ratio[1]) + elif stream["codec_type"] == "audio": + audio_stream = ix + + if not self.audio_br: + self.audio_br = float(self.probe["streams"][audio_stream]["bit_rate"]) + else: + self.audio_br = self.audio_br * 1000 + + self.output_filename = ( + self.output_dir + + "small_" + + self.split_fname[0].replace(" ", "_") + + datetime.strftime(datetime.now(), "_%Y%m%d%H%M%S.mp4") + ) + + if self.times: + if self.times.get("from"): + self.times["ss"] = self.times["from"] or "00:00:00" + del self.times["from"] + else: + self.times["ss"] = "00:00:00" + + from_seconds = seconds_from_ts_string(self.times["ss"]) + + if self.times.get("to"): + to_seconds = seconds_from_ts_string(self.times["to"]) + self.length = to_seconds - from_seconds + else: + self.length = self.duration - from_seconds + else: + self.time_from_file_name() + + if self.length <= 0: + raise Exception( + f""" + Time Paradox? + + Something is wrong with your clipping times. Use this + information to further diagnose the problem: + + - Your video is {self.duration / 60} minutes long + - You want to START clipping at {self.times["ss"]} + - You want to STOP clipping at {self.times["to"]} + """ + ) + + def init_from_config(self, config_file: str) -> None: + """ + Set the Class values from a json file + :param config_file: path to a json file containing parameters for TwoPass() + """ + with open(config_file) as f: + config = json.load(f) + self.__dict__.update(**config) + + def generate_params(self, codec: str): + """ + Create params for the ffmpeg.output() function + :param codec: ffmpeg video codec to use during encoding + :return: dictionary containing parameters for ffmpeg's first and second pass. + """ + params = { + "pass1": { + "pass": 1, + "f": "null", + "vsync": "cfr", # not sure if this is unique to x264 or not + "c:v": codec, + }, + "pass2": {"pass": 2, "b:a": self.audio_br, "c:v": codec}, + } + + if codec == "libx264": + params["pass2"]["c:a"] = "aac" + + params["pass1"].update(**self.bitrate_dict) + params["pass2"].update(**self.bitrate_dict) + + return params + + def create_bitrate_dict(self) -> None: + """ + Perform the calculation specified in ffmpeg's documentation that generates + the video bitrates needed to achieve the target file size + """ + br = math.floor((self.target_filesize * 8192) / self.length - (self.audio_br / 1000)) * 1000 + self.bitrate_dict = { + "b:v": br, + "minrate": br * 0.5, + "maxrate": br * 1.45, + "bufsize": br * 2, + } + + def time_from_file_name(self): + """ + Create the -ss and -to fields from a file's name + """ + fname = self.fname + startstring = f"{fname[0:2]}:{fname[2:4]}:{fname[4:6]}" + endstring = f"{fname[7:9]}:{fname[9:11]}:{fname[11:13]}" + times = {} + + try: + int(fname[0:6]) + startseconds = seconds_from_ts_string(startstring) + times["ss"] = startstring + try: + int(fname[11:13]) + endseconds = seconds_from_ts_string(endstring) + length = endseconds - startseconds + times["to"] = endstring + except: + length = self.duration - startseconds + except: + length = self.duration + + self.length = length + self.times = times + + def apply_video_filters(self, video): + """ + Function to apply the crop and resolution parameters to a video object + :param video: the ffmpeg video object from the Class's input video file + :return: the video object after it has been cropped or resized + """ + + if self.crop: + crop = self.crop.split("x") + video = video.crop(x=crop[0], y=crop[1], width=crop[2], height=crop[3]) + self.ratio = int(crop[2]) / int(crop[3]) + + if self.resolution: + video = video.filter("scale", self.resolution) + x = int(self.resolution.split("x")[0]) + y = int(self.resolution.split("x")[1]) + outputratio = x / y + + if self.ratio != outputratio: + logging.warning( + """ + Your output resolution's aspect ratio does not match the + input resolution's or your croped resolution's aspect ratio. + """ + ) + + return video + + def run(self) -> float: + """ + Perform the CPU-intensive encoding job + :return: the output file's size + """ + # generate run parameters + self.create_bitrate_dict() + params = self.generate_params(codec=self.codec) + + # separate streams from ffinput + ffinput = ffmpeg.input(self.filename, **self.times) + video = self.apply_video_filters(ffinput.video) + audio = ffinput.audio + + # First Pass + ffOutput = ffmpeg.output(video, "pipe:", **params["pass1"]) + ffOutput = ffOutput.global_args("-loglevel", "quiet", "-stats") + print("Performing first pass") + std_out, std_err = ffOutput.run(capture_stdout=True) + + # Second Pass + ffOutput = ffmpeg.output(video, audio, self.output_filename, **params["pass2"]) + ffOutput = ffOutput.global_args("-loglevel", "quiet", "-stats") + print("\nPerforming second pass") + ffOutput.run(overwrite_output=True) + + # save the output file size and return it + self.output_filesize = os.path.getsize(self.output_filename) * 0.00000095367432 + + return self.output_filesize + + +def seconds_from_ts_string(ts_string: str): + return int(ts_string[0:2]) * 60 * 60 + int(ts_string[3:5]) * 60 + int(ts_string[6:8]) diff --git a/utils/arguments.py b/utils/arguments.py new file mode 100644 index 0000000..67709c9 --- /dev/null +++ b/utils/arguments.py @@ -0,0 +1,34 @@ +from argparse import ArgumentParser, Namespace + + +def get_args() -> Namespace: + parser = ArgumentParser( + prog="ffmpeg4discord", + description="Video compression script.", + epilog="Compress those sick clips, boi.", + ) + + parser.add_argument("filename", help="The full file path of the file that you wish to compress.") + parser.add_argument( + "-o", + "--output-dir", + default="", + help="The desired output directory where the file will land.", + ) + parser.add_argument( + "-s", + "--target-filesize", + default=8, + type=float, + help="The output file size in MB. Free Discord accepts a max of 8MB.", + ) + parser.add_argument("-a", "--audio-br", type=float, help="Audio bitrate in kbps.") + + # video filters + parser.add_argument("-x", "--crop", help="Cropping dimensions. Example: 255x0x1410x1080") + parser.add_argument("-r", "--resolution", help="The output resolution of your final video.") + + # configuraiton json file + parser.add_argument("--config", help="JSON file containing the run's configuration") + + return vars(parser.parse_args())