Skip to content

Instantly share code, notes, and snippets.

@stuartlangridge
Created April 16, 2025 06:43
Show Gist options
  • Save stuartlangridge/c0ae266306604c7db23e9855eea83976 to your computer and use it in GitHub Desktop.
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
#!/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