ffmpegelixirphoenixvideo-processingapibucket-doc

How to Use FFmpeg with Elixir (No Installation Required)

·Javid Jamae·6 min read
How to Use FFmpeg with Elixir (No Installation Required)

You want video processing in your Elixir app. Transcode user uploads, generate thumbnails from a Phoenix LiveView, batch-convert clips in a GenServer. The obvious first thought: find an FFmpeg wrapper.

The existing options aren't great. ffmpex wraps FFmpeg's CLI and requires the binary on every machine. Membrane Framework is powerful but complex, designed for real-time streaming pipelines, not one-off transcode jobs. Both require system-level FFmpeg installs that complicate your Docker builds and make deployment to Fly.io or Gigalixir harder than it needs to be.

There's a simpler path: call a cloud FFmpeg API over HTTP. Your Elixir code stays pure, your deployments stay clean, and you get video processing without touching a NIF or a system dependency.

The Elixir HTTP approach to video processing

FFmpeg Micro exposes a REST API that accepts video URLs, processes them with FFmpeg on cloud infrastructure, and returns results. From Elixir, it's just HTTP calls. No binary, no port, no NIF.

The workflow is three steps: upload your video, create a transcode job, download the result.

Upload a video file from Elixir

FFmpeg Micro uses presigned URLs for uploads. You request an upload URL, PUT the file to it, then confirm the upload. This is what it looks like with Req (the modern Elixir HTTP client):

defmodule MyApp.FFmpegMicro do
  @base_url "https://api.ffmpeg-micro.com"

  def upload_file(file_path, api_key) do
    filename = Path.basename(file_path)
    file_size = File.stat!(file_path).size
    content_type = content_type_for(filename)

    # Step 1: Get presigned URL
    {:ok, %{body: presign_body}} =
      Req.post("#{@base_url}/v1/upload/presigned-url",
        json: %{filename: filename, contentType: content_type, fileSize: file_size},
        headers: [{"authorization", "Bearer #{api_key}"}]
      )

    upload_url = presign_body["result"]["uploadUrl"]
    gcs_filename = presign_body["result"]["filename"]

    # Step 2: PUT file directly to GCS
    file_binary = File.read!(file_path)
    {:ok, %{status: 200}} =
      Req.put(upload_url,
        body: file_binary,
        headers: [{"content-type", content_type}]
      )

    # Step 3: Confirm upload
    {:ok, %{body: confirm_body}} =
      Req.post("#{@base_url}/v1/upload/confirm",
        json: %{filename: gcs_filename, fileSize: file_size},
        headers: [{"authorization", "Bearer #{api_key}"}]
      )

    {:ok, confirm_body["result"]["gcsUrl"]}
  end

  defp content_type_for(filename) do
    case Path.extname(filename) do
      ".mp4" -> "video/mp4"
      ".webm" -> "video/webm"
      ".mov" -> "video/quicktime"
      ".avi" -> "video/avi"
      _ -> "video/mp4"
    end
  end
end

The presigned URL is valid for a short window. Upload the file directly to Google Cloud Storage, then confirm it landed. The confirmation response gives you a gcsUrl you'll pass to the transcode endpoint.

Create a transcode job

Once your video is uploaded (or if you already have a public URL), create a transcode:

def transcode(input_url, output_format, opts \\ [], api_key) do
  body = %{
    inputs: [%{url: input_url}],
    outputFormat: output_format
  }

  body =
    case Keyword.get(opts, :preset) do
      nil -> body
      preset -> Map.put(body, :preset, preset)
    end

  body =
    case Keyword.get(opts, :options) do
      nil -> body
      options -> Map.put(body, :options, options)
    end

  {:ok, %{body: response}} =
    Req.post("#{@base_url}/v1/transcodes",
      json: body,
      headers: [{"authorization", "Bearer #{api_key}"}]
    )

  {:ok, response}
end

Use it like this:

# Simple preset-based transcode
{:ok, job} = MyApp.FFmpegMicro.transcode(
  gcs_url,
  "mp4",
  [preset: %{quality: "high", resolution: "1080p"}],
  api_key
)

# Advanced: custom FFmpeg options
{:ok, job} = MyApp.FFmpegMicro.transcode(
  gcs_url,
  "webm",
  [options: [
    %{"option" => "-c:v", "argument" => "libvpx-vp9"},
    %{"option" => "-crf", "argument" => "30"},
    %{"option" => "-b:v", "argument" => "0"}
  ]],
  api_key
)

job_id = job["id"]

The API returns immediately with a job ID and status: "queued". Processing happens asynchronously.

