diff --git a/AGENTS.md b/AGENTS.md index eb82eba..ec13843 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,95 +1,66 @@ # exmpeg -A Rustler NIF over `rsmpeg` (FFmpeg 9) that replaces shelling out to the -`ffmpeg` / `ffprobe` CLIs. It ships on Hex with precompiled NIFs, so the -public API, the option validators, and the error taxonomy are a contract +The README describes the library, its requirements, and the untrusted-input +rules. The `Exmpeg` moduledoc documents the public functions and their +options, and `RELEASE.md` the release flow. exmpeg ships on Hex, so the +public API, the option validators, and the error reasons are a contract with strangers. -Two files own what this one does not repeat: the `Exmpeg` moduledoc -documents the public functions and their options, `RELEASE.md` the -release flow. +## Checks -## Gates +- Run `task test:integration` after any change to a demux, mux, or codec + path. It needs `ffmpeg` and `ffprobe` on `PATH`; each test skips itself + when one is missing. CI runs it on every pull request and every push to + `main`. +- The Taskfile sets `EXMPEG_BUILD=1`, so a local build never downloads a + precompiled NIF. +- The toolchain comes from the host: the Elixir, OTP, and Rust versions in + `.github/workflows/ci.yml`, plus FFmpeg 9 with its headers, `pkg-config`, + and libclang. On Arch that is `pacman -S ffmpeg clang`. A distro with an + older FFmpeg builds 9 from source as `.github/actions/setup/action.yml` + does. -`task test:integration` is the expensive one. It builds fixtures with the -`ffmpeg` CLI and asserts packet timing with `ffprobe`, so both must be on -`PATH`; each test skips itself when one is missing. Run it after any -change to a demux, mux, or codec path. CI runs it on every pull request -and on every push to `main`. +## Rules -The first `task compile` builds the NIF from source and takes several -minutes. The Taskfile sets `EXMPEG_BUILD=1`, so a local build never pulls -a precompiled artefact. - -The toolchain comes from the host: the Elixir, OTP, and Rust versions in -`.github/workflows/ci.yml`, plus FFmpeg 9 with its headers, `pkg-config`, -and libclang for bindgen. On Arch that is `pacman -S ffmpeg clang`. A -distro with an older FFmpeg builds 9 from source the way -`.github/actions/setup/action.yml` does. - -## Layout - -`lib/exmpeg/native.ex` holds the `rustler_precompiled` stubs and stays -private to the library. Stub names match the Rust NIF symbols verbatim. - -## House decisions - -- `#![deny(unsafe_code)]` sits at the crate root and `ffi_helpers.rs` is - the single audit surface: it re-enables `unsafe`, and every block there - hides behind a safe function with a `SAFETY:` comment. An `unsafe` - block anywhere else fails the build, which is the point. -- Every NIF entry point runs inside `run_with_panic_protection`, so a - Rust panic returns `{:error, %{type: "nif_panic"}}` instead of taking - down the VM. -- A native error uses a `type` string from a closed set: - `invalid_request`, `io_error`, `decode_error`, `encode_error`, - `unsupported`, `runtime_error`, `cancelled`, `nif_panic`. - `Exmpeg.Error.from_native/1` maps it to an atom, and an unknown string - falls through to `:native_error`. -- The `build_*` functions in `Exmpeg` match the NIF result map strictly - in the function head, so a dropped field fails there instead of - producing a half-filled struct. `test/exmpeg/nif_contract_test.exs` - exercises them without a NIF call. -- Every `def` has a `@spec`, and credo enforces strict module layout. - `.credo.exs` excludes `lib/exmpeg/native.ex` from the layout check - because `use RustlerPrecompiled` needs its module attributes first. -- Every input opens with FFmpeg's `protocol_whitelist` pinned: - `file,crypto,data` for a path, `crypto,data` for `{:memory, _}` and for - a loaded buffer. -- `rsmpeg` comes from our fork `rubas/rsmpeg` at a pinned `rev`, because - no crates.io release supports FFmpeg 9 yet. The `TODO(revert: ...)` in - `native/exmpeg_native/Cargo.toml` names the condition to go back. - `task upgrade` skips a git dependency, so move the `rev` by hand. -- The precompiled binaries link an LGPL FFmpeg, so `libx264` and - `libx265` return `:unsupported` there. A source build against a - GPL-enabled FFmpeg 9 gets them. +- `unsafe` lives only in `native/exmpeg_native/src/ffi_helpers.rs`. Put + each block behind a safe function with a `SAFETY:` comment. The crate + root has `#![deny(unsafe_code)]`, so `unsafe` anywhere else fails the + build. +- A native error has a `type` string from a closed set: `invalid_request`, + `io_error`, `decode_error`, `encode_error`, `unsupported`, + `runtime_error`, `cancelled`, `nif_panic`. `Exmpeg.Error.from_native/1` + maps it to an atom. A new `type` needs its `to_reason/1` clause in + `lib/exmpeg/error.ex` and a test, or it silently becomes `:native_error`. +- Wrap every NIF error with `Error.from_native/1`. Never return a raw + `{:error, %{type: _}}` map to a caller. +- The `build_*` functions in `Exmpeg` match the NIF result map strictly in + the function head, so a missing field fails there and does not produce a + half-filled struct. `test/exmpeg/nif_contract_test.exs` tests them + without a NIF call. +- Keep every option validator in `lib/exmpeg.ex`. It turns a typo into + `:invalid_request` instead of an unclear native failure. +- Every `def` has a `@spec`. Credo enforces strict module layout; + `.credo.exs` excludes `lib/exmpeg/native.ex`, because + `use RustlerPrecompiled` needs its module attributes first. +- `lib/exmpeg/native.ex` holds the `rustler_precompiled` stubs and is + private to the library. Stub names match the Rust NIF symbols exactly. +- Never shell out from `lib/`. Only `test/support/fixtures.ex` and the + integration assertions use the `ffmpeg` and `ffprobe` CLIs. +- `rsmpeg` comes from our fork `rubas/rsmpeg` at a pinned `rev`. The + `TODO(revert: ...)` in `native/exmpeg_native/Cargo.toml` names when to go + back to crates.io. `task upgrade` skips git dependencies, so move the + `rev` by hand. ## Add an operation -1. Implement it in `native/exmpeg_native/src/.rs`, returning +1. Implement it in `native/exmpeg_native/src/.rs`. Return `Result` with a `type` from the set above. -2. Add the `nif_` entry point in `src/lib.rs`: `schedule = "DirtyIo"` - for I/O-bound work, `"DirtyCpu"` for codec work. +2. Add the `nif_` entry point in `src/lib.rs` inside + `run_with_panic_protection`. Use `schedule = "DirtyIo"` for I/O work and + `"DirtyCpu"` for codec work. 3. Add the stub and its wrapper in `lib/exmpeg/native.ex`. -4. Build the typed public API in `lib/exmpeg.ex`: validate the options, - call `Native`, map errors through `Error.from_native/1`. +4. Add the typed public function in `lib/exmpeg.ex`: validate the options, + call `Native`, and map errors through `Error.from_native/1`. 5. Test three ways: option validation, the NIF map shape in - `nif_contract_test.exs`, and a round trip against a synthetic fixture - in `integration_test.exs`. - -## Pitfalls - -- Never return a raw `{:error, %{type: _}}` NIF map to a caller. Wrap it - with `Error.from_native/1` so the caller matches on `Exmpeg.Error`. -- Never add a `type` string without its `to_reason/1` clause in - `lib/exmpeg/error.ex` and a test. Without the clause the new type - degrades to `:native_error` and nobody notices. -- Never skip an option validator in `lib/exmpeg.ex` for speed. It runs - once per call and turns a typo into `:invalid_request` instead of an - opaque native failure. -- Never probe an untrusted upload by path. A path input allows the `file` - protocol, so a crafted on-disk HLS or DASH manifest points FFmpeg at - other local files. Pass the bytes as `{:memory, binary}` or through - `Exmpeg.load_buffer/1`. -- Never shell out from `lib/`. The `ffmpeg` and `ffprobe` CLIs belong to - `test/support/fixtures.ex` and the integration assertions only. + `nif_contract_test.exs`, and a round trip on a synthetic fixture in + `integration_test.exs`. diff --git a/README.md b/README.md index 5bc07ed..350aeae 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,39 @@ # exmpeg -Native Elixir bindings for FFmpeg via the [`rsmpeg`](https://crates.io/crates/rsmpeg) Rust crate. +Elixir bindings for FFmpeg. A [Rustler](https://github.com/rusterlium/rustler) +NIF on the [`rsmpeg`](https://crates.io/crates/rsmpeg) crate runs FFmpeg in +the BEAM process, so you do not shell out to `ffmpeg` or `ffprobe`. Every +call returns plain Elixir structs and maps. -This library replaces shelling out to the `ffmpeg` / `ffprobe` CLIs with an -in-process [Rustler](https://github.com/rusterlium/rustler) NIF. Every call -runs against the FFmpeg shared libraries the NIF was linked at compile time -and returns structured results as plain Elixir structs / maps. +## Installation + +```elixir +def deps do + [ + {:exmpeg, "~> 0.6"} + ] +end +``` + +The Hex package ships precompiled NIFs for `aarch64-apple-darwin`, +`x86_64-unknown-linux-gnu`, and `aarch64-unknown-linux-gnu`. You need no +Rust toolchain and no FFmpeg install to use them. See +[Runtime requirements](#runtime-requirements) for the system libraries the +host must have. ## What it covers -| Operation | Replaces | -| ------------------------ | ------------------------------------------------------------ | -| `Exmpeg.probe/1` | `ffprobe -show_format -show_streams` | -| `Exmpeg.remux/3` | `ffmpeg -i in -c copy out` (with optional `-ss` / `-t` cut) | -| `Exmpeg.extract_frame/3` | `ffmpeg -ss T -i in -frames:v 1 out.jpg` | -| `Exmpeg.extract_audio/3` | `ffmpeg -i in -vn -acodec pcm_s16le out.wav` | -| `Exmpeg.concat/3` | `ffmpeg -f concat -i list.txt -c copy out` | -| `Exmpeg.transcode/3` | `ffmpeg -i in -c:v libvpx-vp9 -c:a libopus out` (and friends) | +| Function | Replaces | +| ------------------------ | ----------------------------------------------------------- | +| `Exmpeg.probe/1` | `ffprobe -show_format -show_streams` | +| `Exmpeg.remux/3` | `ffmpeg -i in -c copy out`, with an optional `-ss` / `-t` cut | +| `Exmpeg.extract_frame/3` | `ffmpeg -ss T -i in -frames:v 1 out.jpg` | +| `Exmpeg.extract_audio/3` | `ffmpeg -i in -vn -acodec pcm_s16le out.wav` | +| `Exmpeg.concat/3` | `ffmpeg -f concat -i list.txt -c copy out` | +| `Exmpeg.transcode/3` | `ffmpeg -i in -c:v libvpx-vp9 -c:a libopus out`, and others | -`Exmpeg.load_buffer/1` turns a binary into a reusable in-memory input, and -`Exmpeg.version/0` reports the FFmpeg version the NIF is linked against. +`Exmpeg.load_buffer/1` turns a binary into an in-memory input that you can +use many times. `Exmpeg.version/0` returns the FFmpeg version the NIF links. ## Quickstart @@ -29,206 +43,140 @@ and returns structured results as plain Elixir structs / maps. info.format.duration_s #=> 12.345 -# Remux: container change, optional cut window +# Remux: change the container, with an optional cut {:ok, _} = Exmpeg.remux("input.mkv", "output.mp4") {:ok, _} = Exmpeg.remux("input.mp4", "clip.mp4", start_s: 5.0, duration_s: 2.0) # Thumbnail at a timestamp, optionally resized {:ok, _} = Exmpeg.extract_frame("input.mp4", "thumb.jpg", timestamp_s: 1.5, width: 320) -# Audio to WAV with explicit sample rate + channels +# Audio to WAV with a sample rate and channel count {:ok, _} = Exmpeg.extract_audio("input.mp4", "audio.wav", sample_rate: 16_000, channels: 1) -# Concat three same-codec clips +# Concat three clips with the same codecs {:ok, _} = Exmpeg.concat(["a.mp4", "b.mp4", "c.mp4"], "joined.mp4") -# Re-encode to VP9 + Opus at a smaller width / lower audio rate +# Re-encode to VP9 and Opus at a smaller width {:ok, _} = Exmpeg.transcode("input.mov", "output.webm", video_codec: "libvpx-vp9", audio_codec: "libopus", width: 1280, sample_rate: 48_000 ) -# Read the same bytes more than once without copying them again +# Read the same bytes more than once without a new copy {:ok, buffer} = Exmpeg.load_buffer(File.read!("input.mp4")) {:ok, info} = Exmpeg.probe(buffer) {:ok, _} = Exmpeg.extract_frame(buffer, "thumb.jpg", timestamp_s: 1.5) ``` -## Safety +## Runtime requirements -The Rust crate is built on rsmpeg's safe wrappers with -`#![deny(unsafe_code)]` at the root. -`native/exmpeg_native/src/ffi_helpers.rs` is the only module that -contains `unsafe`; everything else stays outside it. The quarantined -operations are the ones rsmpeg does not yet expose safely: - -- clearing `AVCodecParameters.codec_tag` (a single primitive store on a - unique `&mut` borrow), -- `AVAudioFifo::write` / `AVAudioFifo::read` against a frame's - `extended_data` per-channel pointer array, -- assigning a freshly-built `AVDictionary` into - `AVFormatContextOutput.metadata` (libavformat takes ownership), -- comparing two raw `AVChannelLayout`s through - `av_channel_layout_compare`, which reads both and keeps no pointer, to - decide whether audio extraction can skip resampling. - -Every `unsafe` block names its invariant in a `SAFETY:` comment, and unit -tests in the same module exercise the round-trips. - -Every NIF entry point is wrapped in `run_with_panic_protection`, so a -Rust panic surfaces as `{:error, %{type: "nif_panic", ...}}` instead -of taking down the BEAM VM. - -### Untrusted input - -Every input is opened with FFmpeg's `protocol_whitelist` pinned, so a -crafted file cannot drive libavformat into opening attacker-controlled -URLs (the SSRF / local-file-disclosure vector that HLS, DASH, and the -`concat` protocol expose through nested segment opens). The guarantee -differs by input kind: - -- **In-memory input is the path for untrusted media.** Both - `{:memory, binary}` and a buffer from `Exmpeg.load_buffer/1` are - restricted to `crypto,data`: no filesystem and no network reach, so a - crafted upload can reach neither the network nor any local file. Buffer - untrusted uploads (and anything you did not author) through this path. -- **Filesystem-path inputs trust the local filesystem.** They allow - `file,crypto,data`: the network is blocked, but `file` is required. It - is the protocol that opens the path itself, and it also lets a local - HLS/DASH playlist read its sibling segment files. `protocol_whitelist` - applies uniformly to every open libavformat performs, so there is no - way to keep the top-level file open while forbidding the nested ones, - and a crafted *on-disk* manifest can therefore still point FFmpeg at - other local files via a `file:` reference. So do not write an untrusted - upload to a temp file and probe it by path; hand the bytes to - `{:memory, _}` or a buffer instead. - -Single-file demuxers (mp4, mkv, ...) perform no nested opens, so the -whitelist is invisible to them; it only constrains the reference -demuxers, which is exactly where the risk lives. +Each precompiled tarball bundles the seven FFmpeg 9.0.1 shared libraries +(`libavformat`, `libavcodec`, `libavutil`, `libavfilter`, `libswscale`, +`libswresample`, `libavdevice`) next to the NIF. The NIF finds them through +`$ORIGIN` or `@loader_path`, so you need no `LD_LIBRARY_PATH`. -## Installation +The bundled FFmpeg is LGPL only (`--enable-libmp3lame --enable-libopus +--enable-libvpx --enable-libwebp`, no `--enable-gpl`), so the MIT package can +redistribute it. It has no `libx264` or `libx265`. `transcode/3` with +`video_codec: "libx264"` or `"libx265"` returns +`{:error, %Exmpeg.Error{reason: :unsupported}}`. To use them, build from +source against your own GPL FFmpeg 9. -```elixir -def deps do - [ - {:exmpeg, "~> 0.6"} - ] -end -``` +The host must supply the rest: -The published Hex package ships precompiled NIFs for common targets -(`aarch64-apple-darwin`, `x86_64-unknown-linux-gnu`, -`aarch64-unknown-linux-gnu`); consumers do not need a Rust toolchain to -use them. - -To build the NIF from source, install Rust 1.98 or newer and set -`EXMPEG_BUILD=1` before compiling. - -## Build requirements - -- FFmpeg 9.x shared libraries on the linker / loader path. `rsmpeg` - discovers them via `pkg-config`; set `FFMPEG_PKG_CONFIG_PATH` when - building against a non-default install. -- Access to GitHub: the NIF builds on - [our rsmpeg fork](https://github.com/rubas/rsmpeg) until an rsmpeg - release on crates.io supports FFmpeg 9, so Cargo fetches it from there. -- Rust 1.98+ for source builds, and libclang for `bindgen`. Set - `LIBCLANG_PATH` when libclang is not in a default library path. -- Elixir 1.17+ / OTP 26+ (the NIF targets Erlang NIF version 2.17). - -## Runtime requirements (precompiled NIF consumers) - -The published Hex package ships precompiled NIF tarballs that **bundle -the seven FFmpeg 9.0.1 shared libraries** (`libavformat`, `libavcodec`, -`libavutil`, `libavfilter`, `libswscale`, `libswresample`, `libavdevice`) -next to the NIF and use `$ORIGIN` / `@loader_path` so the loader finds -them without `LD_LIBRARY_PATH` gymnastics. Consumers therefore do **not** need to -install FFmpeg 9 separately. - -The bundled FFmpeg is built **LGPL-only** (`--enable-libmp3lame ---enable-libopus --enable-libvpx --enable-libwebp`, no `--enable-gpl`), so -the precompiled binaries can be redistributed under this package's MIT -license. H.264 / H.265 software encoding via `libx264` / `libx265` is GPL -and is **not** in the precompiled binaries; calling `transcode/3` with -`video_codec: "libx264"` (or `"libx265"`) on a precompiled install -returns `{:error, %Error{reason: :unsupported}}`. To use them, build -from source (`EXMPEG_BUILD=1`) against your own GPL-enabled FFmpeg 9. - -What is **not** bundled and must be on the host: - -- glibc, with `libm`, `libdl`, and `libpthread`. The floor differs per - architecture, because the two builds reference different versioned math - symbols: **x86_64 needs glibc 2.35 or newer**, which Ubuntu 22.04 and - Debian 12 clear; **aarch64 needs glibc 2.38 or newer**, which Ubuntu - 24.04 and Debian 13 clear. An older host has to build from source. - Check a host with `ldd --version`. -- The codec system libraries that libavcodec dlopens at decode/encode - time: - - `libmp3lame` (`libmp3lame0`) - - `libopus` (`libopus0`) - - `libvpx` (`libvpx9` or newer) - - `libwebp` (`libwebp7` or newer), for `.webp` frame output -- Their transitive system deps (`libgsm`, `libnuma`, ...) which the - distro packages above pull in automatically. - -For Debian / Ubuntu: +- glibc with `libm`, `libdl`, and `libpthread`. x86_64 needs glibc 2.35 or + newer (Ubuntu 22.04, Debian 12). aarch64 needs glibc 2.38 or newer + (Ubuntu 24.04, Debian 13). An older host must build from source. Check + with `ldd --version`. +- The codec libraries that libavcodec loads: `libmp3lame`, `libopus`, + `libvpx` (9 or newer), and `libwebp` (7 or newer, for `.webp` frames). + The distro packages pull in their own dependencies. ```bash +# Debian / Ubuntu sudo apt install -y libmp3lame0 libopus0 libvpx9 libwebp7 -``` - -For macOS (Apple Silicon, via Homebrew): -```bash +# macOS (Homebrew) brew install lame opus libvpx webp ``` -Source builds (`EXMPEG_BUILD=1`) link directly against the system's -FFmpeg 9 install and so behave like a normal `pkg-config` consumer: -they need the dev packages (`libavcodec-dev` & friends) at build time -and the matching runtime libs at load time. +## Build from source + +Set `EXMPEG_BUILD=1` before you compile. A source build links the FFmpeg on +the host and needs: -## Errors +- FFmpeg 9.x with its dev headers (`libavcodec-dev` and the others). + `rsmpeg` finds them through `pkg-config`. Set `FFMPEG_PKG_CONFIG_PATH` + for an install outside the default path. +- Rust 1.98 or newer, and libclang for `bindgen`. Set `LIBCLANG_PATH` when + libclang is outside the default library path. +- Access to GitHub. The NIF uses + [our `rsmpeg` fork](https://github.com/rubas/rsmpeg) until an `rsmpeg` + release on crates.io supports FFmpeg 9. +- Elixir 1.17 or newer and OTP 26 or newer (NIF version 2.17). -Every call returns either `{:ok, value}` or `{:error, %Exmpeg.Error{}}`. -`t:Exmpeg.Error.reason/0` enumerates the categories: `:invalid_request`, +## Errors and cancellation + +Every call returns `{:ok, value}` or `{:error, %Exmpeg.Error{}}`. +`t:Exmpeg.Error.reason/0` lists the reasons: `:invalid_request`, `:io_error`, `:decode_error`, `:encode_error`, `:unsupported`, `:runtime_error`, `:cancelled`, `:nif_panic`, `:native_error`. -A long-running operation (`remux/3`, `extract_frame/3`, `extract_audio/3`, -`concat/3`, `transcode/3`) checks whether the calling process is still -alive roughly every 100 ms. If the caller dies mid-operation (a `Task` -timeout, a supervised shutdown, a disconnect) the native work stops at -the next check, the partial output is removed, and the call resolves to -`{:error, %Exmpeg.Error{reason: :cancelled}}` (which the dead caller -never observes). The operation is uninterruptible between checks. - -The checks run in the packet loops, so the open of an input cannot be -cancelled. The open reads the container header (`avformat_open_input`) -and then analyzes the streams (`avformat_find_stream_info`). The FFmpeg -defaults limit only the analysis: about 5 MB of packets (`probesize`) -and 5 to 90 s of media, by format (`analyzeduration`). Nothing limits the -header read. An mp4 `moov` index, for example, grows with the sample -count, so a long mp4 can read tens of MB before the analysis starts. -`probe/1` is only this open, so it is not cancellable. A read that -blocks in the kernel, for example on a stalled network mount, holds the -dirty scheduler thread until it returns. +`remux/3`, `extract_frame/3`, `extract_audio/3`, `concat/3`, and +`transcode/3` check about every 100 ms that the calling process is alive. +When the caller dies (a `Task` timeout, a supervisor shutdown, a +disconnect), the work stops at the next check, the NIF removes the partial +output, and the call returns `{:error, %Exmpeg.Error{reason: :cancelled}}`. + +The checks run in the packet loops, so you cannot cancel the open of an +input. The open reads the container header (`avformat_open_input`), then +analyzes the streams (`avformat_find_stream_info`). FFmpeg limits only the +analysis: about 5 MB of packets (`probesize`) and 5 to 90 s of media, +by format (`analyzeduration`). Nothing limits the header read. An mp4 +`moov` index grows with the sample count, so a long mp4 can read tens of MB +before the analysis starts. `probe/1` is only this open, so you cannot +cancel it. A read that blocks in the kernel, for example on a stalled +network mount, holds the dirty scheduler thread until it returns. + +## Untrusted input + +Every input opens with FFmpeg's `protocol_whitelist` set. A crafted file +cannot make libavformat open a URL of the attacker's choice, as HLS, DASH, +and the `concat` protocol can through nested opens. The limit depends on +the input kind: + +- `{:memory, binary}` and a buffer from `Exmpeg.load_buffer/1` allow only + `crypto,data`. They reach no file and no network. Use them for uploads + and for any media you did not create. +- A file path allows `file,crypto,data`. The network is blocked, but + `file` must stay, because it opens the path itself and the segment files + of a local HLS or DASH playlist. The whitelist applies to every open, so + a crafted manifest on disk can still point FFmpeg at other local files. + Do not write an upload to a temp file and probe it by path. + +Single-file demuxers such as mp4 and mkv do no nested opens, so the +whitelist does not affect them. + +## Safety + +The crate root has `#![deny(unsafe_code)]`. +`native/exmpeg_native/src/ffi_helpers.rs` is the only module with +`unsafe`. It wraps the few raw FFmpeg calls that `rsmpeg` has no safe +wrapper for. Each block has a `SAFETY:` comment, and unit tests in the same +module cover them. + +Every NIF entry point runs in `run_with_panic_protection`. A Rust panic +returns `{:error, %Exmpeg.Error{reason: :nif_panic}}` and does not stop the +BEAM. ## Development -```bash -task setup # mix deps.get -task compile # build the NIF (first run takes several minutes) -task test # fast Elixir unit tests -task test:rust # cargo test -task lint # mix credo --strict + cargo clippy -D warnings -task check # full local gate -task test:integration # end-to-end tests against a generated clip -``` +`task check` runs the format check, compile, lint, the Elixir and Rust unit +tests, and `zizmor` on the workflows. `task --list` shows every task. The first `task compile` builds +the NIF and takes several minutes. -`task test:integration` synthesises a small MP4 with the `ffmpeg` CLI and +`task test:integration` generates a small MP4 with the `ffmpeg` CLI and checks packet timing with `ffprobe`, so both must be on `PATH`. ## License