Skip to content

Commit

Permalink
The Ephemeral Web UI (#14)
Browse files Browse the repository at this point in the history
* initial web work

* adding arg

* beginning path work

* html work

* webform functionality

* output filename bug

* glob to remove log files

* Update README.md

* PR comments

* better output
  • Loading branch information
zfleeman authored Feb 2, 2024
1 parent 5ff479e commit 9b64832
Show file tree
Hide file tree
Showing 6 changed files with 272 additions and 24 deletions.
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ The `ffmpeg4discord.py` script takes a video file as its input and encodes it to

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
## Installation and Usage
You must first have `ffmpeg` installed on your system. `ffmpeg` needs to be registered in your PATH.

Install the required Python packages, which includes `ffmpeg-python`, with:
Expand All @@ -18,7 +18,7 @@ Call the script with:

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.

### Special install instructions for Windows users
### Special instructions for Windows users

If you do not have ffmpeg installed, you can use the included `windows_setup.py` file to do about 90% of the installation.

Expand Down Expand Up @@ -50,6 +50,8 @@ This script downloads ffmpeg, extracts it into the current directory, and launch
- 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)
- `--web`
- A Boolean flag. No value is needed after the flag. See [Web UI](#web-ui) for more information on the Web UI.
- `--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.
Expand Down Expand Up @@ -106,3 +108,25 @@ python D:/ffmpeg4discord/ffmpeg4discord.py 000050-000145.mp4 \
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)

## Web UI

The Web UI can be activated by adding `--web` to your `ffmpeg4discord.py` call.

```
python "C:/path/to/ffmpeg4discord.py" cool_clip.mp4 -r 1280x720 -s 20 --web
```

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 the `python` statement will fill in the defaults for the form. You can override/replace the values.

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.

https://github.com/zfleeman/ffmpeg4discord/assets/1808564/ff323bcb-4747-437b-808f-ce48b8c269ce

The Flask server doesn't automatically stop itself, yet, so you'll have to handle that by closing the terminal it leaves hanging.

## Thanks!

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?

I like working on this! Enjoy!
93 changes: 85 additions & 8 deletions ffmpeg4discord.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,98 @@
import sys
import os
from glob import glob
import webbrowser
from flask import Flask, render_template, url_for, request
from random import randint
import time
import threading
from pathlib import Path

sys.dont_write_bytecode = True
from utils.arguments import get_args
from twopass import TwoPass


# get args from the command line
args = get_args()
web = args.pop("web")
path = Path(args["filename"]).resolve()
args["filename"] = path

# instantiate the TwoPass class and save our target file size for comparison in the loop
# instantiate the TwoPass class
twopass = TwoPass(**args)
end_fs = args["target_filesize"]

while twopass.run() >= end_fs:

def twopass_loop(target_filesize: float):
while twopass.run() >= target_filesize:
print(
f"\nThe output file size ({round(twopass.output_filesize, 2)}MB) is still above the target of {target_filesize}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"\nThe output file size ({round(twopass.output_filesize, 2)}MB) is still above the target of {end_fs}MB.\nRestarting...\n"
f"\nSUCCESS!!\nThe smaller file ({round(twopass.output_filesize, 2)}MB) is located at {twopass.output_filename}"
)
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}")
def seconds_to_timestamp(seconds: int):
hours, remainder = divmod(seconds, 3600)
minutes, seconds = divmod(remainder, 60)

# Use f-strings to format the timestamp
timestamp = f"{hours:02d}:{minutes:02d}:{seconds:02d}"

return timestamp


def open_browser():
time.sleep(0.5)
webbrowser.open(f"http://localhost:{port}")


if web:
app = Flask(__name__, static_folder=path.parent)

@app.route("/")
def index():
return render_template(
"web.html",
filename=url_for("static", filename=path.name),
resolution=twopass.resolution,
target_filesize=twopass.target_filesize,
audio_br=twopass.audio_br,
crop=twopass.crop,
output_dir=twopass.output_dir,
)

@app.route("/encode", methods=["POST"])
def form_twopass():
# generate new times from the selection
ss = int(request.form.get("startTime"))
to = int(request.form.get("endTime"))
twopass.length = to - ss
twopass.times = {"ss": seconds_to_timestamp(ss), "to": seconds_to_timestamp(to)}
target_filesize = float(request.form.get("target_filesize"))

# update TwoPass from web form
twopass.resolution = request.form.get("resolution")
twopass.target_filesize = target_filesize
twopass.audio_br = float(request.form.get("audio_br")) * 1000
twopass.crop = request.form.get("crop")
twopass.output_dir = request.form.get("output_dir")

twopass_loop(target_filesize)

for file in glob("ffmpeg2pass*"):
os.remove(file)

return f"Your compressed video file ({round(twopass.output_filesize, 2)}MB) is located at <strong>{Path(twopass.output_filename).resolve()}</strong>"

port = randint(5000, 6000)
threading.Thread(target=open_browser, name="Open Browser").start()
app.run("0.0.0.0", port=port)
else:
twopass_loop(args["target_filesize"])
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
ffmpeg-python
pyperclip
flask
144 changes: 144 additions & 0 deletions templates/web.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Ephemeral Web UI for ffmpeg4discord</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css">
</head>
<body>
<div class="container">
<div class="row mt-3">
<video id="myVideo" preload="auto" controls>
<source src="{{ filename }}" type="video/mp4">
Your browser does not support the video tag.
</video>
<p class="text-muted"><em>Drag the video playhead, and use the buttons below to set the Start and End times.</em></p>
</div>
<form id="encodeForm" action="/encode" method="post">
<div class="row mb-3">
<div class="col">
<button type="button" class="btn btn-outline-primary" onclick="setTime('start')"><span class="bi-skip-start-fill"></span> Set Start Time</button>
</div>
<div class="col">
<button type="button" class="btn btn-outline-primary" onclick="setTime('end')">Set End Time <span class="bi-skip-end-fill"></span></button>
</div>
</div>
<div class="row mb-3">
<div class="col">
<label for="startTime" class="form-label padding-top">Start Time:</label>
<input class="form-range" type="range" name="startTime" id="startTime" min="0" defaultValue="0" step="1" max="0">
<span id="startTimeLabel">0</span>
</div>
<div class="col">
<label for="endTime" class="form-label">End Time:</label>
<input class="form-range" type="range" name="endTime" id="endTime" min="0" defaultValue="0" step="1" max="0">
<span id="endTimeLabel">0</span>
</div>
</div>
<div class="row">
<div class="col">
<button type="button" class="btn btn-outline-primary" onclick="selectVideoPart()"><span class="bi-play"></span> Preview Selection</button>
</div>
</div>
<hr />
<div class="row mb-3">
<h3>Output Parameters</h3>
<div class="col">
<label for="resolution" class="form-label">Resolution</label>
<input class="form-control" name="resolution" id="resolution" type="text" value="{{ resolution }}" />
</div>
<div class="col">
<label for="target_filesize" class="form-label">Target File Size (MB)</label>
<input class="form-control" name="target_filesize" id="target_filesize" type="text" value="{{ target_filesize }}" />
</div>
<div class="col">
<label for="audio_br" class="form-label">Audio Bitrate (kbps)</label>
<input class="form-control" name="audio_br" id="audio_br" type="text" value="{{ audio_br/1000 }}" />
</div>
<div class="col">
<label for="crop" class="form-label">Crop</label>
<input class="form-control" name="crop" id="crop" type="text" value="{{ crop }}" />
</div>
<div class="col">
<label for="output_dir" class="form-label">Output Directory</label>
<input class="form-control" name="output_dir" id="output_dir" type="text" value="{{ output_dir }}" />
</div>
</div>
<div class="row mb-3">
<div class="col">
<button id="spinButton" onclick="startSpin()" class="submission btn btn-primary btn-lg" type="submit">
<span class="bi-film"></span> Encode
</button>
<span id="spinIcon" class="spinner-border spinner-border-sm" role="status" aria-hidden="true" hidden></span>
</div>
</div>
</form>
</div>
<script>
const video = document.getElementById('myVideo');
const startTimeInput = document.getElementById('startTime');
const endTimeInput = document.getElementById('endTime');
const startTimeLabel = document.getElementById('startTimeLabel');
const endTimeLabel = document.getElementById('endTimeLabel');

video.addEventListener('loadedmetadata', () => {
// Set the maximum value of the input elements to the video duration
startTimeInput.max = video.duration;
endTimeInput.max = video.duration;
});

startTimeInput.addEventListener('input', updateStartTimeLabel);
endTimeInput.addEventListener('input', updateEndTimeLabel);

function updateStartTimeLabel() {
startTimeLabel.textContent = startTimeInput.value + ' seconds';
}

function updateEndTimeLabel() {
endTimeLabel.textContent = endTimeInput.value + ' seconds';
}

function setTime(type) {
const currentTime = Math.floor(video.currentTime);

if (type === 'start') {
startTimeInput.value = currentTime;
startTimeLabel.textContent = currentTime + ' seconds';
} else if (type === 'end') {
endTimeInput.value = currentTime;
endTimeLabel.textContent = currentTime + ' seconds';
}
}

function selectVideoPart() {
const startTime = parseInt(startTimeInput.value);
const endTime = parseInt(endTimeInput.value);

if (!isNaN(startTime) && !isNaN(endTime) && startTime < endTime) {
video.currentTime = startTime;
video.play();

setTimeout(() => {
video.pause();
video.currentTime = endTime;
}, (endTime - startTime) * 1000);
} else {
alert('Please enter valid start and end times.');
}
}

function startSpin() {
// Get the icon element
var icon = document.getElementById('spinIcon');
icon.hidden = false;

// Disable the button temporarily to prevent multiple clicks
document.getElementById('spinButton').disabled = true;
document.getElementById('encodeForm').submit();

}
</script>
</body>
</html>
20 changes: 11 additions & 9 deletions twopass/twopass.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
import json
import os
from datetime import datetime
from pathlib import Path

logging.getLogger().setLevel(logging.INFO)


class TwoPass:
def __init__(
self,
filename: str,
filename: Path,
target_filesize: float,
output_dir: str = "",
times: dict = {},
Expand Down Expand Up @@ -49,7 +50,7 @@ def __init__(
self.codec = codec

self.filename = filename
self.fname = self.filename.replace("\\", "/").split("/")[-1]
self.fname = filename.name
self.split_fname = self.fname.split(".")
self.output_dir = output_dir

Expand All @@ -74,13 +75,6 @@ def __init__(
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"
Expand Down Expand Up @@ -217,6 +211,14 @@ def run(self) -> float:
Perform the CPU-intensive encoding job
:return: the output file's size
"""

self.output_filename = (
self.output_dir
+ "small_"
+ self.filename.stem.replace(" ", "_")
+ datetime.strftime(datetime.now(), "_%Y%m%d%H%M%S.mp4")
)

# generate run parameters
self.create_bitrate_dict()
params = self.generate_params(codec=self.codec)
Expand Down
10 changes: 5 additions & 5 deletions utils/arguments.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from argparse import ArgumentParser, Namespace
from argparse import ArgumentParser, Namespace, BooleanOptionalAction


def get_args() -> Namespace:
Expand All @@ -7,7 +7,7 @@ def get_args() -> Namespace:
description="Video compression script.",
epilog="Compress those sick clips, boi.",
)

parser.add_argument("--web", action=BooleanOptionalAction)
parser.add_argument("filename", help="The full file path of the file that you wish to compress.")
parser.add_argument(
"-o",
Expand All @@ -22,11 +22,11 @@ def get_args() -> Namespace:
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.")
parser.add_argument("-a", "--audio-br", type=float, default=96, 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.")
parser.add_argument("-x", "--crop", default="", help="Cropping dimensions. Example: 255x0x1410x1080")
parser.add_argument("-r", "--resolution", default="", help="The output resolution of your final video.")

# configuraiton json file
parser.add_argument("--config", help="JSON file containing the run's configuration")
Expand Down

0 comments on commit 9b64832

Please sign in to comment.