HLS is a video cut into short files plus a text playlist that lists them. A player reads the playlist, fetches the pieces in order, and with several quality levels it switches between them to suit the connection. Nothing about it needs a streaming server: you make the files once and put them on any web host or CDN.
A 720p clip packaged in the browser: a master playlist, a rendition playlist and its segments, in one ZIP.
The single-rendition version is one command. It re-encodes to H.264 and AAC, puts a keyframe every 6 seconds, and cuts there:
ffmpeg -i input.mp4 -c:v libx264 -preset veryfast -crf 21 \
-force_key_frames "expr:gte(t,n_forced*6)" -c:a aac -b:a 128k \
-f hls -hls_time 6 -hls_playlist_type vod \
-hls_segment_filename 'seg_%03d.ts' index.m3u8
Install it with brew install ffmpeg on a Mac or apt install ffmpeg on
Debian and Ubuntu. On a 30-second 1080p clip it wrote index.m3u8 and five
segments of exactly 6 seconds:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.000000,
seg_000.ts
#EXTINF:6.000000,
seg_001.ts
...
#EXTINF:6.000000,
seg_004.ts
#EXT-X-ENDLIST
Every command here was run with FFmpeg 7.1.5 (Debian 13) and the output shown is what it printed.
Why the keyframe flag matters
The segmenter can only cut where a new segment can be decoded on its own,
which is at a keyframe. -hls_time 6 is a target, not a guarantee: FFmpeg
cuts at the first keyframe after 6 seconds. Leave the keyframes to the encoder
and they land wherever it likes. The same 30 fps clip, without
-force_key_frames:
#EXT-X-TARGETDURATION:8
#EXTINF:8.333333,
seg_000.ts
#EXTINF:8.333333,
seg_001.ts
#EXTINF:8.333333,
seg_002.ts
#EXTINF:5.000000,
seg_003.ts
8.33 seconds is x264's default maximum gap between keyframes (250 frames) at
30 fps. -force_key_frames "expr:gte(t,n_forced*N)" places one every N
seconds whatever the frame rate, so N should always equal -hls_time. The
older way is -g with a frame count (-g 180 is 6 seconds only at 30 fps),
which goes wrong the moment a source arrives at 25 or 60 fps.
Six seconds is the segment length Apple recommends in its HLS authoring guidance. Shorter segments start faster and switch quality sooner, at the cost of more files and requests.
Several quality levels and a master playlist
For adaptive streaming, encode the same video at several sizes and bitrates
and add a master playlist that lists them. One FFmpeg run does all of it:
split makes three copies of the picture, scale sizes each one, and
-var_stream_map says which video and audio streams form each rendition.
ffmpeg -i input.mp4 \
-filter_complex "[0:v]split=3[a][b][c];[a]scale=-2:1080[v0];[b]scale=-2:720[v1];[c]scale=-2:360[v2]" \
-map "[v0]" -map "[v1]" -map "[v2]" -map 0:a -map 0:a -map 0:a \
-c:v libx264 -preset veryfast -force_key_frames "expr:gte(t,n_forced*6)" \
-b:v:0 5000k -maxrate:v:0 5500k -bufsize:v:0 10000k \
-b:v:1 2800k -maxrate:v:1 3000k -bufsize:v:1 5600k \
-b:v:2 800k -maxrate:v:2 900k -bufsize:v:2 1600k \
-c:a aac -b:a 128k -ac 2 \
-f hls -hls_time 6 -hls_playlist_type vod -hls_flags independent_segments \
-hls_segment_filename 'v%v/seg_%03d.ts' \
-master_pl_name master.m3u8 \
-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" 'v%v/index.m3u8'
%v becomes the rendition number, so you get a folder per quality and one
master.m3u8 at the top:
.
|-- master.m3u8
|-- v0
| |-- index.m3u8
| |-- seg_000.ts
| ...
|-- v1
| |-- index.m3u8
| ...
`-- v2
|-- index.m3u8
...
FFmpeg writes the master playlist from what it actually encoded, with the measured bandwidth, the frame size and the codec strings a player uses to decide what it can play:
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-STREAM-INF:BANDWIDTH=5222389,AVERAGE-BANDWIDTH=5175163,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
v0/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2992709,AVERAGE-BANDWIDTH=2954608,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
v1/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=965568,AVERAGE-BANDWIDTH=949324,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2"
v2/index.m3u8
scale=-2:720 sets the height and works out the width from the source's
shape, rounded to an even number. A 4:3 source comes out 960×720 and a
portrait phone video 406×720, never stretched. The bitrates are a starting
point for 16:9 H.264; Apple's authoring guidance has a full ladder. Keep
-force_key_frames the same for every rendition, as it is here, so the
segments line up and the player can switch between them at any boundary.
fMP4 segments instead of .ts
Add two options and the same encode is written as fragmented MP4: an
init.mp4 with the setup data and .m4s media segments.
ffmpeg -i input.mp4 -c:v libx264 -preset veryfast -crf 21 \
-force_key_frames "expr:gte(t,n_forced*6)" -c:a aac -b:a 128k \
-f hls -hls_time 6 -hls_playlist_type vod \
-hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4 \
-hls_segment_filename 'seg_%03d.m4s' index.m3u8
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="init.mp4"
#EXTINF:6.000000,
seg_000.m4s
...
For H.264 there is no need to switch: .ts plays everywhere HLS plays. Use
fMP4 when you encode HEVC (Apple's players take HEVC in fMP4 segments), or
when the same segments also have to feed a DASH or CMAF setup. The same two
options work in the ladder command.
Check what you made
Apple's mediastreamvalidator is the thorough check, but it is a macOS
download from Apple's streaming tools page
and needs a developer sign-in. FFmpeg itself covers the three things that
usually go wrong.
:::steps title="Three checks"
-
The playlist parses and lists every rendition.
ffprobereads a master playlist as a set of programs:ffprobe -v error -show_entries program=program_id:program_tags=variant_bitrate:stream=codec_name,width,height -of compact master.m3u8program|program_id=0|tag:variant_bitrate=5222389|stream|codec_name=h264|width=1920|height=1080 stream|codec_name=aac program|program_id=1|tag:variant_bitrate=2992709|stream|codec_name=h264|width=1280|height=720 stream|codec_name=aac program|program_id=2|tag:variant_bitrate=965568|stream|codec_name=h264|width=640|height=360 stream|codec_name=aac -
Every segment starts on a keyframe. The first frame of each
.tsshould be anIframe:for s in seg_*.ts; do printf "%s " $s; ffprobe -v error -select_streams v -show_entries frame=pict_type -read_intervals "%+#1" -of default=nw=1:nk=1 $s | head -1; doneseg_000.ts I seg_001.ts I seg_002.ts I seg_003.ts I seg_004.ts IAnything other than
Ion a segment means the cut fell between keyframes. The#EXTINFlines in the playlist should match your-hls_time, apart from the last segment. -
It plays end to end. Decoding every rendition to nowhere reads each segment the playlists list, and prints nothing when all is well:
ffmpeg -v error -i master.m3u8 -map 0 -f null - && echo "exit $?"exit 0
:::
Then open it in a real player. Safari plays an .m3u8 in a plain <video>
tag; other browsers need a player library such as hls.js or Video.js.
Serving it
HLS is static files. Upload the whole folder, keep the relative paths, and
point the player at master.m3u8. Two headers decide whether it plays.
Content types. The HLS specification (RFC 8216)
says a playlist must be served as application/vnd.apple.mpegurl (or
audio/mpegurl). nginx 1.26's stock mime.types already has
application/vnd.apple.mpegurl for .m3u8, video/mp2t for .ts and
video/mp4 for .mp4, but no entry for .m4s, which then goes out as the
default type. On an object store or CDN, check the type each file was uploaded
with.
CORS. When the player page and the files are on different domains, a
player library fetches the playlist and segments with JavaScript, and the
browser blocks them unless the files carry Access-Control-Allow-Origin.
An nginx block that sets both:
location ~ \.(m3u8|ts|m4s|mp4)$ {
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
video/iso.segment m4s;
video/mp4 mp4;
}
add_header Access-Control-Allow-Origin "*";
}
A types block inside a location replaces the whole list for that location,
which is why all four are named. Checked with curl -I against nginx 1.26.3, with the ladder in c/ and the fMP4 encode in e/:
-- c/master.m3u8
HTTP/1.1 200 OK
Content-Type: application/vnd.apple.mpegurl
Access-Control-Allow-Origin: *
-- c/v0/seg_000.ts
HTTP/1.1 200 OK
Content-Type: video/mp2t
Access-Control-Allow-Origin: *
-- e/seg_000.m4s
HTTP/1.1 200 OK
Content-Type: video/iso.segment
Access-Control-Allow-Origin: *
* lets any site embed your stream. Put your own site's origin there instead
if that matters. FFmpeg also played the ladder over HTTP from that server
without an error.
In the browser
The HLS Stream Generator runs FFmpeg compiled to WebAssembly, so nothing is uploaded. It re-encodes every rendition to H.264 and AAC with a keyframe forced every 4 seconds.
The tool encodes each rendition at a fixed frame size (1280×720 for 720p, 1920×1080 for 1080p) whatever the shape of the source. A portrait or 4:3 video is squeezed into that frame, with only an aspect-ratio flag in the stream to undo it, and the master playlist reports the wrong resolution. For anything that isn't 16:9, use the ladder command above, which keeps the shape.
The settings appear once a video is loaded; 720p and 1080p are on by default.
- Open the HLS Stream Generator and drop the video in. Most formats work, MP4, MOV, MKV, WebM and AVI among them.
- Leave Segment Duration at 4 seconds (Recommended). The keyframes are 4 seconds apart whatever you pick, so the other lengths come out uneven: in our run of the tool's FFmpeg arguments, 6 seconds gave segments of 8, 4, 8, 4 and 6 seconds.
- Under Quality Variants, tick the levels you want. With Auto-filter variants based on input resolution on, levels larger than the source are skipped, so a 720p clip gets only the 720p rendition.
- Press Generate HLS Stream. The time depends on the length of the video and the number of renditions; each one is a full encode.
- Press Download HLS Package (ZIP). It holds
playlist.m3u8(the master), one playlist per rendition and the.tssegments, all in one folder. Unzip it on your server and point the player atplaylist.m3u8.
Audio only
A podcast or a music stream is the same job without the picture, and there are no keyframes to line up: audio frames are tens of milliseconds long. Two bitrates, the low one in mono:
ffmpeg -i episode.mp3 -map 0:a -map 0:a -vn -c:a aac \
-b:a:0 64k -ac:a:0 1 -b:a:1 128k \
-f hls -hls_time 6 -hls_playlist_type vod \
-hls_segment_filename 'a%v/seg_%03d.ts' -master_pl_name master.m3u8 \
-var_stream_map "a:0 a:1" 'a%v/index.m3u8'
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=72052,AVERAGE-BANDWIDTH=72524,CODECS="mp4a.40.2"
a0/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=144857,AVERAGE-BANDWIDTH=143173,CODECS="mp4a.40.2"
a1/index.m3u8
An MP3 packaged as three AAC renditions.
Whichever route you took, you now have a folder with a master playlist at the top. Upload it as it is, check the two headers, and give your player the URL of the master playlist.