ffmpeg4discord 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.1
2
+ Name: ffmpeg4discord
3
+ Version: 0.1.0
4
+ Summary: A tool to help convert a video file to a target file size.
5
+ Author-email: Zach Fleeman <zfleeman@gmail.com>
6
+ Project-URL: Homepage, https://github.com/zfleeman/ffmpeg4discord
7
+ Project-URL: Issues, https://github.com/zfleeman/ffmpeg4discord/issues
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: Flask==3.0.1
12
+ Requires-Dist: ffmpeg-python==0.2.0
13
+
14
+ # `ff4d` -- Target File Size Video Compression for Discord with FFmpeg
15
+ This repository houses scripts and applications to assist with compressing a video file to a desired size.
16
+
17
+ `ff4d` takes a video file as its input and encodes it to be less than 25MB unless specified otherwise. Discord's free-tier file size sharing limit is 25MB. You can change the file's name in a way that trims the file to a timestamped section. I created this package to share large NVIDIA ShadowPlay clips on Discord quickly, without the need for a visual editor.
18
+
19
+ 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.
20
+
21
+ ## Installation and Usage
22
+
23
+ This package works with Python >= 3.8. For Windows users, ensure that Python is added to your system's PATH.
24
+
25
+ Install this package with the following command.
26
+
27
+ ```
28
+ pip install ffmpeg4discord
29
+ ```
30
+
31
+ You must first have FFmpeg installed on your system. `ffmpeg` needs to be registered in your PATH. macOS or Linux users can use their favorite package manager to do this, but this process is a little more tricky for Windows users.
32
+
33
+ ### Help for Windows users
34
+
35
+ After installing this package, Windows users can run this command in a terminal to download the necessary `ffmpeg` binaries:
36
+
37
+ ```
38
+ install-ffmpeg-windows
39
+ ```
40
+
41
+ This will place `ffmpeg.exe`, `ffprobe.exe`, and `ffplay.exe` into the same location as your Python executable.
42
+
43
+ ## Usage
44
+
45
+ Run the program with:
46
+
47
+ ```
48
+ ff4d cool_clip.mp4
49
+ ```
50
+
51
+ I've had a good time using this command with a Batch file on Windows. Refer to the [Sample Batch File](https://github.com/zfleeman/ffmpeg4discord#sample-batch-file) section for more information.
52
+
53
+ ### File name formatting
54
+ 1) `000020.mp4`
55
+ - This clips and compresses the video from 00:00:20 to the end of the clip.
56
+ 2) `000020-000145.mp4`
57
+ - This clips and compresses the video from 00:00:20 to 00:01:45.
58
+ 3) `SomethingElse.mp4`
59
+ - Compresses the entire video if the first six characters of the file's name aren't numeric.
60
+
61
+ ### Optional Arguments
62
+ - `-o`, `--output`
63
+ - default: the current working directory
64
+ - If there is a folder that you want all of your smaller clips to land in, specify it with this argument.
65
+ - `-s`, `--filesize`
66
+ - default: `25.0`
67
+ - Increase or decrease this value if you want to compress your video to something other than the 8MB Discord limit.
68
+ - `-a`, `--audio-br`
69
+ - 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.
70
+ - `-r`, `--resolution`
71
+ - Example: `1280x720`
72
+ - 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`
73
+ - `-x`, `--crop`
74
+ - Example: `255x0x1410x1080`
75
+ - 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.
76
+ - [FFmpeg crop documentation](https://ffmpeg.org/ffmpeg-filters.html#Examples-61)
77
+ - `--web`
78
+ - A Boolean flag. No value is needed after the flag. See [Web UI](#web-ui) for more information on the Web UI.
79
+ - `--config`
80
+ - Example: `custom_run_config.json`
81
+ - Path to a json file containing the configuration for the above parameters. This config file takes precedence over all of the other flags.
82
+
83
+ ### JSON Configuration
84
+ 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.
85
+
86
+ ```
87
+ {
88
+ "target_filesize": 8.0,
89
+ "audio_br": 96,
90
+ "crop": "",
91
+ "resolution": "",
92
+ "codec": "libx264",
93
+ "times": {
94
+ "from": "00:00:00",
95
+ "to": "00:00:40"
96
+ }
97
+ }
98
+ ```
99
+
100
+ Notes:
101
+ - 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:
102
+ - `"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).
103
+ - `"times": {"from": "00:00:10"}` -> trim the clip from `00:00:10` to the end of the file
104
+ - `"times": {"to": "00:00:20"}` -> trim the clip from the beginning of the file up to `00:00:20`
105
+ - You can set `audio_br` to `null` if you want to maintain the clip's audio bitrate.
106
+
107
+ ## Detailed Example
108
+
109
+ ```
110
+ ff4d 000050-000145.mp4 \
111
+ -c 1280x0x2560x1440 \
112
+ -r 1920x1080 \
113
+ -s 50 \
114
+ -a 48 \
115
+ -o D:/shadowplay/
116
+ ```
117
+
118
+ 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.
119
+
120
+ ![](https://i.imgur.com/WJXA723.png)
121
+
122
+ ## Web UI
123
+
124
+ The web UI can be activated by adding `--web` to your `ff4d` call.
125
+
126
+ ```
127
+ ff4d cool_clip.mp4 -r 1280x720 -s 20 --web
128
+ ```
129
+
130
+ That command will spin up a Flask server on your local machine and launch a rendered webpage with the video as the centerpiece. The flags you provide to `ff4d` will fill in the defaults for the form. You can override/replace the values.
131
+
132
+ You can drag the video playhead to different portions of the video and click the "Set Start/End Time" buttons to specify the section of the video you want to be clipped out. You can also use the range sliders underneath the buttons if you prefer. A "Preview Selection" button is provided for your convenience, and it does what it sounds like.
133
+
134
+ https://github.com/zfleeman/ffmpeg4discord/assets/1808564/ff323bcb-4747-437b-808f-ce48b8c269ce
135
+
136
+ The Flask server doesn't automatically stop itself, so you'll have to terminate it manually by closing the terminal it leaves hanging.
137
+
138
+ ## Sample Batch File
139
+
140
+ To enable "drag and drop" functionality for this package, Windows users can create a `.bat` file with the following code snippet. Once the file is created, you can drag and drop any `.mp4` file on top of it, and it will run with the flags specified in the "[Optional Arguments](https://github.com/zfleeman/ffmpeg4discord#file-name-formatting)" section. This example is a Batch file that will launch the web UI.
141
+
142
+ ```batch
143
+ @echo off
144
+ Set filename=%1
145
+ ff4d %filename% -o "C:/output/folder/" --web
146
+ DEL "ffmpeg2*"
147
+ PAUSE
148
+ ```
149
+
150
+ ## Thanks!
151
+
152
+ Yes, this is a simple collection of Python files using FFmpeg tricks that is masquerading as a robust Audio/Video tool. But! I use this nearly every day to quickly share videos with people on various messaging apps that have built-in video players. I don't have to share a link that embeds a video player this way, and I guess that's important to me?
153
+
154
+ I like working on this! Enjoy!
@@ -0,0 +1,141 @@
1
+ # `ff4d` -- Target File Size Video Compression for Discord with FFmpeg
2
+ This repository houses scripts and applications to assist with compressing a video file to a desired size.
3
+
4
+ `ff4d` takes a video file as its input and encodes it to be less than 25MB unless specified otherwise. Discord's free-tier file size sharing limit is 25MB. You can change the file's name in a way that trims the file to a timestamped section. I created this package to share large NVIDIA ShadowPlay clips on Discord quickly, without the need for a visual editor.
5
+
6
+ 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.
7
+
8
+ ## Installation and Usage
9
+
10
+ This package works with Python >= 3.8. For Windows users, ensure that Python is added to your system's PATH.
11
+
12
+ Install this package with the following command.
13
+
14
+ ```
15
+ pip install ffmpeg4discord
16
+ ```
17
+
18
+ You must first have FFmpeg installed on your system. `ffmpeg` needs to be registered in your PATH. macOS or Linux users can use their favorite package manager to do this, but this process is a little more tricky for Windows users.
19
+
20
+ ### Help for Windows users
21
+
22
+ After installing this package, Windows users can run this command in a terminal to download the necessary `ffmpeg` binaries:
23
+
24
+ ```
25
+ install-ffmpeg-windows
26
+ ```
27
+
28
+ This will place `ffmpeg.exe`, `ffprobe.exe`, and `ffplay.exe` into the same location as your Python executable.
29
+
30
+ ## Usage
31
+
32
+ Run the program with:
33
+
34
+ ```
35
+ ff4d cool_clip.mp4
36
+ ```
37
+
38
+ I've had a good time using this command with a Batch file on Windows. Refer to the [Sample Batch File](https://github.com/zfleeman/ffmpeg4discord#sample-batch-file) section for more information.
39
+
40
+ ### File name formatting
41
+ 1) `000020.mp4`
42
+ - This clips and compresses the video from 00:00:20 to the end of the clip.
43
+ 2) `000020-000145.mp4`
44
+ - This clips and compresses the video from 00:00:20 to 00:01:45.
45
+ 3) `SomethingElse.mp4`
46
+ - Compresses the entire video if the first six characters of the file's name aren't numeric.
47
+
48
+ ### Optional Arguments
49
+ - `-o`, `--output`
50
+ - default: the current working directory
51
+ - If there is a folder that you want all of your smaller clips to land in, specify it with this argument.
52
+ - `-s`, `--filesize`
53
+ - default: `25.0`
54
+ - Increase or decrease this value if you want to compress your video to something other than the 8MB Discord limit.
55
+ - `-a`, `--audio-br`
56
+ - 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.
57
+ - `-r`, `--resolution`
58
+ - Example: `1280x720`
59
+ - 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`
60
+ - `-x`, `--crop`
61
+ - Example: `255x0x1410x1080`
62
+ - 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.
63
+ - [FFmpeg crop documentation](https://ffmpeg.org/ffmpeg-filters.html#Examples-61)
64
+ - `--web`
65
+ - A Boolean flag. No value is needed after the flag. See [Web UI](#web-ui) for more information on the Web UI.
66
+ - `--config`
67
+ - Example: `custom_run_config.json`
68
+ - Path to a json file containing the configuration for the above parameters. This config file takes precedence over all of the other flags.
69
+
70
+ ### JSON Configuration
71
+ 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.
72
+
73
+ ```
74
+ {
75
+ "target_filesize": 8.0,
76
+ "audio_br": 96,
77
+ "crop": "",
78
+ "resolution": "",
79
+ "codec": "libx264",
80
+ "times": {
81
+ "from": "00:00:00",
82
+ "to": "00:00:40"
83
+ }
84
+ }
85
+ ```
86
+
87
+ Notes:
88
+ - 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:
89
+ - `"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).
90
+ - `"times": {"from": "00:00:10"}` -> trim the clip from `00:00:10` to the end of the file
91
+ - `"times": {"to": "00:00:20"}` -> trim the clip from the beginning of the file up to `00:00:20`
92
+ - You can set `audio_br` to `null` if you want to maintain the clip's audio bitrate.
93
+
94
+ ## Detailed Example
95
+
96
+ ```
97
+ ff4d 000050-000145.mp4 \
98
+ -c 1280x0x2560x1440 \
99
+ -r 1920x1080 \
100
+ -s 50 \
101
+ -a 48 \
102
+ -o D:/shadowplay/
103
+ ```
104
+
105
+ 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.
106
+
107
+ ![](https://i.imgur.com/WJXA723.png)
108
+
109
+ ## Web UI
110
+
111
+ The web UI can be activated by adding `--web` to your `ff4d` call.
112
+
113
+ ```
114
+ ff4d cool_clip.mp4 -r 1280x720 -s 20 --web
115
+ ```
116
+
117
+ That command will spin up a Flask server on your local machine and launch a rendered webpage with the video as the centerpiece. The flags you provide to `ff4d` will fill in the defaults for the form. You can override/replace the values.
118
+
119
+ You can drag the video playhead to different portions of the video and click the "Set Start/End Time" buttons to specify the section of the video you want to be clipped out. You can also use the range sliders underneath the buttons if you prefer. A "Preview Selection" button is provided for your convenience, and it does what it sounds like.
120
+
121
+ https://github.com/zfleeman/ffmpeg4discord/assets/1808564/ff323bcb-4747-437b-808f-ce48b8c269ce
122
+
123
+ The Flask server doesn't automatically stop itself, so you'll have to terminate it manually by closing the terminal it leaves hanging.
124
+
125
+ ## Sample Batch File
126
+
127
+ To enable "drag and drop" functionality for this package, Windows users can create a `.bat` file with the following code snippet. Once the file is created, you can drag and drop any `.mp4` file on top of it, and it will run with the flags specified in the "[Optional Arguments](https://github.com/zfleeman/ffmpeg4discord#file-name-formatting)" section. This example is a Batch file that will launch the web UI.
128
+
129
+ ```batch
130
+ @echo off
131
+ Set filename=%1
132
+ ff4d %filename% -o "C:/output/folder/" --web
133
+ DEL "ffmpeg2*"
134
+ PAUSE
135
+ ```
136
+
137
+ ## Thanks!
138
+
139
+ Yes, this is a simple collection of Python files using FFmpeg tricks that is masquerading as a robust Audio/Video tool. But! I use this nearly every day to quickly share videos with people on various messaging apps that have built-in video players. I don't have to share a link that embeds a video player this way, and I guess that's important to me?
140
+
141
+ I like working on this! Enjoy!
File without changes
@@ -0,0 +1,101 @@
1
+ import sys
2
+ import os
3
+ from glob import glob
4
+ import webbrowser
5
+ from flask import Flask, render_template, url_for, request
6
+ import time
7
+ import threading
8
+ from pathlib import Path
9
+
10
+ sys.dont_write_bytecode = True
11
+ from ffmpeg4discord import arguments
12
+ from ffmpeg4discord.twopass import TwoPass
13
+
14
+ # get args from the command line
15
+ args = arguments.get_args()
16
+ web = args.pop("web")
17
+ port = args.pop("port")
18
+ path = Path(args["filename"]).resolve()
19
+ args["filename"] = path
20
+
21
+ # instantiate the TwoPass class
22
+ twopass = TwoPass(**args)
23
+
24
+
25
+ def twopass_loop(target_filesize: float) -> str:
26
+ while twopass.run() >= target_filesize:
27
+ print(
28
+ f"\nThe output file size ({round(twopass.output_filesize, 2)}MB) is still above the target of {target_filesize}MB.\nRestarting...\n"
29
+ )
30
+ os.remove(twopass.output_filename)
31
+
32
+ # adjust the class's target file size to set a lower bitrate for the next run
33
+ twopass.target_filesize -= 0.2
34
+
35
+ success_message = f"Your compressed video file ({round(twopass.output_filesize, 2)}MB) is located at {Path(twopass.output_filename).resolve()}"
36
+ print(success_message)
37
+
38
+ return success_message
39
+
40
+
41
+ def seconds_to_timestamp(seconds: int) -> str:
42
+ hours, remainder = divmod(seconds, 3600)
43
+ minutes, seconds = divmod(remainder, 60)
44
+
45
+ # Use f-strings to format the timestamp
46
+ timestamp = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
47
+
48
+ return timestamp
49
+
50
+
51
+ def open_browser() -> None:
52
+ time.sleep(0.5)
53
+ webbrowser.open(f"http://localhost:{port}")
54
+
55
+
56
+ def main() -> None:
57
+ if web:
58
+ app = Flask(__name__, static_folder=path.parent)
59
+
60
+ @app.route("/")
61
+ def index():
62
+ return render_template(
63
+ "web.html", file_url=url_for("static", filename=path.name), twopass=twopass, alert_hidden=True
64
+ )
65
+
66
+ @app.route("/encode", methods=["POST"])
67
+ def form_twopass():
68
+ # generate new times from the selection
69
+ ss = int(request.form.get("startTime"))
70
+ to = int(request.form.get("endTime"))
71
+ twopass.length = to - ss
72
+ twopass.times = {"ss": seconds_to_timestamp(ss), "to": seconds_to_timestamp(to)}
73
+ target_filesize = float(request.form.get("target_filesize"))
74
+
75
+ # update TwoPass from web form
76
+ twopass.resolution = request.form.get("resolution")
77
+ twopass.target_filesize = target_filesize
78
+ twopass.audio_br = float(request.form.get("audio_br")) * 1000
79
+ twopass.crop = request.form.get("crop")
80
+ twopass.output_dir = request.form.get("output_dir")
81
+
82
+ success_message = twopass_loop(target_filesize)
83
+
84
+ for file in glob("ffmpeg2pass*"):
85
+ os.remove(file)
86
+
87
+ return render_template(
88
+ "web.html",
89
+ file_url=url_for("static", filename=path.name),
90
+ twopass=twopass,
91
+ alert=success_message,
92
+ )
93
+
94
+ threading.Thread(target=open_browser, name="Open Browser").start()
95
+ app.run("0.0.0.0", port=port)
96
+ else:
97
+ twopass_loop(twopass.target_filesize)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
@@ -0,0 +1,37 @@
1
+ from argparse import ArgumentParser, Namespace, BooleanOptionalAction
2
+
3
+
4
+ def get_args() -> Namespace:
5
+ parser = ArgumentParser(
6
+ prog="ffmpeg4discord",
7
+ description="This script takes a video file and compresses it to a target file size.",
8
+ epilog="For more help: https://github.com/zfleeman/ffmpeg4discord",
9
+ )
10
+ parser.add_argument("filename", help="The file path of the file that you wish to compress.", required=True)
11
+ parser.add_argument(
12
+ "-o",
13
+ "--output-dir",
14
+ default="",
15
+ help="The desired output directory where the file will land.",
16
+ )
17
+ parser.add_argument(
18
+ "-s",
19
+ "--target-filesize",
20
+ default=25,
21
+ type=float,
22
+ help="The output file size in MB.",
23
+ )
24
+ parser.add_argument("-a", "--audio-br", type=float, default=96, help="Audio bitrate in kbps.")
25
+
26
+ # video filters
27
+ parser.add_argument("-x", "--crop", default="", help="Cropping dimensions. Example: 255x0x1410x1080")
28
+ parser.add_argument("-r", "--resolution", default="", help="The output resolution of your final video.")
29
+
30
+ # configuraiton json file
31
+ parser.add_argument("--config", help="JSON file containing the run's configuration")
32
+
33
+ # web
34
+ parser.add_argument("--web", action=BooleanOptionalAction, help="Launch ffmpeg4discord's Web UI in your browser.")
35
+ parser.add_argument("-p", "--port", type=int, default=5333, help="Local port for the Flask application.")
36
+
37
+ return vars(parser.parse_args())
@@ -0,0 +1,152 @@
1
+ <!doctype html>
2
+ <html lang="en" data-bs-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ffmpeg4discord's Web UI</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css">
9
+ </head>
10
+ <body>
11
+ <div class="container">
12
+ <div class="row mt-3">
13
+ <div class="alert alert-light" role="alert" {{ "hidden" if alert_hidden else "" }}>
14
+ <p>{{ alert }}</p>
15
+ <span class="text-danger">Remember to terminate this server before starting another job.</span>
16
+ </div>
17
+ </div>
18
+ <div class="row mt-3">
19
+ <video id="myVideo" preload="auto" controls>
20
+ <source src="{{ file_url }}" type="video/mp4">
21
+ Your browser does not support the video tag.
22
+ </video>
23
+ <p class="text-muted"><em>Drag the video playhead, and use the buttons below to set the Start and End times.</em></p>
24
+ </div>
25
+ <form id="encodeForm" action="/encode" method="post">
26
+ <div class="row mb-3">
27
+ <div class="col">
28
+ <button type="button" class="btn btn-outline-primary" onclick="setTime('start')"><span class="bi-skip-start-fill"></span> Set Start Time</button>
29
+ </div>
30
+ <div class="col">
31
+ <button type="button" class="btn btn-outline-primary" onclick="setTime('end')">Set End Time <span class="bi-skip-end-fill"></span></button>
32
+ </div>
33
+ </div>
34
+ <div class="row mb-3">
35
+ <div class="col">
36
+ <label for="startTime" class="form-label padding-top">Start Time:</label>
37
+ <input class="form-range" type="range" name="startTime" id="startTime" min="0" step="1" max="0">
38
+ <span id="startTimeLabel">{{ twopass.from_seconds|string + " seconds" if twopass.from_seconds else "0 seconds" }}</span>
39
+ </div>
40
+ <div class="col">
41
+ <label for="endTime" class="form-label">End Time:</label>
42
+ <input class="form-range" type="range" name="endTime" id="endTime" min="0" step="1" max="0">
43
+ <span id="endTimeLabel">{{ twopass.to_seconds|string + " seconds" if twopass.to_seconds else twopass.length|string + " seconds" }}</span>
44
+ </div>
45
+ </div>
46
+ <div class="row">
47
+ <div class="col">
48
+ <button type="button" class="btn btn-outline-primary" onclick="selectVideoPart()"><span class="bi-play"></span> Preview Selection</button>
49
+ </div>
50
+ </div>
51
+ <hr />
52
+ <div class="row mb-3">
53
+ <h3>Output Parameters</h3>
54
+ <div class="col">
55
+ <label for="resolution" class="form-label">Resolution</label>
56
+ <input class="form-control" name="resolution" id="resolution" type="text" value="{{ twopass.resolution }}" />
57
+ </div>
58
+ <div class="col">
59
+ <label for="target_filesize" class="form-label">Target File Size (MB)</label>
60
+ <input class="form-control" name="target_filesize" id="target_filesize" type="text" value="{{ twopass.target_filesize }}" />
61
+ </div>
62
+ <div class="col">
63
+ <label for="audio_br" class="form-label">Audio Bitrate (kbps)</label>
64
+ <input class="form-control" name="audio_br" id="audio_br" type="text" value="{{ twopass.audio_br/1000 }}" />
65
+ </div>
66
+ <div class="col">
67
+ <label for="crop" class="form-label">Crop</label>
68
+ <input class="form-control" name="crop" id="crop" type="text" value="{{ twopass.crop }}" />
69
+ </div>
70
+ <div class="col">
71
+ <label for="output_dir" class="form-label">Output Directory</label>
72
+ <input class="form-control" name="output_dir" id="output_dir" type="text" value="{{ twopass.output_dir }}" />
73
+ </div>
74
+ </div>
75
+ <div class="row mb-3">
76
+ <div class="col">
77
+ <button id="spinButton" onclick="startSpin()" class="submission btn btn-primary btn-lg" type="submit">
78
+ <span class="bi-film"></span> Encode
79
+ </button>
80
+ <span id="spinIcon" class="spinner-border spinner-border-sm" role="status" aria-hidden="true" hidden></span>
81
+ </div>
82
+ </div>
83
+ </form>
84
+ </div>
85
+ <script>
86
+ const video = document.getElementById('myVideo');
87
+ const startTimeInput = document.getElementById('startTime');
88
+ const endTimeInput = document.getElementById('endTime');
89
+ const startTimeLabel = document.getElementById('startTimeLabel');
90
+ const endTimeLabel = document.getElementById('endTimeLabel');
91
+
92
+ video.addEventListener('loadedmetadata', () => {
93
+ // Set the maximum value of the input elements to the video duration
94
+ startTimeInput.max = video.duration;
95
+ endTimeInput.max = video.duration;
96
+ startTimeInput.value = "{{ twopass.from_seconds if twopass.from_seconds else 0 }}"
97
+ endTimeInput.value = "{{ twopass.to_seconds if twopass.to_seconds else twopass.length }}"
98
+ });
99
+
100
+ startTimeInput.addEventListener('input', updateStartTimeLabel);
101
+ endTimeInput.addEventListener('input', updateEndTimeLabel);
102
+
103
+ function updateStartTimeLabel() {
104
+ startTimeLabel.textContent = startTimeInput.value + ' seconds';
105
+ }
106
+
107
+ function updateEndTimeLabel() {
108
+ endTimeLabel.textContent = endTimeInput.value + ' seconds';
109
+ }
110
+
111
+ function setTime(type) {
112
+ const currentTime = Math.floor(video.currentTime);
113
+
114
+ if (type === 'start') {
115
+ startTimeInput.value = currentTime;
116
+ startTimeLabel.textContent = currentTime + ' seconds';
117
+ } else if (type === 'end') {
118
+ endTimeInput.value = currentTime;
119
+ endTimeLabel.textContent = currentTime + ' seconds';
120
+ }
121
+ }
122
+
123
+ function selectVideoPart() {
124
+ const startTime = parseInt(startTimeInput.value);
125
+ const endTime = parseInt(endTimeInput.value);
126
+
127
+ if (!isNaN(startTime) && !isNaN(endTime) && startTime < endTime) {
128
+ video.currentTime = startTime;
129
+ video.play();
130
+
131
+ setTimeout(() => {
132
+ video.pause();
133
+ video.currentTime = endTime;
134
+ }, (endTime - startTime) * 1000);
135
+ } else {
136
+ alert('Please enter valid start and end times.');
137
+ }
138
+ }
139
+
140
+ function startSpin() {
141
+ // Get the icon element
142
+ var icon = document.getElementById('spinIcon');
143
+ icon.hidden = false;
144
+
145
+ // Disable the button temporarily to prevent multiple clicks
146
+ document.getElementById('spinButton').disabled = true;
147
+ document.getElementById('encodeForm').submit();
148
+
149
+ }
150
+ </script>
151
+ </body>
152
+ </html>
@@ -0,0 +1,250 @@
1
+ import ffmpeg
2
+ import math
3
+ import logging
4
+ import json
5
+ import os
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ logging.getLogger().setLevel(logging.INFO)
10
+
11
+
12
+ class TwoPass:
13
+ def __init__(
14
+ self,
15
+ filename: Path,
16
+ target_filesize: float,
17
+ output_dir: str = "",
18
+ times: dict = {},
19
+ audio_br: float = None,
20
+ codec: str = "libx264",
21
+ crop: str = "",
22
+ resolution: str = "",
23
+ config: str = "",
24
+ ) -> None:
25
+ """
26
+ A Class to resize a video file to a specified MB target.
27
+ This class utilizes ffmpeg's two-pass encoding technique with the
28
+ ffmpeg-python wrapper package.
29
+ https://trac.ffmpeg.org/wiki/Encode/H.264#twopass
30
+
31
+ :param filename: video file that needs to be compressed
32
+ :param output_dir: directory that the new, compressed output is delivered to
33
+ :param times: dict containing "from" and "to" timestamp keys in the format 00:00:00
34
+ :param target_filesize: desired file size of the output file, in MB
35
+ :param audio_br: desired audio bitrate for the output file in kbps
36
+ :param codec: ffmpeg video codec to use when encoding the file
37
+ :param crop: coordinates for cropping the video to a different resolution
38
+ :param resolution: output file's final resolution e.g. 1280x720
39
+ :param config: json containing values for the above params
40
+ """
41
+
42
+ if config:
43
+ self.init_from_config(config_file=config)
44
+ else:
45
+ self.target_filesize = target_filesize
46
+ self.crop = crop
47
+ self.resolution = resolution
48
+ self.times = times
49
+ self.audio_br = audio_br
50
+ self.codec = codec
51
+
52
+ self.filename = filename
53
+ self.fname = filename.name
54
+ self.split_fname = self.fname.split(".")
55
+ self.output_dir = output_dir
56
+
57
+ self.probe = ffmpeg.probe(filename=filename)
58
+ self.duration = math.floor(float(self.probe["format"]["duration"]))
59
+
60
+ if len(self.probe["streams"]) > 2:
61
+ logging.warning(
62
+ "This media file has more than two streams, which could cause errors during the encoding job."
63
+ )
64
+
65
+ for stream in self.probe["streams"]:
66
+ ix = stream["index"]
67
+ if stream["codec_type"] == "video":
68
+ display_aspect_ratio = self.probe["streams"][ix]["display_aspect_ratio"].split(":")
69
+ self.ratio = int(display_aspect_ratio[0]) / int(display_aspect_ratio[1])
70
+ elif stream["codec_type"] == "audio":
71
+ audio_stream = ix
72
+
73
+ if not self.audio_br:
74
+ self.audio_br = float(self.probe["streams"][audio_stream]["bit_rate"])
75
+ else:
76
+ self.audio_br = self.audio_br * 1000
77
+
78
+ if self.times:
79
+ if self.times.get("from"):
80
+ self.times["ss"] = self.times["from"] or "00:00:00"
81
+ del self.times["from"]
82
+ else:
83
+ self.times["ss"] = "00:00:00"
84
+
85
+ self.from_seconds = seconds_from_ts_string(self.times["ss"])
86
+
87
+ if self.times.get("to"):
88
+ self.to_seconds = seconds_from_ts_string(self.times["to"])
89
+ self.length = self.to_seconds - self.from_seconds
90
+ else:
91
+ self.length = self.duration - self.from_seconds
92
+ else:
93
+ self.time_from_file_name()
94
+
95
+ if self.length <= 0:
96
+ raise Exception(
97
+ f"""
98
+ Time Paradox?
99
+
100
+ Something is wrong with your clipping times. Use this
101
+ information to further diagnose the problem:
102
+
103
+ - Your video is {self.duration / 60} minutes long
104
+ - You want to START clipping at {self.times["ss"]}
105
+ - You want to STOP clipping at {self.times["to"]}
106
+ """
107
+ )
108
+
109
+ def init_from_config(self, config_file: str) -> None:
110
+ """
111
+ Set the Class values from a json file
112
+ :param config_file: path to a json file containing parameters for TwoPass()
113
+ """
114
+ file_path = Path(config_file).resolve()
115
+ with open(file_path) as f:
116
+ config = json.load(f)
117
+ self.__dict__.update(**config)
118
+
119
+ def generate_params(self, codec: str):
120
+ """
121
+ Create params for the ffmpeg.output() function
122
+ :param codec: ffmpeg video codec to use during encoding
123
+ :return: dictionary containing parameters for ffmpeg's first and second pass.
124
+ """
125
+ params = {
126
+ "pass1": {
127
+ "pass": 1,
128
+ "f": "null",
129
+ "vsync": "cfr", # not sure if this is unique to x264 or not
130
+ "c:v": codec,
131
+ },
132
+ "pass2": {"pass": 2, "b:a": self.audio_br, "c:v": codec},
133
+ }
134
+
135
+ if codec == "libx264":
136
+ params["pass2"]["c:a"] = "aac"
137
+
138
+ params["pass1"].update(**self.bitrate_dict)
139
+ params["pass2"].update(**self.bitrate_dict)
140
+
141
+ return params
142
+
143
+ def create_bitrate_dict(self) -> None:
144
+ """
145
+ Perform the calculation specified in ffmpeg's documentation that generates
146
+ the video bitrates needed to achieve the target file size
147
+ """
148
+ br = math.floor((self.target_filesize * 8192) / self.length - (self.audio_br / 1000)) * 1000
149
+ self.bitrate_dict = {
150
+ "b:v": br,
151
+ "minrate": br * 0.5,
152
+ "maxrate": br * 1.45,
153
+ "bufsize": br * 2,
154
+ }
155
+
156
+ def time_from_file_name(self):
157
+ """
158
+ Create the -ss and -to fields from a file's name
159
+ """
160
+ fname = self.fname
161
+ startstring = f"{fname[0:2]}:{fname[2:4]}:{fname[4:6]}"
162
+ endstring = f"{fname[7:9]}:{fname[9:11]}:{fname[11:13]}"
163
+ times = {}
164
+
165
+ try:
166
+ int(fname[0:6])
167
+ self.from_seconds = seconds_from_ts_string(startstring)
168
+ times["ss"] = startstring
169
+ try:
170
+ int(fname[11:13])
171
+ self.to_seconds = seconds_from_ts_string(endstring)
172
+ length = self.to_seconds - self.from_seconds
173
+ times["to"] = endstring
174
+ except:
175
+ length = self.duration - self.from_seconds
176
+ self.to_seconds = self.duration
177
+ except:
178
+ length = self.duration
179
+
180
+ self.length = length
181
+ self.times = times
182
+
183
+ def apply_video_filters(self, video):
184
+ """
185
+ Function to apply the crop and resolution parameters to a video object
186
+ :param video: the ffmpeg video object from the Class's input video file
187
+ :return: the video object after it has been cropped or resized
188
+ """
189
+
190
+ if self.crop:
191
+ crop = self.crop.split("x")
192
+ video = video.crop(x=crop[0], y=crop[1], width=crop[2], height=crop[3])
193
+ self.ratio = int(crop[2]) / int(crop[3])
194
+
195
+ if self.resolution:
196
+ video = video.filter("scale", self.resolution)
197
+ x = int(self.resolution.split("x")[0])
198
+ y = int(self.resolution.split("x")[1])
199
+ outputratio = x / y
200
+
201
+ if self.ratio != outputratio:
202
+ logging.warning(
203
+ """
204
+ Your output resolution's aspect ratio does not match the
205
+ input resolution's or your croped resolution's aspect ratio.
206
+ """
207
+ )
208
+
209
+ return video
210
+
211
+ def run(self) -> float:
212
+ """
213
+ Perform the CPU-intensive encoding job
214
+ :return: the output file's size
215
+ """
216
+
217
+ self.output_filename = str(
218
+ Path(self.output_dir)
219
+ / ("small_" + self.filename.stem.replace(" ", "_") + datetime.strftime(datetime.now(), "_%Y%m%d%H%M%S.mp4"))
220
+ )
221
+
222
+ # generate run parameters
223
+ self.create_bitrate_dict()
224
+ params = self.generate_params(codec=self.codec)
225
+
226
+ # separate streams from ffinput
227
+ ffinput = ffmpeg.input(self.filename, **self.times)
228
+ video = self.apply_video_filters(ffinput.video)
229
+ audio = ffinput.audio
230
+
231
+ # First Pass
232
+ ffOutput = ffmpeg.output(video, "pipe:", **params["pass1"])
233
+ ffOutput = ffOutput.global_args("-loglevel", "quiet", "-stats")
234
+ print("Performing first pass")
235
+ std_out, std_err = ffOutput.run(capture_stdout=True)
236
+
237
+ # Second Pass
238
+ ffOutput = ffmpeg.output(video, audio, self.output_filename, **params["pass2"])
239
+ ffOutput = ffOutput.global_args("-loglevel", "quiet", "-stats")
240
+ print("\nPerforming second pass")
241
+ ffOutput.run(overwrite_output=True)
242
+
243
+ # save the output file size and return it
244
+ self.output_filesize = os.path.getsize(self.output_filename) * 0.00000095367432
245
+
246
+ return self.output_filesize
247
+
248
+
249
+ def seconds_from_ts_string(ts_string: str):
250
+ return int(ts_string[0:2]) * 60 * 60 + int(ts_string[3:5]) * 60 + int(ts_string[6:8])
@@ -0,0 +1,101 @@
1
+ import sys
2
+ import os
3
+ from urllib.request import urlretrieve
4
+ import zipfile
5
+ from pathlib import Path
6
+ import shutil
7
+ import logging
8
+ import platform
9
+
10
+ if platform.system() != "Windows":
11
+ logging.warning(
12
+ """
13
+ THIS SCRIPT IS INTENDED FOR WINDOWS USERS
14
+
15
+ If you are running this script on macOS, consider installing ffmpeg with
16
+ Homebrew: https://formulae.brew.sh/formula/ffmpeg
17
+
18
+ If you are running this script on some Linux, learn more about your options
19
+ to install ffmpeg on its website: https://ffmpeg.org/download.html#build-linux
20
+ """
21
+ )
22
+
23
+ sys.exit()
24
+
25
+ logging.getLogger().setLevel(logging.INFO)
26
+
27
+ path = os.environ.get("PATH", "")
28
+ python_install = Path(sys.executable)
29
+
30
+ if "WindowsApps" in str(python_install):
31
+ print("Microsoft Store Python installation detected.")
32
+ target = Path(sys.executable).parent.parent
33
+ else:
34
+ print("https://python.org Python installation detected.")
35
+ target = Path(sys.executable).parent / "Scripts"
36
+
37
+ target.mkdir(parents=True, exist_ok=True)
38
+
39
+ if str(target) not in path:
40
+ logging.warning(
41
+ """
42
+ The directory we are installing ffmpeg into is not in your
43
+ system's PATH. Windows will not be able to find ffmpeg when
44
+ trying to run ffmpeg4discord. Please ensure that you installed
45
+ Python with the "Add Python to PATH" option selected.
46
+
47
+ More information: https://docs.python.org/3/using/windows.html#finding-the-python-executable
48
+ """
49
+ )
50
+
51
+
52
+ def copy_directory_contents(source: Path, target: Path) -> None:
53
+ source_path = source
54
+ target_path = target
55
+
56
+ # Iterate over files in the source directory
57
+ for item in source_path.iterdir():
58
+ # Construct target item path
59
+ target_item = target_path / item.name
60
+
61
+ # If the item is a file, copy it to the target directory
62
+ if item.is_file():
63
+ shutil.copy2(item, target_item)
64
+ print(f"Copied file: {item} -> {target_item}")
65
+
66
+
67
+ def download_with_progress(url: str, save_path: str) -> None:
68
+ def report(block_num, block_size, total_size):
69
+ downloaded = block_num * block_size
70
+ downloaded_mb = downloaded / (1024 * 1024)
71
+ total_size_mb = total_size / (1024 * 1024)
72
+ progress = min(downloaded / total_size, 1.0)
73
+ percent = round(progress * 100, 2)
74
+ print(f"\rDownloaded {downloaded_mb:.2f}/{total_size_mb:.2f} MB ({percent}%)", end="")
75
+
76
+ save_dir = Path(save_path).parent
77
+ save_dir.mkdir(parents=True, exist_ok=True)
78
+
79
+ urlretrieve(url, save_path, reporthook=report)
80
+ print("\nDownload complete!")
81
+
82
+
83
+ def install() -> None:
84
+ print("Downloading ffmpeg to ffmpeg.zip...")
85
+ download_with_progress("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", "ffmpeg/ffmpeg.zip")
86
+
87
+ print("\nUnzipping ffmpeg.zip...")
88
+ with zipfile.ZipFile("ffmpeg/ffmpeg.zip", "r") as zip_ref:
89
+ zip_ref.extractall("ffmpeg/")
90
+ source = Path(f"ffmpeg/{zip_ref.namelist()[0]}/bin").resolve()
91
+ print("Done!\n")
92
+
93
+ copy_directory_contents(source=source, target=target)
94
+ shutil.rmtree("ffmpeg/")
95
+
96
+ print("\nffmpeg installation complete.")
97
+ print("\nOpen a new Terminal window and type ffmpeg and hit enter to verify that the installation succeeded.")
98
+
99
+
100
+ if __name__ == "__main__":
101
+ install()
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.1
2
+ Name: ffmpeg4discord
3
+ Version: 0.1.0
4
+ Summary: A tool to help convert a video file to a target file size.
5
+ Author-email: Zach Fleeman <zfleeman@gmail.com>
6
+ Project-URL: Homepage, https://github.com/zfleeman/ffmpeg4discord
7
+ Project-URL: Issues, https://github.com/zfleeman/ffmpeg4discord/issues
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: Flask==3.0.1
12
+ Requires-Dist: ffmpeg-python==0.2.0
13
+
14
+ # `ff4d` -- Target File Size Video Compression for Discord with FFmpeg
15
+ This repository houses scripts and applications to assist with compressing a video file to a desired size.
16
+
17
+ `ff4d` takes a video file as its input and encodes it to be less than 25MB unless specified otherwise. Discord's free-tier file size sharing limit is 25MB. You can change the file's name in a way that trims the file to a timestamped section. I created this package to share large NVIDIA ShadowPlay clips on Discord quickly, without the need for a visual editor.
18
+
19
+ 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.
20
+
21
+ ## Installation and Usage
22
+
23
+ This package works with Python >= 3.8. For Windows users, ensure that Python is added to your system's PATH.
24
+
25
+ Install this package with the following command.
26
+
27
+ ```
28
+ pip install ffmpeg4discord
29
+ ```
30
+
31
+ You must first have FFmpeg installed on your system. `ffmpeg` needs to be registered in your PATH. macOS or Linux users can use their favorite package manager to do this, but this process is a little more tricky for Windows users.
32
+
33
+ ### Help for Windows users
34
+
35
+ After installing this package, Windows users can run this command in a terminal to download the necessary `ffmpeg` binaries:
36
+
37
+ ```
38
+ install-ffmpeg-windows
39
+ ```
40
+
41
+ This will place `ffmpeg.exe`, `ffprobe.exe`, and `ffplay.exe` into the same location as your Python executable.
42
+
43
+ ## Usage
44
+
45
+ Run the program with:
46
+
47
+ ```
48
+ ff4d cool_clip.mp4
49
+ ```
50
+
51
+ I've had a good time using this command with a Batch file on Windows. Refer to the [Sample Batch File](https://github.com/zfleeman/ffmpeg4discord#sample-batch-file) section for more information.
52
+
53
+ ### File name formatting
54
+ 1) `000020.mp4`
55
+ - This clips and compresses the video from 00:00:20 to the end of the clip.
56
+ 2) `000020-000145.mp4`
57
+ - This clips and compresses the video from 00:00:20 to 00:01:45.
58
+ 3) `SomethingElse.mp4`
59
+ - Compresses the entire video if the first six characters of the file's name aren't numeric.
60
+
61
+ ### Optional Arguments
62
+ - `-o`, `--output`
63
+ - default: the current working directory
64
+ - If there is a folder that you want all of your smaller clips to land in, specify it with this argument.
65
+ - `-s`, `--filesize`
66
+ - default: `25.0`
67
+ - Increase or decrease this value if you want to compress your video to something other than the 8MB Discord limit.
68
+ - `-a`, `--audio-br`
69
+ - 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.
70
+ - `-r`, `--resolution`
71
+ - Example: `1280x720`
72
+ - 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`
73
+ - `-x`, `--crop`
74
+ - Example: `255x0x1410x1080`
75
+ - 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.
76
+ - [FFmpeg crop documentation](https://ffmpeg.org/ffmpeg-filters.html#Examples-61)
77
+ - `--web`
78
+ - A Boolean flag. No value is needed after the flag. See [Web UI](#web-ui) for more information on the Web UI.
79
+ - `--config`
80
+ - Example: `custom_run_config.json`
81
+ - Path to a json file containing the configuration for the above parameters. This config file takes precedence over all of the other flags.
82
+
83
+ ### JSON Configuration
84
+ 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.
85
+
86
+ ```
87
+ {
88
+ "target_filesize": 8.0,
89
+ "audio_br": 96,
90
+ "crop": "",
91
+ "resolution": "",
92
+ "codec": "libx264",
93
+ "times": {
94
+ "from": "00:00:00",
95
+ "to": "00:00:40"
96
+ }
97
+ }
98
+ ```
99
+
100
+ Notes:
101
+ - 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:
102
+ - `"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).
103
+ - `"times": {"from": "00:00:10"}` -> trim the clip from `00:00:10` to the end of the file
104
+ - `"times": {"to": "00:00:20"}` -> trim the clip from the beginning of the file up to `00:00:20`
105
+ - You can set `audio_br` to `null` if you want to maintain the clip's audio bitrate.
106
+
107
+ ## Detailed Example
108
+
109
+ ```
110
+ ff4d 000050-000145.mp4 \
111
+ -c 1280x0x2560x1440 \
112
+ -r 1920x1080 \
113
+ -s 50 \
114
+ -a 48 \
115
+ -o D:/shadowplay/
116
+ ```
117
+
118
+ 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.
119
+
120
+ ![](https://i.imgur.com/WJXA723.png)
121
+
122
+ ## Web UI
123
+
124
+ The web UI can be activated by adding `--web` to your `ff4d` call.
125
+
126
+ ```
127
+ ff4d cool_clip.mp4 -r 1280x720 -s 20 --web
128
+ ```
129
+
130
+ That command will spin up a Flask server on your local machine and launch a rendered webpage with the video as the centerpiece. The flags you provide to `ff4d` will fill in the defaults for the form. You can override/replace the values.
131
+
132
+ You can drag the video playhead to different portions of the video and click the "Set Start/End Time" buttons to specify the section of the video you want to be clipped out. You can also use the range sliders underneath the buttons if you prefer. A "Preview Selection" button is provided for your convenience, and it does what it sounds like.
133
+
134
+ https://github.com/zfleeman/ffmpeg4discord/assets/1808564/ff323bcb-4747-437b-808f-ce48b8c269ce
135
+
136
+ The Flask server doesn't automatically stop itself, so you'll have to terminate it manually by closing the terminal it leaves hanging.
137
+
138
+ ## Sample Batch File
139
+
140
+ To enable "drag and drop" functionality for this package, Windows users can create a `.bat` file with the following code snippet. Once the file is created, you can drag and drop any `.mp4` file on top of it, and it will run with the flags specified in the "[Optional Arguments](https://github.com/zfleeman/ffmpeg4discord#file-name-formatting)" section. This example is a Batch file that will launch the web UI.
141
+
142
+ ```batch
143
+ @echo off
144
+ Set filename=%1
145
+ ff4d %filename% -o "C:/output/folder/" --web
146
+ DEL "ffmpeg2*"
147
+ PAUSE
148
+ ```
149
+
150
+ ## Thanks!
151
+
152
+ Yes, this is a simple collection of Python files using FFmpeg tricks that is masquerading as a robust Audio/Video tool. But! I use this nearly every day to quickly share videos with people on various messaging apps that have built-in video players. I don't have to share a link that embeds a video player this way, and I guess that's important to me?
153
+
154
+ I like working on this! Enjoy!
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ ffmpeg4discord/__init__.py
4
+ ffmpeg4discord/app.py
5
+ ffmpeg4discord/arguments.py
6
+ ffmpeg4discord/twopass.py
7
+ ffmpeg4discord/windows.py
8
+ ffmpeg4discord.egg-info/PKG-INFO
9
+ ffmpeg4discord.egg-info/SOURCES.txt
10
+ ffmpeg4discord.egg-info/dependency_links.txt
11
+ ffmpeg4discord.egg-info/entry_points.txt
12
+ ffmpeg4discord.egg-info/requires.txt
13
+ ffmpeg4discord.egg-info/top_level.txt
14
+ ffmpeg4discord/templates/web.html
15
+ tests/test_twopass.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ff4d = ffmpeg4discord.app:main
3
+ install-ffmpeg-windows = ffmpeg4discord.windows:install
@@ -0,0 +1,2 @@
1
+ Flask==3.0.1
2
+ ffmpeg-python==0.2.0
@@ -0,0 +1 @@
1
+ ffmpeg4discord
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ffmpeg4discord"
7
+ version = "0.1.0"
8
+ description = "A tool to help convert a video file to a target file size."
9
+ readme = {file = "README.md", content-type = "text/markdown"}
10
+ authors = [{ name = "Zach Fleeman", email = "zfleeman@gmail.com" }]
11
+ requires-python = ">= 3.8"
12
+ dependencies = [
13
+ "Flask==3.0.1",
14
+ "ffmpeg-python==0.2.0"
15
+ ]
16
+ classifiers = [
17
+ "License :: OSI Approved :: MIT License",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/zfleeman/ffmpeg4discord"
22
+ Issues = "https://github.com/zfleeman/ffmpeg4discord/issues"
23
+
24
+ [project.scripts]
25
+ ff4d = "ffmpeg4discord.app:main"
26
+ install-ffmpeg-windows = "ffmpeg4discord.windows:install"
27
+
28
+ [tool.setuptools.package-data]
29
+ ffmpeg4discord = ["templates/*.html"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,111 @@
1
+ import unittest
2
+ from unittest.mock import patch, MagicMock
3
+ from ffmpeg4discord.twopass import TwoPass
4
+ from pathlib import Path
5
+
6
+
7
+ class TestTwoPass(unittest.TestCase):
8
+ def setUp(self):
9
+ self.filename = Path("000100.mp4")
10
+ self.target_filesize = 50.0 # MB
11
+
12
+ @patch("ffmpeg4discord.twopass.ffmpeg.probe")
13
+ def test_init(self, mock_probe: MagicMock):
14
+ # Set up mock values for the probe
15
+ mock_probe.return_value = {
16
+ "streams": [
17
+ {"index": 0, "codec_type": "video", "display_aspect_ratio": "16:9"},
18
+ {"index": 1, "codec_type": "audio", "bit_rate": "128000"},
19
+ ],
20
+ "format": {"duration": "3600"},
21
+ }
22
+
23
+ # Create TwoPass instance
24
+ twopass = TwoPass(self.filename, self.target_filesize)
25
+
26
+ # Check attributes
27
+ self.assertEqual(twopass.filename, self.filename)
28
+ self.assertEqual(twopass.target_filesize, self.target_filesize)
29
+ self.assertEqual(twopass.duration, 3600)
30
+ self.assertEqual(twopass.audio_br, 128000)
31
+
32
+ @patch("ffmpeg4discord.twopass.ffmpeg.probe")
33
+ def test_time_from_file_name(self, mock_probe: MagicMock):
34
+ # Set up mock values for the probe
35
+ mock_probe.return_value = {
36
+ "streams": [
37
+ {"index": 0, "codec_type": "video", "display_aspect_ratio": "16:9"},
38
+ {"index": 1, "codec_type": "audio", "bit_rate": "128000"},
39
+ ],
40
+ "format": {"duration": "3600"},
41
+ }
42
+ # mock_seconds_from_ts_string.return_value = 60
43
+
44
+ # Create TwoPass instance
45
+ twopass = TwoPass(self.filename, self.target_filesize)
46
+
47
+ # Call time_from_file_name
48
+ twopass.time_from_file_name()
49
+
50
+ # Check attributes
51
+ self.assertEqual(twopass.from_seconds, 60)
52
+ self.assertEqual(twopass.times, {"ss": "00:01:00"})
53
+
54
+ @patch("ffmpeg4discord.twopass.ffmpeg.probe")
55
+ def test_create_bitrate_dict(self, mock_probe: MagicMock):
56
+ # Set up mock values for the probe
57
+ mock_probe.return_value = {
58
+ "streams": [
59
+ {"index": 0, "codec_type": "video", "display_aspect_ratio": "16:9"},
60
+ {"index": 1, "codec_type": "audio", "bit_rate": "128000"},
61
+ ],
62
+ "format": {"duration": "120"},
63
+ }
64
+
65
+ # Create TwoPass instance
66
+ twopass = TwoPass(self.filename, self.target_filesize)
67
+
68
+ # Call create_bitrate_dict
69
+ twopass.create_bitrate_dict()
70
+
71
+ # Check bitrate_dict attribute
72
+ self.assertEqual(twopass.bitrate_dict["b:v"], 6698000)
73
+ self.assertEqual(twopass.bitrate_dict["minrate"], 3349000)
74
+ self.assertEqual(twopass.bitrate_dict["maxrate"], 9712100)
75
+ self.assertEqual(twopass.bitrate_dict["bufsize"], 13396000)
76
+
77
+ @patch("ffmpeg4discord.twopass.os.path.getsize")
78
+ @patch("ffmpeg4discord.twopass.ffmpeg.probe")
79
+ @patch("ffmpeg4discord.twopass.ffmpeg.output")
80
+ def test_run(self, mock_output: MagicMock, mock_probe: MagicMock, mock_os_path_getsize: MagicMock):
81
+ # Set up mock values for the probe
82
+ mock_probe.return_value = {
83
+ "streams": [
84
+ {"index": 0, "codec_type": "video", "display_aspect_ratio": "16:9"},
85
+ {"index": 1, "codec_type": "audio", "bit_rate": "128000"},
86
+ ],
87
+ "format": {"duration": "3600"},
88
+ }
89
+
90
+ # Create TwoPass instance
91
+ twopass = TwoPass(self.filename, self.target_filesize)
92
+
93
+ # Mock output.run() to return some values
94
+ mock_run = MagicMock()
95
+ mock_run.run.return_value = ("a", "b")
96
+ mock_output.return_value = mock_run
97
+ mock_output.return_value.global_args.return_value = mock_run
98
+
99
+ # Fake a file size
100
+ mock_os_path_getsize.return_value = 52428799
101
+
102
+ # Call run method
103
+ result = twopass.run()
104
+
105
+ # Check output_filesize attribute and return value
106
+ self.assertLess(twopass.output_filesize, 50) # Mocking output file size
107
+ self.assertLess(result, 50)
108
+
109
+
110
+ if __name__ == "__main__":
111
+ unittest.main()