mp4_muxer 0.1.0

Zero-dependency MP4 muxer for AV1 video streams
Documentation
<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>

<!-- prettier-ignore-start -->

[![crates.io](https://img.shields.io/crates/v/mp4_muxer?label=latest)](https://crates.io/crates/mp4_muxer)
[![Documentation](https://docs.rs/mp4_muxer/badge.svg?version=0.1.0)](https://docs.rs/mp4_muxer/0.1.0)
![MSRV](https://img.shields.io/badge/rustc-1.56+-ab6000.svg)
![License](https://img.shields.io/crates/l/mp4_muxer.svg)
[![Dependency Status](https://deps.rs/crate/mp4_muxer/0.1.0/status.svg)](https://deps.rs/crate/mp4_muxer/0.1.0)
<br />
![downloads](https://img.shields.io/crates/d/mp4_muxer.svg)

<!-- prettier-ignore-end -->

  </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>`

| Method                     | Description                                                                          |
| -------------------------- | ------------------------------------------------------------------------------------ |
| `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`

| Variant             | Cause                                                    |
| ------------------- | -------------------------------------------------------- |
| `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

| Rate       | `fps_num` | `fps_den` |
| ---------- | --------- | --------- |
| 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:

| Any offset > `u32::MAX`? | Box emitted | Offset width |
| ------------------------ | ----------- | ------------ |
| 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

| | Status | Workaround |
| --- | --- | --- |
| 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

| Crate | Purpose |
| ----- | ------- |
|| none.   |

### Compile-time only

| Crate | Purpose |
| ----- | ------- |
|| none.   |

## License

MIT — see [LICENSE](LICENSE).

## Changelog

See [CHANGELOG.md](CHANGELOG.md).