Exmpeg (exmpeg v0.3.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 sibling <stem>.partial.<ext> file and rename onto the final path only after the muxer trailer has been written successfully. A failure mid-encode removes the partial file so the destination is never left half-written.

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. Either a filesystem path (String.t()) or {:memory, binary} to read the entire input from an in-memory buffer through a custom AVIOContext.

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.

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()}

Input source. Either a filesystem path (String.t()) or {:memory, binary} to read the entire input from an in-memory buffer through a custom AVIOContext.

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

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

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