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 ffmpeg
extern 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 needed
use 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

ApproachBest forTradeoff
ffmpeg-nextHigh-throughput in-process pipelines, no subprocess overheadComplex build, must link all libav* libs
Command + FFmpeg binaryServer deployments with FFmpeg installed, easiest to maintainSubprocess overhead; FFmpeg binary required
ChangeThisFile APINo FFmpeg, minimal binary size, Lambda/Fargate targetsNetwork 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.