Skip to the tool workspace
Browser 123

How-to Video and audio

How to package a video for HLS streaming

Turn one video file into an HLS stream you can host yourself: the FFmpeg command for a single rendition, a multi-bitrate ladder with a master playlist, fMP4 segments, how to check the result, and how to serve it.

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.

The HLS Stream Generator after a run: HLS Stream Generated, Video Information showing Resolution 1280x720, Frame Rate, Variants and Total Files, the 720p rendition under Generated Quality Variants, the playlist and .ts segments under Generated Files, and the Download HLS Package (ZIP) button

A 720p clip packaged in the browser: a master playlist, a rendition playlist and its segments, in one ZIP.

The direct wayFFmpegmacOS · Linux · Windows

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"

  1. The playlist parses and lists every rendition. ffprobe reads 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.m3u8
    
    program|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
    
  2. Every segment starts on a keyframe. The first frame of each .ts should be an I frame:

    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; done
    
    seg_000.ts I
    seg_001.ts I
    seg_002.ts I
    seg_003.ts I
    seg_004.ts I
    

    Anything other than I on a segment means the cut fell between keyframes. The #EXTINF lines in the playlist should match your -hls_time, apart from the last segment.

  3. 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.

16:9 sources only, for now

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 HLS Configuration panel: General Settings with Segment Duration set to 4 seconds (Recommended), Audio Bitrate and Audio Channels, the Auto-filter variants based on input resolution checkbox, and the 720p and 1080p cards, both ticked, under Quality Variants

The settings appear once a video is loaded; 720p and 1080p are on by default.

  1. Open the HLS Stream Generator and drop the video in. Most formats work, MP4, MOV, MKV, WebM and AVI among them.
  2. 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.
  3. 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.
  4. Press Generate HLS Stream. The time depends on the length of the video and the number of renditions; each one is a full encode.
  5. Press Download HLS Package (ZIP). It holds playlist.m3u8 (the master), one playlist per rendition and the .ts segments, all in one folder. Unzip it on your server and point the player at playlist.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
The Audio HLS Stream Generator after a run: Audio HLS Stream Generated, Audio Information with Sample Rate, Channels, Variants and Total Files, the Low, Medium and High renditions under Generated Quality Variants, the generated files, and the Download Audio HLS Package (ZIP) button

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.

Questions people ask

Why are my segments 8 seconds when I asked for 6?

The segmenter can only cut on a keyframe, and the encoder put its keyframes somewhere else. x264's default is a keyframe at most every 250 frames, which is 8.33 seconds at 30 fps, and that is exactly what a 6-second request gave in our test. Add -force_key_frames "expr:gte(t,n_forced*6)" with the same number as -hls_time and every segment comes out 6.000000 seconds.

Should I use .ts or fMP4 segments?

For H.264 and AAC, either: .ts is the older, widest-supported choice, fMP4 is what HEVC and CMAF workflows need. Swap the container with -hls_segment_type fmp4 and nothing else in the command changes. The playlist then gains an EXT-X-MAP line pointing at the init.mp4 segment.

Can I just upload the folder to any web host?

Yes, HLS is plain files over HTTP and needs no streaming server. Two things to check: the server sends .m3u8 as application/vnd.apple.mpegurl, and, if the player page is on a different domain from the files, the files carry an Access-Control-Allow-Origin header. Stock nginx gets the first right for .m3u8 and .ts but knows nothing about .m4s.

Does HLS stop people downloading my video?

No. The playlist and segments are ordinary files, and anyone with the master playlist URL can fetch every segment and join them back together: ffmpeg -i master.m3u8 does it in one line. Keeping a video private needs encryption with a key server or a DRM service, which is a different job from packaging.

Do I need several quality levels?

Not for a short clip on a fast site. One rendition is a valid HLS stream and plays everywhere HLS plays. The ladder earns its place when viewers are on phones or slow connections: the player starts on a low rendition and steps up as the bandwidth allows, instead of stalling.

Tools in this guide

Everything runs on your device

All guides

/
123 items
Runs in your browser·Your files stay on this device
Out0 B
Ready—