Poll for completion and download

Check job status until it completes, then grab a signed download URL:

def wait_for_completion(job_id, api_key, max_attempts \\ 60) do
  Enum.reduce_while(1..max_attempts, nil, fn _i, _acc ->
    Process.sleep(2_000)

    {:ok, %{body: job}} =
      Req.get("#{@base_url}/v1/transcodes/#{job_id}",
        headers: [{"authorization", "Bearer #{api_key}"}]
      )

    case job["status"] do
      "completed" -> {:halt, {:ok, job}}
      "failed" -> {:halt, {:error, job["error"]}}
      _ -> {:cont, nil}
    end
  end)
  |> case do
    nil -> {:error, :timeout}
    result -> result
  end
end

def download_url(job_id, api_key) do
  {:ok, %{body: body}} =
    Req.get("#{@base_url}/v1/transcodes/#{job_id}/download",
      headers: [{"authorization", "Bearer #{api_key}"}]
    )

  {:ok, body["url"]}
end

Putting it together in a Phoenix context

A real-world example: a Phoenix LiveView that accepts a video upload and transcodes it to WebM:

defmodule MyAppWeb.VideoLive do
  use MyAppWeb, :live_view

  @api_key Application.compile_env!(:my_app, :ffmpeg_micro_api_key)

  def handle_event("transcode", %{"url" => video_url}, socket) do
    Task.start(fn ->
      {:ok, job} = MyApp.FFmpegMicro.transcode(video_url, "webm",
        [preset: %{quality: "medium"}], @api_key)

      {:ok, _completed} = MyApp.FFmpegMicro.wait_for_completion(job["id"], @api_key)
      {:ok, url} = MyApp.FFmpegMicro.download_url(job["id"], @api_key)

      send(self(), {:transcode_complete, url})
    end)

    {:noreply, assign(socket, :status, "Processing...")}
  end
end

For production, you'd move the polling into an Oban job or a dedicated GenServer so it doesn't block a process. But the HTTP calls stay identical.

Why not ffmpex or Membrane?

ffmpex is a CLI wrapper. It builds FFmpeg commands as strings and shells out. That works on your laptop but means every deployment target needs FFmpeg installed with the right codecs compiled in. On Fly.io, that's an extra buildpack. On Gigalixir, it's a custom Docker image.

Membrane is built for real-time media pipelines (streaming, WebRTC, live transcoding). It's the right tool if you're building a video conferencing app. For batch processing (convert this file, resize that video, extract audio), it's overengineered.

An HTTP API sidesteps both problems. Your mix.exs stays clean, your deploys stay fast, and you don't care what codecs are available because the cloud handles it.

Common pitfalls

  • Don't pass fileSize as a string. The API rejects "1024" (string). It must be the integer 1024. Elixir's File.stat!/1 returns an integer, so you're fine if you use it directly.
  • Don't forget the content-type header on the PUT. GCS will accept the upload without it, but the file may not be readable by FFmpeg downstream.
  • Poll with backoff for large files. A 2-second interval works for clips under a minute. For longer videos, bump to 5-10 seconds to avoid hitting rate limits.
  • Use async supervision for production. Don't call Process.sleep in a LiveView process. Wrap polling in an Oban job or a Task.Supervisor.

FAQ

Can I use HTTPoison instead of Req?
Yes. The API is standard REST/JSON. Any HTTP client works. Req is recommended because it's the modern default in the Elixir ecosystem and handles JSON encoding/decoding natively.

What video formats does the API support?
Input: MP4, WebM, AVI, MOV, MKV. Output: MP4, WebM, and any format FFmpeg can produce via custom options.

Is there a rate limit?
The free tier includes 10 minutes of processing per month. Paid plans scale from there. There's no per-request rate limit, but concurrent job limits apply per plan.

Do I need to upload first, or can I pass a public URL?
If your video is already at a public HTTPS URL, pass it directly as the input. The upload flow is only needed for local files or files behind auth.

What about GenServer-based batch processing?
Works great. Spawn a GenServer that holds a queue of URLs, processes them sequentially or in parallel (respecting your plan's concurrency limit), and writes results to your database. The HTTP calls are stateless, so you can distribute across multiple nodes in a cluster.

*Last verified: July 2026 against FFmpeg Micro API v1*

About Javid Jamae

Founder & CEO at FFmpeg Micro

Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.

Software EngineeringVideo ProcessingFFmpegCloud ArchitectureAPI DesignAutomation

Ready to process videos at scale?

Start using FFmpeg Micro's simple API today. No infrastructure required.

Get Started Free