Converting videos to HLS and MPEG-DASH with Rust
Use Rust to run FFmpeg, convert a local video into HLS and MPEG-DASH, and serve the completed files over HTTP. This walkthrough builds one HLS rendition and two DASH video representations, then plays each format with ffplay. The Rust code manages processes and files; FFmpeg does the encoding, so you do not need FFmpeg development headers or Rust bindings.
Understand the output
HLS uses .m3u8 playlists; DASH uses an XML .mpd manifest. Both describe media segments that a
player fetches over HTTP. Adaptive bitrate playback additionally needs multiple compatible
representations and a player that selects among them as conditions change.
The HLS example packages a single encoding. The DASH example offers 1920 × 1080 and 1280 × 720 video with a shared audio track. Creating these files and playing them locally establishes packaging and playback, not bandwidth adaptation, improved delivery latency, or a production streaming service.
Setting up a Rust project
Use Bash on Linux, a stable Rust toolchain from rustup, and a
FFmpeg distribution containing ffmpeg, ffprobe, and
ffplay. ffplay is a separately packaged player on some distributions; check it before creating
the project. Playback needs a desktop session and audio output. The recipe was tested with Rust
and Cargo 1.98.1 and FFmpeg, ffprobe, and ffplay 9.0.1. These are tested versions, not intrinsic
minimums; use the capability checks below when evaluating another maintained release.
On Ubuntu or Debian, the distribution package is an installation option:
sudo apt-get update && sudo apt-get install -y ffmpeg
Check versions and capabilities. Your build needs the libx264 and AAC encoders, the HLS and DASH
muxers and demuxers, and the testsrc2, geq, and aevalsrc filters used by the sample generator.
cargo --version && rustc --version &&
ffmpeg -version && ffprobe -version && ffplay -version &&
ffmpeg -encoders && ffmpeg -muxers && ffmpeg -demuxers && ffmpeg -filters
Create the project outside an existing Cargo project or an ancestor .cargo configuration.
Cargo discovers configuration in parent directories,
and project creation can enroll a new package in an enclosing workspace. This paste checks that
boundary before creating files and leaves your shell’s directory and options unchanged:
(
set -eu
for tool in cargo rustc ffmpeg ffprobe ffplay; do
command -v "$tool" >/dev/null
done
directory=$(pwd -P)
while :; do
for config in "$directory/Cargo.toml" "$directory/.cargo/config" "$directory/.cargo/config.toml"; do
if [ -e "$config" ] || [ -L "$config" ]; then
printf 'Choose a directory outside this Cargo configuration: %s\n' "$config" >&2
exit 1
fi
done
[ "$directory" = / ] && break
directory=$(dirname "$directory")
done
cargo new --vcs none --edition 2021 video_converter
)
If video_converter already exists, choose a fresh directory rather than deleting an existing
project. All subsequent shell blocks run from the parent directory containing video_converter.
Replace the generated video_converter/Cargo.toml with this complete manifest. The direct
versions are pinned to the tested API; keep the generated Cargo.lock for repeat builds.
[package]
name = "video_converter"
version = "0.1.0"
edition = "2021"
[workspace]
[dependencies]
anyhow = "=1.0.104"
tokio = { version = "=1.53.2", features = ["macros", "rt-multi-thread"] }
warp = { version = "=0.3.7", default-features = false }
Create a known input
Generate a 9.375-second, 1920 × 1080 video at 24 frames per second with a rising audio tone. The moving test pattern has eight black-and-white blocks across its top; these encode the frame number, starting at zero. They make repeated or out-of-order content easier to identify. The subshell refuses an existing input before FFmpeg runs:
(
set -eu
cd video_converter
if [ -e input.mp4 ] || [ -L input.mp4 ]; then
printf 'input.mp4 already exists; use a fresh project for this sample.\n' >&2
exit 1
fi
ffmpeg -hide_banner -loglevel error -nostdin -n \
-f lavfi -i 'testsrc2=size=1920x1080:rate=24:duration=9.375' \
-f lavfi -i 'aevalsrc=0.1*sin(2*PI*(440*t+40*t*t)):s=48000:d=9.375' \
-filter_threads 1 \
-vf "geq=lum='if(lt(Y,120),16+219*mod(floor(N/pow(2,floor(X/240))),2),lum(X,Y))':cb='cb(X,Y)':cr='cr(X,Y)'" \
-c:v libx264 -threads:v 2 -preset fast -crf 18 -pix_fmt yuv420p \
-c:a aac -b:a 128k -shortest input.mp4
)
The verified playback uses this 9.375-second sample. For your own source, use a valid local MP4
with an unrotated, square-pixel 16:9 video at least
1920 × 1080 and an audio track. This fixed ladder targets that input; it does not choose suitable
sizes or bitrates for arbitrary footage. Only the first video and first audio streams are used.
The converter normalizes video to 24 frames per second and yuv420p. Check other clip durations
and players separately.
Integrating FFmpeg with Rust
Rust’s Command passes arguments
directly to FFmpeg without a shell. The runner below canonicalizes the input, requires a new
output directory, and waits for each child to finish. It reports success only after both
conversions return successfully without error-level diagnostics.
Implementing video conversion to HLS and MPEG-DASH
Replace video_converter/src/main.rs with this complete program:
use anyhow::{ensure, Context, Result};
use std::io::{self, Write};
use std::path::Path;
use std::process::Command;
const COMMON_ARGS: &[&str] = &[
"-hide_banner", "-loglevel", "error", "-nostdin", "-n", "-xerror",
"-threads", "2",
];
const ENCODING_ARGS: &[&str] = &[
"-c:v", "libx264", "-threads:v", "2", "-filter_threads", "1",
"-preset", "medium", "-profile:v", "main", "-pix_fmt", "yuv420p",
"-r", "24", "-g", "96", "-sc_threshold", "0", "-flags", "+cgop",
"-force_key_frames", "expr:gte(t,n_forced*4)",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000",
];
const HLS_ARGS: &[&str] = &[
"-map", "0:v:0", "-map", "0:a:0",
"-crf", "23", "-f", "hls", "-hls_time", "4",
"-hls_playlist_type", "vod", "-hls_segment_filename", "segment_%03d.ts",
"index.m3u8",
];
const DASH_ARGS: &[&str] = &[
"-map", "0:v:0", "-map", "0:v:0", "-map", "0:a:0",
"-filter:v:0", "scale=1920:1080", "-filter:v:1", "scale=1280:720",
"-b:v:0", "2M", "-b:v:1", "1M",
"-f", "dash", "-seg_duration", "4", "-use_template", "1", "-use_timeline", "1",
"-format_options", "use_editlist=0",
"-init_seg_name", "init-$RepresentationID$.m4s",
"-media_seg_name", "chunk-$RepresentationID$-$Number%05d$.m4s",
"-adaptation_sets", "id=0,streams=v id=1,streams=a", "manifest.mpd",
];
fn convert(input: &Path, output: &Path, format_args: &[&str]) -> Result<()> {
std::fs::create_dir(output).context("Use a new output directory")?;
let result = Command::new("ffmpeg")
.args(COMMON_ARGS)
.arg("-i").arg(input)
.args(ENCODING_ARGS)
.args(format_args)
.current_dir(output)
.output()
.with_context(|| format!("Could not launch FFmpeg for {}", input.display()))?;
io::stderr().write_all(&result.stderr)?;
ensure!(result.status.success() && result.stderr.is_empty(),
"FFmpeg failed for {} ({})", input.display(), result.status);
Ok(())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
ensure!(args.len() == 2,
"Usage: cargo run --bin video_converter -- INPUT.mp4 NEW_OUTPUT_DIR");
let input = std::fs::canonicalize(&args[0]).context("Cannot open input")?;
let output = Path::new(&args[1]);
std::fs::create_dir(output).context("Use a new output directory")?;
convert(&input, &output.join("hls"), HLS_ARGS)?;
convert(&input, &output.join("dash"), DASH_ARGS)?;
println!("Conversion complete.");
Ok(())
}
Converting to HLS
HLS produces hls/index.m3u8 and MPEG-TS segments. Open this media playlist directly: a
single-rendition example does not need a master playlist. CRF 23 selects constant-quality rate
control, so its bitrate varies with the footage. It is not a 2 Mbit/s target or a peak-rate cap.
The HLS muxer cuts at keyframes after the target segment duration. At 24 frames per second, the 96-frame closed GOP and forced keyframes align four-second boundaries. The sample’s final segment is shorter because 9.375 seconds is not a multiple of four.
Converting to MPEG-DASH
Mapping the video twice creates distinct 1080p and 720p representations in one video adaptation
set; audio has its own set. The DASH muxer writes
initialization fragments, media fragments, and their timeline into dash/manifest.mpd.
The MP4 container option use_editlist=0
disables edit lists. In the tested FFmpeg DASH reader,
this avoids applying the AAC startup trim again at each fragment boundary and dropping samples
between segments.
The DASH encoders use illustrative average bitrate targets of 2 Mbit/s and 1 Mbit/s. They do not also use CRF, which would select a different rate-control mode in libx264. These values are not hard bandwidth limits or a quality recommendation for every video. Both video representations use the same keyframe schedule.
Building a simple streaming server in Rust
Create the directory for a separate server executable:
mkdir video_converter/src/bin
Save this complete program as video_converter/src/bin/server.rs. It serves the specified
output directory on loopback and assigns content types to the streaming files. The ready message
appears after a successful bind; an occupied port returns an error instead.
use anyhow::{ensure, Result};
use warp::{Filter, Reply};
#[tokio::main(worker_threads = 2)]
async fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
ensure!(args.len() == 2, "Usage: cargo run --bin server -- OUTPUT_DIR PORT");
let directory = std::fs::canonicalize(&args[0])?;
let port: u16 = args[1].parse()?;
let routes = warp::fs::dir(directory).map(|file: warp::fs::File| {
let content_type = match file.path().extension().and_then(|ext| ext.to_str()) {
Some("m3u8") => "application/vnd.apple.mpegurl",
Some("mpd") => "application/dash+xml",
Some("ts") => "video/mp2t",
Some("m4s") => "video/mp4",
_ => return file.into_response(),
};
let mut response = file.into_response();
response.headers_mut().insert(
warp::http::header::CONTENT_TYPE,
warp::http::HeaderValue::from_static(content_type),
);
response
});
let (address, server) = warp::serve(routes)
.try_bind_ephemeral(([127, 0, 0, 1], port))?;
println!("Serving http://{address}/");
server.await;
Ok(())
}
Both audio and video DASH fragments are MP4 containers; the MPD describes which track each contains. The server and player use the same local origin, so this example needs no permissive CORS configuration. It has no upload endpoint or authentication.
Testing the streaming setup
From the parent directory, build and run the converter, then start the server only if conversion succeeds:
(
cd video_converter &&
cargo run --bin video_converter -- ./input.mp4 ./output &&
cargo run --bin server -- ./output 3030
)
The converter prints Conversion complete. The server then prints its URL. If port 3030 is occupied, choose another port in the server command and use it in the URLs below. Do not run another converter against the same directory while serving it.
In a second terminal, play both manifests with the installed ffplay player. The first player exits at the end before the second starts:
ffplay -autoexit -threads 2 http://127.0.0.1:3030/hls/index.m3u8 &&
ffplay -autoexit -threads 2 http://127.0.0.1:3030/dash/manifest.mpd
Each playback should show the same moving pattern and changing blocks for about 9.375 seconds,
with a steadily rising tone. The generated input has 225 video frames. DASH advertises both
1920 × 1080 and 1280 × 720 video representations; ffplay chooses a video stream, so this playback
does not demonstrate switching between them. Quit a player with q. Stop the foreground server
with Ctrl+C in its terminal; the output files remain on disk.
A missing segment can stall playback even when its playlist loads. Keep the whole output tree
together, and inspect the actual video and audio through the end of both streams. Loading a
manifest alone does not establish that it references the right content.
Handle a failed conversion
An existing output directory fails without replacing it. A missing file, absent audio track, failed FFmpeg launch, nonzero native exit, or reported decoder error prevents the completion message and the chained server command. FFmpeg can sometimes recover damaged media without reporting an error; successful conversion does not prove that an arbitrary source was intact.
Failure after encoding starts can leave partial files, including a completed HLS conversion when
DASH fails. Do not serve that directory. Preserve it for inspection and retry the converter with a
fresh output name, such as ./output-retry; after success, start the server with that same name.
If dependency download or compilation fails, retain the project and rerun the build command after
fixing connectivity or the compiler error. The Cargo commands build before running, so an old
executable is not used after a failed build.
Keep this as a local conversion and playback workflow. For a separate Rust task involving several FFmpeg jobs at once, see concurrent video watermarking with Rust.
