0.4.1 - 2026-06-17
Changed
- Build and test against FFmpeg 8.1 (was 8.0.1): the CI verification
build, the distributed precompiled-NIF release artefacts, and the local
devenvtoolchain now all target FFmpeg 8.1. The pinned rsmpeg revision compiles unchanged against 8.1's libavformat 62.x, so there is no source or API change. Consumers building from source (EXMPEG_BUILD=1) should link against an FFmpeg 8.x install; precompiled-NIF consumers are unaffected.
0.4.0 - 2026-06-11
Added
Exmpeg.load_buffer/1returns an opaque, reusableExmpeg.Bufferfor 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 repeatedconcat/3inputs) is then a refcount bump, not another copy. A buffer is accepted anywhere an input source is.
Changed
transcode/3no longer silently downmixes a surround (>2-channel) source to stereo when:channelsis omitted and the audio is re-encoded. It now returns{:error, %Exmpeg.Error{reason: :invalid_request}}and requires an explicit:channels(1 or 2), matchingextract_audio/3. Mono/stereo sources are unaffected.- Updated
rustlerto 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_precompiledrequirement 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_requestbefore 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_whitelistpinned.{:memory, binary}inputs are restricted tocrypto,data(no filesystem, no network) and filesystem-path inputs tofile,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/3skips 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 finalcurrent_pts_s(the largest written packet pts) instead of0.0when no:duration_swas given, so a subscriber renderingcurrent_pts_s / total_duration_ssees ~100% at completion rather than 0%.- A Rust panic mid-write no longer leaks the
.partialfile on disk. The output write now arms an RAII guard that removes the partial on any early exit, including a panic unwind (whichcatch_unwindonly catches one frame up), and is disarmed only after the rename onto the destination succeeds. extract_audio/3to.opus/.oggat a sample rate other than 48 kHz no longer produces a file whose container duration is wrong. The Ogg muxer pins Opus streams to a1/48000time_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/3with 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:fpscomponent 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/3with a custom:video_filterthat has nofpsfilter no longer corrupts output timing. Filtered frames are now stepped by one frame interval in the encoder time_base instead of by a bare1, so a chain likecrop=...keeps the real duration instead of collapsing the stream to a few microseconds. The default filter chain is unaffected.concat/3no 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_snow reports the summed input durations instead of0.0.remux/3andconcat/3now 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_errorthey returned before.remux/3with:duration_sno 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:cancellederror 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 optionalstart_s/duration_swindow,:drop_audio/:drop_video/:drop_subtitles, and a:tagskeyword/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 anAVFilterGraph, plus a raw:video_filterfilter-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 customAVIOContextCustomwith read + seek callbacks so demuxers that seek (mp4moov,matroskacues) work without a temp file. - Progress callbacks -
remux/3,extract_audio/3,concat/3, andtranscode/3acceptprogress: pid()and send throttled{:exmpeg_progress, %{...}}messages plus a final tick afterwrite_trailer.
Packaging
- Precompiled NIFs for
aarch64-apple-darwin,x86_64-unknown-linux-gnu, andaarch64-unknown-linux-gnu; other targets build from source withEXMPEG_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. Allunsafeis confined toffi_helpers.rs, each block carrying aSAFETY: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.