<div align="center">
<img src="assets/logo.svg" alt="mp4_muxer logo" width="160" height="160">
<h1>mp4_muxer</h1>
<p>
<strong>Single-pass MP4 writer for AV1 packets. Zero runtime dependencies.</strong>
</p>
<p>
[](https://crates.io/crates/mp4_muxer)
[](https://docs.rs/mp4_muxer/0.1.0)


[](https://deps.rs/crate/mp4_muxer/0.1.0)
<br />

</p>
</div>
---
**Documentation**: [https://docs.rs/mp4_muxer](https://docs.rs/mp4_muxer)
**Source Code**: [https://github.com/NameOfShadow/mp4_muxer](https://github.com/NameOfShadow/mp4_muxer)
---
No FFmpeg. No `mp4parse`. No shell-out. AV1 OBU packets in, playable `.mp4` out — one pass, one `Vec<u8>` of Rust.
```rust
use mp4_muxer::Mp4Writer;
use std::fs::File;
let file = File::create("out.mp4")?;
let mut muxer = Mp4Writer::new(file, 320, 240, 30, 1)?;
muxer.write_frame(&av1_packet, true)?;
muxer.finish()?;
```
```text
$ ffprobe -v error -show_streams out.mp4
[STREAM]
codec_type=video
codec_name=av1
width=320
height=240
r_frame_rate=30/1
[/STREAM]
```
## Features
- **Zero runtime dependencies** — pure `std`, no `mp4parse-rust`, no `bento4`, no FFmpeg.
- **Single-pass writer** — `mdat` precedes `moov`, so chunk offsets are known when `moov` is built. No rewind, no second pass.
- **Automatic `co64` fallback** — 64-bit chunk offsets kick in the moment any frame lands past 4 GiB. `stco` truncation is not silent here.
- **Drift-free timing** — media timescale is `fps_num`, sample duration is `fps_den`. `60000/1001` stays exact.
- **Recoverable writer** — `finish` returns `W`, so a `Cursor<Vec<u8>>` gives you the bytes back with `.into_inner()`.
- **Explicit errors** — `Error::Io`, `Error::NoFrames`, `Error::InvalidFps`. No panics on I/O.
- **AV1 sync-sample tracking** — keyframes go in `stss`; seekers work.
- **Small** — ~500 lines, one file.
## Installation
```toml
[dependencies]
mp4_muxer = "0.1"
```
Requires Rust **1.56+**.
## Quick start
```rust
use mp4_muxer::Mp4Writer;
use std::fs::File;
fn mux_av1<I>(packets: I, width: u32, height: u32) -> mp4_muxer::Result<()>
where
I: IntoIterator<Item = (Vec<u8>, bool)>, // (obu bytes, is_keyframe)
{
let file = File::create("out.mp4")?;
let mut m = Mp4Writer::new(file, width, height, 30, 1)?;
for (obu, is_key) in packets {
m.write_frame(&obu, is_key)?;
}
// finish() consumes the muxer and returns the underlying writer,
// so File::sync_all() / Cursor::into_inner() still works after muxing.
let _file = m.finish()?;
Ok(())
}
```
Feed it real AV1 from `rav1e`:
```rust
use mp4_muxer::Mp4Writer;
use rav1e::{prelude::*, Config, EncoderConfig};
use std::fs::File;
let cfg = EncoderConfig { width: 320, height: 240, ..Default::default() };
let mut enc: Context<u8> = Config::new().with_encoder_config(cfg).new_context()?;
let mut m = Mp4Writer::new(File::create("out.mp4")?, 320, 240, 30, 1)?;
while let Ok(pkt) = enc.receive_packet() {
m.write_frame(&pkt.data, pkt.frame_type == FrameType::KEY)?;
}
m.finish()?;
```
## Reference
### `Mp4Writer<W>`
| `new(w, width, height, fps_num, fps_den)` | Write `ftyp` + placeholder `mdat` header. Returns `Result<Self>`. |
| `write_frame(obu, is_key)` | Append one AV1 OBU. Records offset/size/keyframe flag. |
| `frames_written()` | Frames written so far. |
| `finish()` | Patch `mdat` size, append `moov`. Consumes `self`, returns `W`. |
### `Error`
| `Error::Io(io::Error)` | Underlying writer failed. Wraps `std::io::Error`. |
| `Error::NoFrames` | `finish()` called before any `write_frame`. |
| `Error::InvalidFps` | `fps_num` or `fps_den` was zero. |
### FPS encoding
| 24 fps | `24` | `1` |
| 30 fps | `30` | `1` |
| 29.97 fps | `30000` | `1001` |
| 59.94 fps | `60000` | `1001` |
| 60 fps | `60` | `1` |
## Output layout
Every produced file is exactly this box tree:
```text
ftyp — major "isom", compatible "av01"
mdat — raw AV1 OBU bytes
moov
├─ mvhd — movie header, timescale 1000
└─ trak — video track
├─ tkhd — width, height, matrix
└─ mdia
├─ mdhd — media timescale = fps_num
├─ hdlr — handler = "vide"
└─ minf
├─ vmhd
├─ dinf → dref
└─ stbl
├─ stsd → av01 → av1C
├─ stts — one entry, sample_delta = fps_den
├─ stss — 1-based sync sample indices
├─ stsc — 1 sample per chunk
├─ stsz — per-sample sizes
└─ stco | co64 — absolute chunk offsets
```
Because `mdat` sits before `moov`, every offset in `stco` / `co64` is the absolute file position of the corresponding frame. No offsets are patched at finalisation — only the `mdat` size field.
### `stco` vs `co64`
`build_stco_or_co64` picks per file:
| no | `stco` | 32-bit |
| yes | `co64` | 64-bit |
The `mdat` box itself is still capped at 4 GiB of payload — you get `Error::Io` (`InvalidData`) instead of a corrupted file if you cross that line.
### Timing
Media timescale is set to `fps_num` and per-sample duration to `fps_den`. For `60000/1001` (59.94 fps) that gives you `(sample_delta / timescale) = 1001/60000` exactly per frame — no accumulated drift over long clips.
## What it does not do
| Audio track (`mp4a`, Opus) | not supported | mux video only |
| B-frames / DTS ≠ PTS | not supported | encode with `pts == dts` |
| Fragmented MP4 (fMP4 / DASH) | not supported | use a fragmented muxer |
| Bitstream parsing | not supported | you tag keyframes yourself |
| `mdat` > 4 GiB | rejected | split output, or send a PR for `largesize` |
## Dependencies
### Runtime
| — | none. |
### Compile-time only
| — | none. |
## License
MIT — see [LICENSE](LICENSE).
## Changelog
See [CHANGELOG.md](CHANGELOG.md).