TL;DR
We'll package one encode two ways with FFmpeg (MPEG-TS and fMP4), diff the playlists, then add an HEVC rung and catch the
hev1vshvc1tag problem with a one-lineffprobecheck you can drop into CI.📦 Code: github.com/USER/hls-fmp4 (replace before publishing)
HLS used to mean .ts files. Today the spec requires fragmented MP4 for HEVC, AV1 and several newer audio codecs, so if you plan to add any of them you'll want fMP4 in the pipeline anyway. #ffmpeg #video
Versions used: ffmpeg 6.1.1 with libx264 and libx265, ffprobe, and a Linux shell. Newer FFmpeg should behave the same, but re-check the tag defaults in step 5.
1. 🎥 Make a test clip
ffmpeg -y -f lavfi -i "testsrc2=size=1280x720:rate=24:duration=60" \
-f lavfi -i "sine=frequency=440:duration=60" \
-c:v libx264 -preset veryfast -pix_fmt yuv420p -c:a aac -shortest src.mp4
2. 📦 Package as MPEG-TS
# ts.sh
mkdir -p ts
C="-c:v libx264 -preset veryfast -g 144 -keyint_min 144 -sc_threshold 0 -c:a aac -b:a 128k"
ffmpeg -y -i src.mp4 $C -f hls -hls_time 6 -hls_playlist_type vod \
-hls_segment_type mpegts -hls_segment_filename 'ts/seg_%03d.ts' ts/index.m3u8
head -8 ts/index.m3u8
#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,
💡 Tip:
-g 144 -keyint_min 144 -sc_threshold 0makes segments exactly 6 s at 24 fps. Without it the muxer cuts on the next keyframe, which can be 10 s away.
3. 📦 Package as fMP4
Same encode settings, two changed flags:
# fmp4.sh
mkdir -p fm
C="-c:v libx264 -preset veryfast -g 144 -keyint_min 144 -sc_threshold 0 -c:a aac -b:a 128k"
ffmpeg -y -i src.mp4 $C -f hls -hls_time 6 -hls_playlist_type vod \
-hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4 \
-hls_segment_filename 'fm/seg_%03d.m4s' fm/index.m3u8
head -9 fm/index.m3u8
ls fm
#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
#EXTINF:6.000000,
index.m3u8 init.mp4 seg_000.m4s seg_001.m4s ...
Two differences to remember:
| MPEG-TS | fMP4 | |
|---|---|---|
EXT-X-VERSION |
3 | 7 |
| Init segment | none |
init.mp4 via EXT-X-MAP
|
| Segment extension | .ts |
.m4s |
The RFC says each fMP4 segment must have an EXT-X-MAP tag, and EXT-X-MAP needs compatibility version 6 or higher (RFC 8216 3.3 and 4.3.2.5). FFmpeg set version 7 for us.
4. 📏 Compare sizes
cat ts/*.ts | wc -c
cat fm/init.mp4 fm/*.m4s | wc -c
17086568
16523545
That's about 3 percent smaller for fMP4 on this clip. Don't generalize it: this is a synthetic test pattern, and real catalogues will differ. Run it on your own titles.
⚠️ Note: if you try to
ffprobea lone.m4sfile you'll seetrun track id unknown, no tfhd was foundanderror reading header. The media segments aren't playable withoutinit.mp4. Probe the init segment, or concatenate init + segment first.
5. 🪤 Add an HEVC rung and hit the tag trap
Apple's authoring spec says HEVC in HLS must be in fMP4, so we're already in the right container. Now package it the naive way:
# hevc_naive.sh
ffmpeg -y -i src.mp4 -c:v libx265 -g 144 -keyint_min 144 -x265-params scenecut=0 \
-c:a aac -f hls -hls_time 6 -hls_playlist_type vod \
-hls_segment_type fmp4 -hls_fmp4_init_filename init_naive.mp4 \
-hls_segment_filename 'hn_%03d.m4s' hn.m3u8
ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,codec_tag_string -of csv=p=0 init_naive.mp4
hevc,hev1
hev1 keeps parameter sets inside the samples. The spec recommends avc1, hvc1 and dvh1 over avc3, hev1 and dvhe (Apple authoring spec 1.10). Add the tag:
# hevc_fixed.sh
ffmpeg -y -i src.mp4 -c:v libx265 -tag:v hvc1 -g 144 -keyint_min 144 \
-x265-params scenecut=0 -c:a aac -f hls -hls_time 6 -hls_playlist_type vod \
-hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4 \
-hls_segment_filename 'hv_%03d.m4s' hv.m3u8
ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,codec_tag_string -of csv=p=0 init.mp4
hevc,hvc1
💡 Tip: the stream plays in desktop browsers either way, which is why this slips through. I did not test on Apple hardware here, so treat "may not play" as the risk, and check on a real device before shipping.
6. ✅ A tiny CI check
# scripts/check_tag.sh
set -euo pipefail
tag=$(ffprobe -v error -select_streams v:0 -show_entries stream=codec_tag_string -of csv=p=0 "$1")
case "$tag" in
avc1|hvc1|dvh1) echo "OK: $tag" ;;
*) echo "FAIL: codec tag is $tag, expected avc1/hvc1/dvh1"; exit 1 ;;
esac
$ ./scripts/check_tag.sh init_naive.mp4
FAIL: codec tag is hev1, expected avc1/hvc1/dvh1
$ ./scripts/check_tag.sh init.mp4
OK: hvc1
7. 🧭 Master playlist and CODECS
Apple's spec requires CODECS on every EXT-X-STREAM-INF. FFmpeg's -var_stream_map writes it for you:
ffmpeg -y -i src.mp4 -c:v libx264 -preset veryfast -c:a aac -f hls \
-hls_segment_type fmp4 -hls_time 6 -master_pl_name master.m3u8 \
-var_stream_map "v:0,a:0" -hls_segment_filename 'm_%v_%03d.m4s' m_%v.m3u8
grep STREAM-INF master.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=75900,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
⚠️ Note:
BANDWIDTH=75900here is clearly too low for a real ladder; I did not set VOD playlist type or bitrates on this quick example. Always setBANDWIDTHfrom real measured bitrates before shipping.
What's next
- Wire the tag check into your packaging tests for every rendition.
- Add
-g/-keyint_minalignment across rungs (see the segment audit post if you have it). - Read Apple's HLS authoring spec sections on video and playlists once; they're short.
Wrapping up: fMP4 is two flag changes away, and the HEVC tag is the one thing that bites. Which codec are you adding next?
Top comments (0)