Created
April 16, 2025 06:43
-
-
Save stuartlangridge/c0ae266306604c7db23e9855eea83976 to your computer and use it in GitHub Desktop.
makehls, a Python script that makes an HLS streamable version of a video by constructing an ffmpeg command line
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
To make a video display in a web page so it adapts to the user, | |
you want to do two things: | |
1. serve the video at a size that matches the user's screen and | |
device. This is the job of responsive video, and is described by | |
https://scottjehl.com/posts/using-responsive-video/ very well | |
2. serve the video at different bitrates/compressions/sizes etc | |
so that the browser can adapt based on bandwidth to keep streaming | |
the video and not buffering. This is the job of HLS, which is | |
basically breaking up the video into a load of little bits and | |
then providing an m3u8 playlist which links to them all, and the | |
browser knows how to use that playlist to get the next bit of the | |
video to stream. Safari does HLS natively, other browsers don't, | |
but hls.js (https://github.com/video-dev/hls.js/) polyfills it. | |
Creating the different little bits (.ts files) and the playlist is | |
something ffmpeg knows how to do, and is well described at | |
https://www.mux.com/articles/how-to-convert-mp4-to-hls-format-with-ffmpeg-a-step-by-step-guide | |
Taking a video file and constructing all this stuff to give a bunch | |
of files and an HTML snippet... is the job of this script. | |
""" | |
import sys | |
import argparse | |
import os | |
import logging | |
import subprocess | |
import time | |
default_outputs = [ | |
# (width height video output bitrate in k audio bitrate in k) | |
(1920, 1080, 5000, 192), | |
(1280, 720, 2800, 128), | |
( 854, 480, 1400, 96), | |
( 320, 180, 800, 64), | |
] | |
SNIPPET = """ | |
<!-- copy this into your HTML file and adjust videoSrc --> | |
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script> | |
<video id="hlsv" controls></video> | |
<script> | |
const video = document.getElementById('hlsv'); | |
const videoSrc = '$FN$/master.m3u8'; | |
// First check for native browser HLS support | |
if (video.canPlayType('application/vnd.apple.mpegurl')) { | |
video.src = videoSrc; | |
// If no native HLS support, check if HLS.js is supported | |
} else if (Hls.isSupported()) { | |
const hls = new Hls(); | |
hls.loadSource(videoSrc); | |
hls.attachMedia(video); | |
} | |
</script> | |
""" | |
def process(vf, od, outputs): | |
logging.info("Processing video file '%s'", vf) | |
logging.info("Outputting results into directory '%s'", od) | |
logging.info("Making output videos of sizes '%s'", | |
",".join([f"{w}x{h}" for (w, h, vb, ab) in outputs])) | |
# construct ffmpeg command line | |
# parameters | |
hls_time = 10 # seconds | |
prelude = ["-i", vf] | |
hls = [ | |
"-f", "hls", | |
"-hls_time", str(hls_time), | |
"-hls_playlist_type", "vod", | |
"-hls_flags", "independent_segments", | |
"-hls_segment_type", "mpegts", | |
"-hls_segment_filename", f"{od}/stream_%v/data%03d.ts", | |
"-master_pl_name", "master.m3u8" | |
] | |
# fcv0 is [0:v]split=3[v1][v2][v3] | |
fcv0 = "[0:v]split=" + str(len(outputs)) + "".join([f"[v{n}]" for n in range(1, len(outputs)+1)]) | |
# each fcv is [v1]scale=w=1920:h=1080[v1out] | |
fcvs = [f"[v{n+1}]scale=w={w}:h={h}[v{n+1}out]" for (n, (w, h, vb, ab)) in enumerate(outputs)] | |
fcv = "; ".join([fcv0] + fcvs) | |
filter_complex = ["-filter_complex", fcv] | |
# each vmap is -map "[v1out]" -c:v:0 libx264 -b:v:0 5000k -maxrate:v:0 5350k -bufsize:v:0 7500k | |
vmaps = [] | |
for (n, (w, h, vb, ab)) in enumerate(outputs): | |
maxrate = int(vb * 1.07) | |
bufsize = int(vb * 1.5) | |
map_param = [ | |
"-map", f"[v{n+1}out]", | |
f"-c:v:{n}", "libx264", | |
f"-b:v:{n}", f"{vb}k", | |
f"-maxrate:v:{n}", f"{maxrate}k", | |
f"-bufsize:v:{n}", f"{bufsize}k" | |
] | |
vmaps += map_param | |
# each amap is -map a:0 -c:a aac -b:a:0 192k -ac 2 | |
amaps = [] | |
for (n, (w, h, vb, ab)) in enumerate(outputs): | |
map_param = [ | |
"-map", f"a:0", "-c:a", "aac", f"-b:a:{n}", f"{ab}k", "-ac", "2" | |
] | |
amaps += map_param | |
var_stream_map = [ | |
"-var_stream_map", | |
" ".join([f"v:{n},a:{n}" for n in range(len(outputs))]), | |
f"{od}/stream_%v/playlist.m3u8" | |
] | |
cmd = ["ffmpeg"] + prelude + filter_complex + vmaps + amaps + hls + var_stream_map | |
logging.debug(" ".join(cmd)) | |
# Create the output directory | |
os.makedirs(od) | |
# write the HTML snippet | |
snippet = SNIPPET.replace("$FN$", os.path.basename(od)) | |
htmlfile = os.path.join(od, "snippet.html") | |
with open(htmlfile, mode="w") as fp: | |
fp.write(snippet) | |
logging.info("Example HTML usage snippet written to '%s'", htmlfile) | |
# and finally execute the ffmpeg command | |
start = time.time() | |
proc = subprocess.run(cmd, universal_newlines=True, capture_output=True) | |
end = time.time() | |
logging.info("Conversion to HLS complete in '%s' seconds", int(end - start)) | |
if __name__ == "__main__": | |
parser = argparse.ArgumentParser() | |
parser.add_argument("videofile", help="the video file to convert") | |
parser.add_argument("--outputdir", | |
help="the directory where output will be placed. " | |
"Must not already exist, and will be created. " | |
"Defaults to <videofile>_data.") | |
do = ",".join([f"{h}" for (w, h, vb, ab) in default_outputs]) | |
parser.add_argument("--outputs", | |
help="which output video resolutions (by height) to generate, " | |
f"comma-separated. Defaults to all possible, which is {do}.", | |
default=do) | |
parser.add_argument("--debug", | |
help="turn on detailed logging of what's going on", | |
action="store_true") | |
args = parser.parse_args() | |
logging.basicConfig(format='%(message)s', | |
level=logging.DEBUG if args.debug else logging.INFO) | |
outputdir = args.outputdir if args.outputdir else f"{args.videofile}_data" | |
if os.path.exists(outputdir): | |
logging.fatal("The output dir '%s' already exists. It must not.", outputdir) | |
sys.exit(1) | |
if not os.path.exists(args.videofile): | |
logging.fatal("The video file '%s' doesn't exist.", args.videofile) | |
sys.exit(2) | |
outputs = [] | |
for requestedh in args.outputs.split(","): | |
matches = [(w, h, vb, ab) for (w, h, vb, ab) in default_outputs if requestedh == str(h)] | |
if not matches: | |
logging.fatal(f"You asked for an output called '%s', which I don't know about. (I know about {do})", requestedh) | |
sys.exit(3) | |
outputs.append(matches[0]) | |
outputs.sort(reverse=True) | |
process(args.videofile, outputdir, outputs) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment