Alv-Around

Ffmpeg on roids with rust

2026-04-28

Concurrent and cpu intensive applications are hard to get right! here is my attempt to it..


It takes some time to wrap your head about concurrency. Luckily, the widespread adoption of async/await syntax has simplified this process. However, things start to get messy when your async task need to do cpu intensive computations.


So I set myself to replicate this scenario, by taking one of the most used media transformation tool, ffmpeg, and create a microservice able to perform to an arbitrary number of requests send over the network in a timely and reliable manner. To my surprise this was much easier that I expected, and to find out the reason why you will have to read further. The code show below can be found here: https://github.com/alv-around/ffmpeg_processor.

Creating the http server

We start by creating a simple http server with axum and async support with tokio. The http server receive a request with a video in the request. The server should then, store the video temporarily, run ffmpeg on the video and returned the transformed media back to the client.

use axum::body::Body;
use axum::extract::Path;
use axum::http::header;
use axum::response::IntoResponse;
use axum::{
    Router,
    routing::{get, post},
};
use futures::StreamExt;
use tokio::fs::{self, File};
use tokio::io::AsyncWriteExt;
use tower_http::trace::TraceLayer;
use tracing::instrument;

#[tokio::main]
async fn main() {

    let app = Router::new()
        .route("/about", get(|| async { "this is an experiment" }))
        .route("/{video_id}", post(register_video))

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    tracing::info!("listening on {}", listener.local_addr().unwrap());
    let _server = axum::serve(listener, app).await;
}

#[instrument]
async fn register_video(
    Path(id): Path<String>,
    body: Body,
) -> Result<impl IntoResponse, &'static str> {
    let mut video = body.into_data_stream();
    let path = format!("tmp/test_file_{}.mp4", id);
    let output = format!("tmp/output_{}.mp4", id);
    let mut file = File::create(&path)
        .await
        .map_err(|_| "error creating file")?;

    while let Some(frame) = video.next().await {
        let chunk = frame.map_err(|_| "error reading file")?;
        file.write_all(&chunk)
            .await
            .map_err(|_| "error backing file")?;
    }

    tracing::debug!("file stored in path: {}", path);
    
    // TODO: run ffmpeg here

    match fs::read(output).await {
        Ok(bytes) => Ok(([(header::CONTENT_TYPE, "video/mp4")], bytes)),
        Err(_) => Err("error reading the output file"),
    }
}

Easy ffmpeg

At first I though of integrate ffmpeg through Foreign Function Iterfaces (FFI). Ffmpeg is written in C, and FFI is the way bind code written into another language in your project. Rust provide a straightforward method to do FFI, and there are lost of tutorials online to integrate C code in your rust base.


However, after a quick online search I found the ez-ffmpeg crate which provide a direct integration with ffmpeg in pure rust. Moreover, ez-ffmpeg provides an async feature flag, meaning we can use ez-ffmpeg in our async route without worrying that one task will block other tasks in our runtime. With the feature async activated the ez-ffmpeg function looks like:

use ez_ffmpeg::{FfmpegContext, FfmpegScheduler};

pub async fn run_ffmpeg(input_path: String, output_path: String) -> Result<(), &'static str> {
    // 1. Build the FFmpeg context
    let context = FfmpegContext::builder()
        .input(input_path)
        .output(output_path)
        .build()
        .map_err(|_| "error initializing ffmpeg")?;

    FfmpegScheduler::new(context)
        .start()
        .map_err(|_| "error processing video")?
        .await
        .map_err(|_| "error processing video")?;

    Ok(())
}

Afterwards, we just need to call and await run_ffmpeg from the register_video function in main.rs and that's it!

The only drawback from using ez-ffmpeg is that we need to make sure that libx264 is installed as a system dependency in order for it to compile. Fortunately for a nix user like my self, this can be easily solved by adding ffmpeg as a build input.

Testing

The only thing missing is putting the system to trial, by sending many requests at the same time. I created a harness test and with default values, the harness send 1000 requests each with a 6 second video. On my local machine, the ffmpeg_processor takes 6-8 seconds to process all the videos with a 99.5% success rate! For the amount of effort put, this is an astounishing result.

Future Work

That's it! In this demo we've sketched how high-throughput system can look like using Rust and Nix. But there are many performance improvements that I left out for the sake of this demo. For example, In the current implementation, axum automatically starts as many worker core in the running system and each worker runs a single-threaded ffmpeg instance. However we could easily reduce the threads used by axum and create an create additional multi-threaded ffmpeg workers to process the videos.


We could also improve how videos get transport by using object storages instead of sending them over http request, or using gRPCs for the communication between client and server. But that, in another post :)