Blog
Engineering 5 min read

Generate HLS from an mp4 with ffmpeg

The exact ffmpeg command for an adaptive HLS ladder from an mp4 — bitrate ladder, keyframe alignment, and the gotchas that break ABR switching.

rehelios

Engineering

To generate HLS from an mp4 with ffmpeg, encode the source into two or more bitrate/resolution renditions with aligned keyframes, segment each one into .ts (or fMP4) chunks with -hls_time, and write a master .m3u8 that lists all of them via -var_stream_map. A single ffmpeg -i in.mp4 -f hls out.m3u8 command only produces one rendition — that’s HLS, but it isn’t adaptive, and it’s the mistake almost everyone makes on the first attempt.

The command: a real adaptive ladder in one ffmpeg call

This produces three renditions (1080p, 720p, 480p) plus a master playlist, all in a single pass over the source:

ffmpeg -i input.mp4 \
  -filter_complex "\
[0:v]split=3[v1][v2][v3]; \
[v1]scale=w=1920:h=1080[v1out]; \
[v2]scale=w=1280:h=720[v2out]; \
[v3]scale=w=854:h=480[v3out]" \
  -map "[v1out]" -c:v:0 libx264 -profile:v:0 high -b:v:0 5000k -maxrate:v:0 5350k -bufsize:v:0 7500k \
  -map "[v2out]" -c:v:1 libx264 -profile:v:1 main -b:v:1 2800k -maxrate:v:1 2996k -bufsize:v:1 4200k \
  -map "[v3out]" -c:v:2 libx264 -profile:v:2 main -b:v:2 1400k -maxrate:v:2 1498k -bufsize:v:2 2100k \
  -map a:0 -c:a:0 aac -b:a:0 128k -ac 2 \
  -map a:0 -c:a:1 aac -b:a:1 128k -ac 2 \
  -map a:0 -c:a:2 aac -b:a:2 96k  -ac 2 \
  -preset veryfast -g 48 -keyint_min 48 -sc_threshold 0 \
  -force_key_frames "expr:gte(t,n_forced*2)" \
  -f hls -hls_time 4 -hls_playlist_type vod -hls_flags independent_segments \
  -hls_segment_filename "v%v/segment%d.ts" \
  -master_pl_name master.m3u8 \
  -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \
  v%v/prog_index.m3u8

Run that and you get v0/, v1/, v2/ directories each with their own media playlist and segments, plus master.m3u8 in the working directory referencing all three. Point hls.js or a native <video> tag at master.m3u8 and the player switches renditions on its own as bandwidth changes — that’s what “adaptive” actually means, and it only happens because the master playlist lists multiple EXT-X-STREAM-INF variants for the player to choose between.

Why one rendition isn’t adaptive

ffmpeg -i input.mp4 -f hls -hls_time 6 out.m3u8 is a completely valid command and it produces a working .m3u8 with segments. It is not adaptive bitrate streaming — there’s exactly one quality level, so a viewer on a bad connection gets the same bitrate as one on fiber, just buffering more. Adaptive HLS requires at least two renditions and a master playlist that ties them together with bandwidth/resolution metadata, which is what -var_stream_map and -master_pl_name do above. The ladder itself (how many renditions, which resolutions and bitrates) is its own design problem — we cover the reasoning in the bitrate ladder guide and how ABR selection works client-side in adaptive bitrate streaming.

Keyframe alignment: the gotcha that breaks mid-stream switching

This is where most DIY pipelines quietly fail. For a player to switch from the 720p stream to the 480p stream between segments, every rendition’s segments have to start on a keyframe at the same timestamp. If GOP lengths drift between renditions — or drift within a single rendition because the source has variable frame rate — segments won’t align, and switching either stalls or glitches.

Three flags fix this:

  • -g 48 -keyint_min 48 forces a fixed GOP length (48 frames — 2 seconds at 24fps) with no flexibility, applied identically to every rendition since they’re encoded from the same filter graph in one command.
  • -sc_threshold 0 disables ffmpeg’s scene-cut detection, which otherwise inserts extra keyframes wherever it detects a hard cut — exactly the kind of GOP-length drift that breaks alignment.
  • -force_key_frames "expr:gte(t,n_forced*2)" is the belt-and-suspenders version: it forces a keyframe every 2 seconds by wall-clock time regardless of frame rate quirks, which matters if the source isn’t a clean constant frame rate.

-hls_time 4 tells the HLS muxer to target 4-second segments, but it always cuts on the next keyframe, not at the exact second — so the GOP size has to divide evenly into the segment duration (2s GOP → 4s segments = 2 GOPs per segment) or you get uneven segment lengths across renditions.

Segment duration, playlist type, and the fMP4 option

-hls_time is a target, not a guarantee — shorter segments (2-4s) mean faster ABR switching and lower start latency but more HTTP requests and playlist overhead; longer segments (6-10s) compress better and mean fewer requests but slower reaction to bandwidth drops. For pre-recorded video, -hls_playlist_type vod is what you want — it emits #EXT-X-PLAYLIST-TYPE:VOD and writes the full segment list once, versus the default event behavior meant for live streams. Swap .ts segments for fMP4 (-hls_segment_type fmp4) if you need the same segments to also serve a DASH manifest without a second encode — see HLS vs DASH for when that trade-off is worth making.

When to stop doing this yourself

The command above works. Running it once on a laptop is not the hard part — the hard part is everything downstream of it, running in production, for every upload, indefinitely:

  • The pipeline. Detecting new uploads, running ffmpeg reliably at scale, retrying failures, handling weird source codecs and variable frame rates that break the flags above in ways you won’t see until they hit real user files.
  • Packaging. Regenerating the ladder when you want a new resolution tier, keeping DASH in sync with HLS if you need both, versioning playlists.
  • Storage and CDN. Segments and playlists need to live somewhere durable and be served from edge locations with correct caching and CORS headers, or ABR switching stalls on latency instead of bandwidth.
  • Signed playback. Token-gating segment URLs so paid content can’t be scraped from the manifest is its own auth layer, not an ffmpeg flag.
  • Monitoring. Knowing when a transcode silently failed or produced misaligned keyframes before a viewer’s player does.

None of that is exotic engineering, but it’s real, ongoing work with real failure modes, and it’s exactly what a managed encoder is for. rehelios encodes to adaptive HLS (and DASH) for free on upload or import-by-URL, and bills only for storage and delivery, per GB — no per-minute encoding meter, no ladder to babysit. We’ve written up the full math on doing this yourself versus paying per GB in the real cost of self-hosting video vs buying; if you’d rather skip straight to the API, the docs cover upload, import, and signed playback end to end.

Put your first video live today

Create an account, upload a file, and have a fast, embeddable video live in minutes. Pay only for what you store and stream.