# `Exmpeg`
[🔗](https://github.com/rubas/exmpeg/blob/v0.3.0/lib/exmpeg.ex#L1)

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.

# `concat_opt`

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

Options accepted by `concat/3`.

# `concat_stats`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

Options accepted by `extract_frame/3`.

# `extract_frame_stats`

```elixir
@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`

```elixir
@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`

```elixir
@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()) =&gt; String.t()}}
  | {:progress, pid()}
```

Options accepted by `remux/3`.

# `remux_stats`

```elixir
@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`

```elixir
@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()) =&gt; 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`

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

# `concat`

```elixir
@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`

```elixir
@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` - `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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
