Exmpeg (exmpeg v0.4.0)

Copy Markdown View Source

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.

Options accepted 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

concat_opt()

@type concat_opt() :: {:progress, pid()}

Options accepted by concat/3.

concat_stats()

@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.

extract_audio_opt()

@type extract_audio_opt() ::
  {:sample_rate, pos_integer()}
  | {:channels, 1..2}
  | {:bitrate, pos_integer()}
  | {:progress, pid()}

Options accepted by extract_audio/3.

extract_audio_stats()

@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.

extract_frame_opt()

@type extract_frame_opt() ::
  {:timestamp_s, number()} | {:width, pos_integer()} | {:height, pos_integer()}

Options accepted by extract_frame/3.

extract_frame_stats()

@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.

input_source()

@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.Buffer from load_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.

remux_opt()

@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.

remux_stats()

@type remux_stats() :: %{
  packets_written: non_neg_integer(),
  packets_dropped: non_neg_integer(),
  streams_copied: non_neg_integer()
}

Stats returned by remux/3.

transcode_opt()

@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.

transcode_stats()

@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

concat(inputs, output, opts \\ [])

@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}

extract_audio(input, output, opts \\ [])

@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:

ExtensionEncoder
.wavpcm_s16le
.mp3libmp3lame
.m4a / .aacaac
.opus / .ogglibopus
.flacflac

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 - 1 for mono or 2 for 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"
}

extract_frame(input, output, opts \\ [])

@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 (default 0.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"}

load_buffer(binary)

@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")

probe(source)

@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.

remux(input, output, opts \\ [])

@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}

transcode(input, output, opts \\ [])

@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) or 2 (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
}

version()

@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