Packaging HLS with FFmpeg and Bento4
This page sits beneath adaptive bitrate packaging within the broader Media Delivery & Publishing Automation layer, and it walks one concrete, reproducible recipe: take a single conditioned mezzanine file and turn it into a CMAF/fMP4 HLS package — encoder ladder, fragmentation, master playlist, and a validation gate — using FFmpeg for encoding and Bento4 for packaging. The split matters: FFmpeg is an excellent encoder but assembles HLS playlists per-rendition, while mp4hls produces one clean CMAF track set with correct CODECS strings shared across variants.
Problem Framing
The failure this recipe prevents is the silently mis-aligned ladder. When each rendition is encoded independently with scene-cut keyframes left on, the encoder places IDR frames wherever the content changes, so the 720p stream and the 1080p stream cut at different instants. The package still builds and still plays — until a player switches renditions mid-stream and the segments don’t line up. Symptoms show up downstream, not at packaging time:
mediastreamvalidator: ERROR: Segment durations vary between variants (v0=6.006s, v2=5.339s)
mediastreamvalidator: WARNING: EXT-X-TARGETDURATION 7 but max segment is 6.006 (rounding ok)
hls.js: bufferStalledError on level switch 2 -> 0 at 42.0s (fragment boundary mismatch)
A second, quieter failure is the hand-assembled CODECS attribute. If the RESOLUTION, BANDWIDTH, or CODECS string in the master playlist doesn’t match the actual bitstream, players on strict platforms (Apple TV, some smart TVs) refuse the variant outright. Deriving those values from the real fragmented MP4 — which is exactly what mp4hls does — removes the whole class of transcription errors. The rest of this page encodes the ladder so segments are frame-aligned, then lets Bento4 derive the manifest metadata from the bitstream.
Solution Architecture
The pipeline has three deterministic stages and a gate. FFmpeg encodes each rung of the ladder with a fixed GOP equal to fps × segment_duration and scene-cut detection disabled, so every rendition carries an IDR frame on the same wall-clock boundary. mp4fragment rewrites each MP4 into a CMAF-conformant fragmented MP4 with a moof/mdat cadence matching that GOP. mp4hls then consumes the fragmented inputs, cuts CMAF segments on the shared boundaries, and writes the master playlist plus one media playlist per rung. Finally, Apple’s mediastreamvalidator (or an ffprobe fallback) confirms the package before it reaches CDN distribution and cache invalidation.
Because the ladder is encoded once and packaged once, the recipe is deterministic: given the same mezzanine and the same ABR_SEGMENT_DURATION, the segment boundaries are byte-reproducible, which is what makes golden-file testing for transcode output viable on the packaged result.
Implementation
Pin the toolchain — ffmpeg 6.0+ and Bento4 (the mp4-tools distribution, 1.6.0-641 or newer). The driver below encodes three rungs, fragments them, and packages. It reads one environment value, ABR_SEGMENT_DURATION, and drives both the GOP and the segment length from it so the two can never drift apart.
# python 3.10+
# subprocess wrappers around ffmpeg 6.x and Bento4 mp4-tools 1.6.0-641
import os
import subprocess
from pathlib import Path
SEGMENT_S = int(os.environ.get("ABR_SEGMENT_DURATION", "6"))
FPS = 30 # probe this from the mezzanine in production; hard-coded here for clarity
GOP = FPS * SEGMENT_S # one IDR every segment == frame-aligned variant switches
# (name, height, video kbps, audio kbps) — a compact three-rung ladder.
LADDER = [("1080p", 1080, 5000, 128), ("720p", 720, 2800, 128), ("540p", 540, 1400, 96)]
def encode_rung(src: str, out: Path, height: int, vkbps: int, akbps: int) -> Path:
"""Encode one rung with a fixed GOP and scene-cut keyframes disabled."""
dst = out / f"{height}.mp4"
subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-i", src,
"-vf", f"scale=-2:{height}",
"-c:v", "libx264", "-profile:v", "high", "-preset", "medium",
"-b:v", f"{vkbps}k", "-maxrate", f"{int(vkbps*1.1)}k", "-bufsize", f"{vkbps*2}k",
"-x264opts", f"keyint={GOP}:min-keyint={GOP}:no-scenecut",
"-force_key_frames", f"expr:gte(t,n_forced*{SEGMENT_S})",
"-c:a", "aac", "-b:a", f"{akbps}k", "-ac", "2",
"-movflags", "+faststart", str(dst)],
check=True, timeout=1800,
)
return dst
def fragment(mp4: Path) -> Path:
"""Rewrite a progressive MP4 as a CMAF-conformant fragmented MP4."""
frag = mp4.with_suffix(".frag.mp4")
subprocess.run(
["mp4fragment", "--fragment-duration", str(SEGMENT_S * 1000), str(mp4), str(frag)],
check=True, timeout=600,
)
return frag
def package(fragments: list[Path], out_dir: Path) -> None:
"""Build the CMAF HLS master + media playlists from fragmented inputs."""
out_dir.mkdir(parents=True, exist_ok=True)
subprocess.run(
["mp4hls", "--force", "--hls-version", "7", "--segment-duration", str(SEGMENT_S),
"--output-dir", str(out_dir), "--master-playlist-name", "master.m3u8",
*[str(f) for f in fragments]],
check=True, timeout=600,
)
def build(src: str, work: str = "/var/lib/mediapipe/hls") -> Path:
work_dir = Path(work)
raw = work_dir / "raw"; raw.mkdir(parents=True, exist_ok=True)
frags = [fragment(encode_rung(src, raw, h, v, a)) for _, h, v, a in LADDER]
package(frags, work_dir / "package")
return work_dir / "package" / "master.m3u8"
The load-bearing flags are keyint=GOP:min-keyint=GOP:no-scenecut (a rigid GOP) plus -force_key_frames on the same cadence as belt-and-braces insurance. --hls-version 7 tells mp4hls to emit fMP4 (CMAF) segments with an EXT-X-MAP init segment rather than legacy MPEG-TS. The exact segment length is a delivery decision worked through in tuning HLS segment duration for seek latency — this recipe just makes the encoder and packager obey a single source of truth for it.
Verification
Confirm three properties before publishing: the master playlist exists with all rungs, segment durations match across variants, and the package passes a validator.
# 1. The master playlist should list one STREAM-INF per rung with derived CODECS strings.
cat /var/lib/mediapipe/hls/package/master.m3u8
grep -c "EXT-X-STREAM-INF" /var/lib/mediapipe/hls/package/master.m3u8 # expect 3
# 2. Segment boundaries must agree across variants (durations equal, last one may differ).
for v in media-1 media-2 media-3; do
echo "== $v =="; grep "EXTINF" /var/lib/mediapipe/hls/package/$v/$v.m3u8 | head -3
done
# 3. Authoritative check: Apple's mediastreamvalidator (ships with the HLS Tools).
mediastreamvalidator /var/lib/mediapipe/hls/package/master.m3u8
# 3b. Fallback when the Apple tool is unavailable: confirm each init segment decodes.
ffprobe -v error -show_entries stream=codec_name,width,height \
-of default=noprint_wrappers=1 /var/lib/mediapipe/hls/package/media-1/init.mp4
A healthy package shows identical EXTINF durations across the three media playlists (within rounding), a mediastreamvalidator run with no ERROR lines, and CODECS strings in master.m3u8 such as avc1.640028,mp4a.40.2 that match the probed bitstream. Any variance in segment duration means the GOP alignment failed — re-encode with the fixed-GOP flags before packaging again.
Failure Modes & Edge Cases
| Edge case | Symptom | Remediation |
|---|---|---|
| Scene-cut keyframes left on | mediastreamvalidator reports varying segment durations |
Add no-scenecut and pin keyint=min-keyint=fps×seg |
fps mismatched to real frame rate |
GOP is not a whole number of segments; drift accumulates | Probe r_frame_rate with ffprobe and compute GOP from it |
| Variable frame rate source | Fragment boundaries wander even with fixed GOP | Force CFR with -vsync cfr -r <fps> before encoding |
mp4fragment skipped |
mp4hls rejects a progressive MP4 or emits TS |
Always fragment first; feed only .frag.mp4 to mp4hls |
| Audio-only rung requested | Player has no video fallback ladder | Keep audio muxed per rung, or add a dedicated audio group |
| Legacy device targeting | fMP4 unsupported on very old clients | Emit a second MPEG-TS package (--hls-version 3) as a fallback |
For the multi-format case — one CMAF track set shared across HLS and MPEG-DASH — swap mp4hls for mp4dash, which consumes the same fragmented inputs and is covered in the parent adaptive bitrate packaging guide.
FAQ
Why fragment with mp4fragment before mp4hls instead of packaging the MP4 directly?
Bento4 packagers expect a fragmented (CMAF) MP4 so they can cut segments on existing moof boundaries rather than rewriting the whole file. Feeding a progressive MP4 either fails or forces a fallback path. Fragmenting first with a fragment duration equal to your segment length guarantees the packager cuts exactly where the encoder placed IDR frames.
Can I skip Bento4 and let FFmpeg write the HLS playlists?
You can, using the FFmpeg hls muxer with -hls_segment_type fmp4. The reason to reach for Bento4 is a single CMAF track set shared cleanly across HLS and DASH, byte-range single-file output, and CODECS strings derived from the actual bitstream instead of hand-assembled. For an HLS-only pipeline the FFmpeg muxer is often enough.
How do I know the CODECS string is correct?
Let mp4hls derive it — it reads the real sample entries and writes the matching RFC 6381 codec string into each EXT-X-STREAM-INF. Verify by cross-checking against ffprobe on the init segment: the profile/level in avc1.640028 must match the encoded profile (High) and level. A mismatched string is what makes strict TV players silently drop a variant.
Related
- Up to the parent guide: Adaptive Bitrate Packaging
- Sibling decision: Tuning HLS segment duration for seek latency
- Where alignment starts: Extracting keyframe timestamps from MP4
- Next stage: CDN distribution and cache invalidation
- Pipeline overview: Media Delivery & Publishing Automation