Why HLS Is Still the Streaming Workhorse
HTTP Live Streaming (HLS) packages media as small MPEG-TS segments behind one or
more playlists (.m3u8). The client chooses an appropriate rendition on every
segment request, so you can scale across slow hotel Wi-Fi and fibre-to-the-home
without juggling separate video files. Modern browsers (Safari in particular)
ship native HLS support, while the rest happily run it with
hls.js.
The nice part is that Apple’s original spec still maps cleanly to the tools we use every day:
- ffmpeg can transcode a master video into Adaptive Bitrate (ABR) rungs and generate the playlists.
- Plyr provides a polished HTML5 UI with Picture-in-Picture, AirPlay, and accessibility baked in.
- Next.js + MDX lets us drop the player inside content, documentation, or
landing pages without reaching for
<iframe>embeds.
Encoding the Ladder with a Thin ffmpeg Wrapper
A production-ready workflow usually pairs a client-side React player with a
minimal shell script that wraps ffmpeg. The script turns a single MP4 into a
three-rung ladder (1080p, 720p, 480p) plus a master manifest. Key details:
force_original_aspect_ratio=decreaseprevents accidental upscaling and keeps dimensions divisible by two (a requirement for H.264).- Audio tracks are duplicated across renditions, so the player can switch video qualities without resync headaches.
-hls_flags independent_segmentsforces keyframes at segment boundaries to guarantee seamless switching.
Running it is as simple as invoking ffmpeg with multiple mapped outputs:
ffmpeg -y -i input.mp4 \
-filter_complex "\
[0:v]split=3[v1080][v720][v480]; \
[v1080]scale=w=1920:h=1080:force_original_aspect_ratio=decrease:force_divisible_by=2,format=yuv420p[v1080out]; \
[v720] scale=w=1280:h=720:force_original_aspect_ratio=decrease:force_divisible_by=2,format=yuv420p[v720out]; \
[v480] scale=w=854:h=480:force_original_aspect_ratio=decrease:force_divisible_by=2,format=yuv420p[v480out]" \
-map "[v1080out]" -map a? -c:v:0 libx264 -profile:v:0 high -level:v:0 4.0 -preset veryfast -g 60 -sc_threshold 0 -b:v:0 5000k -maxrate:v:0 5500k -bufsize:v:0 10000k -c:a:0 aac -b:a:0 128k -ac:a:0 2 \
-map "[v720out]" -map a? -c:v:1 libx264 -profile:v:1 high -level:v:1 4.0 -preset veryfast -g 60 -sc_threshold 0 -b:v:1 2800k -maxrate:v:1 3300k -bufsize:v:1 6600k -c:a:1 aac -b:a:1 128k -ac:a:1 2 \
-map "[v480out]" -map a? -c:v:2 libx264 -profile:v:2 main -level:v:2 3.1 -preset veryfast -g 60 -sc_threshold 0 -b:v:2 1200k -maxrate:v:2 1500k -bufsize:v:2 3000k -c:a:2 aac -b:a:2 96k -ac:a:2 2 \
-f hls -hls_time 6 -hls_playlist_type vod -hls_segment_type mpegts -hls_flags independent_segments \
-hls_segment_filename "output-%v-%03d.ts" \
-master_pl_name "master.m3u8" \
-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \
"output_%v.m3u8"You end up with a directory containing a master playlist plus per-rendition playlists and segment files that can be pushed to S3, Cloudflare R2, or any static host.
Handling Safari URL Encoding Quirks
Safari is notoriously strict about URL encoding inside playlists. If a segment
name contains spaces, non-ASCII characters, or even emoji, the browser expects
percent-encoded references inside the .m3u8 even though the underlying object
store serves the raw filename. Two practical safeguards solve this:
-
After ffmpeg finishes, loop over the generated
.m3u8files and rewrite the segment references withurllib.parse.quote. The script below streams the playlist line by line, copies comments verbatim, and percent-encodes anything that will be requested from the CDN:bashfor m3u8 in output/*.m3u8; do tmp=$(mktemp) python3 - "$m3u8" "$tmp" <<'PY' import sys, urllib.parse src, dst = sys.argv[1:] with open(src, 'r', encoding='utf-8') as infile, open(dst, 'w', encoding='utf-8') as outfile: for line in infile: stripped = line.strip('\n') if stripped.startswith('#') or not stripped: outfile.write(line) continue encoded = urllib.parse.quote(stripped, safe='/-._~') outfile.write(encoded + '\n') PY mv "$tmp" "$m3u8" doneSafari will refuse to load
segment 001.tsor场景-01.tsif those names appear raw inside the playlist because it performs an additional decode step before issuing the HTTP request. Pre-encoding them ensures the browser seessegment%20001.tsand matches the object storage key exactly. -
Provide
hls.jswith a custom loader that resolves relative URLs against the manifest’s base URL and normalises the encoding. That way Chromium/Firefox avoid double-encoding while Safari keeps working when the user switches to a non-native rendition.
How the Plyr + HLS Player Works
The React player wraps a vanilla <video> element
and layers on three important behaviours:
- Progressive enhancement: it lazily imports both
plyrandhls.json the client so the server render stays lightweight. - Intelligent feature detection: Safari receives the
.m3u8directly via its native decoder while Chromium/Firefox mount anHlsinstance and listen for fatal errors to attempt recovery or restart playback. - User-gesture safe autoplay: a
createSafePlayHandlerhelper caches the original user gesture so Safari’s autoplay policy is satisfied even after the stream buffers.
That architecture is battle-tested for real marketing pages, and we can reuse most of the ideas here.
Bringing HLS into This Blog
To make the blog self-contained, I added a lightweight
<HlsVideoPlayer /> MDX component that mirrors the behaviour above:
- It dynamically loads
plyrandhls.jsonly when a post renders on the client. - Native HLS playback is preferred when the browser exposes
canPlayType('application/vnd.apple.mpegurl'). - Non-Safari browsers get a tuned
Hlsinstance with worker support and fatal error recovery, plus the custom loader mentioned above to normalise segment URLs. - Plyr’s UI is initialised with an SVG sprite (
public/plyr.svg) so the controls stay visually consistent across the site.
Embed Example
(Sample stream courtesy of the Mux public test feeds.)
You can drop the same component into any MDX document:
<HlsVideoPlayer
src="https://cdn.example.com/videos/product-launch/master.m3u8"
poster="/images/product-launch/poster.jpg"
title="Product launch teaser"
preload="metadata"
playsInline
/>With the pipeline in place, editors can illustrate performance case studies or product walkthroughs with real video, and everything continues to work for users on slow connections, mobile Safari, or desktop Chromium.
If you run into mobile fullscreen quirks, the Video.js HTML5 tech docs outline the platform-specific hooks and events you can reuse to keep controls reliable on iOS and Android. For production QA, this ffprobe-driven quality checklist walks through validating bitrate ladders, caption tracks, and manifest health before you ship new encodes.

Comments