0.4.0 - 2026-06-11

Added

  • Exmpeg.load_buffer/1 returns an opaque, reusable Exmpeg.Buffer for in-memory input. Unlike {:memory, binary}, which copies the whole binary into the NIF on every call, a buffer copies the bytes once into a refcounted native resource; passing it to later operations (or as repeated concat/3 inputs) is then a refcount bump, not another copy. A buffer is accepted anywhere an input source is.

Changed

  • transcode/3 no longer silently downmixes a surround (>2-channel) source to stereo when :channels is omitted and the audio is re-encoded. It now returns {:error, %Exmpeg.Error{reason: :invalid_request}} and requires an explicit :channels (1 or 2), matching extract_audio/3. Mono/stereo sources are unaffected.
  • Updated rustler to 0.38. Source builds (EXMPEG_BUILD=1) now require Rust 1.91 or newer (rustler 0.38 raised its MSRV). Precompiled-NIF consumers are unaffected.
  • Widened the rustler_precompiled requirement to ~> 0.9 (already resolved to 0.9.0).
  • Bumped dev/test dependencies (credo, ex_doc, ex_dna, ex_slop) and refreshed transitive Rust crate versions and GitHub Actions pins.

Security

  • Output paths that look like a protocol URL (http://, ftp://, rtmp://, tcp://, ...) are now rejected with :invalid_request before any work, closing a write-side SSRF where libavformat would open a network write from a caller-controlled destination. Local paths (including Windows drive letters) are unaffected.
  • Every input is now opened with FFmpeg's protocol_whitelist pinned. {:memory, binary} inputs are restricted to crypto,data (no filesystem, no network) and filesystem-path inputs to file,crypto,data. This closes an SSRF / local-file-disclosure vector where a crafted manifest demuxer (HLS, DASH, concat) could drive libavformat into opening attacker-controlled URLs from untrusted media.

Performance

  • extract_audio/3 skips the resampler and the sample FIFO when the decoded audio already matches the target format, rate, and channel layout and the encoder accepts arbitrary chunk sizes (the PCM case), sending each decoded frame straight to the encoder. Output is unchanged; the conversion path is unaffected.

Fixed

  • remux/3's closing progress message now reports the real final current_pts_s (the largest written packet pts) instead of 0.0 when no :duration_s was given, so a subscriber rendering current_pts_s / total_duration_s sees ~100% at completion rather than 0%.
  • A Rust panic mid-write no longer leaks the .partial file on disk. The output write now arms an RAII guard that removes the partial on any early exit, including a panic unwind (which catch_unwind only catches one frame up), and is disarmed only after the rename onto the destination succeeds.
  • extract_audio/3 to .opus / .ogg at a sample rate other than 48 kHz no longer produces a file whose container duration is wrong. The Ogg muxer pins Opus streams to a 1/48000 time_base, so encoder packets are now rescaled into the muxer's chosen stream time_base before writing (previously a 16 kHz extraction reported ~3x short). WAV/MP3/M4A/FLAC are unaffected (the rescale is an identity there).
  • transcode/3 with a mix of copied and re-encoded streams no longer desyncs them when the source does not start at timestamp 0 (MPEG-TS captures, edit-list offsets). Re-encoded streams use zero-based counters; copied packets now have the container start time subtracted too, so both share a zero origin while keeping their true inter-stream offset.
  • Disk writes now use a per-call unique partial path (<stem>.partial.<nonce>.<ext>) instead of a deterministic one. Two concurrent writes to the same destination (duplicate jobs, a retry racing a slow first attempt, two nodes on shared storage) no longer share a partial file, so one call can no longer unlink or rename another's in-progress output. The guarantee is now last-complete-rename-wins: every observable destination state is a complete file.
  • Several inputs that passed option validation but then raised inside the NIF decode now return {:error, %Exmpeg.Error{reason: :invalid_request}} as the contract promises: an :fps component past the i32 range, a bitrate past the i64 range, a non-UTF-8 path/output binary, and an opts list containing a non-{key, value} element.
  • transcode/3 with a custom :video_filter that has no fps filter no longer corrupts output timing. Filtered frames are now stepped by one frame interval in the encoder time_base instead of by a bare 1, so a chain like crop=... keeps the real duration instead of collapsing the stream to a few microseconds. The default filter chain is unaffected.
  • concat/3 no longer corrupts timing when an input's container duration is unknown (mkv/webm from a non-seekable sink such as MediaRecorder or an interrupted capture). The per-stream offset is now advanced from the tracked end of the written packets instead of being left in place, so the following input no longer overlaps and trips the monotonic-dts ratchet (which flattened its real inter-frame gaps). duration_s now reports the summed input durations instead of 0.0.
  • remux/3 and concat/3 now return {:error, %Exmpeg.Error{reason: :unsupported}} for an unknown output extension or a codec the chosen container cannot hold (e.g. h264 into .wav), matching the documented contract, instead of the generic :io_error they returned before.
  • remux/3 with :duration_s no longer truncates other streams when one stream reaches the window edge first. Each kept stream now ends individually; the copy loop stops only once every kept stream has passed the window (or the input hits EOF), so interleaved audio is not cut short relative to video. Packets with no pts are bounded by their dts instead of bypassing the window check.
  • Long-running operations (remux/3, extract_frame/3, extract_audio/3, concat/3, transcode/3) now stop when the calling process dies. They check caller liveness on a ~100 ms throttle and, on a dead caller, remove the partial output and return {:error, %Exmpeg.Error{reason: :cancelled}} instead of running the dirty-scheduler job to completion. Adds the new :cancelled error reason.

0.3.0 - 2026-05-20

Initial public release. Native Elixir bindings for FFmpeg 8 via the rsmpeg Rust crate, packaged as a Rustler NIF - in-process calls instead of shelling out to the ffmpeg / ffprobe CLIs.

Operations

  • Exmpeg.version/0 - linked FFmpeg sub-library versions and configure flags.
  • Exmpeg.probe/1 - container + per-stream metadata (ffprobe).
  • Exmpeg.remux/3 - stream copy between containers with optional start_s / duration_s window, :drop_audio / :drop_video / :drop_subtitles, and a :tags keyword/map for container metadata.
  • Exmpeg.extract_frame/3 - single-frame thumbnail at a timestamp, written as .jpg / .png / .bmp / .webp, with optional aspect-preserving resize.
  • Exmpeg.extract_audio/3 - decoded audio to .wav, .mp3, .m4a / .aac, .opus / .ogg, or .flac, with optional sample-rate / channel-count / bitrate selection. Sample rate snaps to the encoder's supported list when needed (e.g. libopus 8/12/16/24/48 kHz).
  • Exmpeg.concat/3 - join multiple inputs sharing the same stream layout into a single output without re-encoding.
  • Exmpeg.transcode/3 - per-stream re-encode with codec / bitrate / scale / fps / sample-rate selection driven by an AVFilterGraph, plus a raw :video_filter filter-graph spec. Streams marked "copy" are stream-copied.

Inputs and progress

  • Memory inputs - every read-side operation accepts {:memory, binary} in place of a filesystem path, backed by a custom AVIOContextCustom with read + seek callbacks so demuxers that seek (mp4 moov, matroska cues) work without a temp file.
  • Progress callbacks - remux/3, extract_audio/3, concat/3, and transcode/3 accept progress: pid() and send throttled {:exmpeg_progress, %{...}} messages plus a final tick after write_trailer.

Packaging

  • Precompiled NIFs for aarch64-apple-darwin, x86_64-unknown-linux-gnu, and aarch64-unknown-linux-gnu; other targets build from source with EXMPEG_BUILD=1.
  • The precompiled tarballs bundle an LGPL-only FFmpeg 8 (libmp3lame, libopus, libvpx; no --enable-gpl), so they redistribute cleanly under this package's MIT license. H.264 / H.265 software encoding (libx264 / libx265, GPL) is not in the precompiled binaries and returns :unsupported; build from source against a GPL-enabled FFmpeg 8 to use them. H.264/H.265 decoding is unaffected.

Safety

  • The Rust crate is built on rsmpeg's safe wrappers with #![deny(unsafe_code)] at the root. All unsafe is confined to ffi_helpers.rs, each block carrying a SAFETY: comment.
  • Every NIF entry point is wrapped in run_with_panic_protection, so a Rust panic surfaces as {:error, %{type: "nif_panic"}} instead of crashing the BEAM.
  • Size options (:width / :height / :sample_rate) are bounded at the API boundary so an absurd value cannot trigger an out-of-memory allocation inside the NIF.
  • Disk writes are atomic: operations write a <stem>.partial.<ext> sibling and rename onto the destination only after the muxer trailer is written; a mid-encode failure removes the partial.