ez-ffmpeg is a Rust crate (no relation to the JavaScript "Ez FFmpeg" project) that runs FFmpeg pipelines inside your process — no subprocess, no hand-written decode loop. As of 0.14 its HLS ladder recipe writes fragmented-MP4 segments as well as MPEG-TS, switched by one enum value, and the crate is on crates.io and GitHub. This article packages HLS both ways, verifies every artifact with ffprobe, and walks through two design decisions that only become visible when something fails.
The first decision: fMP4 or TS
If you are packaging HLS yourself, the container question comes before any code. A decision rather than a definition:
-
Pick fMP4 when you target modern players (hls.js, AVPlayer, ExoPlayer) on a new project. It is the container CMAF standardized on, so HLS and DASH can share one set of segments; header data lives in a single
init.mp4instead of being repeated in every segment; and if you ever head toward LL-HLS, fMP4 is the ground it is built on. - Pick TS when legacy set-top boxes and older smart TVs are a hard requirement. TS also has an operational property fMP4 gives up: every segment is self-contained and independently playable, which makes single-file inspection one step shorter.
Need both? The code below switches with one enum value.
Everything here was compiled and run against FFmpeg 7.1.3 + ez-ffmpeg 0.15; the outputs quoted are real, none are mocked up. What the crate does not fix, stated up front: installing and linking FFmpeg. It sits on the libav libraries, so FFmpeg 7.1–8.x must be present. What it removes is everything after the link succeeds — the hand-written demux/decode/filter/encode/mux loop, or the Command::new("ffmpeg") subprocess and its stderr scraping.
The minimal runnable path: single-bitrate fMP4
[dependencies]
ez-ffmpeg = "0.15" # Rust >= 1.80, FFmpeg 7.1-8.x
use ez_ffmpeg::{FfmpegContext, Output};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// The hls muxer writes into an existing directory; it does not mkdir by default.
std::fs::create_dir_all("hls_single")?;
FfmpegContext::builder()
.input("input.mp4")
.output(
Output::from("hls_single/index.m3u8")
.set_format("hls") // -f hls
.set_video_codec("libx264")
.set_audio_codec("aac")
// Fixed 6 s GOP at 30 fps, no scene-cut keyframes: segment
// boundaries can only land on keyframes, so pin them down.
.set_video_codec_opt("g", "180")
.set_video_codec_opt("sc_threshold", "0")
.set_format_opt("hls_time", "6") // target segment length
.set_format_opt("hls_playlist_type", "vod") // full playlist, ENDLIST at the end
.set_format_opt("hls_segment_type", "fmp4") // .m4s instead of .ts
.set_format_opt("hls_fmp4_init_filename", "init.mp4")
.set_format_opt("hls_segment_filename", "hls_single/seg_%05d.m4s"),
)
.build()?
.start()?
.wait()?;
println!("wrote hls_single/index.m3u8");
Ok(())
}
The input is a 12-second lavfi-generated test clip (640x360, 30 fps, H.264+AAC). Afterwards hls_single/ holds four files — index.m3u8, init.mp4, seg_00000.m4s, seg_00001.m4s — and the playlist reads, verbatim:
#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_00000.m4s
#EXTINF:6.000000,
seg_00001.m4s
#EXT-X-ENDLIST
Twelve seconds split into exactly two 6.000000-second segments, with EXT-X-MAP pointing at the one init segment — the structural signature of fMP4 HLS. The #EXT-X-VERSION:7 is the muxer's doing: EXT-X-MAP requires protocol version 6+, and FFmpeg writes 7 for fMP4 output.
Mapping the options (and the step I got wrong first)
If you know the CLI, the options map one to one — same names, minus the dash. Container options go through set_format_opt, encoder options through set_video_codec_opt; both feed FFmpeg's own AVOption system.
| ffmpeg CLI | ez-ffmpeg |
|---|---|
-f hls |
.set_format("hls") |
-hls_time 6 |
.set_format_opt("hls_time", "6") |
-hls_playlist_type vod |
.set_format_opt("hls_playlist_type", "vod") |
-hls_segment_type fmp4 |
.set_format_opt("hls_segment_type", "fmp4") |
-hls_fmp4_init_filename init.mp4 |
.set_format_opt("hls_fmp4_init_filename", "init.mp4") |
-hls_segment_filename ... |
.set_format_opt("hls_segment_filename", "...") |
-g 180 -sc_threshold 0 |
.set_video_codec_opt("g", "180") etc. |
Those two GOP lines are the step I got wrong. My first version left them out, and the segment durations came out as:
#EXTINF:8.333333,
seg_00000.m4s
#EXTINF:3.666667,
seg_00001.m4s
hls_time=6 is a target, not a command. The muxer can only cut at keyframes, and x264's default keyint is 250 — at 30 fps the first cut opportunity is at 8.33 s, so a request for 6 becomes the reality of 8.33 + 3.67. To make the cuts land on 6, make the keyframes land on 6: g = 6 x 30 = 180, plus sc_threshold=0 so scene-cut detection cannot insert extra ones. With those two lines, you get the 6.000000 + 6.000000 playlist above.
On the builder path this trap is yours to find. The ladder recipe in the next section turns it into a validation rule instead: segment_duration must be an integer multiple of the GOP length, and a violation fails before run() does any work — rather than producing a drifted playlist you discover in production.
Probe before you build
FFmpeg builds differ widely — distro packages, vcpkg, and source builds each trim differently. Instead of failing mid-pipeline, ask up front:
use ez_ffmpeg::capabilities;
fn main() {
// FFmpeg builds differ in what got compiled in. The probe answers for
// *this* linked build, before any job is wired up.
if !capabilities::is_muxer_available("hls") {
eprintln!("linked FFmpeg build has no hls muxer");
std::process::exit(1);
}
// Segments go through the file protocol; probe it the same way. For a
// stream published elsewhere you would probe "http" or "srt" here.
if !capabilities::is_output_protocol_available("file") {
eprintln!("linked FFmpeg build cannot write files");
std::process::exit(1);
}
println!("hls muxer + file output protocol: available");
}
is_muxer_available answers exactly one question — is this muxer compiled into the linked build — and deliberately does not promise that the network or TLS backends a job needs at runtime are also there. One namespace trap worth knowing: muxer names and protocol names are separate worlds. The muxer called srt is the SubRip subtitle format, unrelated to the SRT streaming protocol; protocols go through is_output_protocol_available. Missing encoders are a different path: since 0.14, build() fails with encoder 'libx264' is not available in the linked FFmpeg build — the error names the encoder instead of a generic not-found.
The ABR ladder: one decode, N renditions
A single bitrate is a buffering spinner for anyone on a weak connection. ABR means encoding the same content at several resolution/bitrate pairs and letting the player switch. Done by hand that is: a split filter fan-out, a scale per branch, fixed-GOP keyframe alignment per branch, one hls muxer per branch, and a hand-assembled master playlist. In 0.14 that orchestration is a recipe:
use ez_ffmpeg::recipes::{HlsLadder, HlsSegmentType};
fn main() -> Result<(), Box<dyn std::error::Error>> {
HlsLadder::new("input.mp4", "hls_out")
.rendition(854, 480, "1400k")
.rendition(640, 360, "800k")
.segment_duration(4.0)
.segment_type(HlsSegmentType::Fmp4)
.run()?;
println!("wrote hls_out/master.m3u8 and per-rendition playlists");
Ok(())
}
┌─▶ scale 854x480 ─▶ x264 (GOP-aligned) ─▶ hls muxer ─▶ 480p/…
input.mp4 ─▶ decode ─▶ split
└─▶ scale 640x360 ─▶ x264 (GOP-aligned) ─▶ hls muxer ─▶ 360p/…
after every rendition succeeds ─▶ write master.m3u8 (BANDWIDTH = video+audio × 1.1)
What it wires for you: the [0:v]split=2[s0][s1];[s0]scale=854:480,... filtergraph; per-rendition g/keyint_min/sc_threshold=0 plus closed-GOP x264 params, so every rendition's keyframe PTS sequence coincides and segments stay switchable across bitrates; and a master playlist whose BANDWIDTH folds video + audio bitrate plus 10% muxing overhead — an engineering estimate from the nominal bitrates (the RFC defines the field as peak segment bit rate) — the number players use to pick a variant, so under-reporting it makes players overestimate their headroom. Swap HlsSegmentType::Fmp4 for MpegTs (or delete the line — TS is the default) and the same code is a TS ladder; the master's EXT-X-VERSION switches between 7 and 3 accordingly.
One wording matter, stated the way the module docs state it: this is fMP4 HLS as a structural claim — init segment, EXT-X-MAP, .m4s segments, probe-able end to end — not a claim of CMAF compliance or Apple validation.
The recipe's boundaries, copied from its docs rather than hidden: input must be constant-frame-rate (probed from the file when possible; callback inputs need an explicit .fps(30, 1)); one video stream plus at most one audio stream; VOD only; no encryption, no audio groups, no live/event playlists; and the master carries no CODECS attribute (strict validators will flag that). And one Apple-specific trap: Apple requires the hvc1 sample-entry tag for HEVC, FFmpeg's MP4/fMP4 muxer writes hev1 by default — the muxer's codec-tag choice, independent of which encoder produced the stream — and this recipe has no per-stream codec-tag override. Prefer H.264 for Apple targets.
Two craftsmanship details (the part that is not in the README)
Directories are created only after a successful build. Before 0.14, the ladder created its output tree first and built the job second — so a configuration rejected at build time (the classic case: a linked FFmpeg without libx264) left a trail of empty rendition directories. The order is now: wire every rendition Output as pure configuration, let build() resolve encoders, and only mkdir once it has accepted the job. File I/O is deferred to start() anyway, so creating directories earlier bought nothing but litter. Verified by asking for an encoder that does not exist:
use ez_ffmpeg::recipes::{HlsLadder, HlsSegmentType};
fn main() {
// Deliberately request an encoder this FFmpeg build does not have.
let result = HlsLadder::new("input.mp4", "hls_reject")
.rendition(640, 360, "800k")
.segment_type(HlsSegmentType::Fmp4)
.video_codec("libx264_that_is_not_here")
.run();
println!("run() -> {:?}", result.err().map(|e| e.to_string()));
println!(
"hls_reject/ exists after the failed build: {}",
std::path::Path::new("hls_reject").exists()
);
}
run() -> Some("Open output error: encoder 'libx264_that_is_not_here' is not
available in the linked FFmpeg build — ...")
hls_reject/ exists after the failed build: false
The master playlist follows the same rule: its text is computed up front but written only after the transcode succeeds, so a failed run leaves no dangling master.m3u8. One ordering consequence to know (per the docs and code order; not exercised with a callback input here): a one-shot callback-backed input is consumed by the build step, which happens before directory creation.
Windows paths are parsed twice — and the two parsers disagree. A path handed to FFmpeg is interpreted once by the OS file APIs (which on Windows accept both / and \) and once by the hls muxer's own string splitting, which only knows /. hlsenc.c derives the fMP4 init segment's output directory with strrchr(m3u8_name, '/') — no DOS-path handling on that route, even though the master-URL code elsewhere in the same file does carry a \ fallback. The consequence: pass a \-separated path on Windows and init.mp4 lands silently outside the rendition directory, so the EXT-X-MAP the player fetches is a 404. The 0.14 fix normalizes every path that enters an FFmpeg option string to forward slashes — lossless, since Windows file APIs accept / — with one deliberate exception: verbatim \\?\ paths are prefix-sensitive, rewriting their separators can change which object they name, so they pass through untouched (prefer regular paths for fMP4 output on Windows). The contract is frozen in the test suite: the expected option strings are literal "out/720p/index.m3u8"-style values, forward slashes on every platform.
The run, verbatim
The ladder's output tree (12 s input, 4 s segments, 3 per rendition):
hls_out/
├── master.m3u8
├── 480p/
│ ├── index.m3u8
│ ├── init.mp4 (1.4 KB)
│ └── seg_00000..2.m4s (3 segments)
└── 360p/
├── index.m3u8
├── init.mp4
└── seg_00000..2.m4s
One init.mp4 per rendition is the expected layout — each rendition is its own hls muxer instance, and each media playlist references only its own init segment. The master:
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=1020800,RESOLUTION=640x360
360p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1680800,RESOLUTION=854x480
480p/index.m3u8
ffprobe acceptance. A bare .m4s is not readable on its own — the headers live in the init segment:
$ ffprobe hls_out/480p/seg_00000.m4s
... trun track id unknown, no tfhd was found
... error reading header
Concatenate the init in front and it is a complete stream — which is what "single init segment" means operationally, and a step TS simply does not have:
$ ffprobe -show_entries format=duration \
"concat:hls_out/480p/init.mp4|hls_out/480p/seg_00000.m4s"
duration=4.023023
Probing hls_out/master.m3u8 directly reads both renditions (h264 854x480 + aac, h264 640x360 + aac), and the two renditions' EXTINF sequences are byte-identical (diff is empty) — the fixed-GOP alignment doing its job.
When not to use this
- Large-scale distribution or a live platform: use a dedicated packager (Shaka Packager) or CDN-side just-in-time packaging. Encryption, DRM, audio groups, subtitle renditions, LL-HLS — none of that is here; this recipe is a minimal viable set for VOD ABR.
-
A one-off conversion:
ffmpeg -i in.mp4 -f hls ...on the command line is shorter. Writing a Rust program for a single run is a detour. - Where in-process earns its keep: packaging as part of program logic — packaging user uploads on demand, emitting HLS from programmatically generated content, or running upload/accounting/notification in the same process right after the packaging step, without managing a child process and parsing its stderr.
Next
You now have the minimal fMP4 path, a TS/fMP4-switchable ABR ladder, the probe-before-build pattern, and two design decisions that only show up on the failure path. examples/hls_conversion and examples/hls_abr_ladder in the repository (github.com/YeautyYE/ez-ffmpeg) are the runnable versions.
Top comments (0)