use std::io::{self, Seek, SeekFrom, Write};
#[derive(Debug)]
pub enum Error {
Io(io::Error),
NoFrames,
InvalidFps,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Io(e) => write!(f, "I/O error: {e}"),
Error::NoFrames => write!(f, "cannot finish: no frames were written"),
Error::InvalidFps => write!(f, "fps_num and fps_den must both be non-zero"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for Error {
fn from(e: io::Error) -> Self { Error::Io(e) }
}
pub type Result<T> = std::result::Result<T, Error>;
struct Frame {
offset: u64,
size: u32,
is_keyframe: bool,
}
#[inline]
fn write_u16be(w: &mut impl Write, v: u16) -> io::Result<()> {
w.write_all(&v.to_be_bytes())
}
#[inline]
fn write_u32be(w: &mut impl Write, v: u32) -> io::Result<()> {
w.write_all(&v.to_be_bytes())
}
#[inline]
fn write_u64be(w: &mut impl Write, v: u64) -> io::Result<()> {
w.write_all(&v.to_be_bytes())
}
fn wrap_box(fourcc: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let size = 8 + payload.len();
debug_assert!(size <= u32::MAX as usize, "box '{}' too large for 32-bit size field", std::str::from_utf8(fourcc).unwrap_or("?"));
let mut out = Vec::with_capacity(size);
out.extend_from_slice(&(size as u32).to_be_bytes());
out.extend_from_slice(fourcc);
out.extend_from_slice(payload);
out
}
fn concat(boxes: &[&[u8]]) -> Vec<u8> {
let total: usize = boxes.iter().map(|b| b.len()).sum();
let mut out = Vec::with_capacity(total);
for b in boxes { out.extend_from_slice(b); }
out
}
fn build_ftyp() -> Vec<u8> {
let mut payload = Vec::with_capacity(16);
payload.extend_from_slice(b"isom"); payload.extend_from_slice(&0x0200u32.to_be_bytes()); payload.extend_from_slice(b"isomav01"); wrap_box(b"ftyp", &payload)
}
fn build_mvhd(duration_ts: u64, timescale: u32) -> Vec<u8> {
let mut p = Vec::with_capacity(108);
p.extend_from_slice(&[1, 0, 0, 0]); write_u64be(&mut p, 0).unwrap(); write_u64be(&mut p, 0).unwrap(); write_u32be(&mut p, timescale).unwrap();
write_u64be(&mut p, duration_ts).unwrap();
write_u32be(&mut p, 0x00010000).unwrap(); write_u16be(&mut p, 0x0100).unwrap(); p.extend_from_slice(&[0u8; 10]); write_unity_matrix(&mut p);
p.extend_from_slice(&[0u8; 24]); write_u32be(&mut p, 2).unwrap(); wrap_box(b"mvhd", &p)
}
fn build_tkhd(duration_ts: u64, width: u32, height: u32) -> Vec<u8> {
let mut p = Vec::with_capacity(92);
p.extend_from_slice(&[1, 0, 0, 3]); write_u64be(&mut p, 0).unwrap(); write_u64be(&mut p, 0).unwrap(); write_u32be(&mut p, 1).unwrap(); write_u32be(&mut p, 0).unwrap(); write_u64be(&mut p, duration_ts).unwrap();
p.extend_from_slice(&[0u8; 8]); write_u16be(&mut p, 0).unwrap(); write_u16be(&mut p, 0).unwrap(); write_u16be(&mut p, 0).unwrap(); write_u16be(&mut p, 0).unwrap(); write_unity_matrix(&mut p);
write_u32be(&mut p, width << 16).unwrap(); write_u32be(&mut p, height << 16).unwrap(); wrap_box(b"tkhd", &p)
}
fn write_unity_matrix(w: &mut Vec<u8>) {
const M: [u32; 9] = [
0x00010000, 0, 0,
0, 0x00010000, 0,
0, 0, 0x40000000,
];
for &v in &M { write_u32be(w, v).unwrap(); }
}
fn build_mdhd(duration_ts: u64, timescale: u32) -> Vec<u8> {
let mut p = Vec::with_capacity(36);
p.extend_from_slice(&[1, 0, 0, 0]); write_u64be(&mut p, 0).unwrap(); write_u64be(&mut p, 0).unwrap(); write_u32be(&mut p, timescale).unwrap();
write_u64be(&mut p, duration_ts).unwrap();
write_u16be(&mut p, 0x55C4).unwrap();
write_u16be(&mut p, 0).unwrap(); wrap_box(b"mdhd", &p)
}
fn build_hdlr() -> Vec<u8> {
let name = b"VideoHandler\0";
let mut p = Vec::with_capacity(24 + name.len());
p.extend_from_slice(&[0, 0, 0, 0]); write_u32be(&mut p, 0).unwrap(); p.extend_from_slice(b"vide"); p.extend_from_slice(&[0u8; 12]); p.extend_from_slice(name);
wrap_box(b"hdlr", &p)
}
fn build_vmhd() -> Vec<u8> {
let mut p = Vec::with_capacity(12);
p.extend_from_slice(&[0, 0, 0, 1]); p.extend_from_slice(&[0u8; 8]); wrap_box(b"vmhd", &p)
}
fn build_dref() -> Vec<u8> {
let url_entry = {
let mut e = Vec::with_capacity(12);
write_u32be(&mut e, 12).unwrap(); e.extend_from_slice(b"url "); e.extend_from_slice(&[0, 0, 0, 1]); e
};
let mut p = Vec::with_capacity(8 + url_entry.len());
p.extend_from_slice(&[0, 0, 0, 0]); write_u32be(&mut p, 1).unwrap(); p.extend_from_slice(&url_entry);
wrap_box(b"dref", &p)
}
fn build_dinf() -> Vec<u8> {
wrap_box(b"dinf", &build_dref())
}
fn build_av1c() -> Vec<u8> {
let cfg: [u8; 4] = [0x81, 0x04, 0x0C, 0x00];
wrap_box(b"av1C", &cfg)
}
fn build_av01_entry(width: u32, height: u32) -> Vec<u8> {
let av1c = build_av1c();
let mut p = Vec::with_capacity(78 + av1c.len());
p.extend_from_slice(&[0u8; 6]); write_u16be(&mut p, 1).unwrap(); p.extend_from_slice(&[0u8; 16]); write_u16be(&mut p, width as u16).unwrap();
write_u16be(&mut p, height as u16).unwrap();
write_u32be(&mut p, 0x00480000).unwrap(); write_u32be(&mut p, 0x00480000).unwrap(); write_u32be(&mut p, 0).unwrap(); write_u16be(&mut p, 1).unwrap(); p.extend_from_slice(&[0u8; 32]); write_u16be(&mut p, 0x0018).unwrap(); write_u16be(&mut p, 0xFFFF).unwrap(); p.extend_from_slice(&av1c);
wrap_box(b"av01", &p)
}
fn build_stsd(width: u32, height: u32) -> Vec<u8> {
let av01 = build_av01_entry(width, height);
let mut p = Vec::with_capacity(8 + av01.len());
p.extend_from_slice(&[0, 0, 0, 0]); write_u32be(&mut p, 1).unwrap(); p.extend_from_slice(&av01);
wrap_box(b"stsd", &p)
}
fn build_stts(sample_count: u32, sample_duration: u32) -> Vec<u8> {
let mut p = Vec::with_capacity(16);
p.extend_from_slice(&[0, 0, 0, 0]); write_u32be(&mut p, 1).unwrap(); write_u32be(&mut p, sample_count).unwrap();
write_u32be(&mut p, sample_duration).unwrap();
wrap_box(b"stts", &p)
}
fn build_stss(frames: &[Frame]) -> Vec<u8> {
let keys: Vec<u32> = frames.iter().enumerate()
.filter(|(_, f)| f.is_keyframe)
.map(|(i, _)| (i + 1) as u32)
.collect();
let mut p = Vec::with_capacity(8 + 4 * keys.len());
p.extend_from_slice(&[0, 0, 0, 0]);
write_u32be(&mut p, keys.len() as u32).unwrap();
for k in keys { write_u32be(&mut p, k).unwrap(); }
wrap_box(b"stss", &p)
}
fn build_stsc() -> Vec<u8> {
let mut p = Vec::with_capacity(20);
p.extend_from_slice(&[0, 0, 0, 0]);
write_u32be(&mut p, 1).unwrap(); write_u32be(&mut p, 1).unwrap(); write_u32be(&mut p, 1).unwrap(); write_u32be(&mut p, 1).unwrap(); wrap_box(b"stsc", &p)
}
fn build_stsz(frames: &[Frame]) -> Vec<u8> {
let mut p = Vec::with_capacity(12 + 4 * frames.len());
p.extend_from_slice(&[0, 0, 0, 0]);
write_u32be(&mut p, 0).unwrap(); write_u32be(&mut p, frames.len() as u32).unwrap();
for f in frames { write_u32be(&mut p, f.size).unwrap(); }
wrap_box(b"stsz", &p)
}
fn build_stco_or_co64(frames: &[Frame]) -> Vec<u8> {
let needs_co64 = frames.iter().any(|f| f.offset > u32::MAX as u64);
if needs_co64 {
let mut p = Vec::with_capacity(8 + 8 * frames.len());
p.extend_from_slice(&[0, 0, 0, 0]);
write_u32be(&mut p, frames.len() as u32).unwrap();
for f in frames { write_u64be(&mut p, f.offset).unwrap(); }
wrap_box(b"co64", &p)
} else {
let mut p = Vec::with_capacity(8 + 4 * frames.len());
p.extend_from_slice(&[0, 0, 0, 0]);
write_u32be(&mut p, frames.len() as u32).unwrap();
for f in frames { write_u32be(&mut p, f.offset as u32).unwrap(); }
wrap_box(b"stco", &p)
}
}
fn build_stbl(frames: &[Frame], width: u32, height: u32,
sample_count: u32, sample_duration: u32) -> Vec<u8> {
let stsd = build_stsd(width, height);
let stts = build_stts(sample_count, sample_duration);
let stss = build_stss(frames);
let stsc = build_stsc();
let stsz = build_stsz(frames);
let stco = build_stco_or_co64(frames);
let payload = concat(&[&stsd, &stts, &stss, &stsc, &stsz, &stco]);
wrap_box(b"stbl", &payload)
}
fn build_minf(frames: &[Frame], width: u32, height: u32,
sample_count: u32, sample_duration: u32) -> Vec<u8> {
let vmhd = build_vmhd();
let dinf = build_dinf();
let stbl = build_stbl(frames, width, height, sample_count, sample_duration);
let payload = concat(&[&vmhd, &dinf, &stbl]);
wrap_box(b"minf", &payload)
}
fn build_mdia(frames: &[Frame], width: u32, height: u32,
sample_count: u32, sample_duration: u32,
duration_ts: u64, timescale: u32) -> Vec<u8> {
let mdhd = build_mdhd(duration_ts, timescale);
let hdlr = build_hdlr();
let minf = build_minf(frames, width, height, sample_count, sample_duration);
let payload = concat(&[&mdhd, &hdlr, &minf]);
wrap_box(b"mdia", &payload)
}
#[allow(clippy::too_many_arguments)]
fn build_trak(frames: &[Frame], width: u32, height: u32,
sample_count: u32, sample_duration: u32,
duration_ts: u64, timescale: u32,
movie_timescale: u32) -> Vec<u8> {
let tkhd_duration = duration_ts * movie_timescale as u64 / timescale as u64;
let tkhd = build_tkhd(tkhd_duration, width, height);
let mdia = build_mdia(frames, width, height, sample_count, sample_duration,
duration_ts, timescale);
let payload = concat(&[&tkhd, &mdia]);
wrap_box(b"trak", &payload)
}
fn build_moov(frames: &[Frame], width: u32, height: u32,
sample_count: u32, sample_duration: u32,
duration_ts: u64, timescale: u32) -> Vec<u8> {
const MOVIE_TIMESCALE: u32 = 1000;
let movie_duration = duration_ts * MOVIE_TIMESCALE as u64 / timescale as u64;
let mvhd = build_mvhd(movie_duration, MOVIE_TIMESCALE);
let trak = build_trak(frames, width, height, sample_count, sample_duration,
duration_ts, timescale, MOVIE_TIMESCALE);
let payload = concat(&[&mvhd, &trak]);
wrap_box(b"moov", &payload)
}
pub struct Mp4Writer<W: Write + Seek> {
writer: W,
width: u32,
height: u32,
fps_num: u32,
fps_den: u32,
frames: Vec<Frame>,
mdat_data_start: u64,
}
impl<W: Write + Seek> Mp4Writer<W> {
pub fn new(mut writer: W, width: u32, height: u32, fps_num: u32, fps_den: u32) -> Result<Self> {
if fps_num == 0 || fps_den == 0 {
return Err(Error::InvalidFps);
}
let ftyp = build_ftyp();
writer.write_all(&ftyp)?;
writer.write_all(&[0, 0, 0, 0])?;
writer.write_all(b"mdat")?;
let mdat_data_start = ftyp.len() as u64 + 8;
Ok(Mp4Writer {
writer, width, height, fps_num, fps_den,
frames: Vec::new(),
mdat_data_start,
})
}
pub fn write_frame(&mut self, obu_data: &[u8], is_keyframe: bool) -> Result<()> {
let offset = self.writer.stream_position()?;
let size = obu_data.len() as u32;
self.writer.write_all(obu_data)?;
self.frames.push(Frame { offset, size, is_keyframe });
Ok(())
}
pub fn frames_written(&self) -> usize { self.frames.len() }
pub fn finish(mut self) -> Result<W> {
if self.frames.is_empty() {
return Err(Error::NoFrames);
}
let end_pos = self.writer.stream_position()?;
let mdat_header_pos = self.mdat_data_start - 8;
let mdat_size = end_pos - mdat_header_pos;
if mdat_size > u32::MAX as u64 {
return Err(Error::Io(io::Error::new(
io::ErrorKind::InvalidData,
"mdat exceeds 4 GiB — extended size not yet supported",
)));
}
self.writer.seek(SeekFrom::Start(mdat_header_pos))?;
write_u32be(&mut self.writer, mdat_size as u32)?;
self.writer.seek(SeekFrom::Start(end_pos))?;
let timescale = self.fps_num;
let sample_duration = self.fps_den;
let sample_count = self.frames.len() as u32;
let duration_ts = sample_count as u64 * sample_duration as u64;
let moov = build_moov(
&self.frames, self.width, self.height,
sample_count, sample_duration,
duration_ts, timescale,
);
self.writer.write_all(&moov)?;
Ok(self.writer)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn fake_obu(is_key: bool) -> Vec<u8> {
if is_key { vec![0x12, 0x00, 0xAA, 0xBB, 0xCC] }
else { vec![0x32, 0x00, 0xDD, 0xEE] }
}
fn mux_to_vec(width: u32, height: u32, frames: usize) -> Vec<u8> {
let mut muxer = Mp4Writer::new(Cursor::new(Vec::new()), width, height, 30, 1).unwrap();
for i in 0..frames {
muxer.write_frame(&fake_obu(i == 0), i == 0).unwrap();
}
muxer.finish().unwrap().into_inner()
}
#[test]
fn happy_path() {
let mut muxer = Mp4Writer::new(Cursor::new(Vec::new()), 320, 240, 30, 1).unwrap();
for i in 0..4 {
muxer.write_frame(&fake_obu(i == 0), i == 0).unwrap();
}
assert_eq!(muxer.frames_written(), 4);
muxer.finish().unwrap();
}
#[test]
fn produces_ftyp_mdat_moov_in_order() {
let bytes = mux_to_vec(640, 360, 10);
assert_eq!(&bytes[4..8], b"ftyp");
let ftyp_size = u32::from_be_bytes(bytes[0..4].try_into().unwrap()) as usize;
assert_eq!(&bytes[ftyp_size + 4..ftyp_size + 8], b"mdat");
let mdat_size = u32::from_be_bytes(bytes[ftyp_size..ftyp_size + 4].try_into().unwrap()) as usize;
let moov_start = ftyp_size + mdat_size;
assert_eq!(&bytes[moov_start + 4..moov_start + 8], b"moov");
}
#[test]
fn empty_finish_returns_error() {
let muxer = Mp4Writer::new(Cursor::new(Vec::new()), 100, 100, 30, 1).unwrap();
assert!(matches!(muxer.finish(), Err(Error::NoFrames)));
}
#[test]
fn zero_fps_rejected() {
let r = Mp4Writer::new(Cursor::new(Vec::new()), 100, 100, 0, 1);
assert!(matches!(r, Err(Error::InvalidFps)));
let r = Mp4Writer::new(Cursor::new(Vec::new()), 100, 100, 30, 0);
assert!(matches!(r, Err(Error::InvalidFps)));
}
#[test]
fn ftyp_declares_isom_and_av01() {
let ftyp = build_ftyp();
assert_eq!(&ftyp[4..8], b"ftyp");
assert_eq!(&ftyp[8..12], b"isom");
assert!(ftyp.windows(4).any(|w| w == b"av01"));
}
#[test]
fn every_box_reports_its_own_length() {
for b in [
build_ftyp(),
build_vmhd(),
build_dinf(),
build_hdlr(),
build_av1c(),
build_stsc(),
build_mvhd(90_000, 1000),
build_tkhd(90_000, 320, 240),
build_mdhd(90_000, 30),
] {
let reported = u32::from_be_bytes(b[0..4].try_into().unwrap()) as usize;
assert_eq!(reported, b.len(),
"box size mismatch for '{}'",
std::str::from_utf8(&b[4..8]).unwrap_or("?"));
}
}
#[test]
fn stts_encodes_one_entry() {
let stts = build_stts(100, 3000);
assert_eq!(&stts[4..8], b"stts");
assert_eq!(&stts[8..12], &[0, 0, 0, 0]); assert_eq!(u32::from_be_bytes(stts[12..16].try_into().unwrap()), 1); assert_eq!(u32::from_be_bytes(stts[16..20].try_into().unwrap()), 100); assert_eq!(u32::from_be_bytes(stts[20..24].try_into().unwrap()), 3000); }
#[test]
fn stss_lists_keyframes_by_1based_index() {
let frames = vec![
Frame { offset: 0, size: 10, is_keyframe: true },
Frame { offset: 10, size: 8, is_keyframe: false },
Frame { offset: 18, size: 12, is_keyframe: true },
];
let stss = build_stss(&frames);
assert_eq!(u32::from_be_bytes(stss[12..16].try_into().unwrap()), 2);
assert_eq!(u32::from_be_bytes(stss[16..20].try_into().unwrap()), 1);
assert_eq!(u32::from_be_bytes(stss[20..24].try_into().unwrap()), 3);
}
#[test]
fn stsz_lists_per_sample_sizes() {
let frames = vec![
Frame { offset: 0, size: 42, is_keyframe: true },
Frame { offset: 42, size: 17, is_keyframe: false },
];
let stsz = build_stsz(&frames);
assert_eq!(u32::from_be_bytes(stsz[16..20].try_into().unwrap()), 2);
assert_eq!(u32::from_be_bytes(stsz[20..24].try_into().unwrap()), 42);
assert_eq!(u32::from_be_bytes(stsz[24..28].try_into().unwrap()), 17);
}
#[test]
fn stco_used_for_small_files() {
let frames = vec![
Frame { offset: 1000, size: 50, is_keyframe: true },
Frame { offset: 1050, size: 60, is_keyframe: false },
];
let out = build_stco_or_co64(&frames);
assert_eq!(&out[4..8], b"stco");
assert_eq!(u32::from_be_bytes(out[12..16].try_into().unwrap()), 2); assert_eq!(u32::from_be_bytes(out[16..20].try_into().unwrap()), 1000);
assert_eq!(u32::from_be_bytes(out[20..24].try_into().unwrap()), 1050);
}
#[test]
fn co64_kicks_in_past_4_gib() {
let frames = vec![
Frame { offset: 0, size: 10, is_keyframe: true },
Frame { offset: (u32::MAX as u64) + 1_000, size: 10, is_keyframe: false },
];
let out = build_stco_or_co64(&frames);
assert_eq!(&out[4..8], b"co64");
assert_eq!(u32::from_be_bytes(out[12..16].try_into().unwrap()), 2); assert_eq!(u64::from_be_bytes(out[16..24].try_into().unwrap()), 0);
assert_eq!(
u64::from_be_bytes(out[24..32].try_into().unwrap()),
(u32::MAX as u64) + 1_000,
);
}
}