Rust's FFmpeg story is split: you can link against libav libraries via ffmpeg-next for in-process conversion, or call the ffmpeg binary via Command. The in-process path is faster for high-throughput pipelines but has a significantly more complex build setup (linking all of libav). For most server deployments, Command::new is the pragmatic choice — it's reliable, debuggable, and avoids linking complexity. Use the API when FFmpeg isn't available in your environment.
Method 1: ffmpeg-next crate (in-process libav bindings)
ffmpeg-next provides Rust bindings to FFmpeg's libav* libraries. It runs the conversion in-process with no subprocess overhead, but requires all of FFmpeg's native libraries at compile and link time.
# Cargo.toml
[dependencies]
ffmpeg-next = "7"
# You need FFmpeg libraries installed:
# Ubuntu: apt install libavcodec-dev libavformat-dev libavutil-dev libswresample-dev
# macOS: brew install ffmpegextern crate ffmpeg_next as ffmpeg;
use ffmpeg::format::{input, output};
use ffmpeg::media::Type;
use ffmpeg::codec;
use ffmpeg::software::resampling;
use ffmpeg::util::frame::audio::Audio;
use std::path::Path;
fn mp4_to_mp3(
in_path: &str,
out_path: &str,
bitrate: usize, // e.g. 192000 for 192kbps
) -> Result<(), Box> {
ffmpeg::init()?;
let mut ictx = input(&in_path)?;
// Find the first audio stream
let audio_stream_idx = ictx
.streams()
.best(Type::Audio)
.ok_or("No audio stream found")?
.index();
let ictx_stream = ictx.stream(audio_stream_idx).unwrap();
let decoder_ctx = codec::context::Context::from_parameters(ictx_stream.parameters())?;
let mut decoder = decoder_ctx.decoder().audio()?;
// Set up output context
let mut octx = output(&out_path)?;
let codec = codec::encoder::find(codec::Id::MP3)
.ok_or("MP3 codec not found")?;
let mut encoder_ctx = codec::context::Context::new_with_codec(codec);
{
let mut enc = encoder_ctx.encoder().audio()?;
enc.set_bit_rate(bitrate);
enc.set_rate(44100);
enc.set_channel_layout(ffmpeg::channel_layout::ChannelLayout::STEREO);
enc.set_format(ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar));
}
let mut encoder = encoder_ctx.encoder().audio()?.open_as(codec)?;
let mut out_stream = octx.add_stream(codec)?;
out_stream.set_parameters(&encoder);
octx.write_header()?;
// Set up resampler: decode_fmt -> encode_fmt
let mut resampler = resampling::context::Context::get(
decoder.format(),
decoder.channel_layout(),
decoder.rate(),
encoder.format(),
encoder.channel_layout(),
encoder.rate(),
)?;
for (stream, packet) in ictx.packets() {
if stream.index() == audio_stream_idx {
decoder.send_packet(&packet)?;
let mut decoded = Audio::empty();
while decoder.receive_frame(&mut decoded).is_ok() {
let mut resampled = Audio::empty();
resampler.run(&decoded, &mut resampled)?;
encoder.send_frame(&resampled)?;
let mut encoded = ffmpeg::Packet::empty();
while encoder.receive_packet(&mut encoded).is_ok() {
encoded.set_stream(0);
encoded.write_interleaved(&mut octx)?;
}
}
}
}
// Flush encoder
encoder.send_eof()?;
let mut encoded = ffmpeg::Packet::empty();
while encoder.receive_packet(&mut encoded).is_ok() {
encoded.set_stream(0);
encoded.write_interleaved(&mut octx)?;
}
octx.write_trailer()?;
Ok(())
}
fn main() -> Result<(), Box> {
mp4_to_mp3("video.mp4", "audio.mp3", 192_000)?;
println!("Done");
Ok(())
}
The ffmpeg-next build is complex — if you hit linker errors, check that pkg-config can find the FFmpeg libraries: pkg-config --modversion libavcodec. The crate uses pkg-config to discover library paths.
Method 2: Command::new + FFmpeg binary (simpler, most practical)
For most Rust services running on a server with FFmpeg installed, calling the binary via Command is simpler, more maintainable, and easier to debug than linking libav.
# Cargo.toml — no audio dependencies neededuse std::process::Command;
use std::path::Path;
use std::fs;
fn mp4_to_mp3(
in_path: &str,
out_path: &str,
bitrate_kbps: u32,
) -> Result<(), Box> {
// Remove output if it exists (FFmpeg requires -y flag or no existing file)
if Path::new(out_path).exists() {
fs::remove_file(out_path)?;
}
let output = Command::new("ffmpeg")
.args([
"-i", in_path,
"-vn", // no video
"-acodec", "libmp3lame",
"-b:a", &format!("{}k", bitrate_kbps),
"-ar", "44100", // sample rate
"-ac", "2", // stereo
"-y", // overwrite
out_path,
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"FFmpeg failed ({}): {}",
output.status,
&stderr[stderr.len().saturating_sub(500)..]
).into());
}
// Verify output
let meta = fs::metadata(out_path)?;
if meta.len() == 0 {
return Err("FFmpeg produced empty output — no audio stream?".into());
}
Ok(())
}
fn main() -> Result<(), Box> {
mp4_to_mp3("video.mp4", "audio.mp3", 192)?;
println!("Done: {}", fs::metadata("audio.mp3")?.len() / 1024);
Ok(())
}
Method 3: ChangeThisFile API (reqwest, no FFmpeg)
The API runs FFmpeg server-side. Source auto-detected from filename — pass target=mp3. Free tier: 1,000 conversions/month, no card needed.
# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["multipart", "blocking"] }use reqwest::blocking::{Client, multipart};
use std::fs;
use std::path::Path;
const API_KEY: &str = "ctf_sk_your_key_here";
fn mp4_to_mp3_api(in_path: &str, out_path: &str) -> Result<(), Box> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(300)) // large files may take time
.build()?;
let file_bytes = fs::read(in_path)?;
let filename = Path::new(in_path)
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let form = multipart::Form::new()
.part(
"file",
multipart::Part::bytes(file_bytes)
.file_name(filename)
.mime_str("video/mp4")?,
)
.text("target", "mp3");
let resp = client
.post("https://changethisfile.com/v1/convert")
.header("Authorization", format!("Bearer {}", API_KEY))
.multipart(form)
.send()?;
if !resp.status().is_success() {
return Err(format!("API error: {}", resp.status()).into());
}
fs::write(out_path, resp.bytes()?)?;
Ok(())
}
fn main() -> Result<(), Box> {
mp4_to_mp3_api("video.mp4", "audio.mp3")?;
println!("Done");
Ok(())
}
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| ffmpeg-next | High-throughput in-process pipelines, no subprocess overhead | Complex build, must link all libav* libs |
| Command + FFmpeg binary | Server deployments with FFmpeg installed, easiest to maintain | Subprocess overhead; FFmpeg binary required |
| ChangeThisFile API | No FFmpeg, minimal binary size, Lambda/Fargate targets | Network call; 25MB file limit on free tier |
Production tips
- Prefer Command over ffmpeg-next for most projects. The ffmpeg-next build is notoriously finicky — pkg-config paths, library version mismatches, and linker flag issues consume significant debugging time. Command is straightforward and FFmpeg is available on all major Linux distros.
- Always capture both stdout and stderr. FFmpeg writes progress and error messages to stderr. Use output() to capture both; examine stderr on non-zero exit codes.
- Validate output file size. A zero-byte output file means the conversion silently failed (missing audio stream, codec not compiled in). Always check fs::metadata(out)?.len() > 0.
- For async Rust + Command, use tokio::process::Command. The std::process::Command blocks the thread. tokio::process::Command is the async equivalent — use .output().await for non-blocking subprocess calls.
- Check libmp3lame availability before deploying. Run: ffmpeg -codecs 2>/dev/null | grep -i lame. Some minimal FFmpeg builds lack libmp3lame. Install ffmpeg (not ffmpeg-minimal) from your package manager.
For Rust services on a server, Command + FFmpeg binary is the lowest-friction path. ffmpeg-next is worth the setup for high-throughput in-process pipelines. For Lambda, Fargate, or anywhere FFmpeg isn't available, the API handles it via reqwest. Free tier: 1,000 conversions/month.