Native Elixir bindings for FFmpeg via the rsmpeg Rust crate.
Replaces shelling out to the ffmpeg / ffprobe CLIs with an
in-process NIF: every call runs against the FFmpeg shared libraries
this NIF was linked at compile time, and structured results come back
as plain Elixir structs and maps.
Quickstart
{:ok, info} = Exmpeg.probe("input.mkv")
info.format.duration_s
#=> 12.345
Exmpeg.MediaInfo.first(info, :video).codec
#=> "h264"
{:ok, %{packets_written: n}} =
Exmpeg.remux("input.mkv", "output.mp4")Scope
This release covers:
version/0- linked FFmpeg version info.probe/1- container + per-stream metadata (ffprobe).remux/3- stream copy between containers, optionally trimmed by a start/duration window (ffmpeg -i ... -c copy ...).extract_frame/3- single image at a timestamp (.jpg,.png,.bmp,.webp).extract_audio/3- audio stream to.wav,.mp3,.m4a/.aac,.opus/.ogg, or.flac.concat/3- stream-copy concatenation of multiple inputs that share the same stream layout.transcode/3- per-stream re-encode with codec, bitrate, scale, fps and filter selection.
Output atomicity
Operations that write to disk (remux/3, extract_frame/3,
extract_audio/3, concat/3, transcode/3) write to a unique
sibling <stem>.partial.<nonce>.<ext> file and rename onto the final
path only after the muxer trailer has been written successfully. A
failure mid-encode removes that partial file so the destination is
never left half-written.
The partial path is unique per call, so two writes to the same
destination (duplicate jobs, a retry racing a slow first attempt,
two nodes on shared storage) never share a partial and cannot corrupt
each other. The resulting guarantee is last-complete-rename-wins:
every state an observer can see at the destination is a complete file.
A hard crash mid-write may leave a <stem>.partial.* sibling behind
(it is never renamed onto the destination); sweep those if needed.
Summary
Types
Options accepted by concat/3.
Stats returned by concat/2.
Options accepted by extract_audio/3.
Stats returned by extract_audio/3.
Options accepted by extract_frame/3.
Stats returned by extract_frame/3.
Input source, accepted by every read-side operation
Options accepted by remux/3.
Stats returned by remux/3.
Options accepted by transcode/3.
Stats returned by transcode/3.
Functions
Joins inputs into a single output without re-encoding.
Decodes the best audio stream of input and writes it to output.
Decodes one video frame from input at :timestamp_s (default 0.0)
and writes it as an image at output.
Loads binary into a reusable Exmpeg.Buffer for in-memory input.
Probes path and returns container / stream metadata.
Stream-copies input to output without re-encoding.
Re-encodes input to output with per-stream codec selection.
Returns the version of every FFmpeg sub-library this NIF is linked
against, plus the ./configure flags used to build them.
Types
@type concat_opt() :: {:progress, pid()}
Options accepted by concat/3.
@type concat_stats() :: %{ packets_written: non_neg_integer(), inputs_joined: non_neg_integer(), streams_copied: non_neg_integer(), duration_s: float() }
Stats returned by concat/2.
@type extract_audio_opt() :: {:sample_rate, pos_integer()} | {:channels, 1..2} | {:bitrate, pos_integer()} | {:progress, pid()}
Options accepted by extract_audio/3.
@type extract_audio_stats() :: %{ sample_rate: pos_integer(), channels: 1..2, samples_written: non_neg_integer(), duration_s: float(), codec: String.t() }
Stats returned by extract_audio/3.
@type extract_frame_opt() :: {:timestamp_s, number()} | {:width, pos_integer()} | {:height, pos_integer()}
Options accepted by extract_frame/3.
@type extract_frame_stats() :: %{ width: pos_integer(), height: pos_integer(), timestamp_s: float(), pts_known: boolean(), codec: String.t() }
Stats returned by extract_frame/3.
@type input_source() :: Path.t() | {:memory, binary()} | Exmpeg.Buffer.t()
Input source, accepted by every read-side operation:
- a filesystem path (
String.t()); {:memory, binary}to read the input from an in-memory binary through a custom AVIOContext - the binary is copied into the NIF on every call, so prefer a path or a buffer for large or reused media;- an
Exmpeg.Bufferfromload_buffer/1, which copies the bytes once and is reused across calls without re-copying.
For untrusted media (uploads, anything you did not author), use
{:memory, binary} or load_buffer/1: both are restricted to FFmpeg's
crypto,data protocols, so a crafted file cannot reach the network or
any local file. A path input necessarily allows the file protocol (to
open the path), which a crafted on-disk manifest can abuse to read
sibling local files - so do not write an untrusted upload to a temp
file and probe it by path. See the "Untrusted input" section of the
README.
@type remux_opt() :: {:start_s, number()} | {:duration_s, number()} | {:drop_audio, boolean()} | {:drop_video, boolean()} | {:drop_subtitles, boolean()} | {:tags, [{String.t(), String.t()}] | %{optional(String.t()) => String.t()}} | {:progress, pid()}
Options accepted by remux/3.
@type remux_stats() :: %{ packets_written: non_neg_integer(), packets_dropped: non_neg_integer(), streams_copied: non_neg_integer() }
Stats returned by remux/3.
@type transcode_opt() :: {:video_codec, String.t()} | {:audio_codec, String.t()} | {:video_bitrate, pos_integer()} | {:audio_bitrate, pos_integer()} | {:width, pos_integer()} | {:height, pos_integer()} | {:fps, {pos_integer(), pos_integer()}} | {:sample_rate, pos_integer()} | {:channels, 1..2} | {:video_filter, String.t()} | {:drop_audio, boolean()} | {:drop_video, boolean()} | {:drop_subtitles, boolean()} | {:tags, [{String.t(), String.t()}] | %{optional(String.t()) => String.t()}} | {:progress, pid()}
Options accepted by transcode/3.
Codec selection uses encoder short names ("libvpx-vp9", "aac",
"libopus", "libmp3lame", "flac"). Pass "copy" (or omit) to
stream-copy that media type.
The GPL H.264 / H.265 encoders ("libx264", "libx265") are not
compiled into the precompiled (LGPL) binaries and return
{:error, %Exmpeg.Error{reason: :unsupported}} there; build from
source (EXMPEG_BUILD=1) against a GPL-enabled FFmpeg 8 to use them.
@type transcode_stats() :: %{ streams_copied: non_neg_integer(), streams_reencoded: non_neg_integer(), packets_written: non_neg_integer(), duration_s: float() }
Stats returned by transcode/3.
Functions
@spec concat([input_source()], Path.t(), [concat_opt()]) :: {:ok, concat_stats()} | {:error, Exmpeg.Error.t()}
Joins inputs into a single output without re-encoding.
Every input must share the same stream layout (same number of streams
and same codec id per stream index). Mismatches return
{:error, %Error{reason: :invalid_request}}.
PTS / DTS values are shifted by the cumulative duration of preceding inputs so the resulting timeline is monotonic.
Returns
%{packets_written: 3456, inputs_joined: 3, streams_copied: 2, duration_s: 6.04}
@spec extract_audio(input_source(), Path.t(), [extract_audio_opt()]) :: {:ok, extract_audio_stats()} | {:error, Exmpeg.Error.t()}
Decodes the best audio stream of input and writes it to output.
The encoder is picked from the output extension:
| Extension | Encoder |
|---|---|
.wav | pcm_s16le |
.mp3 | libmp3lame |
.m4a / .aac | aac |
.opus / .ogg | libopus |
.flac | flac |
Options
:sample_rate- target sample rate in Hz (default: source). For codecs that only accept a fixed list of rates (libopus snaps to[8000, 12000, 16000, 24000, 48000]), the closest supported rate is used.:channels-1for mono or2for stereo. Defaults to the source layout when the source is mono or stereo; sources with more channels (5.1, 7.1, ...) require an explicit value and otherwise return:invalid_request.:bitrate- target bitrate in bps. Ignored by lossless codecs (pcm_s16le,flac); used as a quality hint for the lossy codecs.
Returns
%{
sample_rate: 16_000,
channels: 1,
samples_written: 32_322,
duration_s: 2.020125,
codec: "pcm_s16le"
}
@spec extract_frame(input_source(), Path.t(), [extract_frame_opt()]) :: {:ok, extract_frame_stats()} | {:error, Exmpeg.Error.t()}
Decodes one video frame from input at :timestamp_s (default 0.0)
and writes it as an image at output.
The output codec is inferred from the extension:
.jpg/.jpeg-> MJPEG.png-> PNG.bmp-> BMP.webp-> WebP
Options
:timestamp_s- capture point in seconds (default0.0). The decoder seeks to the preceding keyframe and decodes forward, so the actually-returned timestamp may be a few hundred milliseconds early or late depending on the GOP structure. The exact pts of the returned frame is reported in the result map.:width/:height- resize to this size in pixels. When only one dimension is given the other is computed to preserve the source aspect ratio. Both are rounded down to the nearest even value so the encoder's pixel format requirements are met.
Returns
%{width: 1280, height: 720, timestamp_s: 1.501, pts_known: true, codec: "mjpeg"}
@spec load_buffer(binary()) :: {:ok, Exmpeg.Buffer.t()} | {:error, Exmpeg.Error.t()}
Loads binary into a reusable Exmpeg.Buffer for in-memory input.
The bytes are copied once into a refcounted native resource. Passing
the returned buffer to several operations (e.g. probe/1 then
transcode/3, or as repeated concat/3 inputs) reuses that one copy
instead of re-copying the binary on every call, which {:memory, _}
does. Prefer this over {:memory, _} when the same bytes feed more
than one call.
{:ok, buf} = Exmpeg.load_buffer(File.read!("clip.mp4"))
{:ok, info} = Exmpeg.probe(buf)
{:ok, _} = Exmpeg.extract_audio(buf, "out.opus")
@spec probe(input_source()) :: {:ok, Exmpeg.MediaInfo.t()} | {:error, Exmpeg.Error.t()}
Probes path and returns container / stream metadata.
Reads the file with avformat_open_input + avformat_find_stream_info,
so the result reflects what the FFmpeg demuxer actually sees - not what
the file extension suggests.
@spec remux(input_source(), Path.t(), [remux_opt()]) :: {:ok, remux_stats()} | {:error, Exmpeg.Error.t()}
Stream-copies input to output without re-encoding.
Every input stream is added to the output container with codec
parameters preserved verbatim. The output container is inferred from
the file extension (.mp4, .mkv, .mov, ...). A muxer / codec
combination that the FFmpeg build does not support returns
{:error, %Error{reason: :unsupported}}.
Options
:start_s- drop packets whose pts is earlier than this offset (in seconds). The result is not keyframe-aligned: video that does not start on a keyframe will be unplayable until the next keyframe.:duration_s- stop after this many seconds past:start_s.
Returns
A stats map of what the muxer accepted:
%{packets_written: 1234, packets_dropped: 0, streams_copied: 2}
@spec transcode(input_source(), Path.t(), [transcode_opt()]) :: {:ok, transcode_stats()} | {:error, Exmpeg.Error.t()}
Re-encodes input to output with per-stream codec selection.
Each stream is either copied or re-encoded based on the corresponding
:video_codec / :audio_codec option. "copy" (or an omitted option)
preserves the source codec; any other value is resolved through
avcodec_find_encoder_by_name - if FFmpeg wasn't built with that
encoder, the call returns {:error, %Error{reason: :unsupported}}.
Options
:video_codec/:audio_codec- encoder short name (default"copy").:video_bitrate/:audio_bitrate- target bitrate in bps.:width/:height- output video size in pixels. Specifying one derives the other from the source aspect ratio. Always rounded down to the nearest even value.:fps- target framerate as{num, den}. Defaults to the source.:sample_rate- target audio sample rate in Hz.:channels-1(mono) or2(stereo). A mono or stereo source is carried through when omitted; a source with more than 2 channels (5.1, 7.1, ...) requires an explicit value rather than being silently downmixed, and returns{:error, %Error{reason: :invalid_request}}otherwise. Applies only when the audio stream is re-encoded.
Returns
%{
streams_copied: 0,
streams_reencoded: 2,
packets_written: 312,
duration_s: 2.04
}
@spec version() :: {:ok, %{ avformat: String.t(), avcodec: String.t(), avutil: String.t(), license: String.t(), configuration: String.t() }} | {:error, Exmpeg.Error.t()}
Returns the version of every FFmpeg sub-library this NIF is linked
against, plus the ./configure flags used to build them.
iex> {:ok, %{avformat: avformat}} = Exmpeg.version()
iex> String.match?(avformat, ~r/^\d+\.\d+\.\d+$/)
true